dsh-git-badge 0.20.0 → 0.20.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/CHANGELOG.md CHANGED
@@ -10,6 +10,22 @@ npm. 0.6.0 is the first release recorded here.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.20.1] - 2026-10-13
14
+
15
+ ### Fixed
16
+
17
+ - **The row's token survives a status dot.** Upstream mounts the leading seat
18
+ only while a row's primary state is idle, and gives the 16px cell to its own
19
+ solid status dot otherwise: green for an unread completion, orange for a
20
+ pending interaction. A busy session that went idle therefore showed the dot
21
+ until the session was opened, while its checkout already had something to say.
22
+ The plugin now rides the row-action strip — the one per-row seat React mounts
23
+ in every state, hidden by CSS rather than unmounted — and paints the node
24
+ half's token over the dot from that marker plus one injected stylesheet. The
25
+ cell still decides: the fallback appears only when a solid dot occupies it, a
26
+ running spinner is left alone, `in sync` never replaces the dot, and the mark
27
+ is removed the moment the real seat returns. No upstream byte changes.
28
+
13
29
  ## [0.20.0] - 2026-10-13
14
30
 
15
31
  ### Added
package/lib/client.js CHANGED
@@ -595,6 +595,9 @@ window.__ModuleLoader__.load({
595
595
  return react_jsx_runtime.jsx("span", {
596
596
  role: "img",
597
597
  "aria-label": "git action: " + token,
598
+ // The fallback overlay in the SAME cell is suppressed by this
599
+ // marker, so a status transition can never paint two glyphs.
600
+ "data-git-badge-leading": "",
598
601
  style: { ...META_STYLE, ...(colour === void 0 ? {} : { color: colour }), ...GLYPH_TEXT, display: "inline-flex", alignItems: "center" },
599
602
  children: actionGlyphNode(token)
600
603
  });
@@ -602,6 +605,207 @@ window.__ModuleLoader__.load({
602
605
  return null;
603
606
  }
604
607
 
608
+ //#region leading-cell fallback (a solid status dot owns the cell)
609
+ /**
610
+ * The leading cell's SOLID status dots. Upstream replaces the whole
611
+ * `sidebar.session.row.leading` seat with one of these whenever the row's
612
+ * primary state is not idle, so the plugin's glyph is not mounted at all:
613
+ * `done` (green) when a background turn finished unread, `warning` (orange)
614
+ * when a pending interaction waits. `ongoing` is the running spinner, an
615
+ * `svg` with no solid dot, and stays upstream's alone.
616
+ */
617
+ const ROW_FALLBACK_DOT = '[data-state="done"], [data-state="warning"]';
618
+
619
+ /**
620
+ * The fallback's stylesheet. The plugin paints the token into the occupied
621
+ * 16px cell from a marker attribute, so it adds NO foreign node to a
622
+ * React-managed cell: the dot element steps aside and a `::after` draws the
623
+ * node half's glyph, coloured by the severity custom property.
624
+ *
625
+ * `:has()` keeps the real seat and the fallback exclusive even during the
626
+ * one commit where a row flips back to idle: a cell that already holds
627
+ * `data-git-badge-leading` never paints the overlay. A browser without
628
+ * `:has()` drops the whole rule, so the fallback is simply absent — it can
629
+ * never double-draw.
630
+ */
631
+ const ROW_FALLBACK_OVERLAY = "[data-git-badge-overlay]:not(:has([data-git-badge-leading]))";
632
+ const ROW_FALLBACK_CSS = [
633
+ ROW_FALLBACK_OVERLAY + " > span:first-child > " + ROW_FALLBACK_DOT + " { display: none; }",
634
+ ROW_FALLBACK_OVERLAY + " > span:first-child::after {",
635
+ ' content: var(--git-badge-glyph, "");',
636
+ " display: inline-flex;",
637
+ " align-items: center;",
638
+ " justify-content: center;",
639
+ " width: 16px;",
640
+ " height: 20px;",
641
+ " color: var(--git-badge-color, currentColor);",
642
+ " font-size: 13px;",
643
+ " line-height: 1;",
644
+ "}",
645
+ ROW_FALLBACK_OVERLAY + "[data-git-badge-drawn] > span:first-child::after {",
646
+ ' content: "";',
647
+ " background-color: var(--git-badge-color, currentColor);",
648
+ " -webkit-mask-image: var(--git-badge-mask-src);",
649
+ " mask-image: var(--git-badge-mask-src);",
650
+ " -webkit-mask-repeat: no-repeat;",
651
+ " mask-repeat: no-repeat;",
652
+ " -webkit-mask-position: center;",
653
+ " mask-position: center;",
654
+ " -webkit-mask-size: var(--git-badge-mask-size, 12px);",
655
+ " mask-size: var(--git-badge-mask-size, 12px);",
656
+ "}"
657
+ ].join("\n");
658
+
659
+ /**
660
+ * One `DRAWN_ICONS` entry as a CSS mask URL, so the fallback paints the
661
+ * EXACT artwork the real seat draws. The mask supplies the shape and the
662
+ * element's `background-color` supplies the severity colour, which is why
663
+ * the SVG is painted black: only its alpha is read.
664
+ */
665
+ function glyphMaskDataUri(icon) {
666
+ const shapes = icon.shapes.map((shape) => {
667
+ if (shape.circle === true) return '<circle cx="6" cy="6" r="5"/>';
668
+ if (shape.stroke === true) return '<path d="' + shape.d + '" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
669
+ return '<path d="' + shape.d + '" fill="#000"/>';
670
+ }).join("");
671
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="' + icon.viewBox + '">' + shapes + "</svg>";
672
+ return 'url("data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg) + '")';
673
+ }
674
+
675
+ /**
676
+ * The fallback's mark for a token, or undefined when the dot should stay.
677
+ *
678
+ * `in sync` is deliberately excluded: it is a disc, the row's own dot is
679
+ * also a dot, and replacing an unread marker with "nothing to do" would
680
+ * throw away the only signal that the turn ended. Only a real action earns
681
+ * the cell.
682
+ */
683
+ function fallbackGlyph(token, severity) {
684
+ if (typeof token !== "string" || token === "" || token === "in sync") return void 0;
685
+ const colour = SEVERITY_FILL[severity];
686
+ const drawn = DRAWN_ICONS[token];
687
+ if (drawn !== void 0) return { mask: glyphMaskDataUri(drawn), size: drawn.size + "px", colour };
688
+ const glyph = ACTION_GLYPH[token];
689
+ if (glyph === void 0) return void 0;
690
+ return { glyph, colour };
691
+ }
692
+
693
+ /** Write one mark onto the row element. */
694
+ function applyFallbackMark(host, mark) {
695
+ host.setAttribute("data-git-badge-overlay", "");
696
+ if (mark.mask !== void 0) {
697
+ host.setAttribute("data-git-badge-drawn", "");
698
+ host.style.setProperty("--git-badge-mask-src", mark.mask);
699
+ host.style.setProperty("--git-badge-mask-size", mark.size);
700
+ host.style.removeProperty("--git-badge-glyph");
701
+ } else {
702
+ host.removeAttribute("data-git-badge-drawn");
703
+ host.style.removeProperty("--git-badge-mask-src");
704
+ host.style.removeProperty("--git-badge-mask-size");
705
+ // JSON.stringify keeps the quotes `content: var(...)` needs.
706
+ host.style.setProperty("--git-badge-glyph", JSON.stringify(mark.glyph));
707
+ }
708
+ if (mark.colour === void 0) host.style.removeProperty("--git-badge-color");
709
+ else host.style.setProperty("--git-badge-color", mark.colour);
710
+ }
711
+
712
+ /** Remove every trace of a mark, so the row is exactly as upstream left it. */
713
+ function clearFallbackMark(host) {
714
+ if (host === void 0 || host === null) return;
715
+ host.removeAttribute("data-git-badge-overlay");
716
+ host.removeAttribute("data-git-badge-drawn");
717
+ host.style.removeProperty("--git-badge-glyph");
718
+ host.style.removeProperty("--git-badge-mask-src");
719
+ host.style.removeProperty("--git-badge-mask-size");
720
+ host.style.removeProperty("--git-badge-color");
721
+ }
722
+
723
+ /** The row (`[data-row-key]`) an element sits in, or undefined. */
724
+ function rowHost(node) {
725
+ const closest = node?.closest;
726
+ if (typeof closest !== "function") return void 0;
727
+ return closest.call(node, "[data-row-key]") ?? void 0;
728
+ }
729
+
730
+ /**
731
+ * The whole fallback decision in one testable call: does the cell hold a
732
+ * SOLID dot, and does the node half have an action to show? The cell is the
733
+ * authority, so no client-side copy of upstream's status precedence can
734
+ * drift. Returns whether the overlay is on.
735
+ */
736
+ function syncRowFallback(host, info) {
737
+ if (host === void 0 || host === null) return false;
738
+ const cell = host.firstElementChild;
739
+ const occupied = cell !== void 0 && cell !== null && typeof cell.querySelector === "function" && cell.querySelector(ROW_FALLBACK_DOT) !== null;
740
+ if (!occupied) {
741
+ clearFallbackMark(host);
742
+ return false;
743
+ }
744
+ const token = info !== void 0 && info !== null && info.git === true ? info.rowToken : void 0;
745
+ const mark = fallbackGlyph(token, info?.rowSeverity);
746
+ if (mark === void 0) {
747
+ clearFallbackMark(host);
748
+ return false;
749
+ }
750
+ applyFallbackMark(host, mark);
751
+ return true;
752
+ }
753
+
754
+ /** One stylesheet per page; the loader tags it for its own bookkeeping. */
755
+ let rowFallbackStyle = false;
756
+ function ensureRowFallbackStyle() {
757
+ if (rowFallbackStyle) return;
758
+ if (typeof document === "undefined" || document.head === void 0 || document.head === null) return;
759
+ try {
760
+ const style = document.createElement("style");
761
+ style.setAttribute("data-plugin", "dsh-git-badge");
762
+ style.textContent = ROW_FALLBACK_CSS;
763
+ document.head.appendChild(style);
764
+ rowFallbackStyle = true;
765
+ } catch {
766
+ // a missing or closed <head> must never break the badge
767
+ }
768
+ }
769
+
770
+ /**
771
+ * The marker that carries the fallback. It MUST ride
772
+ * `sidebar.workspaces.session.row.action`, because that strip is hidden by
773
+ * CSS rather than unmounted: it is the one per-row seat React mounts in
774
+ * every state, so it is the only place from which a row can be marked while
775
+ * upstream's dot owns the leading cell. It renders a hidden span and adds
776
+ * no button.
777
+ */
778
+ function RowFallbackGlyph({ sessionId }) {
779
+ const ref = react.useRef(null);
780
+ const [occupied, setOccupied] = react.useState(false);
781
+ // `enabled` is the LAZY gate: a row with no solid dot keeps costing
782
+ // nothing, exactly as it did before the fallback existed.
783
+ const info = useGitStatus(sessionId === void 0 ? void 0 : { kind: "session", id: sessionId }, { pr: true, enabled: occupied });
784
+ react.useEffect(() => {
785
+ ensureRowFallbackStyle();
786
+ const host = rowHost(ref.current);
787
+ if (host === void 0) return;
788
+ const cell = host.firstElementChild;
789
+ if (cell === void 0 || cell === null || typeof cell.querySelector !== "function") return;
790
+ const sync = () => setOccupied(cell.querySelector(ROW_FALLBACK_DOT) !== null);
791
+ sync();
792
+ if (typeof MutationObserver !== "function") return;
793
+ const observer = new MutationObserver(sync);
794
+ // The dot swaps in as a child; a state change edits `data-state` in
795
+ // place, so both must be watched. The row's own attributes are NOT
796
+ // observed, which is what keeps the mark from re-triggering itself.
797
+ observer.observe(cell, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-state"] });
798
+ return () => observer.disconnect();
799
+ }, [sessionId]);
800
+ // Layout effect: the mark lands in the same commit that reveals the dot,
801
+ // so the fallback does not flash the green/orange dot first.
802
+ react.useLayoutEffect(() => {
803
+ syncRowFallback(rowHost(ref.current), info);
804
+ }, [info, occupied]);
805
+ return react_jsx_runtime.jsx("span", { ref, "aria-hidden": true, style: { display: "none" } });
806
+ }
807
+ //#endregion
808
+
605
809
  const CARD_CONTAINER = {
606
810
  display: "flex",
607
811
  flexDirection: "column",
@@ -1739,6 +1943,20 @@ window.__ModuleLoader__.load({
1739
1943
  name: "sidebar.session.row.leading",
1740
1944
  id: "git-badge-row-leading"
1741
1945
  }, LeadingTokenGlyph));
1946
+ // The seat above is mounted ONLY while the row's primary state is idle
1947
+ // (upstream's own contract). A row that owns a solid status dot — an
1948
+ // unread completion, or a pending interaction — keeps that cell to
1949
+ // itself, so the token glyph would be invisible exactly when the turn
1950
+ // that produced it just ended, until the session is opened. This strip is
1951
+ // ALWAYS mounted (hidden by CSS, not unmounted), so a marker rides it and
1952
+ // the stylesheet paints the token over the dot. The rule stays honest: no
1953
+ // upstream byte changes, and the cell still belongs to upstream.
1954
+ ctx.slots.inject("sidebar.workspaces.session.row.action", () => ctx.slots.register({
1955
+ name: "sidebar.workspaces.session.row.action",
1956
+ id: "git-badge-row-fallback",
1957
+ order: 300
1958
+ }, RowFallbackGlyph));
1959
+ ensureRowFallbackStyle();
1742
1960
  // The row is ALREADY a HoverCard anchor upstream, so a glyph that opened
1743
1961
  // its own tooltip would nest two cards. This renders the explanation
1744
1962
  // inside that card instead, through the hover seat upstream declares
@@ -1779,7 +1997,7 @@ window.__ModuleLoader__.load({
1779
1997
  // Additive; the host reads apply/inject and ignores the rest. The suite
1780
1998
  // drives these to assert the REQUEST contract — which surface asks for the
1781
1999
  // expensive extras — without a browser, a fetch or a network.
1782
- exports.__internals = { targetQuery, worktreeDetail, linkedWorktreesDetail, ghSkillActions, cleanGate, moveCategory };
2000
+ exports.__internals = { targetQuery, worktreeDetail, linkedWorktreesDetail, ghSkillActions, cleanGate, moveCategory, fallbackGlyph, glyphMaskDataUri, syncRowFallback, clearFallbackMark, ROW_FALLBACK_CSS, ROW_FALLBACK_DOT };
1783
2001
  return module.exports;
1784
2002
  }
1785
2003
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-git-badge",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "description": "Git status badges for DeepSeek Harness: an input-row chip and a sidebar session-row action glyph, on every install — no patch and no manual step. Event-driven freshness over SSE.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",