@kud/gh-ink 0.45.1 → 0.45.3

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
@@ -279,24 +279,52 @@ declare const runInPaneHorizontal: (cmd: string) => Promise<void>;
279
279
  declare const runHere: (cmd: string) => void;
280
280
  declare const COLS: number;
281
281
  /**
282
- * What the tab badge and the header total both count: rows that are somebody's
283
- * work, at the top level.
282
+ * What the tab badge and the header total both count.
284
283
  *
285
- * The exclusions are two different kinds of thing. Headers and the
286
- * show-more/show-less affordances are furniture — not entities at all, which is
287
- * why the search and repo filters drop them too. This function used to exclude
288
- * only the two header kinds while its three siblings excluded all four, so a
289
- * collapsed tail sitting at the top level counted as an item.
290
- * `role: "container"` rows ARE entities, selectable and openable; they simply
291
- * are not work. An epic is the case that forced the distinction: real, owned,
292
- * and on screen until it closes, but the work is the stories under it, and
284
+ * ONE INVARIANT: a piece of work counts once, at whatever depth the board
285
+ * happens to file it. Everything below follows from that, and the name says so
286
+ * because the old one — `workCount` — read as a fact about the tree and so
287
+ * nobody re-asked whether the tree still meant what it had when it was written.
288
+ * It did not, and the count silently went to zero.
289
+ *
290
+ * Furniture never counts: headers and the show-more/show-less affordances are
291
+ * not entities at all, which is why the search and repo filters drop them too.
292
+ * `role: "container"` rows ARE entities, selectable and openable; they are
293
+ * simply not work. An epic is the case that forced the distinction — real,
294
+ * owned, on screen until it closes, but the work is the stories under it, and
293
295
  * counting it as well reported five things to do where there were four.
294
296
  *
297
+ * Everything else counts when it is a LEAF — when nothing hangs under it. That
298
+ * is the only predicate under which the invariant holds. A PR on a tab of its
299
+ * own counts one; the same PR after its ticket becomes parseable, so that it now
300
+ * hangs under a story, still counts one. Counting every non-container row would
301
+ * make that transition raise the total, which is the epic bug one level down:
302
+ * nothing was created, the board merely got tidier, and the header claims you
303
+ * have more to do.
304
+ *
305
+ * It also settles the 0-to-1 case in the right direction. A story with no PR is
306
+ * its own leaf and counts one — a ticket in review with nothing to show is real
307
+ * work, arguably the most actionable row on the board. It gains its first PR and
308
+ * still counts one: the story stops being the item and its PR becomes the item.
309
+ * A row earns container-hood by HAVING children, which is why this lives in the
310
+ * predicate rather than in a flag the host has to remember to set.
311
+ *
312
+ * A collapsed tail contributes what it HIDES, and that line is load-bearing:
313
+ * pressing return on a `show-more` splices its rows into `items` in place, so
314
+ * without it the badge would rise on expanding and fall on collapsing — a key
315
+ * that only changes what is drawn would change how much work you have. With it,
316
+ * collapsed contributes `hidden.length` and expanded contributes the same rows
317
+ * as leaves, and the number holds still.
318
+ *
319
+ * Leaf-ness is read off the RENDERED list, so a search that hides a story's PRs
320
+ * turns that story back into a leaf and it counts itself. That is deliberate:
321
+ * the badge describes what is on screen to act on.
322
+ *
295
323
  * One function, both consumers, on purpose. The badge and the whole-board total
296
324
  * are the same claim at two scales; computed apart they drift, and a header that
297
325
  * disagrees with the sum of its own tabs is worse than either number alone.
298
326
  */
299
- declare const topLevelCount: (s: Section) => number;
327
+ declare const workCount: (s: Section) => number;
300
328
  declare const drillCmd: (item: AnyItem) => string | null;
301
329
  declare const explainGhAction: (e: unknown) => string;
