@kud/gh-ink 0.20.0 → 0.22.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/dist/index.d.ts CHANGED
@@ -268,7 +268,7 @@ declare const ActionMenu: ({ item, actions, cursor, }: {
268
268
  actions: Action[];
269
269
  cursor: number;
270
270
  }) => React.JSX.Element;
271
- declare const App: ({ fetcher, cacheKey, title, detailFor, isWorkRepo, initialIncludeWork, jiraBase, jiraKeyRe, jiraTransitions, workToggle, hasCiStatus, ciJob, ciFetcher, ciPollMs, watchPath, watchDebounceMs, extensions, tabHelp, emptyHint, }: {
271
+ declare const App: ({ fetcher, cacheKey, title, detailFor, isWorkRepo, includeWork, jiraBase, jiraKeyRe, jiraTransitions, hasCiStatus, ciJob, ciFetcher, ciPollMs, watchPath, watchDebounceMs, extensions, tabHelp, emptyHint, }: {
272
272
  fetcher: () => Promise<{
273
273
  sections: Section[];
274
274
  login: string;
@@ -278,13 +278,21 @@ declare const App: ({ fetcher, cacheKey, title, detailFor, isWorkRepo, initialIn
278
278
  title?: string;
279
279
  detailFor?: (ctx: DetailContext) => React.ReactNode;
280
280
  isWorkRepo?: (repo: string) => boolean;
281
- initialIncludeWork?: boolean;
281
+ /**
282
+ * Which side of the split to show, chosen ONCE by the host at launch — there
283
+ * is no in-app toggle. `undefined` means no split: every row stands.
284
+ *
285
+ * The host decides from where the command was run, so the answer is settled
286
+ * before the first paint and cannot change under the reader. A `w` key used to
287
+ * flip it mid-session, which meant the inbox could disagree with the scope the
288
+ * command had been started in, with nothing on screen explaining why.
289
+ */
290
+ includeWork?: boolean;
282
291
  tabHelp?: [string, string][];
283
292
  emptyHint?: string;
284
293
  jiraBase?: string;
285
294
  jiraKeyRe?: RegExp;
286
295
  jiraTransitions?: JiraTransition[];
287
- workToggle?: boolean;
288
296
  hasCiStatus?: boolean;
289
297
  ciJob?: string;
290
298
  ciFetcher?: () => Promise<CiStatus | null>;
@@ -350,6 +358,19 @@ type InboxConfig = {
350
358
  * one machine would otherwise share a cache keyed only by section name.
351
359
  */
352
360
  cacheNamespace: string;
361
+ /**
362
+ * How long a cached inbox is trusted before a launch refetches. Every launch
363
+ * past it pays the full query — eight searches with check rollups — so this is
364
+ * the single knob between "always current" and "always slow, and drawing 502s
365
+ * from the API".
366
+ *
367
+ * It can be generous because staleness is bounded from the other end: acting
368
+ * on a row drops the entry outright, and `r` refetches on demand. What the TTL
369
+ * actually governs is how long a change made ELSEWHERE — someone approving
370
+ * your PR while you were not looking — can go unseen, which is a glance
371
+ * arriving late, not a wrong action taken.
372
+ */
373
+ cacheTtlMs: number;
353
374
  };
354
375
  /** Supply the host's opinions. Call once, before rendering. */
355
376
  declare const configureInbox: (config: Partial<InboxConfig>) => void;
@@ -366,4 +387,23 @@ declare const profileOf: (repo: string) => RepoProfile | null;
366
387
  */
367
388
  declare const checkoutDirs: () => string[];
368
389
 
369
- export { type Action, ActionMenu, type AnyItem, App, COLS, type CiStatus, CiStatusLine, type CiStatusState, CommentsPanel, type CommentsPanelProps, type DetailContext, type ExplainSection, type ExtensionTarget, type GHDetail, type GHItem, HealthPanel, type HealthPanelProps, type InboxConfig, type InboxExtension, type JiraTransition, type RepoHeader, type RepoProfile, type Section, type ShowLess, type ShowMore, type Standing, type SubgroupHeader, type TaskRow, buildActions, buildCheckoutCmd, checkoutDirs, clipboard, configureInbox, drillCmd, explainItem, filterByOrigin, filterByRepos, filterBySearch, fitCount, healthColor, healthDisplay, healthGlyph, healthLegend, inboxConfig, insertRepoHeaders, itermRun, jumpToRepo, jumpToRepoPane, layoutGHItems, maxViewStart, moveCursor, openInTab, profileOf, readCache, relativeTime, renderMarkdown, repoPriority, reposInSections, resetInboxConfig, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, sortByRecency, sortItems, toCiStatusState, topLevelCount, truncate, useActionMenu, whoseMove, windowCount, withHeaders, withoutItem, writeCache };
390
+ type RepoFilter = {
391
+ /** Show only repos matching one of these. Empty or absent means show all. */
392
+ include?: readonly string[];
393
+ /** Hide repos matching one of these. Applied after include, and wins. */
394
+ exclude?: readonly string[];
395
+ };
396
+ /**
397
+ * Whether a repo survives the filter. No include list means everything is in;
398
+ * exclude is applied afterwards and always wins, so a reader can take a whole
399
+ * org and drop one repo from it without listing the rest.
400
+ */
401
+ declare const matchesFilter: (repo: string, filter: RepoFilter) => boolean;
402
+ /**
403
+ * Parse a comma-separated pattern list, as a flag supplies it. Blank entries and
404
+ * surrounding whitespace are dropped, so a trailing comma or a quoted list with
405
+ * spaces after the commas both behave.
406
+ */
407
+ declare const parsePatterns: (value: string) => string[];
408
+
409
+ export { type Action, ActionMenu, type AnyItem, App, COLS, type CiStatus, CiStatusLine, type CiStatusState, CommentsPanel, type CommentsPanelProps, type DetailContext, type ExplainSection, type ExtensionTarget, type GHDetail, type GHItem, HealthPanel, type HealthPanelProps, type InboxConfig, type InboxExtension, type JiraTransition, type RepoFilter, type RepoHeader, type RepoProfile, type Section, type ShowLess, type ShowMore, type Standing, type SubgroupHeader, type TaskRow, buildActions, buildCheckoutCmd, checkoutDirs, clipboard, configureInbox, drillCmd, explainItem, filterByOrigin, filterByRepos, filterBySearch, fitCount, healthColor, healthDisplay, healthGlyph, healthLegend, inboxConfig, insertRepoHeaders, itermRun, jumpToRepo, jumpToRepoPane, layoutGHItems, matchesFilter, maxViewStart, moveCursor, openInTab, parsePatterns, profileOf, readCache, relativeTime, renderMarkdown, repoPriority, reposInSections, resetInboxConfig, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, sortByRecency, sortItems, toCiStatusState, topLevelCount, truncate, useActionMenu, whoseMove, windowCount, withHeaders, withoutItem, writeCache };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import React2, { createContext, useState, useRef, useEffect, useMemo, useContext } from 'react';
2
2
  import { useWindowSize, useInput, Text, Box } from 'ink';
3
- import { colors, ScrollView, TextInput, LoadingScreen, Switch, StatusMessage, FooterHints, useListCursor, Tabs } from '@kud/ink-ui';
3
+ import { colors, ScrollView, TextInput, LoadingScreen, StatusMessage, FooterHints, useListCursor, Tabs } 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
6
  import { $ } from 'zx';
@@ -611,7 +611,8 @@ var healthLegend = [
611
611
  var EMPTY = {
612
612
  repoPriority: [],
613
613
  profiles: [],
614
- cacheNamespace: "gh-ink"
614
+ cacheNamespace: "gh-ink",
615
+ cacheTtlMs: 6e5
615
616
  };
616
617
  var current = EMPTY;
617
618
  var configureInbox = (config) => {
@@ -630,7 +631,7 @@ var checkoutDirs = () => [
630
631
  ];
631
632
 
632
633
  // src/inbox/cache.ts
633
- var CACHE_TTL_MS = 12e4;
634
+ var cacheTtlMs = () => inboxConfig().cacheTtlMs;
634
635
  var CACHE_VERSION = 2;
635
636
  var cacheDir = () => join(
636
637
  process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
@@ -647,7 +648,7 @@ var readCache = (key) => {
647
648
  return null;
648
649
  }
649
650
  };
650
- var isFresh = (cached, now = Date.now()) => !!cached && now - cached.at < CACHE_TTL_MS;
651
+ var isFresh = (cached, now = Date.now()) => !!cached && now - cached.at < cacheTtlMs();
651
652
  var writeCache = (key, data) => {
652
653
  try {
653
654
  mkdirSync(cacheDir(), { recursive: true });
@@ -1284,6 +1285,20 @@ var drillLabel = (item) => {
1284
1285
  if (item.kind === "pr") return "Open PR";
1285
1286
  return "Drill in";
1286
1287
  };
1288
+ var UNSUBSCRIBE_MUTATION = `
1289
+ mutation($id: ID!) {
1290
+ updateSubscription(input: { subscribableId: $id, state: UNSUBSCRIBED }) {
1291
+ subscribable { viewerSubscription }
1292
+ }
1293
+ }
1294
+ `;
1295
+ var unsubscribeFrom = async (item) => {
1296
+ const view = item.kind === "pr" ? "pr" : "issue";
1297
+ const found = await quietly`gh ${view} view ${item.number} --repo ${item.repo} --json id --jq .id`;
1298
+ const id = found.stdout.trim();
1299
+ if (!id) throw new Error(`no node id for ${item.repo}#${item.number}`);
1300
+ await quietly`gh api graphql -f query=${UNSUBSCRIBE_MUTATION} -f id=${id}`;
1301
+ };
1287
1302
  var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions, onRefresh, onRemove, onOpenView, ext) => {
1288
1303
  if (item.kind === "repo-header" || item.kind === "subgroup-header" || item.kind === "show-more" || item.kind === "show-less")
1289
1304
  return [];
@@ -1369,6 +1384,14 @@ var buildActions = (item, login, showFlash, jiraBase, jiraKeyRe, jiraTransitions
1369
1384
  showFlash(`\u2713 Copied ${item.repo}`);
1370
1385
  }
1371
1386
  });
1387
+ actions.push({
1388
+ label: "Unsubscribe",
1389
+ hint: "u",
1390
+ run: () => {
1391
+ showFlash(`\u22EF Unsubscribing from #${item.number}\u2026`);
1392
+ void unsubscribeFrom(item).then(() => showFlash(`\u2713 Unsubscribed from #${item.number}`)).catch(() => showFlash(`\u2717 Unsubscribe failed for #${item.number}`));
1393
+ }
1394
+ });
1372
1395
  if (item.kind === "pr" && item.branch) {
1373
1396
  actions.push({
1374
1397
  label: "Copy branch name",
@@ -1556,7 +1579,7 @@ var InboxHeader = ({
1556
1579
  const total = sections.reduce((n, s) => n + topLevelCount(s), 0);
1557
1580
  const countSeg = loading ? " loading\u2026 " : quiet ? " " : ` ${String(total).padStart(3)} item${total !== 1 ? "s" : ""} \xB7 `;
1558
1581
  const userSeg = loading || quiet ? "" : `@${login} `;
1559
- const workLabel = work === void 0 ? "" : " w work \u25CF\u2500\u25CB home ";
1582
+ const workLabel = work === void 0 ? "" : ` ${work ? "work" : "home"} `;
1560
1583
  const [statusText, statusColor] = hasPending ? [`\u25CF ${pendingSummary || "new"} \xB7 r apply`, "#FF8700"] : refreshing ? ["\u21BB refreshing\u2026", "cyan"] : fetchedAt ? [`updated ${agoText(fetchedAt)}`, void 0] : ["", void 0];
1561
1584
  const statusSeg = statusText ? statusText + " " : "";
1562
1585
  const fill = Math.max(
@@ -1567,11 +1590,7 @@ var InboxHeader = ({
1567
1590
  /* @__PURE__ */ jsx(Text2, { color: "#FF8700", bold: true, children: brand }),
1568
1591
  /* @__PURE__ */ jsx(Text2, { dimColor: true, children: countSeg }),
1569
1592
  userSeg ? /* @__PURE__ */ jsx(Text2, { children: userSeg }) : null,
1570
- work !== void 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
1571
- /* @__PURE__ */ jsx(Text2, { dimColor: true, children: " w " }),
1572
- /* @__PURE__ */ jsx(Switch, { left: "work", right: "home", value: work ? "left" : "right" }),
1573
- /* @__PURE__ */ jsx(Text2, { dimColor: true, children: " " })
1574
- ] }) : null,
1593
+ work !== void 0 ? /* @__PURE__ */ jsx(Text2, { dimColor: true, children: workLabel }) : null,
1575
1594
  statusText ? /* @__PURE__ */ jsx(
1576
1595
  Text2,
1577
1596
  {
@@ -1857,7 +1876,6 @@ var LegendGroup = ({
1857
1876
  ] });
1858
1877
  var LEGEND_FOOTNOTE = "Each item lands in the FIRST tab that claims it, so counts are residuals rather than totals.";
1859
1878
  var HelpModal = ({
1860
- workToggle,
1861
1879
  extensions,
1862
1880
  hasJira,
1863
1881
  tabHelp,
@@ -1882,11 +1900,11 @@ var HelpModal = ({
1882
1900
  ["s", "switch to branch here"],
1883
1901
  ["j", "open repo in new tab"],
1884
1902
  ["p", "open repo in new pane"],
1903
+ ["u", "unsubscribe from this item"],
1885
1904
  ...hasJira ? [["t", "Jira: move / open ticket"]] : [],
1886
1905
  ["/", "search"],
1887
1906
  ["f", "filter by repo"],
1888
1907
  ["r", "refresh"],
1889
- ...workToggle ? [["w", "toggle work / home"]] : [],
1890
1908
  ...extensionLegend(extensions),
1891
1909
  ["?", "this help"],
1892
1910
  ["q", "quit"]
@@ -2063,7 +2081,6 @@ var BrowseScreen = ({
2063
2081
  pendingSummary,
2064
2082
  fetchedAt,
2065
2083
  refreshError,
2066
- workToggle,
2067
2084
  hidden,
2068
2085
  onOpenPr,
2069
2086
  onOpenIssue,
@@ -2073,18 +2090,15 @@ var BrowseScreen = ({
2073
2090
  ciJob,
2074
2091
  tabHelp,
2075
2092
  isWorkRepo,
2076
- initialIncludeWork,
2093
+ includeWork,
2077
2094
  brand,
2078
2095
  mergedUrls,
2079
2096
  transients,
2080
2097
  onTabChange
2081
2098
  }) => {
2082
2099
  const { rows } = useWindowSize();
2083
- const [includeWork, setIncludeWork] = useState(
2084
- () => workToggle ? initialIncludeWork ?? true : true
2085
- );
2086
- const applyWork = (secs, include) => !workToggle || !isWorkRepo ? secs : filterByOrigin(secs, include ? "work" : "home", isWorkRepo);
2087
- const initialSections = applyWork(sections, includeWork);
2100
+ const applyWork = (secs) => includeWork === void 0 || !isWorkRepo ? secs : filterByOrigin(secs, includeWork ? "work" : "home", isWorkRepo);
2101
+ const initialSections = applyWork(sections);
2088
2102
  const [localSections, setLocalSections] = useState(initialSections);
2089
2103
  const [tabIdx, setTabIdx] = useState(0);
2090
2104
  const [cursors, setCursors] = useState(
@@ -2120,7 +2134,7 @@ var BrowseScreen = ({
2120
2134
  setViewStarts((p) => ({ ...p, [activeId]: 0 }));
2121
2135
  }, [search]);
2122
2136
  useEffect(() => {
2123
- setLocalSections(applyWork(sections, includeWork));
2137
+ setLocalSections(applyWork(sections));
2124
2138
  }, [sections]);
2125
2139
  useEffect(() => {
2126
2140
  setTabIdx((prev) => Math.min(prev, Math.max(0, localSections.length - 1)));
@@ -2314,15 +2328,6 @@ var BrowseScreen = ({
2314
2328
  onRefresh?.();
2315
2329
  return;
2316
2330
  }
2317
- if (workToggle && input === "w") {
2318
- const next = !includeWork;
2319
- const nextSections = applyWork(sections, next);
2320
- const keptIdx = nextSections.findIndex((s) => s.id === activeId);
2321
- setIncludeWork(next);
2322
- setLocalSections(nextSections);
2323
- if (keptIdx >= 0) setTabIdx(keptIdx);
2324
- return;
2325
- }
2326
2331
  const ext = extensionFor(input, extensions);
2327
2332
  if (ext) {
2328
2333
  onOpenExt?.(ext.id, {
@@ -2413,6 +2418,11 @@ var BrowseScreen = ({
2413
2418
  }
2414
2419
  return;
2415
2420
  }
2421
+ if (input === "u" && (activeItem.kind === "pr" || activeItem.kind === "issue")) {
2422
+ showFlash(`\u22EF Unsubscribing from #${activeItem.number}\u2026`);
2423
+ void unsubscribeFrom(activeItem).then(() => showFlash(`\u2713 Unsubscribed from #${activeItem.number}`)).catch(() => showFlash(`\u2717 Unsubscribe failed for #${activeItem.number}`));
2424
+ return;
2425
+ }
2416
2426
  if (input === "b" && activeItem.kind === "pr" && activeItem.branch) {
2417
2427
  clipboard(activeItem.branch);
2418
2428
  showFlash(`\u2713 Copied ${activeItem.branch}`);
@@ -2494,7 +2504,6 @@ var BrowseScreen = ({
2494
2504
  const overlay = help ? /* @__PURE__ */ jsx(
2495
2505
  HelpModal,
2496
2506
  {
2497
- workToggle,
2498
2507
  extensions,
2499
2508
  hasJira: !!jiraBase,
2500
2509
  tabHelp,
@@ -2526,7 +2535,7 @@ var BrowseScreen = ({
2526
2535
  brand,
2527
2536
  sections: localSections,
2528
2537
  login,
2529
- work: workToggle ? includeWork : void 0,
2538
+ work: includeWork,
2530
2539
  refreshing,
2531
2540
  hasPending,
2532
2541
  pendingSummary,
@@ -2638,11 +2647,10 @@ var App = ({
2638
2647
  title = "inbox",
2639
2648
  detailFor,
2640
2649
  isWorkRepo,
2641
- initialIncludeWork,
2650
+ includeWork,
2642
2651
  jiraBase,
2643
2652
  jiraKeyRe,
2644
2653
  jiraTransitions,
2645
- workToggle,
2646
2654
  hasCiStatus,
2647
2655
  ciJob,
2648
2656
  ciFetcher,
@@ -2851,7 +2859,7 @@ var App = ({
2851
2859
  brand: brandOf(title),
2852
2860
  sections: [],
2853
2861
  login: "",
2854
- work: workToggle && state.phase === "loading" ? initialIncludeWork ?? true : void 0,
2862
+ work: state.phase === "loading" ? includeWork : void 0,
2855
2863
  loading: state.phase === "loading",
2856
2864
  quiet: state.phase !== "loading"
2857
2865
  }
@@ -2910,9 +2918,8 @@ var App = ({
2910
2918
  pendingSummary,
2911
2919
  fetchedAt,
2912
2920
  refreshError: refreshError ?? void 0,
2913
- workToggle,
2914
2921
  isWorkRepo,
2915
- initialIncludeWork,
2922
+ includeWork,
2916
2923
  hidden: overlay !== null,
2917
2924
  mergedUrls,
2918
2925
  transients,
@@ -2955,4 +2962,18 @@ var App = ({
2955
2962
  ] });
2956
2963
  };
2957
2964
 
2958
- export { ActionMenu, App, COLS, CiStatusLine, CommentsPanel, HealthPanel, buildActions, buildCheckoutCmd, checkoutDirs, clipboard, configureInbox, drillCmd, explainItem, filterByOrigin, filterByRepos, filterBySearch, fitCount, healthColor, healthDisplay, healthGlyph, healthLegend, inboxConfig, insertRepoHeaders, itermRun, jumpToRepo, jumpToRepoPane, layoutGHItems, maxViewStart, moveCursor, openInTab, profileOf, readCache, relativeTime, renderMarkdown, repoPriority, reposInSections, resetInboxConfig, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, sortByRecency, sortItems, toCiStatusState, topLevelCount, truncate, useActionMenu, whoseMove, windowCount, withHeaders, withoutItem, writeCache };
2965
+ // src/inbox/filter.ts
2966
+ var toRegExp = (pattern) => {
2967
+ const full = pattern.includes("/") ? pattern : `${pattern}/*`;
2968
+ const escaped = full.replace(/[.+^${}()|[\]\\]/g, "\\$&");
2969
+ return new RegExp(`^${escaped.replace(/\*/g, "[^/]*")}$`, "i");
2970
+ };
2971
+ var matchesAny = (repo, patterns) => patterns.some((p) => toRegExp(p).test(repo));
2972
+ var matchesFilter = (repo, filter) => {
2973
+ const { include = [], exclude = [] } = filter;
2974
+ if (include.length > 0 && !matchesAny(repo, include)) return false;
2975
+ return !matchesAny(repo, exclude);
2976
+ };
2977
+ var parsePatterns = (value) => value.split(",").map((p) => p.trim()).filter(Boolean);
2978
+
2979
+ export { ActionMenu, App, COLS, CiStatusLine, CommentsPanel, HealthPanel, buildActions, buildCheckoutCmd, checkoutDirs, clipboard, configureInbox, drillCmd, explainItem, filterByOrigin, filterByRepos, filterBySearch, fitCount, healthColor, healthDisplay, healthGlyph, healthLegend, inboxConfig, insertRepoHeaders, itermRun, jumpToRepo, jumpToRepoPane, layoutGHItems, matchesFilter, maxViewStart, moveCursor, openInTab, parsePatterns, profileOf, readCache, relativeTime, renderMarkdown, repoPriority, reposInSections, resetInboxConfig, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, sortByRecency, sortItems, toCiStatusState, topLevelCount, truncate, useActionMenu, whoseMove, windowCount, withHeaders, withoutItem, writeCache };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kud/gh-ink",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Ink components for rendering GitHub PR review comments and health — controlled, presentation-only, built on @kud/ink-ui and fed by @kud/gh.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",