dsh-git-badge 0.14.1 → 0.16.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
@@ -233,41 +233,42 @@ window.__ModuleLoader__.load({
233
233
 
234
234
  /**
235
235
  * The /gh picker's sub-actions — the skill invocations the checkout
236
- * justifies, most urgent first. Same ranking the server's nextStep uses,
237
- * but spoken in skill arguments; the generic "next" entry is skipped when
238
- * a specific rule already names the same situation (its why would be
239
- * identical, and two rows for one fact is noise).
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.
240
240
  */
241
241
  function ghSkillActions(info) {
242
242
  if (info === void 0 || info === null || info.git !== true) return [];
243
243
  const subs = [];
244
244
  const seen = new Set();
245
- const add = (args, why) => {
246
- if (seen.has(args)) return;
245
+ const add = (args, why, what) => {
246
+ if (args === void 0 || args === null || args === "" || seen.has(args)) return;
247
247
  seen.add(args);
248
- subs.push({ args, why });
248
+ subs.push({ args, why, what });
249
249
  };
250
+ const next = info.next;
251
+ if (next !== void 0 && next !== null) add(next.args, next.why, next.what);
250
252
  const ahead = info.ahead || 0;
251
253
  const behind = info.behind || 0;
252
254
  const staged = info.stagedFiles || 0;
253
255
  const unstaged = info.unstagedFiles || 0;
254
256
  const untracked = info.untrackedFiles || 0;
255
- if (behind > 0) add("pull", behind + " behind " + (info.upstream ?? "upstream"));
256
- if (ahead > 0) add("push", ahead + " ahead of " + (info.upstream ?? "upstream"));
257
- if (staged > 0) add("commit", staged + " staged");
258
- if (unstaged > 0) add("commit", unstaged + " unstaged");
259
- if (unstaged === 0 && untracked > 0) add("commit", untracked + " untracked");
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");
260
266
  const pr = info.pr;
261
267
  if (pr !== void 0 && pr !== null && pr.number !== void 0) {
262
- if (pr.state === "failing") add("checks " + pr.number, "checks failing on #" + pr.number);
263
- add("pr view " + pr.number, "open pull request #" + pr.number);
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");
264
270
  } else if (ahead === 0 && behind === 0 && info.upstream !== void 0 && staged + unstaged + untracked === 0) {
265
- add("pr", "branch is pushed and has no pull request");
266
- }
267
- const next = info.next;
268
- if (next !== void 0 && next !== null && typeof next.command === "string"
269
- && !subs.some((entry) => entry.why === next.why)) {
270
- add("next", next.why);
271
+ add("pr", "branch is pushed and has no pull request", "open a pull request for this branch");
271
272
  }
272
273
  return subs.slice(0, 6);
273
274
  }
@@ -435,9 +436,9 @@ window.__ModuleLoader__.load({
435
436
  "aria-label":
436
437
  (isWorktree ? "git worktree: " : "git: ") +
437
438
  STATUS_LABEL[status] +
438
- // an inferred checkout is not where the conversation lives, so the
439
- // accessible name says where the fact came from
440
- (info.worktreeInferred === true ? " (inferred from an open pull request in this repository)" : ""),
439
+ // a followed checkout is not where the conversation lives, and the
440
+ // accessible name says so — the session registered it explicitly
441
+ (info.worktreeFollowed === true ? " (registered for this session)" : ""),
441
442
  focusable: "false",
442
443
  style: { flex: "none", display: "block", color: META_STYLE.color }
443
444
  };
@@ -584,7 +585,7 @@ window.__ModuleLoader__.load({
584
585
  const checkout = [name === "" ? String(info.branch) : name + " on " + info.branch];
585
586
  // the node half followed this tree because it is the one whose branch
586
587
  // has the open PR — say so, because nothing on the row can
587
- if (info.worktreeInferred === true) checkout.push("its branch has the open pull request");
588
+
588
589
  bits.push("checkout: " + checkout.join(" \u00B7 "));
589
590
  }
590
591
  const pr = formatPrDetail(info.pr);
@@ -622,7 +623,7 @@ window.__ModuleLoader__.load({
622
623
 
623
624
  /**
624
625
  * The `checkout` row: the directory the CONVERSATION itself names, present
625
- * only when the badge is describing an inferred worktree instead. The chip's
626
+ * only when the badge is describing a FOLLOWED worktree instead. The chip's
626
627
  * branch and counts are the tree's in that case, so without this row the
627
628
  * checkout's own state is nowhere on screen. Branch, then files (or "clean"),
628
629
  * then sync counts only when they are nonzero — the chip's own rule.
@@ -641,15 +642,13 @@ window.__ModuleLoader__.load({
641
642
  * The `worktree` row: WHICH linked worktree the badge is describing. The chip
642
643
  * names only the branch (see the note above formatOperationToken), so the
643
644
  * card is the one place a tree is identified — the mark's shape can only say
644
- * *that* the checkout is a worktree, never which. An inferred follow also says
645
- * why, because "which tree" and "why this tree" are different questions and
646
- * the second is the one a reader will ask.
645
+ * *that* the checkout is a worktree, never which.
647
646
  */
648
647
  function worktreeDetail(info) {
649
648
  if (info === void 0 || info === null || info.isWorktree !== true) return void 0;
650
649
  const name = typeof info.worktreeName === "string" ? info.worktreeName : "";
651
650
  if (name === "") return void 0;
652
- return info.worktreeInferred === true ? name + " (inferred from its open pull request)" : name;
651
+ return name;
653
652
  }
654
653
 
655
654
  const CARD_CONTAINER = {
@@ -658,12 +657,223 @@ window.__ModuleLoader__.load({
658
657
  gap: "4px",
659
658
  fontSize: "12px",
660
659
  lineHeight: "18px",
661
- maxWidth: "340px"
660
+ // Auto-width: the card fits its widest row instead of a guessed
661
+ // constant, capped at 600px. The Tooltip's maxWidth must stay >= the
662
+ // cap or IT becomes the clamp.
663
+ width: "max-content",
664
+ maxWidth: "600px"
662
665
  };
666
+ /**
667
+ * The ✎n count's own hover body — the changed-file lists and NOTHING
668
+ * else: the full card stays on the status mark, and this tooltip answers
669
+ * the one question the count poses ("what are these?"). One stacked
670
+ * column with sub-headers: UNSTAGED (tracked, edited — valid regardless
671
+ * 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).
676
+ */
677
+ function CountNames({ info }) {
678
+ const sections = [
679
+ {
680
+ label: "staged",
681
+ names: info?.stagedNames,
682
+ total: info?.stagedNamesTotal
683
+ },
684
+ {
685
+ label: "unstaged",
686
+ names: info?.unstagedNames,
687
+ total: info?.unstagedNamesTotal
688
+ },
689
+ {
690
+ label: "untracked",
691
+ names: info?.untrackedNames,
692
+ total: info?.untrackedNamesTotal
693
+ }
694
+ ].filter((section) => Array.isArray(section.names) && section.names.length > 0);
695
+ if (sections.length === 0) {
696
+ return react_jsx_runtime.jsx("span", { style: CARD_CONTAINER, children: "no changed files" });
697
+ }
698
+ return react_jsx_runtime.jsxs("div", { style: CARD_CONTAINER, children:
699
+ sections.map((section) => react_jsx_runtime.jsxs("div", { style: CARD_ROW, children: [
700
+ react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: section.label }),
701
+ react_jsx_runtime.jsx("span", {
702
+ style: { ...CARD_VALUE, display: "flex", flexDirection: "column", gap: "2px" },
703
+ children: [
704
+ ...section.names.map((name) => react_jsx_runtime.jsx("span", { children: name }, section.label + name)),
705
+ (typeof section.total === "number" && section.total > section.names.length
706
+ ? "\u2026 and " + (section.total - section.names.length) + " more"
707
+ : null)
708
+ ]
709
+ })
710
+ ] }, section.label))
711
+ });
712
+ }
713
+
714
+ /**
715
+ * Compress git's relative age to its shortest honest form: 43 seconds
716
+ * ago -> 43s ago, 2 hours ago -> 2h ago, weeks -> w, months -> mo (to
717
+ * stay distinct from minutes), years -> y.
718
+ */
719
+ function shortWhen(when) {
720
+ return String(when)
721
+ .replace(/(\d+) seconds? ago/u, "$1s ago")
722
+ .replace(/(\d+) minutes? ago/u, "$1m ago")
723
+ .replace(/(\d+) hours? ago/u, "$1h ago")
724
+ .replace(/(\d+) days? ago/u, "$1d ago")
725
+ .replace(/(\d+) weeks? ago/u, "$1w ago")
726
+ .replace(/(\d+) months? ago/u, "$1mo ago")
727
+ .replace(/(\d+) years? ago/u, "$1y ago");
728
+ }
729
+
730
+ /**
731
+ * The branch name's own hover body — the branch's LINEAGE, and it ALWAYS
732
+ * renders once the lazy detail payload has landed. Rows: upstream, sync
733
+ * in words, the branch's last 10 commits (WHAT THIS BRANCH HAS, full
734
+ * stop — no base-relative filtering, which empties on every push or
735
+ * merge). The pull request has no row here — the chip's own PR token
736
+ * already carries number, CI state and the link.
737
+ */
738
+ function BranchLineage({ info }) {
739
+ const rows = [];
740
+ const add = (label, value) => {
741
+ if (value === void 0 || value === null || value === "") return;
742
+ rows.push(cardRow(label, value));
743
+ };
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.
765
+ const pr = info?.pr;
766
+ const mergeParts = [];
767
+ if (pr !== void 0 && pr !== null && pr.number !== void 0) {
768
+ if (pr.draft === true) mergeParts.push("blocked: draft");
769
+ else if (pr.mergeState === "BLOCKED") {
770
+ mergeParts.push(pr.review === "REVIEW_REQUIRED" ? "blocked: review required"
771
+ : pr.state === "failing" ? "blocked: checks failing"
772
+ : "blocked");
773
+ } 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");
775
+ }
776
+ if (info?.mainAhead !== void 0 || info?.mainBehind !== void 0) {
777
+ mergeParts.push((info.mainAhead || 0) + " ahead, " + (info.mainBehind || 0) + " behind main");
778
+ }
779
+ if (mergeParts.length > 0) add("merge", mergeParts.join(" \u00B7 "));
780
+ const commits = info?.branchCommits;
781
+ if (Array.isArray(commits) && commits.length > 0) {
782
+ // Full-width block, not the label/value two-column row: commit lines
783
+ // use the card's whole width, and each line is nowrap with CSS
784
+ // ellipsis so long subjects clip at the edge instead of pushing the
785
+ // card out. `when` is compressed the way git log users shorten it:
786
+ // 43 seconds ago -> 43s ago.
787
+ rows.push(
788
+ react_jsx_runtime.jsxs("div", {
789
+ style: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0 },
790
+ 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
+ ]
808
+ }, commit.hash))
809
+ }, "branch-commits")
810
+ );
811
+ }
812
+ // NO pull-request row: the chip's own PR token (number, CI state, link)
813
+ // carries it — a second rendering two pixels away is pure duplication.
814
+ return react_jsx_runtime.jsx("div", { style: CARD_CONTAINER, children: rows });
815
+ }
816
+
663
817
  const CARD_ROW = { display: "flex", gap: "8px", alignItems: "baseline" };
664
818
  const CARD_LABEL = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", minWidth: "62px" };
665
819
  const CARD_VALUE = { minWidth: 0, overflowWrap: "anywhere" };
666
820
 
821
+ // The host's plugin-card chrome, reproduced inline: its CSS module is not
822
+ // exported to plugins, so these values mirror PluginCard.module.css so our
823
+ // card sits in the same list as its siblings.
824
+ const CARD_BASE = {
825
+ border: ".5px solid var(--dsw-alias-border-l4, #e4e8eb)",
826
+ borderRadius: "16px",
827
+ listStyle: "none",
828
+ transition: "border-color .16s, background .16s"
829
+ };
830
+ const CARD_CLOSED = { ...CARD_BASE, background: "var(--dsw-alias-bg-layer-3, #ffffff)" };
831
+ const CARD_OPEN = {
832
+ ...CARD_BASE,
833
+ background: "var(--dsw-alias-bg-layer-2, #f7f8f9)",
834
+ borderColor: "var(--dsw-alias-label-dimmed, #b8bfc4)"
835
+ };
836
+ const CARD_HEADER = {
837
+ appearance: "none",
838
+ width: "100%",
839
+ font: "inherit",
840
+ color: "inherit",
841
+ textAlign: "left",
842
+ cursor: "pointer",
843
+ background: "none",
844
+ border: 0,
845
+ borderRadius: "12px",
846
+ display: "flex",
847
+ alignItems: "center",
848
+ gap: "12px",
849
+ padding: "14px 16px"
850
+ };
851
+ const CARD_NAME = { color: "var(--dsw-alias-label-primary, #1f2328)", fontSize: "15px", fontWeight: "600", lineHeight: "1.4" };
852
+ const CARD_DESCRIPTION = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", fontSize: "13px", lineHeight: "1.5" };
853
+
854
+ const CARD_BUTTON = {
855
+ cursor: "pointer",
856
+ color: "inherit",
857
+ background: "none",
858
+ border: "1px solid var(--dsw-alias-border-secondary, #d0d7de)",
859
+ borderRadius: "4px",
860
+ fontSize: "11px",
861
+ lineHeight: "18px",
862
+ padding: "0 6px",
863
+ flex: "none"
864
+ };
865
+
866
+ /** One label/value row — the shape every hover body shares. */
867
+ function cardRow(label, value) {
868
+ return react_jsx_runtime.jsxs("div", {
869
+ style: CARD_ROW,
870
+ children: [
871
+ react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: label }),
872
+ react_jsx_runtime.jsx("span", { style: CARD_VALUE, children: value })
873
+ ]
874
+ }, label);
875
+ }
876
+
667
877
 