302
330
  declare const buildActions: (item: AnyItem, login: string, showFlash: (msg: string) => void, jiraBase?: string, jiraKeyRe?: RegExp, jiraTransitions?: JiraTransition[], onRefresh?: () => void, onRemove?: (item: GHItem) => void, onOpenView?: (item: AnyItem) => boolean, ext?: {
@@ -418,4 +446,4 @@ declare const matchesFilter: (repo: string, filter: RepoFilter) => boolean;
418
446
  */
419
447
  declare const parsePatterns: (value: string) => string[];
420
448
 
421
- export { type Action, ActionMenu, App, COLS, CiStatusLine, type CiStatusState, CommentsPanel, type CommentsPanelProps, type DetailContext, type ExplainSection, type ExtensionTarget, HealthPanel, type HealthPanelProps, type InboxBudget, type InboxExtension, type JiraTransition, type RepoFilter, SIDEBAR_COLS, SidePanel, type Sidebar, type SidebarRow, budgetNotice, buildActions, buildCheckoutCmd, clipboard, counts, drillCmd, explainGhAction, explainItem, fitCount, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, itermRun, jumpToRepo, jumpToRepoPane, matchesFilter, maxViewStart, moveCursor, openInTab, parsePatterns, railCapacity, readCache, renderMarkdown, reposInSections, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, signatureOf, toCiStatusState, topLevelCount, truncate, useActionMenu, windowCount, withHeaders, withoutItem, writeCache };
449
+ export { type Action, ActionMenu, App, COLS, CiStatusLine, type CiStatusState, CommentsPanel, type CommentsPanelProps, type DetailContext, type ExplainSection, type ExtensionTarget, HealthPanel, type HealthPanelProps, type InboxBudget, type InboxExtension, type JiraTransition, type RepoFilter, SIDEBAR_COLS, SidePanel, type Sidebar, type SidebarRow, budgetNotice, buildActions, buildCheckoutCmd, clipboard, counts, drillCmd, explainGhAction, explainItem, fitCount, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, itermRun, jumpToRepo, jumpToRepoPane, matchesFilter, maxViewStart, moveCursor, openInTab, parsePatterns, railCapacity, readCache, renderMarkdown, reposInSections, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, signatureOf, toCiStatusState, truncate, useActionMenu, windowCount, withHeaders, withoutItem, workCount, writeCache };
package/dist/index.js CHANGED
@@ -1352,9 +1352,23 @@ var Backdrop = ({
1352
1352
  children
1353
1353
  }
1354
1354
  ) : children });
1355
- var topLevelCount = (s) => s.items.filter(
1356
- (i) => i.kind !== "repo-header" && i.kind !== "subgroup-header" && i.kind !== "show-more" && i.kind !== "show-less" && !("role" in i && i.role === "container") && depthOf(i) === 0
1357
- ).length;
1355
+ var workCount = (s) => {
1356
+ let n = 0;
1357
+ for (let i = 0; i < s.items.length; i++) {
1358
+ const item = s.items[i];
1359
+ if (!item) continue;
1360
+ if (item.kind === "show-more") {
1361
+ n += item.hidden.length;
1362
+ continue;
1363
+ }
1364
+ if (item.kind === "repo-header" || item.kind === "subgroup-header" || item.kind === "show-less")
1365
+ continue;
1366
+ if ("role" in item && item.role === "container") continue;
1367
+ if (depthOf(s.items[i + 1]) > depthOf(item)) continue;
1368
+ n += 1;
1369
+ }
1370
+ return n;
1371
+ };
1358
1372
  var drillCmd = (item) => {
1359
1373
  if (item.kind === "task")
1360
1374
  return item.ticket ? `jira issue view ${item.ticket}` : null;
@@ -1701,7 +1715,7 @@ var InboxHeader = ({
1701
1715
  appliedSummary,
1702
1716
  fetchedAt
1703
1717
  }) => {
1704
- const total = sections.reduce((n, s) => n + topLevelCount(s), 0);
1718
+ const total = sections.reduce((n, s) => n + workCount(s), 0);
1705
1719
  const countSeg = loading ? " loading\u2026 " : quiet ? " " : ` ${String(total).padStart(3)} item${total !== 1 ? "s" : ""} \xB7 `;
1706
1720
  const userSeg = loading || quiet ? "" : `@${login} `;
1707
1721
  const workLabel = scopeLabel ? ` ${scopeLabel} ` : "";
@@ -1786,14 +1800,20 @@ var RepoHeaderRow = ({
1786
1800
  /* @__PURE__ */ jsx(Text3, { dimColor: !active, children: " " + rule })
1787
1801
  ] });
1788
1802
  };
