@kud/gh-ink 0.1.0 → 0.3.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +204 -3
  2. package/dist/index.js +1783 -13
  3. package/package.json +4 -3
package/dist/index.js CHANGED
@@ -1,8 +1,13 @@
1
- import React2, { useState } from 'react';
1
+ import React2, { useState, useRef, useEffect } from 'react';
2
2
  import { useWindowSize, useInput, Text, Box } from 'ink';
3
- import { colors, ScrollView, TextInput } from '@kud/ink-ui';
3
+ import { colors, ScrollView, TextInput, LoadingScreen, Switch, Tabs, FooterHints } from '@kud/ink-ui';
4
4
  import { isPassCheck, isFailCheck, resolveThread, unresolveThread, replyToThread, rerunFailedRun, mergePr, reRequestReviewer } from '@kud/gh';
5
5
  import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
6
+ import { $ } from 'zx';
7
+ import { spawn } from 'child_process';
8
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { homedir } from 'os';
6
11
 
7
12
  // src/components/comments-panel.tsx
8
13
  var ENTITIES = {
@@ -268,7 +273,8 @@ var CommentsPanel = ({
268
273
  data,
269
274
  error,
270
275
  reload,
271
- onReplyingChange
276
+ onReplyingChange,
277
+ showConversationHeading = true
272
278
  }) => {
273
279
  const [showResolved, setShowResolved] = useState(true);
274
280
  const [threadSel, setThreadSel] = useState(0);
@@ -353,14 +359,16 @@ var CommentsPanel = ({
353
359
  { text: "" }
354
360
  );
355
361
  if (conversation.length === 0 && allThreads.length === 0)
356
- lines.push({ text: "No comments on this PR.", dim: true });
362
+ lines.push({ text: "Nothing has been said on it yet.", dim: true });
357
363
  if (conversation.length > 0) {
358
- lines.push({
359
- text: `Conversation (${conversation.length})`,
360
- color: colors.info,
361
- bold: true
362
- });
363
- lines.push({ text: "" });
364
+ if (showConversationHeading) {
365
+ lines.push({
366
+ text: `Conversation (${conversation.length})`,
367
+ color: colors.info,
368
+ bold: true
369
+ });
370
+ lines.push({ text: "" });
371
+ }
364
372
  for (const c of conversation)
365
373
  lines.push(...commentLines(c, width, fileLink));
366
374
  }
@@ -550,7 +558,7 @@ var HealthPanel = ({
550
558
  i === safeCursor ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: " \u21B5 open" }) : null
551
559
  ] }, checkLabel(c) + i);
552
560
  }) }),
553
- /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Summary, { label: "Reviews", icon: reviewsIcon, parts: reviewParts }) }),
561
+ /* @__PURE__ */ jsx(Box, { marginTop: checks.length ? 1 : 0, children: /* @__PURE__ */ jsx(Summary, { label: "Reviews", icon: reviewsIcon, parts: reviewParts }) }),
554
562
  reviewers.map(([login, state], j) => {
555
563
  const [icon, color] = reviewIcon(state);
556
564
  const active = safeCursor === checks.length + j;
@@ -561,7 +569,7 @@ var HealthPanel = ({
561
569
  active ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: " \u21B5 re-request" }) : null
562
570
  ] }, login);
563
571
  }),