668
878
  /**
669
879
  * Hover card body for the input chip — INPUT CHIP ONLY. Pure presentation
@@ -678,15 +888,7 @@ window.__ModuleLoader__.load({
678
888
  const rows = [];
679
889
  const add = (label, value) => {
680
890
  if (value === void 0 || value === null || value === "") return;
681
- rows.push(
682
- react_jsx_runtime.jsxs("div", {
683
- style: CARD_ROW,
684
- children: [
685
- react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: label }),
686
- react_jsx_runtime.jsx("span", { style: CARD_VALUE, children: value })
687
- ]
688
- }, label)
689
- );
891
+ rows.push(cardRow(label, value));
690
892
  };
691
893
  // The "action" row — the top line, in the card's ordinary row layout:
692
894
  // the gh skill invocation the checkout justifies, derived from the same
@@ -695,45 +897,24 @@ window.__ModuleLoader__.load({
695
897
  // this row is the read-only pointer to it.
696
898
  const topAction = ghSkillActions(data)[0];
697
899
  if (topAction !== void 0) {
698
- add("action:", "/gh " + topAction.args);
699
- }
700
- add("branch", info.branch);
701
- add("upstream", info.upstream === void 0 ? "none configured" : info.upstream);
702
- if (info.ahead !== void 0 || info.behind !== void 0) {
703
- add("sync", "\u2191" + (info.ahead || 0) + " \u2193" + (info.behind || 0));
900
+ // git-comment convention: the invocation, two spaces, then "# "
901
+ // plus what it does
902
+ add("action", "/gh " + topAction.args + " # " + topAction.what);
704
903
  }
904
+ // The branch lineage rows (branch, upstream, sync, pull request) moved
905
+ // to the branch NAME's hover — the card is the checkout's verdict, not
906
+ // the branch's CV. The branch name is also the chip's own text, so a
907
+ // card row for it was pure repetition.
705
908
  add("files", formatFileBreakdown(info));
706
909
  add("operation", info.operation === void 0 || info.operation === null ? void 0 : String(info.operation));
707
- add("pull request", formatPrDetail(data.pr));
708
910
  // WHICH checkout the badge describes, when it is a worktree. The chip shows
709
911
  // the branch alone, so this row is the only place the tree is named.
710
912
  add("worktree", worktreeDetail(data));
711
913
  // The conversation's OWN directory, present only when the badge is
712
- // describing an inferred worktree — the chip's branch and counts are the
914
+ // describing a followed worktree — the chip's branch and counts are the
713
915
  // tree's in that case, so this is where the checkout's own state stays
714
916
  // visible. Absent until the `detail=1` response lands, like commits/stash.
715
- if (data.worktreeInferred === true) add("checkout", formatCheckoutDetail(data.checkout));
716
- if (Array.isArray(data.lastCommits) && data.lastCommits.length > 0) {
717
- rows.push(
718
- react_jsx_runtime.jsxs("div", {
719
- style: CARD_ROW,
720
- children: [
721
- react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: "commits" }),
722
- react_jsx_runtime.jsx("span", {
723
- style: { ...CARD_VALUE, display: "flex", flexDirection: "column", gap: "2px" },
724
- children: data.lastCommits.map((commit) =>
725
- react_jsx_runtime.jsxs("span", {
726
- children: [
727
- react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: commit.hash }),
728
- " " + commit.subject + (commit.when === "" ? "" : " \u00B7 " + commit.when)
729
- ]
730
- }, commit.hash)
731
- )
732
- })
733
- ]
734
- }, "commits")
735
- );
736
- }
917
+ if (data.worktreeFollowed === true) add("checkout", formatCheckoutDetail(data.checkout));
737
918
  add("stash", data.stashCount === void 0 ? void 0 : data.stashCount + " stashed");
738
919
  return react_jsx_runtime.jsx("div", { style: CARD_CONTAINER, children: rows });
739
920
  }
@@ -786,6 +967,11 @@ window.__ModuleLoader__.load({
786
967
  // re-render that no fetch will schedule, so the × bumps a counter here.
787
968
  const [, bumpNotice] = react.useState(0);
788
969
  const seamNotice = seamHint !== null && !seamNoticeDismissed ? seamHint : null;
970
+ // the branch link's hover underline — the PR token's pattern: the
971
+ // affordance appears on hover/focus, never at rest. Its OWN state, not
972
+ // the PR token's linkHover: two links sharing one flag would
973
+ // underline both when either is hovered.
974
+ const [branchLinkHover, setBranchLinkHover] = react.useState(false);
789
975
  const detail = useGitStatus(target, { pr: true, detail: true, enabled: hovered });
790
976
  if (info === void 0 || info.git !== true) return null;
791
977
 
@@ -803,7 +989,8 @@ window.__ModuleLoader__.load({
803
989
  if (Tooltip === void 0) return mark;
804
990
  return react_jsx_runtime.jsx(Tooltip, {
805
991
  side: "top",
806
- maxWidth: 360,
992
+ // matches the body cap: the tooltip must never be the clamp
993
+ maxWidth: 600,
807
994
  // a function label keeps the card's element tree out of every render
808
995
  // until the tooltip actually opens
809
996
  label: () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
@@ -813,7 +1000,74 @@ window.__ModuleLoader__.load({
813
1000
  // the mark is an element now rather than a leading glyph in the string, so
814
1001
  // the SAME StatusMark the sidebar row draws carries the status here too;
815
1002
  // the container's 4px gap supplies the space the emoji's own did
816
- const text = info.branch + formatOperationToken(info) + formatGitSuffix(info);
1003
+ // The branch NAME is its own hover surface: the commits this branch adds
1004
+ // (BranchCommitsList), sharing the mark's lazy detail fetch. The rest of
1005
+ // 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.
1008
+ const branchHover = (() => {
1009
+ // The branch name links to the compare view (base...branch) — the web
1010
+ // page of exactly what the lineage tooltip lists. Vouched http(s)
1011
+ // only (the node half rebuilt the URL from parsed parts); new tab,
1012
+ // never navigating the conversation away.
1013
+ const isLink = typeof detail?.compareUrl === "string" && detail.compareUrl.startsWith("https://");
1014
+ const branch = isLink
1015
+ ? react_jsx_runtime.jsx("a", {
1016
+ href: detail.compareUrl,
1017
+ target: "_blank",
1018
+ rel: "noopener noreferrer",
1019
+ "aria-label": "Compare " + info.branch + " with main on GitHub",
1020
+ onPointerEnter: () => { setHovered(true); setBranchLinkHover(true); },
1021
+ onPointerLeave: () => setBranchLinkHover(false),
1022
+ onFocus: () => { setHovered(true); setBranchLinkHover(true); },
1023
+ onBlur: () => setBranchLinkHover(false),
1024
+ style: {
1025
+ color: "inherit",
1026
+ textDecoration: branchLinkHover ? "underline" : "none",
1027
+ cursor: "pointer"
1028
+ },
1029
+ children: info.branch
1030
+ })
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
1046
+ });
1047
+ })();
1048
+ const text = formatOperationToken(info);
1049
+ // The ✎n count is its OWN hover surface: a names-only tooltip (see
1050
+ // UntrackedList). It shares the mark's lazy detail fetch — resting on
1051
+ // either triggers it once — and degrades to a plain count on a shell
1052
+ // without the Tooltip primitive.
1053
+ const countHover = (() => {
1054
+ const count = react_jsx_runtime.jsx("span", {
1055
+ onPointerEnter: () => setHovered(true),
1056
+ 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
1069
+ });
1070
+ })();
817
1071
  const prToken = formatPrToken(info);
818
1072
  const prUrl = prLinkUrl(info);
819
1073
  const chip = react_jsx_runtime.jsxs("span", {
@@ -846,7 +1100,9 @@ window.__ModuleLoader__.load({
846
1100
  // stays on the whole chip — it is about the badge, not the mark.
847
1101
  hoverableMark,
848
1102
 
1103
+ branchHover,
849
1104
  react_jsx_runtime.jsx("span", { key: "text", children: text }),
1105
+ countHover,
850
1106
  prToken === ""
851
1107
  ? null
852
1108
  : prUrl === void 0
@@ -959,6 +1215,162 @@ window.__ModuleLoader__.load({
959
1215
  // `workspaces` is no longer required: both surfaces target an id and the
960
1216
  // node half resolves the workspace, so the client never needs a service
961
1217
  // lookup. Fewer declared services also means fewer ways to fail to load.
1218
+ /** The built-in ranking order — what "Reset to default" restores. */
1219
+ const DEFAULT_ORDER = ["operation", "unmerged", "sync", "publish", "merge", "commit", "checks"];
1220
+
1221
+ /**
1222
+ * Move one category within the ranking order — the card's only reorder
1223
+ * primitive, pure so the suite can drive it without a browser. Out-of-range
1224
+ * moves are no-ops (the buttons disable at the ends, this is the belt).
1225
+ */
1226
+ function moveCategory(order, index, delta) {
1227
+ const next = [...order];
1228
+ const to = index + delta;
1229
+ if (index < 0 || index >= next.length || to < 0 || to >= next.length) return next;
1230
+ const [moved] = next.splice(index, 1);
1231
+ next.splice(to, 0, moved);
1232
+ return next;
1233
+ }
1234
+
1235
+ /** Human labels for the ranking categories, in default order. */
1236
+ const CATEGORY_LABELS = {
1237
+ operation: ["operation", "resume a paused merge/rebase"],
1238
+ unmerged: ["unmerged", "resolve conflict markers"],
1239
+ sync: ["sync", "pull, or rebase + confirmed force-push"],
1240
+ publish: ["publish", "push local commits"],
1241
+ merge: ["merge", "a merge-ready pull request"],
1242
+ commit: ["commit", "stage and commit working changes"],
1243
+ checks: ["checks", "watch failing CI"]
1244
+ };
1245
+
1246
+ /**
1247
+ * The settings card's body — Settings → Plugins → Git Badge. Reads the
1248
+ * bound settings scope through the store the registration injects and
1249
+ * writes back one field ("order") via the scope's mutate path. Styled like
1250
+ * this plugin's other surfaces rather than the host's card primitives,
1251
+ * which are not exported to plugins.
1252
+ */
1253
+ function GitBadgeOrderCard(props) {
1254
+ const state = props.useGitBadgeOrderCard((snapshot) => snapshot);
1255
+ const t = props.t;
1256
+ // Local expander state, mirroring the host's plugin cards: collapsed by
1257
+ // default, and auto-collapsed again once a save settles clean (so the
1258
+ // card confirms itself the way its siblings do).
1259
+ const [open, setOpen] = react.useState(false);
1260
+ const saveStarted = react.useRef(false);
1261
+ react.useEffect(() => {
1262
+ if (state.saving) {
1263
+ saveStarted.current = true;
1264
+ return;
1265
+ }
1266
+ if (!saveStarted.current) return;
1267
+ saveStarted.current = false;
1268
+ if (!state.dirty && !state.failed) setOpen(false);
1269
+ }, [state.dirty, state.failed, state.saving]);
1270
+ if (!state.available) return null;
1271
+ const rows = state.order.map((cat, index) => {
1272
+ const [label, hint] = CATEGORY_LABELS[cat] ?? [cat, ""];
1273
+ return react_jsx_runtime.jsxs("div", {
1274
+ style: { display: "flex", alignItems: "center", gap: "8px", padding: "2px 0" },
1275
+ children: [
1276
+ react_jsx_runtime.jsx("span", {
1277
+ style: { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", fontFamily: "monospace", minWidth: "16px", textAlign: "right" },
1278
+ children: String(index + 1)
1279
+ }),
1280
+ react_jsx_runtime.jsx("span", { style: { minWidth: "84px" }, children: label }),
1281
+ react_jsx_runtime.jsx("span", { style: { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "1", minWidth: 0 }, children: hint }),
1282
+ react_jsx_runtime.jsx("button", {
1283
+ type: "button",
1284
+ "aria-label": t("moveUp") + " " + label,
1285
+ disabled: state.saving || !state.writable || index === 0,
1286
+ onClick: () => props.move(index, -1),
1287
+ style: CARD_BUTTON,
1288
+ children: "\u2191"
1289
+ }),
1290
+ react_jsx_runtime.jsx("button", {
1291
+ type: "button",
1292
+ "aria-label": t("moveDown") + " " + label,
1293
+ disabled: state.saving || !state.writable || index === state.order.length - 1,
1294
+ onClick: () => props.move(index, 1),
1295
+ style: CARD_BUTTON,
1296
+ children: "\u2193"
1297
+ })
1298
+ ]
1299
+ }, cat);
1300
+ });
1301
+ // The card chrome matches the host's own plugin cards (its CSS module is
1302
+ // not exported to plugins, so the values are reproduced inline): an <li>
1303
+ // with the bordered card, a header button that expands, and a body that
1304
+ // carries the list and the footer actions.
1305
+ return react_jsx_runtime.jsxs("li", {
1306
+ style: open ? CARD_OPEN : CARD_CLOSED,
1307
+ children: [
1308
+ react_jsx_runtime.jsxs("button", {
1309
+ type: "button",
1310
+ "aria-expanded": open,
1311
+ "aria-label": t(open ? "collapse" : "expand") + ": " + t("title"),
1312
+ onClick: () => setOpen(!open),
1313
+ style: CARD_HEADER,
1314
+ children: [
1315
+ react_jsx_runtime.jsxs("span", {
1316
+ style: { display: "flex", flexDirection: "column", flex: "1", gap: "4px", minWidth: 0 },
1317
+ children: [
1318
+ react_jsx_runtime.jsx("span", { style: CARD_NAME, children: t("title") }),
1319
+ react_jsx_runtime.jsx("span", { style: CARD_DESCRIPTION, children: t("description") })
1320
+ ]
1321
+ }),
1322
+ react_jsx_runtime.jsx("span", {
1323
+ "aria-hidden": true,
1324
+ style: open
1325
+ ? { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", transform: "rotate(180deg)", transition: "transform .16s" }
1326
+ : { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", transition: "transform .16s" },
1327
+ children: (primitives !== null && primitives.IconChevronDownOutline14 !== void 0
1328
+ ? react_jsx_runtime.jsx(primitives.IconChevronDownOutline14, {})
1329
+ : "\u25BE")
1330
+ })
1331
+ ]
1332
+ }),
1333
+ open
1334
+ ? react_jsx_runtime.jsxs("div", {
1335
+ style: { borderTop: ".5px solid var(--dsw-alias-border-l2, #e4e8eb)", margin: "0 16px", paddingBottom: "8px" },
1336
+ children: [
1337
+ react_jsx_runtime.jsx("div", { style: { padding: "10px 0 6px" }, children: t("intro") }),
1338
+ react_jsx_runtime.jsx("div", { style: { display: "flex", flexDirection: "column" }, children: rows }),
1339
+ react_jsx_runtime.jsxs("div", {
1340
+ style: { display: "flex", gap: "8px", alignItems: "center", justifyContent: "flex-end", padding: "12px 0 4px" },
1341
+ children: [
1342
+ state.failed ? react_jsx_runtime.jsx("span", { style: { color: "var(--dsw-alias-label-error, #d13438)", flex: "1", fontSize: "12px" }, children: t("failed") }) : null,
1343
+ state.writable ? null : react_jsx_runtime.jsx("span", { style: { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "1", fontSize: "12px" }, children: t("readOnly") }),
1344
+ react_jsx_runtime.jsx("button", {
1345
+ type: "button",
1346
+ disabled: !state.dirty || state.saving,
1347
+ onClick: props.discard,
1348
+ style: CARD_BUTTON,
1349
+ children: t("discard")
1350
+ }),
1351
+ react_jsx_runtime.jsx("button", {
1352
+ type: "button",
1353
+ disabled: !state.dirty || state.saving || !state.writable,
1354
+ onClick: props.save,
1355
+ style: CARD_BUTTON,
1356
+ children: state.saving ? t("saving") : t("save")
1357
+ }),
1358
+ react_jsx_runtime.jsx("button", {
1359
+ type: "button",
1360
+ disabled: state.saving || !state.writable,
1361
+ onClick: props.reset,
1362
+ style: CARD_BUTTON,
1363
+ children: t("reset")
1364
+ })
1365
+ ]
1366
+ })
1367
+ ]
1368
+ })
1369
+ : null
1370
+ ]
1371
+ });
1372
+ }
1373
+
962
1374
  const inject = ["slots", "inputTriggers"];
963
1375
 
964
1376
  /**
@@ -1054,6 +1466,126 @@ window.__ModuleLoader__.load({
1054
1466
  });
1055
1467
  }
1056
1468
 
1469
+ // The settings card (Settings → Plugins → Git Badge): the order lives
1470
+ // in this plugin's `git-badge` settings namespace, which the node half
1471
+ // registers; this half renders the card that edits it through the bound
1472
+ // scope — reads from the scope snapshot, writes one field via mutate.
1473
+ // Guarded: a host without settingsScope loses the card, never the
1474
+ // badges.
1475
+ if (typeof ctx.inject === "function") {
1476
+ try {
1477
+ ctx.inject(["settingsScope", "slots", "locale"], (scopeCtx) => {
1478
+ const NS = "dsh-git-badge";
1479
+ scopeCtx.effect(() => scopeCtx.locale.register(NS, {
1480
+ en: {
1481
+ title: "Git Badge",
1482
+ description: "Which action class the badge suggests first.",
1483
+ expand: "Expand",
1484
+ collapse: "Collapse",
1485
+ intro: "The badge suggests one next action; this is the order it considers the classes in.",
1486
+ save: "Save",
1487
+ discard: "Discard",
1488
+ reset: "Reset to default",
1489
+ saving: "Saving\u2026",
1490
+ failed: "save failed \u2014 try again",
1491
+ readOnly: "read-only on this host",
1492
+ moveUp: "Move up",
1493
+ moveDown: "Move down"
1494
+ }
1495
+ }), "dsh-git-badge: settings locale");
1496
+ const scope = scopeCtx.settingsScope.bind({ namespace: "git-badge" });
1497
+ // draft === null means "mirror the stored order"; any reorder
1498
+ // stages a copy, and only a confirmed write clears it — a
1499
+ // rejected write leaves the draft for a retry rather than
1500
+ // pretending it saved.
1501
+ let draft = null;
1502
+ let saving = false;
1503
+ let failed = false;
1504
+ const subscribers = new Set();
1505
+ let snapshot = null;
1506
+ const storedOrder = () => {
1507
+ const view = scope.getSnapshot();
1508
+ return Array.isArray(view?.value?.order) ? view.value.order : DEFAULT_ORDER;
1509
+ };
1510
+ const build = () => {
1511
+ const view = scope.getSnapshot();
1512
+ const stored = storedOrder();
1513
+ const order = draft ?? stored;
1514
+ return {
1515
+ available: view?.status === "ready",
1516
+ writable: view?.writable === true,
1517
+ order,
1518
+ dirty: draft !== null && draft.join() !== stored.join(),
1519
+ saving,
1520
+ failed
1521
+ };
1522
+ };
1523
+ const publish = () => {
1524
+ snapshot = build();
1525
+ for (const listener of subscribers) listener();
1526
+ };
1527
+ scopeCtx.effect(() => {
1528
+ const off = scope.subscribe(publish);
1529
+ return () => {
1530
+ if (typeof off === "function") off();
1531
+ };
1532
+ }, "dsh-git-badge: settings scope");
1533
+ publish();
1534
+ const store = {
1535
+ getSnapshot: () => snapshot ?? build(),
1536
+ subscribe: (listener) => {
1537
+ subscribers.add(listener);
1538
+ return () => subscribers.delete(listener);
1539
+ }
1540
+ };
1541
+ const save = async () => {
1542
+ if (draft === null || saving) return;
1543
+ const wanted = [...draft];
1544
+ saving = true;
1545
+ failed = false;
1546
+ publish();
1547
+ try {
1548
+ await scope.mutate([{ op: "set", path: ["order"], value: wanted }]);
1549
+ } catch (error) {
1550
+ console.error("[dsh-git-badge] order save failed:", error);
1551
+ }
1552
+ saving = false;
1553
+ if (storedOrder().join() === wanted.join()) draft = null;
1554
+ else failed = true;
1555
+ publish();
1556
+ };
1557
+ scopeCtx.slots.inject("settings.plugin.item", function* () {
1558
+ yield scopeCtx.slots.register({
1559
+ name: "settings.plugin.item",
1560
+ key: "git-badge",
1561
+ locale: NS,
1562
+ inject: () => ({
1563
+ hooks: { gitBadgeOrderCard: store },
1564
+ move: (index, delta) => {
1565
+ draft = moveCategory(draft ?? [...storedOrder()], index, delta);
1566
+ failed = false;
1567
+ publish();
1568
+ },
1569
+ save: () => void save(),
1570
+ discard: () => {
1571
+ draft = null;
1572
+ failed = false;
1573
+ publish();
1574
+ },
1575
+ reset: () => {
1576
+ draft = [...DEFAULT_ORDER];
1577
+ failed = false;
1578
+ publish();
1579
+ }
1580
+ })
1581
+ }, GitBadgeOrderCard);
1582
+ });
1583
+ });
1584
+ } catch (error) {
1585
+ console.error("[dsh-git-badge] settings card unavailable:", error);
1586
+ }
1587
+ }
1588
+
1057
1589
  // inject() re-evaluates when a seam's declaration appears, so boot
1058
1590
  // order relative to the workspace browser does not matter. On an
1059
1591
  // unpatched install the seam is never declared, so this callback never
@@ -1135,7 +1667,7 @@ window.__ModuleLoader__.load({
1135
1667
  // Additive; the host reads apply/inject and ignores the rest. The suite
1136
1668
  // drives these to assert the REQUEST contract — which surface asks for the
1137
1669
  // expensive extras — without a browser, a fetch or a network.
1138
- exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, ghSkillActions };
1670
+ exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, ghSkillActions, moveCategory };
1139
1671
  return module.exports;
1140
1672
  }
1141
1673
  });