1789
- var MERGED_HOLD_MS = 5e3;
1803
+ var MERGED_HOLD_MS = 3e3;
1790
1804
  var MERGED_FRAME_MS = 150;
1791
1805
  var MERGED_FRAMES = ["\u2726", "\u2727", "\u2736", "\u2727"];
1792
1806
  var MERGED_COLOUR = "#A371F7";
1793
1807
  var TRANSIT_HOLD_MS = 7e3;
1794
- var TRANSIT_FRAME_TICKS = 3;
1795
- var PULSE_SETTLE_MS = 6e4;
1808
+ var TRANSIT_FRAME_TICKS = 2;
1809
+ var TRANSIT_FRAME_MS = MERGED_FRAME_MS * TRANSIT_FRAME_TICKS;
1810
+ var rampFrame = (len, loopFrame, transitAt, now = Date.now()) => transitAt == null ? loopFrame % len : Math.max(
1811
+ 0,
1812
+ Math.min(len - 1, Math.floor((now - transitAt) / TRANSIT_FRAME_MS))
1813
+ );
1814
+ var PULSE_SETTLE_MS = 12e3;
1796
1815
  var PULSE_MAX_FRAMES = Math.ceil(PULSE_SETTLE_MS / MERGED_FRAME_MS);
1816
+ var PULSE_IDLE_MS = 6e4;
1797
1817
  var PULSE_SETTLED_FRAME = 3;
1798
1818
  var NO_TRANSIENTS = /* @__PURE__ */ new Map();
1799
1819
  var NO_HOLDS = /* @__PURE__ */ new Map();