564
- /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Summary, { label: "Merge", icon: mergeIcon, parts: [[mText, mColor]] }) }),
572
+ /* @__PURE__ */ jsx(Box, { marginTop: reviewers.length ? 1 : 0, children: /* @__PURE__ */ jsx(Summary, { label: "Merge", icon: mergeIcon, parts: [[mText, mColor]] }) }),
565
573
  confirm === "merge" ? /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsxs(Text, { color: colors.warning, children: [
566
574
  ` Merge #${number}? `,
567
575
  /* @__PURE__ */ jsx(Text, { color: colors.success, children: "y" }),
@@ -596,5 +604,1767 @@ var healthLegend = [
596
604
  ["merged", "Merged"],
597
605
  ["closed", "Closed"]
598
606
  ];
607
+ var cacheDir = () => join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "ambre");
608
+ var cacheFile = (key) => join(cacheDir(), `${key.replace(/[^a-z0-9._-]/gi, "-")}.json`);
609
+ var readCache = (key) => {
610
+ try {
611
+ const raw = JSON.parse(readFileSync(cacheFile(key), "utf8"));
612
+ if (!Array.isArray(raw?.sections)) return null;
613
+ return { sections: raw.sections, login: raw.login ?? "", at: raw.at ?? 0 };
614
+ } catch {
615
+ return null;
616
+ }
617
+ };
618
+ var writeCache = (key, data) => {
619
+ try {
620
+ mkdirSync(cacheDir(), { recursive: true });
621
+ writeFileSync(cacheFile(key), JSON.stringify({ ...data, at: Date.now() }));
622
+ } catch {
623
+ }
624
+ };
625
+ var relativeTime = (iso) => {
626
+ const diff = (Date.now() - new Date(iso).getTime()) / 1e3;
627
+ if (diff < 3600) return `${Math.floor(diff / 60)}m`;
628
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
629
+ if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
630
+ return `${Math.floor(diff / 604800)}w`;
631
+ };
632
+ var healthSentence = (item) => {
633
+ const glyph = healthDisplay[item.health].glyph.trim();
634
+ const d = item.detail;
635
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
636
+ switch (item.health) {
637
+ case "merged":
638
+ return `Merged (${glyph}). Nothing left to do.`;
639
+ case "closed":
640
+ return `Closed (${glyph}) without merging.`;
641
+ case "draft":
642
+ return `Still a draft (${glyph}), so no review is being asked for yet.`;
643
+ case "ci-fail":
644
+ return `CI is failing (${glyph}) \u2014 ${plural(d?.checksFail ?? 0, "check")} red.`;
645
+ case "conflict":
646
+ return `It conflicts with the base branch (${glyph}) and cannot merge until that is resolved.`;
647
+ case "changes-req":
648
+ return `Changes were requested (${glyph}).`;
649
+ case "threads":
650
+ return `${plural(item.unresolved, "review thread")} still open (${glyph}).`;
651
+ case "pending":
652
+ return `${plural(d?.checksPending ?? 0, "check")} still running (${glyph}).`;
653
+ case "approved":
654
+ return `Approved (${glyph}) and ready to merge.`;
655
+ case "waiting":
656
+ return `Nobody has reviewed it yet (${glyph}).`;
657
+ default:
658
+ return "An open issue \u2014 no review state applies.";
659
+ }
660
+ };
661
+ var checksSentence = (d) => {
662
+ if (!d) return null;
663
+ const total = d.checksPass + d.checksFail + d.checksPending;
664
+ if (total === 0) return "No CI checks run on it.";
665
+ const parts = [];
666
+ if (d.checksPass) parts.push(`${d.checksPass} passing`);
667
+ if (d.checksFail) parts.push(`${d.checksFail} failing`);
668
+ if (d.checksPending) parts.push(`${d.checksPending} running`);
669
+ return `Checks: ${parts.join(", ")}.`;
670
+ };
671
+ var turnSentences = (item, login) => {
672
+ if (!item.lastActor) return ["Nothing has been said on it yet."];
673
+ const d = item.detail;
674
+ const when = d?.lastEventAt ? `${relativeTime(d.lastEventAt)} ago` : "earlier";
675
+ if (item.lastActor !== login)
676
+ return [`${item.lastActor} spoke last (\u2190), ${when}. Your reply is owed.`];
677
+ const them = item.author && item.author !== login ? item.author : null;
678
+ if (!them)
679
+ return [
680
+ `You spoke last (\u2192), ${when}. It is waiting on a reviewer, not on you.`
681
+ ];
682
+ const lines = [`You spoke last (\u2192), ${when}. The ball is with ${them}.`];
683
+ if (d?.lastCommitAt && d.lastEventAt && d.lastCommitAt < d.lastEventAt)
684
+ lines.push(
685
+ `Nothing has been pushed since ${relativeTime(d.lastCommitAt)} ago, so it is stalled on ${them}, not on you.`
686
+ );
687
+ return lines;
688
+ };
689
+ var explainItem = (item, login) => {
690
+ const author = item.author && item.author !== login ? item.author : "you";
691
+ const kind = item.kind === "pr" ? "pull request" : "issue";
692
+ const stands = [healthSentence(item)];
693
+ const checks = item.kind === "pr" ? checksSentence(item.detail) : null;
694
+ if (checks) stands.push(checks);
695
+ if (item.conversation > 0)
696
+ stands.push(
697
+ `${item.conversation} comment${item.conversation === 1 ? "" : "s"} across the conversation and its threads.`
698
+ );
699
+ return [
700
+ {
701
+ heading: "What it is",
702
+ lines: [
703
+ `A ${kind} on ${item.repo}, opened by ${author} ${item.age} ago.`
704
+ ]
705
+ },
706
+ { heading: "Where it stands", lines: stands },
707
+ { heading: "Whose turn", lines: turnSentences(item, login) }
708
+ ];
709
+ };
710
+ var repoPriority = (repo) => {
711
+ const profile = process.env.OS_PROFILE ?? "";
712
+ if (profile === "work") {
713
+ if (repo === "theorchard/orchardgo") return 0;
714
+ if (repo.startsWith("theorchard/")) return 1;
715
+ if (repo.startsWith("kud/")) return 2;
716
+ return 3;
717
+ }
718
+ return repo.startsWith("kud/") ? 0 : 1;
719
+ };
720
+ var sortItems = (items) => [...items].sort((a, b) => {
721
+ const pd = repoPriority(a.repo) - repoPriority(b.repo);
722
+ return pd !== 0 ? pd : a.repo.localeCompare(b.repo);
723
+ });
724
+ var sortByRecency = (items) => [...items].sort((a, b) => b.ts - a.ts);
725
+ var insertRepoHeaders = (items) => {
726
+ const result = [];
727
+ let lastRepo = "";
728
+ for (const item of items) {
729
+ if (!item.indent && item.repo !== lastRepo) {
730
+ lastRepo = item.repo;
731
+ result.push({
732
+ kind: "repo-header",
733
+ repo: item.repo,
734
+ age: "",
735
+ indent: false
736
+ });
737
+ }
738
+ result.push(item);
739
+ }
740
+ return result;
741
+ };
742
+ var layoutGHItems = (items, sectionId) => insertRepoHeaders(
743
+ sectionId === "done" ? sortByRecency(items) : sortItems(items)
744
+ );
745
+ var filterByOrigin = (sections, keep, isWorkRepo) => sections.map((s) => {
746
+ const kept = s.items.filter(
747
+ (i) => i.kind !== "repo-header" && i.kind !== "subgroup-header" && i.kind !== "show-more" && i.kind !== "show-less" && (i.kind === "pr" || i.kind === "issue" ? keep === "work" ? isWorkRepo(i.repo) : !isWorkRepo(i.repo) : true)
748
+ );
749
+ const gh = kept.filter(
750
+ (i) => i.kind === "pr" || i.kind === "issue"
751
+ );
752
+ const other = kept.filter((i) => i.kind !== "pr" && i.kind !== "issue");
753
+ return { ...s, items: [...layoutGHItems(gh, s.id), ...other] };
754
+ }).filter(
755
+ (s) => s.items.some(
756
+ (i) => i.kind !== "repo-header" && i.kind !== "subgroup-header"
757
+ )
758
+ );
759
+ var searchText = (i) => i.kind === "pr" || i.kind === "issue" ? `${i.title} ${i.repo} #${i.number}` : i.kind === "jira" ? `${i.summary} ${i.key}` : "";
760
+ var filterBySearch = (sections, query) => {
761
+ const q = query.trim().toLowerCase();
762
+ if (!q) return sections;
763
+ return sections.map((s) => {
764
+ const kept = s.items.filter(
765
+ (i) => i.kind !== "repo-header" && i.kind !== "subgroup-header" && i.kind !== "show-more" && i.kind !== "show-less" && searchText(i).toLowerCase().includes(q)
766
+ );
767
+ const gh = kept.filter(
768
+ (i) => i.kind === "pr" || i.kind === "issue"
769
+ );
770
+ const other = kept.filter((i) => i.kind !== "pr" && i.kind !== "issue");
771
+ return { ...s, items: [...layoutGHItems(gh, s.id), ...other] };
772
+ }).filter(
773
+ (s) => s.items.some(
774
+ (i) => i.kind !== "repo-header" && i.kind !== "subgroup-header"
775
+ )
776
+ );
777
+ };
778
+ var filterByRepos = (sections, repos) => {
779
+ if (repos.size === 0) return sections;
780
+ return sections.map((s) => {
781
+ const kept = s.items.filter(
782
+ (i) => i.kind !== "repo-header" && i.kind !== "subgroup-header" && i.kind !== "show-more" && i.kind !== "show-less" && (i.kind === "pr" || i.kind === "issue" ? repos.has(i.repo) : true)
783
+ );
784
+ const gh = kept.filter(
785
+ (i) => i.kind === "pr" || i.kind === "issue"
786
+ );
787
+ const other = kept.filter((i) => i.kind !== "pr" && i.kind !== "issue");
788
+ return { ...s, items: [...layoutGHItems(gh, s.id), ...other] };
789
+ }).filter(
790
+ (s) => s.items.some(
791
+ (i) => i.kind !== "repo-header" && i.kind !== "subgroup-header"
792
+ )
793
+ );
794
+ };
795
+ var isHeader = (i) => i.kind === "repo-header" || i.kind === "subgroup-header";
796
+ var headerOwnsContent = (items, idx) => {
797
+ const kind = items[idx].kind;
798
+ for (let i = idx + 1; i < items.length; i++) {
799
+ const next = items[i];
800
+ if (!isHeader(next)) return true;
801
+ if (kind === "repo-header" || next.kind === "subgroup-header") return false;
802
+ }
803
+ return false;
804
+ };
805
+ var withoutItem = (sections, target) => sections.map((s) => {
806
+ const kept = s.items.filter(
807
+ (i) => isHeader(i) || !(i.kind === target.kind && i.number === target.number && i.repo === target.repo)
808
+ );
809
+ return {
810
+ ...s,
811
+ items: kept.filter(
812
+ (item, idx) => !isHeader(item) || headerOwnsContent(kept, idx)
813
+ )
814
+ };
815
+ }).filter((s) => s.items.some((i) => !isHeader(i)));
816
+ var reposInSections = (sections) => [
817
+ ...new Set(
818
+ sections.flatMap((s) => s.items).filter((i) => i.kind === "pr" || i.kind === "issue").map((i) => i.repo)
819
+ )
820
+ ].sort();
821
+ var moveCursor = (items, current, dir) => {
822
+ let next = current + dir;
823
+ while (next >= 0 && next < items.length && (items[next].kind === "repo-header" || items[next].kind === "subgroup-header"))
824
+ next += dir;
825
+ if (next < 0 || next >= items.length) return current;
826
+ return next;
827
+ };
828
+ var itemLines = (item, isFirst) => (item.kind === "repo-header" || item.kind === "subgroup-header") && !isFirst ? 2 : 1;
829
+ var fitCount = (items, start, budget) => {
830
+ let lines = 0;
831
+ let count = 0;
832
+ for (let i = start; i < items.length; i++) {
833
+ const cost = itemLines(items[i], i === start);
834
+ if (lines + cost > budget) break;
835
+ lines += cost;
836
+ count++;
837
+ }
838
+ return count;
839
+ };
840
+ var windowCount = (items, start, budget) => {
841
+ const raw = fitCount(items, start, budget);
842
+ return start + raw < items.length ? fitCount(items, start, budget - 1) : raw;
843
+ };
844
+ var maxViewStart = (items, budget) => {
845
+ let start = 0;
846
+ while (start < items.length - 1 && start + windowCount(items, start, budget) < items.length)
847
+ start++;
848
+ return start;
849
+ };
850
+ var withHeaders = (items, idx) => {
851
+ let start = idx;
852
+ while (start > 0 && (items[start - 1].kind === "repo-header" || items[start - 1].kind === "subgroup-header"))
853
+ start--;
854
+ return start;
855
+ };
856
+ var truncate = (str, max) => {
857
+ if (str.length <= max) return str;
858
+ const half = Math.floor((max - 1) / 2);
859
+ return `${str.slice(0, half)}\u2026${str.slice(-half)}`;
860
+ };
861
+ var clipboard = (text) => {
862
+ const p = spawn("pbcopy", [], { stdio: "pipe" });
863
+ p.stdin.write(text);
864
+ p.stdin.end();
865
+ };
866
+ var buildCheckoutCmd = async (repoFull, branch, login) => {
867
+ const [repoOwner, repoName] = repoFull.split("/");
868
+ const projects = process.env.PROJECTS_DIR ?? `${process.env.HOME}/Projects`;
869
+ const profile = process.env.OS_PROFILE ?? "";
870
+ const isWorkRepo = profile === "work" && (repoFull.startsWith("theorchard/") || repoFull.startsWith("kud/") && (repoName ?? "").startsWith("theorchard-"));
871
+ const cloneBase = profile === "work" ? `${projects}/${isWorkRepo ? "work" : "home"}` : projects;
872
+ const searchDirs = profile === "work" ? [`${projects}/work`, `${projects}/home`] : [projects];
873
+ let repoPath = "";
874
+ outer: for (const searchDir of searchDirs) {
875
+ if (!existsSync(searchDir)) continue;
876
+ let entries;
877
+ try {
878
+ entries = readdirSync(searchDir);
879
+ } catch {
880
+ continue;
881
+ }
882
+ for (const entry of entries) {
883
+ const fullPath = join(searchDir, entry);
884
+ try {
885
+ if (!statSync(fullPath).isDirectory()) continue;
886
+ } catch {
887
+ continue;
888
+ }
889
+ for (const remote of ["origin", "upstream"]) {
890
+ const r = await $({
891
+ nothrow: true,
892
+ quiet: true
893
+ })`git -C ${fullPath} remote get-url ${remote}`;
894
+ if (r.exitCode === 0 && r.stdout.includes(repoFull)) {
895
+ repoPath = fullPath;
896
+ break outer;
897
+ }
898
+ }
899
+ }
900
+ }
901
+ if (!repoPath) {
902
+ const candidate = `${cloneBase}/${repoOwner}-${repoName}`;
903
+ if (existsSync(candidate)) repoPath = candidate;
904
+ }
905
+ let cmd;
906
+ if (repoPath) {
907
+ cmd = `cd ${repoPath}`;
908
+ } else if (repoOwner === login) {
909
+ cmd = `cd ${cloneBase} && gh repo clone ${repoFull} && cd ${repoName}`;
910
+ } else {
911
+ const r = await $({
912
+ nothrow: true,
913
+ quiet: true
914
+ })`gh repo list ${login} --fork --limit 200 --json name,parent --jq ${`.[] | select(.parent.nameWithOwner == "${repoFull}") | .name`}`;
915
+ const forkName = r.stdout.trim();
916
+ if (forkName) {
917
+ cmd = `cd ${cloneBase} && git clone git@github.com:${login}/${forkName}.git && cd ${forkName} && git remote add upstream git@github.com:${repoFull}.git`;
918
+ } else {
919
+ cmd = `cd ${cloneBase} && gh repo fork ${repoFull} --clone && cd $(ls -td -- */ | head -1)`;
920
+ }
921
+ }
922
+ if (branch)
923
+ cmd += ` && git fetch origin ${branch} 2>/dev/null; git switch ${branch}`;
924
+ return cmd;
925
+ };
926
+ var resolveRepoPath = async (repoFull) => {
927
+ const projects = process.env.PROJECTS_DIR ?? `${process.env.HOME}/Projects`;
928
+ const profile = process.env.OS_PROFILE ?? "";
929
+ const searchDirs = profile === "work" ? [`${projects}/work`, `${projects}/home`] : [projects];
930
+ for (const searchDir of searchDirs) {
931
+ if (!existsSync(searchDir)) continue;
932
+ let entries;
933
+ try {
934
+ entries = readdirSync(searchDir);
935
+ } catch {
936
+ continue;
937
+ }
938
+ for (const entry of entries) {
939
+ const fullPath = join(searchDir, entry);
940
+ try {
941
+ if (!statSync(fullPath).isDirectory()) continue;
942
+ } catch {
943
+ continue;
944
+ }
945
+ for (const remote of ["origin", "upstream"]) {
946
+ const r = await $({
947
+ nothrow: true,
948
+ quiet: true
949
+ })`git -C ${fullPath} remote get-url ${remote}`;
950
+ if (r.exitCode === 0 && r.stdout.includes(repoFull)) return fullPath;
951
+ }
952
+ }
953
+ }
954
+ return null;
955
+ };
956
+ var itermRun = async (cmd, appleScript) => {
957
+ await $({
958
+ nothrow: true,
959
+ quiet: true,
960
+ env: { ...process.env, ITERM_CMD: cmd }
961
+ })`osascript -e ${appleScript}`;
962
+ };
963
+ var jumpToRepo = async (repoFull, branch, login) => {
964
+ const cmd = await buildCheckoutCmd(repoFull, branch, login);
965
+ await itermRun(
966
+ cmd,
967
+ `tell application "iTerm2"
968
+ tell current window
969
+ create tab with default profile
970
+ tell current session of current tab
971
+ write text (system attribute "ITERM_CMD")
972
+ end tell
973
+ end tell
974
+ end tell`
975
+ );
976
+ };
977
+ var runInPane = async (cmd) => {
978
+ await itermRun(
979
+ cmd,
980
+ `tell application "iTerm2"
981
+ tell current window
982
+ tell current session of current tab
983
+ set newPane to (split vertically with default profile)
984
+ tell newPane
985
+ write text (system attribute "ITERM_CMD")
986
+ end tell
987
+ end tell
988
+ end tell
989
+ end tell`
990
+ );
991
+ };
992
+ var jumpToRepoPane = async (repoFull, branch, login) => {
993
+ const cmd = await buildCheckoutCmd(repoFull, branch, login);
994
+ await runInPane(cmd);
995
+ };
996
+ var openInTab = async (cmd) => {
997
+ await itermRun(
998
+ cmd,
999
+ `tell application "iTerm2"
1000
+ tell current window
1001
+ create tab with default profile
1002
+ tell current session of current tab
1003
+ write text (system attribute "ITERM_CMD")
1004
+ end tell
1005
+ end tell
1006
+ end tell`
1007
+ );
1008
+ };
1009
+ var runInPaneHorizontal = async (cmd) => {
1010
+ await itermRun(
1011
+ cmd,
1012
+ `tell application "iTerm2"
1013
+ tell current window
1014
+ tell current session of current tab
1015
+ set newPane to (split horizontally with default profile)
1016
+ tell newPane
1017
+ write text (system attribute "ITERM_CMD")
1018
+ end tell
1019
+ end tell
1020
+ end tell
1021
+ end tell`
1022
+ );
1023
+ };
1024
+ var runHere = (cmd) => {
1025
+ const proc = spawn(
1026
+ "osascript",
1027
+ [
1028
+ "-e",
1029
+ `delay 0.5
1030
+ tell application "iTerm2"
1031
+ tell current session of current window
1032
+ write text (system attribute "ITERM_CMD")
1033
+ end tell
1034
+ end tell`
1035
+ ],
1036
+ {
1037
+ detached: true,
1038
+ stdio: "ignore",
1039
+ env: { ...process.env, ITERM_CMD: cmd }
1040
+ }
1041
+ );
1042
+ proc.unref();
1043
+ };
1044
+ var FRAME_COLOR = "gray";
1045
+ var FRAME_PAD_X = 1;
1046
+ var FRAME_CHROME_COLS = 2 + FRAME_PAD_X * 2;
1047
+ var COLS = (process.stdout.columns ?? 120) - FRAME_CHROME_COLS;
1048
+ var topLevelCount = (s) => s.items.filter(
1049
+ (i) => i.kind !== "repo-header" && i.kind !== "subgroup-header" && !i.indent
1050
+ ).length;
1051
+ var drillCmd = (item) => {
1052
+ if (item.kind === "jira") return `jira issue view ${item.key}`;
1053
+ return null;
1054
+ };
1055
+ var drillLabel = (item) => {
1056
+ if (item.kind === "jira") return "View ticket";
1057
+ if (item.kind === "issue") return "View issue";
1058
+ if (item.kind === "pr") return "Open PR";
1059
+ return "Drill in";
1060
+ };
1061
+ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions, onRefresh, onRemove, onOpenView) => {
1062
+ if (item.kind === "repo-header" || item.kind === "subgroup-header" || item.kind === "show-more" || item.kind === "show-less")
1063
+ return [];
1064
+ const open = {
1065
+ label: "Open in browser",
1066
+ hint: "o",
1067
+ run: () => {
1068
+ $`open ${item.url}`.catch(() => {
1069
+ });
1070
+ showFlash("\u2197 Opened in browser");
1071
+ }
1072
+ };
1073
+ const copyUrl = {
1074
+ label: "Copy URL",
1075
+ hint: "c",
1076
+ run: () => {
1077
+ clipboard(item.url);
1078
+ const label = item.kind === "jira" ? item.key : `#${item.number}`;
1079
+ showFlash(`\u2713 Copied URL for ${label}`);
1080
+ }
1081
+ };
1082
+ const drill = drillCmd(item);
1083
+ const mountable = item.kind === "pr" || item.kind === "issue";
1084
+ const drillAction = drill || mountable && onOpenView ? {
1085
+ label: drillLabel(item),
1086
+ hint: "d",
1087
+ run: () => {
1088
+ if (onOpenView?.(item)) return;
1089
+ if (drill) {
1090
+ void runInPane(drill).catch(() => {
1091
+ });
1092
+ showFlash(`\u2197 ${drillLabel(item)}`);
1093
+ }
1094
+ }
1095
+ } : null;
1096
+ if (item.kind === "jira") {
1097
+ const base = drillAction ? [drillAction, open, copyUrl] : [open, copyUrl];
1098
+ if (jiraTransitions && jiraTransitions.length > 0) {
1099
+ base.push({
1100
+ label: "Move status",
1101
+ hint: "t",
1102
+ run: () => {
1103
+ },
1104
+ subActions: jiraTransitions.map(
1105
+ ({ label, state, resolutions }) => resolutions && resolutions.length > 0 ? {
1106
+ label,
1107
+ hint: "",
1108
+ run: () => {
1109
+ },
1110
+ subActions: resolutions.map((resolution) => ({
1111
+ label: resolution,
1112
+ hint: "",
1113
+ run: () => {
1114
+ showFlash(`\u22EF ${label} \xB7 ${resolution}\u2026`);
1115
+ void $`jira issue move ${item.key} ${state} --resolution ${resolution}`.then(() => {
1116
+ showFlash(`\u2713 ${label} \xB7 ${resolution}`);
1117
+ setTimeout(() => onRefresh?.(), 1500);
1118
+ }).catch(() => showFlash(`\u2717 Move to ${label} failed`));
1119
+ }
1120
+ }))
1121
+ } : {
1122
+ label,
1123
+ hint: "",
1124
+ run: () => {
1125
+ showFlash(`\u22EF Moving to ${label}\u2026`);
1126
+ void $`jira issue move ${item.key} ${state}`.then(() => {
1127
+ showFlash(`\u2713 Moved to ${label}`);
1128
+ setTimeout(() => onRefresh?.(), 1500);
1129
+ }).catch(() => showFlash(`\u2717 Move to ${label} failed`));
1130
+ }
1131
+ }
1132
+ )
1133
+ });
1134
+ }
1135
+ return base;
1136
+ }
1137
+ const actions = drillAction ? [drillAction, open, copyUrl] : [open, copyUrl];
1138
+ actions.push({
1139
+ label: "Copy repo name",
1140
+ hint: "r",
1141
+ run: () => {
1142
+ clipboard(item.repo);
1143
+ showFlash(`\u2713 Copied ${item.repo}`);
1144
+ }
1145
+ });
1146
+ if (item.kind === "pr" && item.branch) {
1147
+ actions.push({
1148
+ label: "Copy branch name",
1149
+ hint: "b",
1150
+ run: () => {
1151
+ clipboard(item.branch);
1152
+ showFlash(`\u2713 Copied ${item.branch}`);
1153
+ }
1154
+ });
1155
+ }
1156
+ if (item.kind === "pr" && item.branch) {
1157
+ actions.push({
1158
+ label: "Switch here",
1159
+ hint: "s",
1160
+ run: () => {
1161
+ const script = [
1162
+ "delay 0.5",
1163
+ 'tell application "iTerm2"',
1164
+ " tell current session of current window",
1165
+ ` write text "git switch ${item.branch}"`,
1166
+ " end tell",
1167
+ "end tell"
1168
+ ].join("\n");
1169
+ const proc = spawn("osascript", ["-e", script], {
1170
+ detached: true,
1171
+ stdio: "ignore"
1172
+ });
1173
+ proc.unref();
1174
+ process.exit(0);
1175
+ }
1176
+ });
1177
+ }
1178
+ if (item.kind === "issue") {
1179
+ actions.push({
1180
+ label: "Open project in new tab",
1181
+ hint: "j",
1182
+ run: () => {
1183
+ showFlash(`\u22EF Opening ${item.repo}\u2026`);
1184
+ void jumpToRepo(item.repo, "", login).then(() => showFlash(`\u2197 Opened ${item.repo} in new tab`)).catch(() => showFlash("\u2717 Jump failed"));
1185
+ }
1186
+ });
1187
+ actions.push({
1188
+ label: "Open project in new pane",
1189
+ hint: "p",
1190
+ run: () => {
1191
+ showFlash(`\u22EF Opening pane for ${item.repo}\u2026`);
1192
+ void jumpToRepoPane(item.repo, "", login).then(() => showFlash(`\u2197 Opened ${item.repo} in new pane`)).catch(() => showFlash("\u2717 Pane failed"));
1193
+ }
1194
+ });
1195
+ actions.push({
1196
+ label: "Close issue",
1197
+ hint: "",
1198
+ run: () => {
1199
+ },
1200
+ subActions: [
1201
+ {
1202
+ label: `Close #${item.number}`,
1203
+ hint: "",
1204
+ run: () => {
1205
+ onRemove?.(item);
1206
+ showFlash(`\u2713 Closed #${item.number}`);
1207
+ void $`gh issue close ${item.number} --repo ${item.repo}`.catch(
1208
+ () => {
1209
+ showFlash(`\u2717 Close failed \u2014 restoring #${item.number}`);
1210
+ onRefresh?.();
1211
+ }
1212
+ );
1213
+ }
1214
+ },
1215
+ { label: "Cancel", hint: "", run: () => {
1216
+ } }
1217
+ ]
1218
+ });
1219
+ }
1220
+ if (item.kind === "pr") {
1221
+ actions.push({
1222
+ label: "Switch in new tab",
1223
+ hint: "j",
1224
+ run: () => {
1225
+ showFlash(`\u22EF Jumping to ${item.repo}\u2026`);
1226
+ void jumpToRepo(item.repo, item.branch ?? "", login).then(() => showFlash(`\u2197 Opened ${item.repo} in new tab`)).catch(() => showFlash("\u2717 Jump failed"));
1227
+ }
1228
+ });
1229
+ actions.push({
1230
+ label: "Switch in new pane",
1231
+ hint: "p",
1232
+ run: () => {
1233
+ showFlash(`\u22EF Opening pane for ${item.repo}\u2026`);
1234
+ void jumpToRepoPane(item.repo, item.branch ?? "", login).then(() => showFlash(`\u2197 Opened ${item.repo} in new pane`)).catch(() => showFlash("\u2717 Pane failed"));
1235
+ }
1236
+ });
1237
+ if (jiraBase && jiraKeyRe) {
1238
+ const jiraKey = !item.indent ? item.title.match(jiraKeyRe)?.[0] : null;
1239
+ if (jiraKey) {
1240
+ actions.push({
1241
+ label: `Open ${jiraKey} in Jira`,
1242
+ hint: "t",
1243
+ run: () => {
1244
+ $`open ${jiraBase}/${jiraKey}`.catch(() => {
1245
+ });
1246
+ showFlash(`\u2197 Opened ${jiraKey} in Jira`);
1247
+ }
1248
+ });
1249
+ }
1250
+ }
1251
+ actions.push({
1252
+ label: "Close PR",
1253
+ hint: "",
1254
+ run: () => {
1255
+ },
1256
+ subActions: [
1257
+ {
1258
+ label: `Close #${item.number}`,
1259
+ hint: "",
1260
+ run: () => {
1261
+ onRemove?.(item);
1262
+ showFlash(`\u2713 Closed #${item.number}`);
1263
+ void $`gh pr close ${item.number} --repo ${item.repo}`.catch(() => {
1264
+ showFlash(`\u2717 Close failed \u2014 restoring #${item.number}`);
1265
+ onRefresh?.();
1266
+ });
1267
+ }
1268
+ },
1269
+ { label: "Cancel", hint: "", run: () => {
1270
+ } }
1271
+ ]
1272
+ });
1273
+ if (item.branch) {
1274
+ actions.push({
1275
+ label: "Close PR + Delete branch",
1276
+ hint: "",
1277
+ run: () => {
1278
+ },
1279
+ subActions: [
1280
+ {
1281
+ label: `Close #${item.number} + delete ${item.branch}`,
1282
+ hint: "",
1283
+ run: () => {
1284
+ onRemove?.(item);
1285
+ showFlash(`\u2713 Closed #${item.number} and deleted ${item.branch}`);
1286
+ void $`gh pr close ${item.number} --repo ${item.repo}`.then(
1287
+ () => $`gh api -X DELETE ${`repos/${item.repo}/git/refs/heads/${item.branch}`}`
1288
+ ).catch(() => {
1289
+ showFlash(`\u2717 Close + delete failed \u2014 restoring`);
1290
+ onRefresh?.();
1291
+ });
1292
+ }
1293
+ },
1294
+ { label: "Cancel", hint: "", run: () => {
1295
+ } }
1296
+ ]
1297
+ });
1298
+ }
1299
+ }
1300
+ return actions;
1301
+ };
1302
+ var agoText = (ms) => {
1303
+ const d = (Date.now() - ms) / 1e3;
1304
+ if (d < 60) return "just now";
1305
+ if (d < 3600) return `${Math.floor(d / 60)}m ago`;
1306
+ if (d < 86400) return `${Math.floor(d / 3600)}h ago`;
1307
+ return `${Math.floor(d / 86400)}d ago`;
1308
+ };
1309
+ var brandOf = (title) => `\u{1F680} ${title[0].toUpperCase()}${title.slice(1)}`;
1310
+ var InboxHeader = ({
1311
+ sections,
1312
+ login,
1313
+ brand,
1314
+ work,
1315
+ loading,
1316
+ refreshing,
1317
+ hasPending,
1318
+ fetchedAt
1319
+ }) => {
1320
+ const total = sections.reduce((n, s) => n + topLevelCount(s), 0);
1321
+ const countSeg = loading ? " loading\u2026 " : ` ${String(total).padStart(3)} item${total !== 1 ? "s" : ""} \xB7 `;
1322
+ const userSeg = loading ? "" : `@${login} `;
1323
+ const workLabel = work === void 0 ? "" : " w work \u25CF\u2500\u25CB home ";
1324
+ const [statusText, statusColor] = hasPending ? ["\u25CF new \xB7 r apply", "#FF8700"] : refreshing ? ["\u21BB refreshing\u2026", "cyan"] : fetchedAt ? [`updated ${agoText(fetchedAt)}`, void 0] : ["", void 0];
1325
+ const statusSeg = statusText ? statusText + " " : "";
1326
+ const fill = Math.max(
1327
+ 4,
1328
+ COLS - brand.length - countSeg.length - userSeg.length - workLabel.length - statusSeg.length
1329
+ );
1330
+ return /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
1331
+ /* @__PURE__ */ jsx(Text, { color: "#FF8700", bold: true, children: brand }),
1332
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: countSeg }),
1333
+ userSeg ? /* @__PURE__ */ jsx(Text, { children: userSeg }) : null,
1334
+ work !== void 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
1335
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " w " }),
1336
+ /* @__PURE__ */ jsx(Switch, { left: "work", right: "home", value: work ? "left" : "right" }),
1337
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " " })
1338
+ ] }) : null,
1339
+ statusText ? /* @__PURE__ */ jsx(
1340
+ Text,
1341
+ {
1342
+ color: statusColor,
1343
+ dimColor: !statusColor,
1344
+ bold: hasPending,
1345
+ children: statusSeg
1346
+ }
1347
+ ) : null,
1348
+ /* @__PURE__ */ jsx(Text, { color: "cyan", dimColor: true, children: "\u254C".repeat(fill) })
1349
+ ] });
1350
+ };
1351
+ var toCiStatusState = (status) => status ? { kind: "ready", status } : { kind: "error" };
1352
+ var sameCiStatusState = (a, b) => {
1353
+ if (a.kind !== b.kind) return false;
1354
+ if (a.kind !== "ready" || b.kind !== "ready") return true;
1355
+ return a.status.job === b.status.job && a.status.buildNumber === b.status.buildNumber && a.status.result === b.status.result && a.status.building === b.status.building;
1356
+ };
1357
+ var jenkinsResultDisplay = (result, building) => {
1358
+ if (building) return ["*", "yellow"];
1359
+ if (result === "SUCCESS") return ["\u2713", "green"];
1360
+ if (result === "FAILURE" || result === "ABORTED") return ["\u2717", "red"];
1361
+ if (result === "UNSTABLE") return ["\xB1", "yellow"];
1362
+ return ["\xB7", "#888888"];
1363
+ };
1364
+ var CiStatusLine = ({
1365
+ state,
1366
+ job
1367
+ }) => {
1368
+ const name = job ?? "ci";
1369
+ if (state.kind === "loading")
1370
+ return /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
1371
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " \xB7 " }),
1372
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: `${name} loading\u2026` })
1373
+ ] });
1374
+ if (state.kind === "error")
1375
+ return /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
1376
+ /* @__PURE__ */ jsx(Text, { color: "red", bold: true, children: " \u2717 " }),
1377
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: `${name} no build / not configured` })
1378
+ ] });
1379
+ const { status } = state;
1380
+ const [, color] = jenkinsResultDisplay(status.result, status.building);
1381
+ const label = status.building ? "BUILDING" : status.result;
1382
+ return /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
1383
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " " }),
1384
+ /* @__PURE__ */ jsx(Text, { color, bold: true, children: "\u25CF " }),
1385
+ /* @__PURE__ */ jsx(Text, { bold: true, children: status.job }),
1386
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " " + label }),
1387
+ /* @__PURE__ */ jsx(Text, { color: "#FF8700", children: " #" + status.buildNumber }),
1388
+ status.age ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: " " + status.age }) : null
1389
+ ] });
1390
+ };
1391
+ var RepoHeaderRow = ({ repo, gap }) => {
1392
+ const label = `\u2500\u2500 ${repo} `;
1393
+ const fill = Math.max(4, 46 - label.length);
1394
+ return /* @__PURE__ */ jsx(Box, { marginTop: gap ? 1 : 0, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: " " + label + "\u2500".repeat(fill) }) });
1395
+ };
1396
+ var ItemRow = ({
1397
+ item,
1398
+ active,
1399
+ gap,
1400
+ login
1401
+ }) => {
1402
+ if (item.kind === "repo-header")
1403
+ return /* @__PURE__ */ jsx(RepoHeaderRow, { repo: item.repo, gap: gap ?? false });
1404
+ if (item.kind === "subgroup-header")
1405
+ return /* @__PURE__ */ jsxs(Box, { marginTop: gap ? 1 : 0, children: [
1406
+ /* @__PURE__ */ jsx(Text, { color: "#FF8700", bold: true, children: " \xBB " }),
1407
+ /* @__PURE__ */ jsx(Text, { bold: true, children: item.label })
1408
+ ] });
1409
+ if (item.kind === "show-more")
1410
+ return /* @__PURE__ */ jsxs(Box, { children: [
1411
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: active ? "\u276F " : " " }),
1412
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2514\u2500 " }),
1413
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: active ? "\u21B5 " : " " }),
1414
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: `+${item.hidden.length} more` })
1415
+ ] });
1416
+ if (item.kind === "show-less")
1417
+ return /* @__PURE__ */ jsxs(Box, { children: [
1418
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: active ? "\u276F " : " " }),
1419
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2514\u2500 " }),
1420
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: active ? "\u21B5 " : " " }),
1421
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "show less" })
1422
+ ] });
1423
+ if (item.kind === "jira") {
1424
+ const titleMax2 = Math.max(20, COLS - item.key.length - 10);
1425
+ return /* @__PURE__ */ jsxs(Box, { marginTop: gap ? 1 : 0, children: [
1426
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: active ? "\u276F " : " " }),
1427
+ /* @__PURE__ */ jsx(Text, { color: "#FF8700", bold: active, children: item.key + " " }),
1428
+ /* @__PURE__ */ jsx(Text, { bold: active, children: truncate(item.summary, titleMax2) })
1429
+ ] });
1430
+ }
1431
+ const { glyph: icon, color } = healthDisplay[item.health];
1432
+ const [turnIcon, turnColor] = !login || !item.lastActor ? [" ", "white"] : item.lastActor === login ? ["\u2192", "#888888"] : ["\u2190", "#FF8700"];
1433
+ const numStr = `#${item.number}`.padEnd(7);
1434
+ const showAuthor = !!item.author && item.author !== login;
1435
+ const unresolvedLabel = item.unresolved > 0 ? `\uF086 ${item.unresolved}` : "";
1436
+ const suffix = [
1437
+ item.age || "",
1438
+ unresolvedLabel,
1439
+ showAuthor ? `by ${item.author}` : ""
1440
+ ].filter(Boolean).join(" ");
1441
+ const repoLabel = item.indent ? item.repo : "";
1442
+ const fixedWidth = 2 + (item.indent ? 3 : 0) + 2 + 2 + 7 + repoLabel.length + suffix.length + 6;
1443
+ const titleMax = Math.max(20, COLS - fixedWidth);
1444
+ return /* @__PURE__ */ jsxs(Box, { children: [
1445
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: active ? "\u276F " : " " }),
1446
+ item.indent ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2514\u2500 " }) : null,
1447
+ /* @__PURE__ */ jsx(Text, { color, bold: true, children: icon + " " }),
1448
+ /* @__PURE__ */ jsx(Text, { color: turnColor, bold: turnIcon === "\u2190", children: turnIcon + " " }),
1449
+ /* @__PURE__ */ jsx(Text, { color: "#FF8700", children: numStr }),
1450
+ /* @__PURE__ */ jsx(Text, { bold: active, children: truncate(item.title, titleMax) + " " }),
1451
+ repoLabel ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: repoLabel }) : null,
1452
+ unresolvedLabel ? /* @__PURE__ */ jsx(Text, { bold: true, color: "#FF8700", children: " " + unresolvedLabel }) : null,
1453
+ showAuthor ? /* @__PURE__ */ jsx(Text, { dimColor: true, italic: true, children: " by " + item.author }) : null,
1454
+ item.age ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: " " + item.age }) : null
1455
+ ] });
1456
+ };
1457
+ var useActionMenu = () => {
1458
+ const [actions, setActions] = useState(null);
1459
+ const [cursor, setCursor] = useState(0);
1460
+ const open = (next) => {
1461
+ if (next.length === 0) return;
1462
+ setCursor(0);
1463
+ setActions(next);
1464
+ };
1465
+ const handleKey = (key) => {
1466
+ if (!actions) return false;
1467
+ if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
1468
+ if (key.downArrow) setCursor((c) => Math.min(actions.length - 1, c + 1));
1469
+ if (key.return) {
1470
+ const action = actions[cursor];
1471
+ if (action?.subActions) {
1472
+ setCursor(0);
1473
+ setActions(action.subActions);
1474
+ } else {
1475
+ setActions(null);
1476
+ action?.run();
1477
+ }
1478
+ }
1479
+ if (key.escape) setActions(null);
1480
+ return true;
1481
+ };
1482
+ return { actions, cursor, open, close: () => setActions(null), handleKey };
1483
+ };
1484
+ var ActionMenu = ({
1485
+ item,
1486
+ actions,
1487
+ cursor
1488
+ }) => {
1489
+ const title = item.kind === "jira" ? item.key : item.kind === "pr" || item.kind === "issue" ? `#${item.number}` : "";
1490
+ return /* @__PURE__ */ jsxs(
1491
+ Box,
1492
+ {
1493
+ flexDirection: "column",
1494
+ borderStyle: "round",
1495
+ borderColor: "cyan",
1496
+ paddingX: 1,
1497
+ marginTop: 1,
1498
+ children: [
1499
+ /* @__PURE__ */ jsx(Text, { color: "cyan", bold: true, children: title }),
1500
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2500".repeat(32) }),
1501
+ actions.map((a, i) => /* @__PURE__ */ jsxs(Box, { children: [
1502
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: i === cursor ? "\u276F " : " " }),
1503
+ /* @__PURE__ */ jsx(Text, { bold: i === cursor, children: a.label }),
1504
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " " + a.hint })
1505
+ ] }, a.label)),
1506
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2500".repeat(32) }),
1507
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate \u21B5 confirm esc cancel" })
1508
+ ]
1509
+ }
1510
+ );
1511
+ };
1512
+ var TURN_LEGEND = [
1513
+ ["\u2190", "#FF8700", "They spoke last \xB7 your turn"],
1514
+ ["\u2192", "#888888", "You spoke last \xB7 waiting on them"]
1515
+ ];
1516
+ var HelpModal = ({
1517
+ workToggle,
1518
+ hasCi,
1519
+ hasJira,
1520
+ tabHelp
1521
+ }) => {
1522
+ const keys = [
1523
+ ["\u2191 \u2193", "navigate"],
1524
+ ["\u2190 \u2192 \xB7 tab", "switch tab"],
1525
+ ["\u21B5 \xB7 d", "open / drill in"],
1526
+ ["m", "actions \xB7 close"],
1527
+ ["e", "explain this row"],
1528
+ ["o", "open in browser"],
1529
+ ["c", "copy URL"],
1530
+ ["b", "copy branch"],
1531
+ ["s", "switch to branch here"],
1532
+ ["j", "open repo in new tab"],
1533
+ ["p", "open repo in new pane"],
1534
+ ...hasJira ? [["t", "Jira: move / open ticket"]] : [],
1535
+ ["/", "search"],
1536
+ ["f", "filter by repo"],
1537
+ ["r", "refresh"],
1538
+ ...workToggle ? [["w", "toggle work / home"]] : [],
1539
+ ...hasCi ? [["J", "Jenkins explorer"]] : [],
1540
+ ["?", "this help"],
1541
+ ["q", "quit"]
1542
+ ];
1543
+ return /* @__PURE__ */ jsxs(
1544
+ Box,
1545
+ {
1546
+ flexDirection: "column",
1547
+ borderStyle: "round",
1548
+ borderColor: "cyan",
1549
+ paddingX: 1,
1550
+ children: [
1551
+ /* @__PURE__ */ jsx(Text, { color: "cyan", bold: true, children: "Legend" }),
1552
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
1553
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginRight: 3, minWidth: 26, children: [
1554
+ /* @__PURE__ */ jsx(Text, { bold: true, dimColor: true, children: "Status" }),
1555
+ healthLegend.map(([health, label]) => {
1556
+ const { glyph: icon, color } = healthDisplay[health];
1557
+ return /* @__PURE__ */ jsxs(Box, { children: [
1558
+ /* @__PURE__ */ jsx(Text, { color, bold: true, children: icon + " " }),
1559
+ /* @__PURE__ */ jsx(Text, { children: " " + label })
1560
+ ] }, health);
1561
+ }),
1562
+ TURN_LEGEND.map(([icon, color, label]) => /* @__PURE__ */ jsxs(Box, { children: [
1563
+ /* @__PURE__ */ jsx(Text, { color, bold: true, children: icon + " " }),
1564
+ /* @__PURE__ */ jsx(Text, { children: " " + label })
1565
+ ] }, icon)),
1566
+ /* @__PURE__ */ jsxs(Box, { children: [
1567
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "#FF8700", children: "\uF086 " }),
1568
+ /* @__PURE__ */ jsx(Text, { children: " Open-thread count" })
1569
+ ] })
1570
+ ] }),
1571
+ tabHelp ? /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginRight: 3, minWidth: 30, children: [
1572
+ /* @__PURE__ */ jsx(Text, { bold: true, dimColor: true, children: "Tabs" }),
1573
+ tabHelp.map(([tab, meaning]) => /* @__PURE__ */ jsxs(Box, { children: [
1574
+ /* @__PURE__ */ jsx(Text, { color: "#FF8700", children: tab.padEnd(10) }),
1575
+ /* @__PURE__ */ jsx(Text, { children: meaning })
1576
+ ] }, tab))
1577
+ ] }) : null,
1578
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
1579
+ /* @__PURE__ */ jsx(Text, { bold: true, dimColor: true, children: "Keys" }),
1580
+ keys.map(([k, label]) => /* @__PURE__ */ jsxs(Box, { children: [
1581
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: k.padEnd(11) }),
1582
+ /* @__PURE__ */ jsx(Text, { children: label })
1583
+ ] }, k))
1584
+ ] })
1585
+ ] }),
1586
+ tabHelp ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Each item lands in the FIRST tab that claims it, so counts are residuals rather than totals." }) : null,
1587
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "esc \xB7 ? close" })
1588
+ ]
1589
+ }
1590
+ );
1591
+ };
1592
+ var ExplainModal = ({ item, login }) => /* @__PURE__ */ jsxs(
1593
+ Box,
1594
+ {
1595
+ flexDirection: "column",
1596
+ borderStyle: "round",
1597
+ borderColor: "cyan",
1598
+ paddingX: 1,
1599
+ width: Math.min(COLS, 78),
1600
+ children: [
1601
+ /* @__PURE__ */ jsx(Text, { color: "#FF8700", bold: true, children: `#${item.number} \xB7 ${item.repo}` }),
1602
+ /* @__PURE__ */ jsx(Text, { children: item.title }),
1603
+ explainItem(item, login).map((section) => /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
1604
+ /* @__PURE__ */ jsx(Text, { bold: true, dimColor: true, children: section.heading }),
1605
+ section.lines.map((line, i) => /* @__PURE__ */ jsx(Text, { children: " " + line }, i))
1606
+ ] }, section.heading)),
1607
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: "esc \xB7 e close" }) })
1608
+ ]
1609
+ }
1610
+ );
1611
+ var RepoPicker = ({
1612
+ repos,
1613
+ selected,
1614
+ cursor
1615
+ }) => {
1616
+ const { rows } = useWindowSize();
1617
+ const budget = Math.max(6, rows - 12);
1618
+ const start = Math.max(
1619
+ 0,
1620
+ Math.min(cursor - Math.floor(budget / 2), repos.length - budget)
1621
+ );
1622
+ const visible = repos.slice(start, start + budget);
1623
+ return /* @__PURE__ */ jsxs(
1624
+ Box,
1625
+ {
1626
+ flexDirection: "column",
1627
+ borderStyle: "round",
1628
+ borderColor: "cyan",
1629
+ paddingX: 1,
1630
+ minWidth: 42,
1631
+ children: [
1632
+ /* @__PURE__ */ jsx(Text, { color: "cyan", bold: true, children: `Filter by repo (${selected.size} on)` }),
1633
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2500".repeat(36) }),
1634
+ visible.map((repo, i) => {
1635
+ const idx = start + i;
1636
+ const on = selected.has(repo);
1637
+ const active = idx === cursor;
1638
+ return /* @__PURE__ */ jsxs(Box, { children: [
1639
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: active ? "\u276F " : " " }),
1640
+ /* @__PURE__ */ jsx(Text, { color: on ? "green" : void 0, children: on ? "\u25C9 " : "\u25CB " }),
1641
+ /* @__PURE__ */ jsx(Text, { bold: active, children: repo })
1642
+ ] }, repo);
1643
+ }),
1644
+ repos.length > budget ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` \u2026 ${repos.length} repos total` }) : null,
1645
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2500".repeat(36) }),
1646
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "space toggle \xB7 a clear all \xB7 \u21B5/esc done" })
1647
+ ]
1648
+ }
1649
+ );
1650
+ };
1651
+ var BrowseScreen = ({
1652
+ sections,
1653
+ login,
1654
+ jiraBase,
1655
+ jiraKeyRe,
1656
+ jiraTransitions,
1657
+ onRefresh,
1658
+ refreshing,
1659
+ hasPending,
1660
+ fetchedAt,
1661
+ workToggle,
1662
+ hidden,
1663
+ onOpenPr,
1664
+ onOpenIssue,
1665
+ onOpenExt,
1666
+ ciStatusState,
1667
+ ciJob,
1668
+ tabHelp,
1669
+ isWorkRepo,
1670
+ initialIncludeWork,
1671
+ brand
1672
+ }) => {
1673
+ const { rows } = useWindowSize();
1674
+ const [includeWork, setIncludeWork] = useState(
1675
+ () => workToggle ? initialIncludeWork ?? true : true
1676
+ );
1677
+ const applyWork = (secs, include) => !workToggle || !isWorkRepo ? secs : filterByOrigin(secs, include ? "work" : "home", isWorkRepo);
1678
+ const initialSections = applyWork(sections, includeWork);
1679
+ const [localSections, setLocalSections] = useState(initialSections);
1680
+ const [tabIdx, setTabIdx] = useState(0);
1681
+ const [cursors, setCursors] = useState(
1682
+ initialSections.map(
1683
+ (s) => Math.max(
1684
+ 0,
1685
+ s.items.findIndex(
1686
+ (i) => i.kind !== "repo-header" && i.kind !== "subgroup-header"
1687
+ )
1688
+ )
1689
+ )
1690
+ );
1691
+ const [viewStarts, setViewStarts] = useState(
1692
+ initialSections.map(() => 0)
1693
+ );
1694
+ const [flash, setFlash] = useState(null);
1695
+ const menu = useActionMenu();
1696
+ const [search, setSearch] = useState(null);
1697
+ const [searchInput, setSearchInput] = useState(false);
1698
+ const [repoFilter, setRepoFilter] = useState(/* @__PURE__ */ new Set());
1699
+ const [repoPicker, setRepoPicker] = useState(false);
1700
+ const [repoCursor, setRepoCursor] = useState(0);
1701
+ const [help, setHelp] = useState(false);
1702
+ const [explain, setExplain] = useState(false);
1703
+ const filterActive = search != null || repoFilter.size > 0;
1704
+ const reserveCiRow = ciStatusState != null;
1705
+ const ciStatus = ciStatusState?.kind === "ready" ? ciStatusState.status : null;
1706
+ const listHeight = Math.max(
1707
+ 5,
1708
+ // -10 rather than -8: the extra 2 are the frame's top and bottom border
1709
+ // rows, so the tree never grows taller than the terminal inside the frame.
1710
+ rows - 10 - (filterActive ? 2 : 0) - (reserveCiRow ? 2 : 0)
1711
+ );
1712
+ useEffect(() => {
1713
+ setCursors((p) => p.map((c, i) => i === tabIdx ? 0 : c));
1714
+ setViewStarts((p) => p.map((v, i) => i === tabIdx ? 0 : v));
1715
+ }, [search]);
1716
+ useEffect(() => {
1717
+ setLocalSections(applyWork(sections, includeWork));
1718
+ }, [sections]);
1719
+ useEffect(() => {
1720
+ setTabIdx((prev) => Math.min(prev, Math.max(0, localSections.length - 1)));
1721
+ setCursors(
1722
+ (prev) => localSections.map((s, i) => {
1723
+ const c = Math.min(prev[i] ?? 0, s.items.length - 1);
1724
+ if (c < 0) return 0;
1725
+ return s.items[c]?.kind === "repo-header" || s.items[c]?.kind === "subgroup-header" ? moveCursor(s.items, c, 1) : c;
1726
+ })
1727
+ );
1728
+ setViewStarts(
1729
+ (prev) => localSections.map(
1730
+ (s, i) => Math.min(prev[i] ?? 0, maxViewStart(s.items, listHeight))
1731
+ )
1732
+ );
1733
+ }, [localSections, listHeight]);
1734
+ const removeItemFromSections = (target) => setLocalSections((prev) => withoutItem(prev, target));
1735
+ const safeTabIdx = Math.min(tabIdx, Math.max(0, localSections.length - 1));
1736
+ const rawSection = localSections[safeTabIdx] ?? {
1737
+ id: "empty",
1738
+ label: "",
1739
+ items: []
1740
+ };
1741
+ const searched = search != null ? filterBySearch([rawSection], search) : [rawSection];
1742
+ const filtered = repoFilter.size > 0 ? filterByRepos(searched, repoFilter) : searched;
1743
+ const section = filterActive ? { ...rawSection, items: filtered[0]?.items ?? [] } : rawSection;
1744
+ const allRepos = reposInSections(localSections);
1745
+ const cursor = cursors[safeTabIdx] ?? 0;
1746
+ const viewStart = viewStarts[safeTabIdx] ?? 0;
1747
+ const visibleCount = windowCount(section.items, viewStart, listHeight);
1748
+ const visibleItems = section.items.slice(viewStart, viewStart + visibleCount);
1749
+ const hasMore = viewStart + visibleCount < section.items.length;
1750
+ const activeItem = section.items[cursor];
1751
+ const showFlash = (msg) => {
1752
+ setFlash(msg);
1753
+ setTimeout(() => setFlash(null), 2e3);
1754
+ };
1755
+ const openDrillView = (item) => {
1756
+ const open = (fn, i) => {
1757
+ menu.close();
1758
+ fn(i);
1759
+ return true;
1760
+ };
1761
+ if (item.kind === "pr" && onOpenPr) return open(onOpenPr, item);
1762
+ if (item.kind === "issue" && onOpenIssue) return open(onOpenIssue, item);
1763
+ return false;
1764
+ };
1765
+ const openMenu = () => {
1766
+ if (!activeItem || activeItem.kind === "repo-header" || activeItem.kind === "subgroup-header")
1767
+ return;
1768
+ const actions = buildActions(
1769
+ activeItem,
1770
+ login,
1771
+ (msg) => {
1772
+ menu.close();
1773
+ showFlash(msg);
1774
+ },
1775
+ jiraBase,
1776
+ jiraKeyRe,
1777
+ jiraTransitions,
1778
+ onRefresh,
1779
+ removeItemFromSections,
1780
+ openDrillView
1781
+ );
1782
+ menu.open(actions);
1783
+ };
1784
+ useInput((input, key) => {
1785
+ if (hidden) return;
1786
+ if (key.ctrl || key.meta) return;
1787
+ if (help) {
1788
+ setHelp(false);
1789
+ return;
1790
+ }
1791
+ if (input === "?") {
1792
+ setHelp(true);
1793
+ return;
1794
+ }
1795
+ if (explain) {
1796
+ setExplain(false);
1797
+ return;
1798
+ }
1799
+ if (repoPicker) {
1800
+ if (key.escape || key.return) return setRepoPicker(false);
1801
+ if (key.upArrow) return setRepoCursor((c) => Math.max(0, c - 1));
1802
+ if (key.downArrow)
1803
+ return setRepoCursor((c) => Math.min(allRepos.length - 1, c + 1));
1804
+ if (input === "a") return setRepoFilter(/* @__PURE__ */ new Set());
1805
+ if (input === " ") {
1806
+ const repo = allRepos[repoCursor];
1807
+ if (repo)
1808
+ setRepoFilter((prev) => {
1809
+ const next = new Set(prev);
1810
+ if (next.has(repo)) next.delete(repo);
1811
+ else next.add(repo);
1812
+ return next;
1813
+ });
1814
+ }
1815
+ return;
1816
+ }
1817
+ if (searchInput) {
1818
+ if (key.return) return setSearchInput(false);
1819
+ if (key.escape) {
1820
+ setSearch(null);
1821
+ return setSearchInput(false);
1822
+ }
1823
+ if (key.backspace || key.delete)
1824
+ return setSearch((s) => (s ?? "").slice(0, -1));
1825
+ if (input && !key.ctrl && !key.meta && !key.tab)
1826
+ return setSearch((s) => (s ?? "") + input);
1827
+ return;
1828
+ }
1829
+ if (input === "/") {
1830
+ setSearch("");
1831
+ setSearchInput(true);
1832
+ return;
1833
+ }
1834
+ if (input === "f" && allRepos.length > 0) {
1835
+ setRepoCursor(0);
1836
+ setRepoPicker(true);
1837
+ return;
1838
+ }
1839
+ if (key.escape && search != null) {
1840
+ setSearch(null);
1841
+ return;
1842
+ }
1843
+ if (key.escape && repoFilter.size > 0) {
1844
+ setRepoFilter(/* @__PURE__ */ new Set());
1845
+ return;
1846
+ }
1847
+ if (menu.handleKey(key)) return;
1848
+ if (key.upArrow) {
1849
+ const next = moveCursor(section.items, cursor, -1);
1850
+ const newVs = Math.min(viewStart, withHeaders(section.items, next));
1851
+ setCursors((p) => p.map((c, i) => i === tabIdx ? next : c));
1852
+ setViewStarts((p) => p.map((v, i) => i === tabIdx ? newVs : v));
1853
+ }
1854
+ if (key.downArrow) {
1855
+ const next = moveCursor(section.items, cursor, 1);
1856
+ let newVs = viewStart;
1857
+ while (next >= newVs + windowCount(section.items, newVs, listHeight))
1858
+ newVs++;
1859
+ setCursors((p) => p.map((c, i) => i === tabIdx ? next : c));
1860
+ setViewStarts((p) => p.map((v, i) => i === tabIdx ? newVs : v));
1861
+ }
1862
+ if (key.leftArrow) setTabIdx((i) => Math.max(0, i - 1));
1863
+ if (key.rightArrow)
1864
+ setTabIdx((i) => Math.min(localSections.length - 1, i + 1));
1865
+ if (key.tab)
1866
+ setTabIdx(
1867
+ (i) => (i + (key.shift ? -1 : 1) + localSections.length) % localSections.length
1868
+ );
1869
+ if (input === "q") process.exit(0);
1870
+ if (input === "r") {
1871
+ onRefresh?.();
1872
+ return;
1873
+ }
1874
+ if (workToggle && input === "w") {
1875
+ const next = !includeWork;
1876
+ setIncludeWork(next);
1877
+ setLocalSections(applyWork(sections, next));
1878
+ return;
1879
+ }
1880
+ if (input === "J" && ciStatus) {
1881
+ onOpenExt?.("jenkins", ciStatus.job);
1882
+ return;
1883
+ }
1884
+ if (!activeItem || activeItem.kind === "repo-header" || activeItem.kind === "subgroup-header")
1885
+ return;
1886
+ if (key.return && activeItem.kind === "show-more") {
1887
+ setLocalSections(
1888
+ (prev) => prev.map((s) => ({
1889
+ ...s,
1890
+ items: s.items.flatMap(
1891
+ (i) => i === activeItem ? [
1892
+ ...activeItem.hidden,
1893
+ {
1894
+ kind: "show-less",
1895
+ toHide: activeItem.hidden,
1896
+ indent: true
1897
+ }
1898
+ ] : [i]
1899
+ )
1900
+ }))
1901
+ );
1902
+ return;
1903
+ }
1904
+ if (key.return && activeItem.kind === "show-less") {
1905
+ setLocalSections(
1906
+ (prev) => prev.map((s) => ({
1907
+ ...s,
1908
+ items: s.items.filter((i) => !activeItem.toHide.includes(i)).flatMap(
1909
+ (i) => i === activeItem ? [
1910
+ {
1911
+ kind: "show-more",
1912
+ hidden: activeItem.toHide,
1913
+ indent: true
1914
+ }
1915
+ ] : [i]
1916
+ )
1917
+ }))
1918
+ );
1919
+ return;
1920
+ }
1921
+ if (activeItem.kind === "show-more" || activeItem.kind === "show-less")
1922
+ return;
1923
+ if (key.return) {
1924
+ if (openDrillView(activeItem)) return;
1925
+ openMenu();
1926
+ return;
1927
+ }
1928
+ if (input === "m") {
1929
+ openMenu();
1930
+ return;
1931
+ }
1932
+ if (input === "e" && activeItem && (activeItem.kind === "pr" || activeItem.kind === "issue")) {
1933
+ setExplain(true);
1934
+ return;
1935
+ }
1936
+ if (input === "o") {
1937
+ $`open ${activeItem.url}`.catch(() => {
1938
+ });
1939
+ const label = activeItem.kind === "jira" ? activeItem.key : `#${activeItem.number}`;
1940
+ showFlash(`\u2197 Opened ${label}`);
1941
+ return;
1942
+ }
1943
+ if (input === "c") {
1944
+ clipboard(activeItem.url);
1945
+ const label = activeItem.kind === "jira" ? activeItem.key : `#${activeItem.number}`;
1946
+ showFlash(`\u2713 Copied URL for ${label}`);
1947
+ return;
1948
+ }
1949
+ if (input === "d") {
1950
+ if (openDrillView(activeItem)) return;
1951
+ const cmd = drillCmd(activeItem);
1952
+ if (cmd) {
1953
+ void runInPane(cmd).catch(() => {
1954
+ });
1955
+ showFlash(`\u2197 ${drillLabel(activeItem)}`);
1956
+ }
1957
+ return;
1958
+ }
1959
+ if (input === "b" && activeItem.kind === "pr" && activeItem.branch) {
1960
+ clipboard(activeItem.branch);
1961
+ showFlash(`\u2713 Copied ${activeItem.branch}`);
1962
+ return;
1963
+ }
1964
+ if (activeItem.kind !== "jira") {
1965
+ if (input === "s" && activeItem.kind === "pr" && activeItem.branch) {
1966
+ const script = [
1967
+ "delay 0.5",
1968
+ 'tell application "iTerm2"',
1969
+ " tell current session of current window",
1970
+ ` write text "git switch ${activeItem.branch}"`,
1971
+ " end tell",
1972
+ "end tell"
1973
+ ].join("\n");
1974
+ const proc = spawn("osascript", ["-e", script], {
1975
+ detached: true,
1976
+ stdio: "ignore"
1977
+ });
1978
+ proc.unref();
1979
+ process.exit(0);
1980
+ }
1981
+ if (input === "j" && (activeItem.kind === "pr" || activeItem.kind === "issue")) {
1982
+ const { repo } = activeItem;
1983
+ const branch = activeItem.kind === "pr" ? activeItem.branch ?? "" : "";
1984
+ showFlash(`\u22EF Opening ${repo}\u2026`);
1985
+ void jumpToRepo(repo, branch, login).then(() => showFlash(`\u2197 Opened ${repo} in new tab`)).catch(() => showFlash("\u2717 Jump failed"));
1986
+ return;
1987
+ }
1988
+ }
1989
+ if (input === "t" && activeItem && activeItem.kind === "jira" && jiraTransitions && jiraTransitions.length > 0) {
1990
+ const jiraKey = activeItem.key;
1991
+ const actions = jiraTransitions.map(
1992
+ ({ label, state, resolutions }) => resolutions && resolutions.length > 0 ? {
1993
+ label,
1994
+ hint: "",
1995
+ run: () => {
1996
+ },
1997
+ subActions: resolutions.map((resolution) => ({
1998
+ label: resolution,
1999
+ hint: "",
2000
+ run: () => {
2001
+ menu.close();
2002
+ showFlash(`\u22EF ${label} \xB7 ${resolution}\u2026`);
2003
+ $`jira issue move ${jiraKey} ${state} --resolution ${resolution}`.then(() => {
2004
+ showFlash(`\u2713 ${label} \xB7 ${resolution}`);
2005
+ setTimeout(() => onRefresh?.(), 1500);
2006
+ }).catch(() => showFlash(`\u2717 Move to ${label} failed`));
2007
+ }
2008
+ }))
2009
+ } : {
2010
+ label,
2011
+ hint: "",
2012
+ run: () => {
2013
+ menu.close();
2014
+ showFlash(`\u22EF Moving to ${label}\u2026`);
2015
+ $`jira issue move ${jiraKey} ${state}`.then(() => {
2016
+ showFlash(`\u2713 Moved to ${label}`);
2017
+ setTimeout(() => onRefresh?.(), 1500);
2018
+ }).catch(() => showFlash(`\u2717 Move to ${label} failed`));
2019
+ }
2020
+ }
2021
+ );
2022
+ menu.open(actions);
2023
+ }
2024
+ });
2025
+ const hints = [
2026
+ ["\u2191\u2193", "nav"],
2027
+ ["\u2190\u2192", "tab"],
2028
+ ["\u21B5/d", "open"],
2029
+ ["m", "actions"],
2030
+ ["e", "explain"],
2031
+ ["/", "search"],
2032
+ ["f", "filter"],
2033
+ ["r", "refresh"],
2034
+ ...ciStatus ? [["J", "jenkins"]] : [],
2035
+ ["?", "help"],
2036
+ ["q", "quit"]
2037
+ ];
2038
+ const matchCount = section.items.filter(
2039
+ (i) => i.kind !== "repo-header" && i.kind !== "subgroup-header"
2040
+ ).length;
2041
+ if (hidden) return null;
2042
+ return /* @__PURE__ */ jsxs(
2043
+ Box,
2044
+ {
2045
+ flexDirection: "column",
2046
+ marginTop: 1,
2047
+ borderStyle: "round",
2048
+ borderColor: FRAME_COLOR,
2049
+ borderDimColor: true,
2050
+ paddingX: FRAME_PAD_X,
2051
+ children: [
2052
+ /* @__PURE__ */ jsx(
2053
+ InboxHeader,
2054
+ {
2055
+ brand,
2056
+ sections: localSections,
2057
+ login,
2058
+ work: workToggle ? includeWork : void 0,
2059
+ refreshing,
2060
+ hasPending,
2061
+ fetchedAt
2062
+ }
2063
+ ),
2064
+ ciStatusState ? /* @__PURE__ */ jsx(CiStatusLine, { state: ciStatusState, job: ciJob }) : null,
2065
+ /* @__PURE__ */ jsx(Box, { marginBottom: 1, children: /* @__PURE__ */ jsx(
2066
+ Tabs,
2067
+ {
2068
+ active: section.id,
2069
+ items: localSections.map((s) => ({
2070
+ value: s.id,
2071
+ label: s.label,
2072
+ count: topLevelCount(s)
2073
+ }))
2074
+ }
2075
+ ) }),
2076
+ search != null ? /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
2077
+ /* @__PURE__ */ jsx(Text, { color: "cyan", children: " / " }),
2078
+ /* @__PURE__ */ jsx(Text, { children: search }),
2079
+ searchInput ? /* @__PURE__ */ jsx(Text, { color: "cyan", children: "\u258F" }) : null,
2080
+ /* @__PURE__ */ jsx(
2081
+ Text,
2082
+ {
2083
+ dimColor: true,
2084
+ children: ` ${matchCount} match${matchCount !== 1 ? "es" : ""}${searchInput ? " \u21B5 accept \xB7 esc clear" : " esc clear"}`
2085
+ }
2086
+ )
2087
+ ] }) : repoFilter.size > 0 ? /* @__PURE__ */ jsxs(Box, { marginBottom: 1, children: [
2088
+ /* @__PURE__ */ jsx(Text, { color: "#FF8700", children: " \u25C9 " }),
2089
+ /* @__PURE__ */ jsx(Text, { children: `${repoFilter.size} repo${repoFilter.size !== 1 ? "s" : ""}` }),
2090
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " f edit \xB7 esc clear" })
2091
+ ] }) : null,
2092
+ help ? /* @__PURE__ */ jsx(
2093
+ Box,
2094
+ {
2095
+ minHeight: listHeight,
2096
+ flexDirection: "column",
2097
+ justifyContent: "center",
2098
+ alignItems: "center",
2099
+ children: /* @__PURE__ */ jsx(
2100
+ HelpModal,
2101
+ {
2102
+ workToggle,
2103
+ hasCi: ciStatus != null,
2104
+ hasJira: !!jiraBase,
2105
+ tabHelp
2106
+ }
2107
+ )
2108
+ }
2109
+ ) : explain && activeItem && (activeItem.kind === "pr" || activeItem.kind === "issue") ? /* @__PURE__ */ jsx(
2110
+ Box,
2111
+ {
2112
+ minHeight: listHeight,
2113
+ flexDirection: "column",
2114
+ justifyContent: "center",
2115
+ alignItems: "center",
2116
+ children: /* @__PURE__ */ jsx(ExplainModal, { item: activeItem, login })
2117
+ }
2118
+ ) : repoPicker ? /* @__PURE__ */ jsx(
2119
+ Box,
2120
+ {
2121
+ minHeight: listHeight,
2122
+ flexDirection: "column",
2123
+ justifyContent: "center",
2124
+ alignItems: "center",
2125
+ children: /* @__PURE__ */ jsx(
2126
+ RepoPicker,
2127
+ {
2128
+ repos: allRepos,
2129
+ selected: repoFilter,
2130
+ cursor: Math.min(repoCursor, Math.max(0, allRepos.length - 1))
2131
+ }
2132
+ )
2133
+ }
2134
+ ) : menu.actions && activeItem && activeItem.kind !== "repo-header" ? /* @__PURE__ */ jsx(
2135
+ Box,
2136
+ {
2137
+ minHeight: listHeight,
2138
+ flexDirection: "column",
2139
+ justifyContent: "center",
2140
+ alignItems: "center",
2141
+ children: /* @__PURE__ */ jsx(
2142
+ ActionMenu,
2143
+ {
2144
+ item: activeItem,
2145
+ actions: menu.actions,
2146
+ cursor: menu.cursor
2147
+ }
2148
+ )
2149
+ }
2150
+ ) : /* @__PURE__ */ jsxs(Box, { flexDirection: "column", minHeight: listHeight, children: [
2151
+ /* @__PURE__ */ jsx(Box, { flexDirection: "column", flexGrow: 1, children: visibleItems.map((item, i) => /* @__PURE__ */ jsx(
2152
+ ItemRow,
2153
+ {
2154
+ item,
2155
+ active: viewStart + i === cursor,
2156
+ login,
2157
+ gap: viewStart + i > 0 && (item.kind === "repo-header" || item.kind === "subgroup-header" || // A header is always followed by a blank line before its
2158
+ // first child — in Other PRs that's "free" because the
2159
+ // child is itself a repo-header (gap above). A jira row
2160
+ // has no such stand-in, so it needs this explicitly. Never
2161
+ // applies between two tickets/PRs — only right after a
2162
+ // header.
2163
+ item.kind === "jira" && ["repo-header", "subgroup-header"].includes(
2164
+ section.items[viewStart + i - 1]?.kind
2165
+ ))
2166
+ },
2167
+ `${viewStart + i}:${item.kind === "jira" ? item.instanceKey ?? item.key : item.kind === "repo-header" ? `header:${item.repo}` : item.kind === "subgroup-header" ? `subgroup:${item.label}` : item.kind === "show-more" ? `show-more:${item.hidden[0]?.repo ?? i}` : item.kind === "show-less" ? `show-less:${item.toHide[0]?.repo ?? i}` : `${item.repo}/${item.number}`}`
2168
+ )) }),
2169
+ hasMore && /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
2170
+ " ",
2171
+ "\u2193 ",
2172
+ section.items.length - viewStart - visibleCount,
2173
+ " more"
2174
+ ] })
2175
+ ] }),
2176
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: flash ? /* @__PURE__ */ jsx(Text, { color: "green", children: flash }) : /* @__PURE__ */ jsx(FooterHints, { hints }) })
2177
+ ]
2178
+ }
2179
+ );
2180
+ };
2181
+ var signatureOf = (sections) => JSON.stringify(sections, (k, v) => k === "age" ? void 0 : v);
2182
+ var App = ({
2183
+ fetcher,
2184
+ cacheKey,
2185
+ title = "inbox",
2186
+ detailFor,
2187
+ isWorkRepo,
2188
+ initialIncludeWork,
2189
+ jiraBase,
2190
+ jiraKeyRe,
2191
+ jiraTransitions,
2192
+ workToggle,
2193
+ hasCiStatus,
2194
+ ciJob,
2195
+ ciFetcher,
2196
+ ciPollMs = 6e4,
2197
+ extensions,
2198
+ tabHelp
2199
+ }) => {
2200
+ const [state, setState] = useState({ phase: "loading" });
2201
+ const [pending, setPending] = useState(null);
2202
+ const [refreshing, setRefreshing] = useState(false);
2203
+ const [fetchedAt, setFetchedAt] = useState(null);
2204
+ const [ciStatusState, setCiStatusState] = useState({
2205
+ kind: "loading"
2206
+ });
2207
+ const applyCiStatus = (status) => setCiStatusState((prev) => {
2208
+ const next = toCiStatusState(status);
2209
+ return sameCiStatusState(prev, next) ? prev : next;
2210
+ });
2211
+ const displayedKey = useRef("");
2212
+ const showData = (sections, login) => {
2213
+ displayedKey.current = signatureOf(sections);
2214
+ setPending(null);
2215
+ setFetchedAt(Date.now());
2216
+ setState({ phase: "browse", sections, login });
2217
+ };
2218
+ const revalidate = (manual = false) => {
2219
+ if (manual) setRefreshing(true);
2220
+ fetcher().then((fresh) => {
2221
+ if (cacheKey) writeCache(cacheKey, fresh);
2222
+ setRefreshing(false);
2223
+ setFetchedAt(Date.now());
2224
+ if (hasCiStatus) applyCiStatus(fresh.ciStatus ?? null);
2225
+ const freshKey = signatureOf(fresh.sections);
2226
+ if (!displayedKey.current) {
2227
+ if (fresh.sections.length === 0) {
2228
+ console.log(`${title[0].toUpperCase()}${title.slice(1)} empty.`);
2229
+ process.exit(0);
2230
+ }
2231
+ showData(fresh.sections, fresh.login);
2232
+ } else if (freshKey !== displayedKey.current) {
2233
+ setPending(fresh);
2234
+ } else {
2235
+ setPending(null);
2236
+ }
2237
+ }).catch((err) => {
2238
+ setRefreshing(false);
2239
+ if (!displayedKey.current) {
2240
+ console.error("Error:", err.message);
2241
+ process.exit(1);
2242
+ }
2243
+ });
2244
+ };
2245
+ const applyOrRefresh = () => {
2246
+ if (pending) showData(pending.sections, pending.login);
2247
+ else revalidate(true);
2248
+ };
2249
+ useEffect(() => {
2250
+ const cached = cacheKey ? readCache(cacheKey) : null;
2251
+ if (cached && cached.sections.length > 0) {
2252
+ displayedKey.current = signatureOf(cached.sections);
2253
+ setFetchedAt(cached.at);
2254
+ setState({
2255
+ phase: "browse",
2256
+ sections: cached.sections,
2257
+ login: cached.login
2258
+ });
2259
+ }
2260
+ revalidate();
2261
+ }, []);
2262
+ useEffect(() => {
2263
+ if (!hasCiStatus || !ciFetcher) return;
2264
+ let live = true;
2265
+ const poll = () => {
2266
+ ciFetcher().then((status) => {
2267
+ if (!live) return;
2268
+ applyCiStatus(status);
2269
+ }).catch(() => {
2270
+ });
2271
+ };
2272
+ poll();
2273
+ const id = setInterval(poll, ciPollMs);
2274
+ return () => {
2275
+ live = false;
2276
+ clearInterval(id);
2277
+ };
2278
+ }, [hasCiStatus, ciFetcher, ciPollMs]);
2279
+ if (state.phase === "loading")
2280
+ return /* @__PURE__ */ jsxs(
2281
+ Box,
2282
+ {
2283
+ flexDirection: "column",
2284
+ marginTop: 1,
2285
+ borderStyle: "round",
2286
+ borderColor: FRAME_COLOR,
2287
+ borderDimColor: true,
2288
+ paddingX: FRAME_PAD_X,
2289
+ children: [
2290
+ /* @__PURE__ */ jsx(
2291
+ InboxHeader,
2292
+ {
2293
+ brand: brandOf(title),
2294
+ sections: [],
2295
+ login: "",
2296
+ work: workToggle ? initialIncludeWork ?? true : void 0,
2297
+ loading: true
2298
+ }
2299
+ ),
2300
+ hasCiStatus ? /* @__PURE__ */ jsx(CiStatusLine, { state: ciStatusState, job: ciJob }) : null,
2301
+ /* @__PURE__ */ jsx(LoadingScreen, { label: `Fetching ${title}\u2026` })
2302
+ ]
2303
+ }
2304
+ );
2305
+ const overlay = state.phase === "pr" ? { kind: "pr", item: state.item } : state.phase === "issue" ? { kind: "issue", item: state.item } : state.phase === "ext" ? {
2306
+ kind: "ext",
2307
+ extId: state.extId,
2308
+ target: state.target
2309
+ } : null;
2310
+ const toBrowse = () => setState({ phase: "browse", sections: state.sections, login: state.login });
2311
+ const removeAndReturn = (target) => setState({
2312
+ phase: "browse",
2313
+ sections: withoutItem(state.sections, target),
2314
+ login: state.login
2315
+ });
2316
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2317
+ /* @__PURE__ */ jsx(
2318
+ BrowseScreen,
2319
+ {
2320
+ brand: brandOf(title),
2321
+ sections: state.sections,
2322
+ login: state.login,
2323
+ jiraBase,
2324
+ jiraKeyRe,
2325
+ jiraTransitions,
2326
+ onRefresh: applyOrRefresh,
2327
+ refreshing,
2328
+ hasPending: pending !== null,
2329
+ fetchedAt,
2330
+ workToggle,
2331
+ isWorkRepo,
2332
+ initialIncludeWork,
2333
+ hidden: overlay !== null,
2334
+ ciStatusState: hasCiStatus ? ciStatusState : void 0,
2335
+ ciJob,
2336
+ tabHelp,
2337
+ onOpenPr: (item) => setState({
2338
+ phase: "pr",
2339
+ item,
2340
+ sections: state.sections,
2341
+ login: state.login
2342
+ }),
2343
+ onOpenIssue: (item) => setState({
2344
+ phase: "issue",
2345
+ item,
2346
+ sections: state.sections,
2347
+ login: state.login
2348
+ }),
2349
+ onOpenExt: (id, target) => setState({
2350
+ phase: "ext",
2351
+ extId: id,
2352
+ target,
2353
+ sections: state.sections,
2354
+ login: state.login
2355
+ })
2356
+ }
2357
+ ),
2358
+ (overlay?.kind === "pr" || overlay?.kind === "issue") && detailFor?.({
2359
+ item: overlay.item,
2360
+ kind: overlay.kind,
2361
+ login: state.login,
2362
+ onBack: toBrowse,
2363
+ onRefresh: applyOrRefresh,
2364
+ onRemove: removeAndReturn
2365
+ }),
2366
+ overlay?.kind === "ext" && extensions?.find((e) => e.id === overlay.extId)?.body(toBrowse, overlay.target)
2367
+ ] });
2368
+ };
599
2369
 
600
- export { CommentsPanel, HealthPanel, healthColor, healthDisplay, healthGlyph, healthLegend, renderMarkdown };
2370
+ export { ActionMenu, App, COLS, CiStatusLine, CommentsPanel, HealthPanel, buildActions, buildCheckoutCmd, clipboard, drillCmd, explainItem, filterByOrigin, filterByRepos, filterBySearch, fitCount, healthColor, healthDisplay, healthGlyph, healthLegend, insertRepoHeaders, itermRun, jumpToRepo, jumpToRepoPane, layoutGHItems, maxViewStart, moveCursor, openInTab, readCache, relativeTime, renderMarkdown, repoPriority, reposInSections, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, sortByRecency, sortItems, toCiStatusState, topLevelCount, truncate, useActionMenu, windowCount, withHeaders, withoutItem, writeCache };