@@ -1825,6 +1845,7 @@ var ItemRow = ({
1825
1845
  merged,
1826
1846
  transient: refreshMark,
1827
1847
  leaving,
1848
+ transitAt,
1828
1849
  prefix = "",
1829
1850
  parent,
1830
1851
  sparkFrame = 0,
@@ -1833,6 +1854,7 @@ var ItemRow = ({
1833
1854
  const backdropped = useBackdropped();
1834
1855
  const transient = leaving ? "out" : refreshMark;
1835
1856
  const transitFrame = Math.floor(sparkFrame / TRANSIT_FRAME_TICKS);
1857
+ const rampAt = (len) => rampFrame(len, transitFrame, transitAt);
1836
1858
  const farewellLabel = leaving ? TRANSIT_LABEL.out : "";
1837
1859
  if (item.kind === "repo-header")
1838
1860
  return /* @__PURE__ */ jsx(RepoHeaderRow, { repo: item.repo, gap: gap ?? false, active });
@@ -1857,7 +1879,7 @@ var ItemRow = ({
1857
1879
  ] });
1858
1880
  if (item.kind === "task") {
1859
1881
  const note = item.note ?? "";
1860
- const transitIcon = !transient ? " " : isDeparture(transient) ? TRANSIT_OUT_FRAMES[transitFrame % TRANSIT_OUT_FRAMES.length] : isArrival(transient) ? TRANSIT_IN_FRAMES[transitFrame % TRANSIT_IN_FRAMES.length] : "\u25C9";
1882
+ const transitIcon = !transient ? " " : isDeparture(transient) ? TRANSIT_OUT_FRAMES[rampAt(TRANSIT_OUT_FRAMES.length)] : isArrival(transient) ? TRANSIT_IN_FRAMES[rampAt(TRANSIT_IN_FRAMES.length)] : "\u25C9";
1861
1883
  const titleMax2 = Math.max(
1862
1884
  1,
1863
1885
  cols - item.key.length - note.length - (item.pill ? pillWidth(item.pill) + 1 : 0) - (farewellLabel ? pillWidth(farewellLabel) + 2 : 0) - prefix.length - 12
@@ -1889,7 +1911,7 @@ var ItemRow = ({
1889
1911
  ] });
1890
1912
  }
1891
1913
  const { glyph: healthIcon, color: healthColor2 } = healthDisplay[item.health];
1892
- const icon = merged ? MERGED_FRAMES[sparkFrame % MERGED_FRAMES.length] : transient && isDeparture(transient) ? TRANSIT_OUT_FRAMES[transitFrame % TRANSIT_OUT_FRAMES.length] : transient && isArrival(transient) ? TRANSIT_IN_FRAMES[transitFrame % TRANSIT_IN_FRAMES.length] : healthIcon;
1914
+ const icon = merged ? MERGED_FRAMES[sparkFrame % MERGED_FRAMES.length] : transient && isDeparture(transient) ? TRANSIT_OUT_FRAMES[rampAt(TRANSIT_OUT_FRAMES.length)] : transient && isArrival(transient) ? TRANSIT_IN_FRAMES[rampAt(TRANSIT_IN_FRAMES.length)] : healthIcon;
1893
1915
  const color = merged ? MERGED_COLOUR : transient ? TRANSIT_COLOUR[transient] : healthColor2;
1894
1916
  const spokeLast = !!login && !!item.lastActor && item.lastActor === login;
1895
1917
  const [turnIcon, turnColor] = item.pinned ? [PIN_MARK, "#FF8700"] : !login || !item.lastActor ? [" ", "white"] : spokeLast ? ["\u2192", "#888888"] : ["\u2190", "#FF8700"];
@@ -2311,6 +2333,7 @@ var BrowseScreen = ({
2311
2333
  brand,
2312
2334
  mergedUrls,
2313
2335
  leavingUrls,
2336
+ transitSince,
2314
2337
  onLeave,
2315
2338
  transients,
2316
2339
  onTabChange,
@@ -2414,7 +2437,7 @@ var BrowseScreen = ({
2414
2437
  out.add(s.id);
2415
2438
  return out;
2416
2439
  }, [localSections, transients]);
2417
- const [railOpen, setRailOpen] = useState(true);
2440
+ const [railOpen, setRailOpen] = useState(false);
2418
2441
  const showRail = railOpen && !!sidebar;
2419
2442
  const [railFocus, setRailFocus] = useState(false);
2420
2443
  const [railCursor, setRailCursor] = useState(0);
@@ -2434,20 +2457,28 @@ var BrowseScreen = ({
2434
2457
  mergedUrls?.length ?? 0,
2435
2458
  leavingUrls?.length ?? 0
2436
2459
  ].join("|") : "";
2460
+ const [pulseArm, setPulseArm] = useState(0);
2461
+ const lastKeyAt = useRef(Date.now());
2462
+ const pulseKey = sparkKey ? `${sparkKey}#${pulseArm}` : "";
2463
+ const [pulseSettled, setPulseSettled] = useState(false);
2437
2464
  useEffect(() => {
2438
- if (!sparkKey) return;
2465
+ if (!pulseKey) return;
2466
+ setPulseSettled(false);
2439
2467
  let frames = 0;
2440
2468
  const id = setInterval(() => {
2441
2469
  frames += 1;
2442
2470
  if (frames >= PULSE_MAX_FRAMES) {
2443
2471
  clearInterval(id);
2444
- setSparkFrame(PULSE_SETTLED_FRAME);
2472
+ setPulseSettled(true);
2445
2473
  return;
2446
2474
  }
2447
2475
  setSparkFrame((f) => f + 1);
2448
2476
  }, MERGED_FRAME_MS);
2449
2477
  return () => clearInterval(id);
2450
- }, [sparkKey]);
2478
+ }, [pulseKey]);
2479
+ useEffect(() => {
2480
+ if (sparkKey) setPulseArm((n) => n + 1);
2481
+ }, [tabIdx]);
2451
2482
  useEffect(() => {
2452
2483
  if (refreshError) showFlash(`\u2717 ${refreshError.message}`);
2453
2484
  }, [refreshError]);
@@ -2482,6 +2513,9 @@ var BrowseScreen = ({
2482
2513
  menu.open(actions);
2483
2514
  };
2484
2515
  useInput((input, key) => {
2516
+ const idleFor = Date.now() - lastKeyAt.current;
2517
+ lastKeyAt.current = Date.now();
2518
+ if (idleFor >= PULSE_IDLE_MS && sparkKey) setPulseArm((n) => n + 1);
2485
2519
  if (hidden) return;
2486
2520
  if (key.ctrl || key.meta) return;
2487
2521
  if (help) {
@@ -2940,12 +2974,16 @@ var BrowseScreen = ({
2940
2974
  items: localSections.map((s) => ({
2941
2975
  value: s.id,
2942
2976
  label: s.label,
2943
- count: topLevelCount(s),
2977
+ count: workCount(s),
2944
2978
  // Its own cell, always two wide, so news arriving never slides the
2945
2979
  // bar — and free to animate for exactly that reason.
2946
2980
  // The ticker undivided — see TAB_PULSE. The row marks' /3 buys quiet
2947
2981
  // for motion sitting beside text, and this is not that.
2948
- marker: tabMarker(markedTabs, s.id, sparkFrame),
2982
+ marker: tabMarker(
2983
+ markedTabs,
2984
+ s.id,
2985
+ pulseSettled ? PULSE_SETTLED_FRAME : sparkFrame
2986
+ ),
2949
2987
  markerColor: "#FF8700"
2950
2988
  }))
2951
2989
  }
@@ -2979,6 +3017,7 @@ var BrowseScreen = ({
2979
3017
  merged: (item.kind === "pr" || item.kind === "issue") && !!mergedUrls?.includes(item.url),
2980
3018
  leaving: (item.kind === "pr" || item.kind === "issue") && !!leavingUrls?.includes(item.url),
2981
3019
  transient: transientOf(transients, item, section.id),
3020
+ transitAt: "url" in item ? transitSince?.get(item.url) : void 0,
2982
3021
  prefix: treePrefix(section.items, viewStart + i),
2983
3022
  parent: item.kind === "task" && depthOf(section.items[viewStart + i + 1]) > depthOf(item),
2984
3023
  sparkFrame,
@@ -3316,8 +3355,24 @@ var App = ({
3316
3355
  const [skippedForBudget, setSkippedForBudget] = useState(false);
3317
3356
  const [mergedUrls, setMergedUrls] = useState([]);
3318
3357
  const [leavingUrls, setLeavingUrls] = useState([]);
3358
+ const leavingSince = useRef(/* @__PURE__ */ new Map());
3359
+ const transitSince = useMemo(() => {
3360
+ const out = new Map(leavingSince.current);
3361
+ for (const [url, tab] of transitTabs.current) {
3362
+ const at = heldSince.get(tab);
3363
+ if (at != null && !out.has(url)) out.set(url, at);
3364
+ }
3365
+ return out;
3366
+ }, [heldSince, transients, leavingUrls]);
3319
3367
  const holdTimers = useRef([]);
3320
3368
  useEffect(() => () => holdTimers.current.forEach(clearTimeout), []);
3369
+ const holdFor = (fn, ms) => {
3370
+ const id = setTimeout(() => {
3371
+ holdTimers.current = holdTimers.current.filter((t) => t !== id);
3372
+ fn();
3373
+ }, ms);
3374
+ holdTimers.current.push(id);
3375
+ };
3321
3376
  if (state.phase === "loading" || state.phase === "empty" || state.phase === "failed")
3322
3377
  return /* @__PURE__ */ jsxs(
3323
3378
  Box,
@@ -3367,31 +3422,30 @@ var App = ({
3367
3422
  const toBrowse = () => setState({ phase: "browse", sections: state.sections, login: state.login });
3368
3423
  const markLeaving = (target) => {
3369
3424
  if (state.phase !== "browse") toBrowse();
3425
+ if (!leavingSince.current.has(target.url))
3426
+ leavingSince.current.set(target.url, Date.now());
3370
3427
  setLeavingUrls(
3371
3428
  (prev) => prev.includes(target.url) ? prev : [...prev, target.url]
3372
3429
  );
3373
- holdTimers.current.push(
3374
- setTimeout(() => {
3375
- setLeavingUrls((prev) => prev.filter((u) => u !== target.url));
3376
- setState(
3377
- (s) => s.phase === "browse" ? { ...s, sections: withoutItem(s.sections, target) } : s
3378
- );
3379
- }, LEAVING_HOLD_MS)
3380
- );
3430
+ holdFor(() => {
3431
+ leavingSince.current.delete(target.url);
3432
+ setLeavingUrls((prev) => prev.filter((u) => u !== target.url));
3433
+ setState(
3434
+ (s) => s.phase === "browse" ? { ...s, sections: withoutItem(s.sections, target) } : s
3435
+ );
3436
+ }, LEAVING_HOLD_MS);
3381
3437
  };
3382
3438
  const markMerged = (target) => {
3383
3439
  toBrowse();
3384
3440
  setMergedUrls(
3385
3441
  (prev) => prev.includes(target.url) ? prev : [...prev, target.url]
3386
3442
  );
3387
- holdTimers.current.push(
3388
- setTimeout(() => {
3389
- setMergedUrls((prev) => prev.filter((u) => u !== target.url));
3390
- setState(
3391
- (s) => s.phase === "browse" ? { ...s, sections: withoutItem(s.sections, target) } : s
3392
- );
3393
- }, MERGED_HOLD_MS)
3394
- );
3443
+ holdFor(() => {
3444
+ setMergedUrls((prev) => prev.filter((u) => u !== target.url));
3445
+ setState(
3446
+ (s) => s.phase === "browse" ? { ...s, sections: withoutItem(s.sections, target) } : s
3447
+ );
3448
+ }, MERGED_HOLD_MS);
3395
3449
  };
3396
3450
  return /* @__PURE__ */ jsxs(Fragment, { children: [
3397
3451
  /* @__PURE__ */ jsx(
@@ -3417,6 +3471,7 @@ var App = ({
3417
3471
  hidden: overlay !== null,
3418
3472
  mergedUrls,
3419
3473
  leavingUrls,
3474
+ transitSince,
3420
3475
  onLeave: markLeaving,
3421
3476
  transients,
3422
3477
  onTabChange: setVisibleTab,
@@ -3473,4 +3528,4 @@ var matchesFilter = (repo, filter) => {
3473
3528
  };
3474
3529
  var parsePatterns = (value) => value.split(",").map((p) => p.trim()).filter(Boolean);
3475
3530
 
3476
- export { ActionMenu, App, COLS, CiStatusLine, CommentsPanel, HealthPanel, SIDEBAR_COLS, SidePanel, budgetNotice, buildActions, buildCheckoutCmd, clipboard, counts, drillCmd, explainGhAction, explainItem, fitCount, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, itermRun, jumpToRepo, jumpToRepoPane, matchesFilter, maxViewStart, moveCursor, openInTab, parsePatterns, railCapacity, readCache, renderMarkdown, reposInSections, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, signatureOf, toCiStatusState, topLevelCount, truncate2 as truncate, useActionMenu, windowCount, withHeaders, withoutItem, writeCache };
3531
+ export { ActionMenu, App, COLS, CiStatusLine, CommentsPanel, HealthPanel, SIDEBAR_COLS, SidePanel, budgetNotice, buildActions, buildCheckoutCmd, clipboard, counts, drillCmd, explainGhAction, explainItem, fitCount, gapsAbove, healthColor, healthDisplay, healthGlyph, healthLegend, itermRun, jumpToRepo, jumpToRepoPane, matchesFilter, maxViewStart, moveCursor, openInTab, parsePatterns, railCapacity, readCache, renderMarkdown, reposInSections, resolveRepoPath, runHere, runInPane, runInPaneHorizontal, sameCiStatusState, signatureOf, toCiStatusState, truncate2 as truncate, useActionMenu, windowCount, withHeaders, withoutItem, workCount, writeCache };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kud/gh-ink",
3
- "version": "0.45.1",
3
+ "version": "0.45.3",
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",