git-coco 0.40.1 → 0.41.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.js CHANGED
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
78
78
  /**
79
79
  * Current build version from package.json
80
80
  */
81
- const BUILD_VERSION = "0.40.1";
81
+ const BUILD_VERSION = "0.41.0";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -16338,6 +16338,11 @@ function replaceRows(state, rows) {
16338
16338
  selectedFileIndex: 0,
16339
16339
  pendingCommitFocused: false,
16340
16340
  pendingKey: undefined,
16341
+ // Rows just landed — clear the boot-loading flag so the history
16342
+ // surface drops the "Loading commits…" placeholder. Safe to clear
16343
+ // unconditionally because `replaceRows` only fires after a real
16344
+ // git log returns.
16345
+ bootLoading: false,
16341
16346
  };
16342
16347
  }
16343
16348
  function appendRows(state, rows) {
@@ -16428,6 +16433,7 @@ function createLogInkState(rows, options = {}) {
16428
16433
  diffViewMode: 'unified',
16429
16434
  inspectorTab: 'inspector',
16430
16435
  inspectorActionIndex: 0,
16436
+ bootLoading: options.bootLoading ?? false,
16431
16437
  };
16432
16438
  }
16433
16439
  function getSelectedInkCommit(state) {
@@ -16629,6 +16635,12 @@ function applyLogInkAction(state, action) {
16629
16635
  inspectorActionIndex: 0,
16630
16636
  pendingKey: undefined,
16631
16637
  };
16638
+ case 'setBootLoading':
16639
+ return {
16640
+ ...state,
16641
+ bootLoading: action.value,
16642
+ pendingKey: undefined,
16643
+ };
16632
16644
  case 'moveTag':
16633
16645
  return {
16634
16646
  ...state,
@@ -18705,7 +18717,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18705
18717
  * fall back to "already seen" so we never block startup.
18706
18718
  */
18707
18719
  const MARKER_BASENAME = 'onboarding.seen';
18708
- function resolveCacheDir$2() {
18720
+ function resolveCacheDir$3() {
18709
18721
  const xdg = process.env.XDG_CACHE_HOME;
18710
18722
  if (xdg && xdg.trim().length > 0) {
18711
18723
  return path__namespace$1.join(xdg, 'coco');
@@ -18713,7 +18725,7 @@ function resolveCacheDir$2() {
18713
18725
  return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
18714
18726
  }
18715
18727
  function getOnboardingMarkerPath() {
18716
- return path__namespace$1.join(resolveCacheDir$2(), MARKER_BASENAME);
18728
+ return path__namespace$1.join(resolveCacheDir$3(), MARKER_BASENAME);
18717
18729
  }
18718
18730
  function hasSeenOnboarding() {
18719
18731
  try {
@@ -18744,14 +18756,14 @@ function markOnboardingSeen() {
18744
18756
  * settings: best-effort, XDG-friendly, no PII in the cache filename.
18745
18757
  */
18746
18758
  const VALID_MODES = ['unified', 'split'];
18747
- function resolveCacheDir$1() {
18759
+ function resolveCacheDir$2() {
18748
18760
  const xdg = process.env.XDG_CACHE_HOME;
18749
18761
  if (xdg && xdg.trim().length > 0) {
18750
18762
  return path__namespace$1.join(xdg, 'coco');
18751
18763
  }
18752
18764
  return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
18753
18765
  }
18754
- function repoKey$1(repoPath) {
18766
+ function repoKey$2(repoPath) {
18755
18767
  // sha1 is used here as a non-security cache-key derivation — we just
18756
18768
  // need a deterministic short identifier for the marker filename. No
18757
18769
  // PII or auth context is hashed.
@@ -18759,7 +18771,7 @@ function repoKey$1(repoPath) {
18759
18771
  return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
18760
18772
  }
18761
18773
  function getDiffViewModeMarkerPath(repoPath) {
18762
- return path__namespace$1.join(resolveCacheDir$1(), `diff-view-mode.${repoKey$1(repoPath)}`);
18774
+ return path__namespace$1.join(resolveCacheDir$2(), `diff-view-mode.${repoKey$2(repoPath)}`);
18763
18775
  }
18764
18776
  function getSavedDiffViewMode(repoPath) {
18765
18777
  try {
@@ -18801,14 +18813,14 @@ const VALID_TABS = [
18801
18813
  'stashes',
18802
18814
  'worktrees',
18803
18815
  ];
18804
- function resolveCacheDir() {
18816
+ function resolveCacheDir$1() {
18805
18817
  const xdg = process.env.XDG_CACHE_HOME;
18806
18818
  if (xdg && xdg.trim().length > 0) {
18807
18819
  return path__namespace$1.join(xdg, 'coco');
18808
18820
  }
18809
18821
  return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
18810
18822
  }
18811
- function repoKey(repoPath) {
18823
+ function repoKey$1(repoPath) {
18812
18824
  // sha1 is used here as a non-security cache-key derivation — we just
18813
18825
  // need a deterministic short identifier for the marker filename so
18814
18826
  // re-creating a repo at the same path keeps the same preference.
@@ -18818,7 +18830,7 @@ function repoKey(repoPath) {
18818
18830
  return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
18819
18831
  }
18820
18832
  function getSidebarTabMarkerPath(repoPath) {
18821
- return path__namespace$1.join(resolveCacheDir(), `sidebar-tab.${repoKey(repoPath)}`);
18833
+ return path__namespace$1.join(resolveCacheDir$1(), `sidebar-tab.${repoKey$1(repoPath)}`);
18822
18834
  }
18823
18835
  function getSavedSidebarTab(repoPath) {
18824
18836
  try {
@@ -19245,9 +19257,16 @@ function getLogInkLayout(input) {
19245
19257
  // graph dominant; focus expansion gives the inspector room for long
19246
19258
  // commit bodies / file lists / action labels. Mirrors the sidebar
19247
19259
  // pattern (sidebarFocused above): instant transition per render.
19248
- const detailWidth = input.inspectorFocused
19249
- ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
19250
- : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
19260
+ //
19261
+ // Help overlay overrides both — it borrows ~50% of the terminal so
19262
+ // hotkey descriptions render in full instead of truncating to
19263
+ // "Move focus...". Capped at 100 cells so a wide terminal doesn't
19264
+ // waste an absurd amount of horizontal space on the cheat sheet.
19265
+ const detailWidth = input.helpOverlayActive
19266
+ ? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
19267
+ : input.inspectorFocused
19268
+ ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
19269
+ : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
19251
19270
  // Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
19252
19271
  // (~36% of width). The transition is instant per render — focus tab to
19253
19272
  // expand, focus away to collapse.
@@ -23739,15 +23758,20 @@ async function loadLogInkContext(git) {
23739
23758
  };
23740
23759
  }
23741
23760
  function loadLogInkContextEntries(git) {
23761
+ // Boot-time per-key fetches. Each load() runs in parallel from
23762
+ // `LogInkApp`'s mount effect. `pullRequest` is intentionally
23763
+ // omitted (#808) — its `gh pr view --json` call duplicates the
23764
+ // slim PR fetch already happening inside `getProviderOverview`,
23765
+ // and the only consumer that needs the *full* enriched response is
23766
+ // the dedicated PR view (`g p`). Lazy-loaded by a separate effect
23767
+ // when the user actually navigates there. Header / yank / workflow
23768
+ // paths read the slim version off `provider.currentPullRequest` so
23769
+ // the chrome stays populated immediately on boot.
23742
23770
  return [
23743
23771
  {
23744
23772
  key: 'branches',
23745
23773
  load: () => safe(getBranchOverview(git)),
23746
23774
  },
23747
- {
23748
- key: 'pullRequest',
23749
- load: () => safe(getPullRequestOverview(git)),
23750
- },
23751
23775
  {
23752
23776
  key: 'tags',
23753
23777
  load: () => safe(getTagOverview(git)),
@@ -23960,8 +23984,15 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
23960
23984
  const input = streams.input || process.stdin;
23961
23985
  const output = streams.output || process.stdout;
23962
23986
  const error = streams.error || process.stderr;
23987
+ // Non-TTY fallback (CI logs, piped output) needs the rows up-front
23988
+ // because the renderer just dumps a static snapshot. Run the
23989
+ // deferred loader synchronously here when present so callers get
23990
+ // the same shape regardless of the entry path.
23963
23991
  if (!canStartLogInkTui(input, output)) {
23964
- await startInteractiveLog(git, rows, {
23992
+ const fallbackRows = options.loadRows && rows.length === 0
23993
+ ? await options.loadRows()
23994
+ : rows;
23995
+ await startInteractiveLog(git, fallbackRows, {
23965
23996
  appLabel: options.appLabel,
23966
23997
  input,
23967
23998
  output,
@@ -23980,6 +24011,7 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
23980
24011
  ink,
23981
24012
  initialView: options.initialView || 'history',
23982
24013
  logArgv: options.logArgv,
24014
+ loadRows: options.loadRows,
23983
24015
  React,
23984
24016
  rows,
23985
24017
  theme: createLogInkTheme(options.theme),
@@ -24076,7 +24108,7 @@ function enrichFilterActionWithRectification(action, state, context) {
24076
24108
  }
24077
24109
  }
24078
24110
  function LogInkApp(deps) {
24079
- const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, logArgv, React, resumeRef, rows, theme } = deps;
24111
+ const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
24080
24112
  const { Box, Text, useApp, useInput, useWindowSize } = ink;
24081
24113
  const h = React.createElement;
24082
24114
  const { exit } = useApp();
@@ -24097,9 +24129,26 @@ function LogInkApp(deps) {
24097
24129
  // user's cache dir so the tip never reappears once dismissed. Lazy
24098
24130
  // initializer so the fs check only runs on mount, not every render.
24099
24131
  const [showOnboarding, setShowOnboarding] = React.useState(() => !hasSeenOnboarding());
24100
- const [state, setState] = React.useState(() => createLogInkState(rows, { activeView: initialView }));
24132
+ const [state, setState] = React.useState(() => createLogInkState(rows, {
24133
+ activeView: initialView,
24134
+ // Boot loader is in flight whenever the caller passed
24135
+ // `loadRows`, regardless of whether `rows` was empty or
24136
+ // pre-populated from the disk cache (#808). The history
24137
+ // surface only shows the "Loading commits…" placeholder when
24138
+ // there are zero visible commits, so cached data renders
24139
+ // immediately while the chrome still flags the refresh.
24140
+ bootLoading: Boolean(loadRows),
24141
+ }));
24101
24142
  const [context, setContext] = React.useState({});
24102
- const [contextStatus, setContextStatus] = React.useState(() => createLogInkContextStatus('loading'));
24143
+ const [contextStatus, setContextStatus] = React.useState(() => {
24144
+ // Boot starts every fetched key in 'loading' so the surfaces show
24145
+ // their loading hints immediately. `pullRequest` is the exception
24146
+ // (#808) — it isn't part of the boot fetch entries; it lazy-loads
24147
+ // when the user enters the PR view. Marking it 'idle' avoids a
24148
+ // permanent "loading" flag in the chrome and lets the dedicated
24149
+ // PR view's own load effect drive its loading state.
24150
+ return updateLogInkContextStatus(createLogInkContextStatus('loading'), 'pullRequest', 'idle');
24151
+ });
24103
24152
  const [detail, setDetail] = React.useState(undefined);
24104
24153
  const [detailLoading, setDetailLoading] = React.useState(false);
24105
24154
  const [filePreview, setFilePreview] = React.useState(undefined);
@@ -24170,9 +24219,78 @@ function LogInkApp(deps) {
24170
24219
  const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
24171
24220
  const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
24172
24221
  const selectedWorktreeFile = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex];
24222
+ // Stash patch per-file segmentation (#808). Hoisted out of the
24223
+ // useInput callback (was running on every keystroke), the yank
24224
+ // handler (was running per `y` press), and renderDiffSurface (was
24225
+ // running per paint) into a single LogInkApp-scoped memo. When the
24226
+ // active stash diff has hundreds of files, the prior fan-out was
24227
+ // re-walking the entire patch text 2-3x per keystroke for no
24228
+ // observable reason — the parsed list is purely a function of the
24229
+ // line array, which only changes when the user opens a different
24230
+ // stash.
24231
+ const stashDiffParsedFiles = React.useMemo(() => stashDiffLines ? parseStashDiffFiles(stashDiffLines) : [], [stashDiffLines]);
24232
+ // Filtered promoted-view lists (#808). These were recomputed inline
24233
+ // inside useInput on every keystroke — for a repo with hundreds of
24234
+ // branches / tags and an active filter, that's hundreds of regex
24235
+ // matches per arrow-key press. Memoizing on (raw list, filter)
24236
+ // collapses the work to one pass per filter / data change.
24237
+ const filteredBranchList = React.useMemo(() => {
24238
+ const all = context.branches?.localBranches || [];
24239
+ if (!state.filter)
24240
+ return all;
24241
+ return all.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter));
24242
+ }, [context.branches?.localBranches, state.filter]);
24243
+ const filteredTagList = React.useMemo(() => {
24244
+ const all = context.tags?.tags || [];
24245
+ if (!state.filter)
24246
+ return all;
24247
+ return all.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter));
24248
+ }, [context.tags?.tags, state.filter]);
24249
+ const filteredStashList = React.useMemo(() => {
24250
+ const all = context.stashes?.stashes || [];
24251
+ if (!state.filter)
24252
+ return all;
24253
+ return all.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter));
24254
+ }, [context.stashes?.stashes, state.filter]);
24255
+ const filteredWorktreeList = React.useMemo(() => {
24256
+ const all = context.worktreeList?.worktrees || [];
24257
+ if (!state.filter)
24258
+ return all;
24259
+ return all.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || ''], state.filter));
24260
+ }, [context.worktreeList?.worktrees, state.filter]);
24173
24261
  const dispatch = React.useCallback((action) => {
24174
24262
  setState((current) => applyLogInkAction(current, action));
24175
24263
  }, []);
24264
+ // Deferred commit-log loader (#808). Runs once on mount when the
24265
+ // caller opted into the lazy boot path. The Ink tree is already on
24266
+ // screen at this point — without this the user stares at a black
24267
+ // terminal during the synchronous git log pre-mount fetch. The
24268
+ // mounted-ref guard prevents a late-resolving promise from
24269
+ // dispatching after the user `q` quits before rows arrive.
24270
+ React.useEffect(() => {
24271
+ if (!loadRows)
24272
+ return;
24273
+ let cancelled = false;
24274
+ void loadRows()
24275
+ .then((nextRows) => {
24276
+ if (cancelled || !mountedRef.current)
24277
+ return;
24278
+ dispatch({ type: 'replaceRows', rows: nextRows });
24279
+ })
24280
+ .catch((error) => {
24281
+ if (cancelled || !mountedRef.current)
24282
+ return;
24283
+ const message = error instanceof Error ? error.message : String(error);
24284
+ dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}` });
24285
+ dispatch({ type: 'setBootLoading', value: false });
24286
+ });
24287
+ return () => {
24288
+ cancelled = true;
24289
+ };
24290
+ // Intentionally one-shot — re-running the boot load on hot
24291
+ // dispatch / loader changes would refetch the entire log on every
24292
+ // re-render. The loader fires once per app mount and that's it.
24293
+ }, []);
24176
24294
  // Auto-dismiss status messages after a short window so transient
24177
24295
  // confirmations ("Pulled current branch", "Edited foo.ts") don't
24178
24296
  // linger forever. Each new message resets the timer; clearing the
@@ -24394,6 +24512,34 @@ function LogInkApp(deps) {
24394
24512
  active = false;
24395
24513
  };
24396
24514
  }, [git]);
24515
+ // Lazy-load the full pullRequest overview (#808). Only fires when
24516
+ // the user actually navigates to the PR view, and only when we
24517
+ // don't already have data (so a workflow-triggered refresh that
24518
+ // hydrated `pullRequest` doesn't re-fetch on view entry). The
24519
+ // dedicated PR view shows its own loading state while this is in
24520
+ // flight; everywhere else (header glyph, yank, workflow runner)
24521
+ // already falls through to the slim `provider.currentPullRequest`
24522
+ // so the chrome stays populated immediately on boot.
24523
+ React.useEffect(() => {
24524
+ if (state.activeView !== 'pull-request')
24525
+ return;
24526
+ if (context.pullRequest)
24527
+ return;
24528
+ let active = true;
24529
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'));
24530
+ void safe(getPullRequestOverview(git)).then((value) => {
24531
+ if (!active)
24532
+ return;
24533
+ setContext((current) => ({
24534
+ ...current,
24535
+ pullRequest: value,
24536
+ }));
24537
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'));
24538
+ });
24539
+ return () => {
24540
+ active = false;
24541
+ };
24542
+ }, [git, state.activeView, context.pullRequest]);
24397
24543
  React.useEffect(() => {
24398
24544
  let active = true;
24399
24545
  async function loadDetail() {
@@ -25145,8 +25291,9 @@ function LogInkApp(deps) {
25145
25291
  else if (state.diffSource === 'stash' && stashDiffLines) {
25146
25292
  // Walk back to the most recent file header at or before the
25147
25293
  // current preview offset — same logic the input-context block
25148
- // uses to expose stashDiffSelectedPath.
25149
- const current = findStashFileForOffset(parseStashDiffFiles(stashDiffLines), state.diffPreviewOffset);
25294
+ // uses to expose stashDiffSelectedPath. Reads the memoized
25295
+ // parse so the yank handler doesn't re-walk the entire patch.
25296
+ const current = findStashFileForOffset(stashDiffParsedFiles, state.diffPreviewOffset);
25150
25297
  if (current) {
25151
25298
  value = current.path;
25152
25299
  label = `path ${current.path}`;
@@ -25189,6 +25336,7 @@ function LogInkApp(deps) {
25189
25336
  selected,
25190
25337
  selectedDetailFile,
25191
25338
  stashDiffLines,
25339
+ stashDiffParsedFiles,
25192
25340
  state.activeView,
25193
25341
  state.branchSort,
25194
25342
  state.diffPreviewOffset,
@@ -25403,43 +25551,25 @@ function LogInkApp(deps) {
25403
25551
  // P4.5: navigation in branches/tags/stash uses the FILTERED list
25404
25552
  // length when a filter is active so j/k stay live instead of getting
25405
25553
  // stuck against a full-list count that no longer matches what's on
25406
- // screen.
25407
- const branchVisibleCount = state.filter
25408
- ? (context.branches?.localBranches || [])
25409
- .filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter))
25410
- .length
25411
- : context.branches?.localBranches.length;
25412
- const tagVisibleCount = state.filter
25413
- ? (context.tags?.tags || [])
25414
- .filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
25415
- .length
25416
- : context.tags?.tags.length;
25417
- const visibleStashes = state.filter
25418
- ? (context.stashes?.stashes || [])
25419
- .filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
25420
- : (context.stashes?.stashes || []);
25421
- const stashVisibleCount = visibleStashes.length;
25422
- const stashSelectedRef = visibleStashes[Math.min(state.selectedStashIndex, Math.max(0, visibleStashes.length - 1))]?.ref;
25423
- // The worktrees promoted view is filterable; mirror the branches /
25424
- // tags / stash pattern and feed the filtered count into the input
25425
- // dispatcher so ↑/↓ stay synchronized with the visible rows.
25426
- const worktreeVisibleCount = state.filter
25427
- ? (context.worktreeList?.worktrees || [])
25428
- .filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
25429
- .length
25430
- : context.worktreeList?.worktrees.length;
25554
+ // screen. The filtered lists are memoized at LogInkApp scope (#808
25555
+ // perf pass) — reading them here is O(1) instead of O(branches +
25556
+ // tags + stashes + worktrees) per keystroke.
25557
+ const branchVisibleCount = filteredBranchList.length;
25558
+ const tagVisibleCount = filteredTagList.length;
25559
+ const stashVisibleCount = filteredStashList.length;
25560
+ const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
25561
+ const worktreeVisibleCount = filteredWorktreeList.length;
25431
25562
  // When the diff view is showing a stash patch, swap the previewLineCount
25432
25563
  // to the stash diff length so the existing pageDetailPreview path
25433
25564
  // (j/k, PgUp/PgDn) scrolls through it without a parallel pipeline.
25434
25565
  const diffPreviewLineCount = state.diffSource === 'stash'
25435
25566
  ? stashDiffLines?.length
25436
25567
  : filePreview?.hunks.length;
25437
- // Parse the active stash diff into per-file sections so `]`/`[` can
25438
- // jump between files and `c` knows which path the cursor is on for
25439
- // a file-level cherry-pick.
25440
- const stashDiffFiles = state.diffSource === 'stash' && stashDiffLines
25441
- ? parseStashDiffFiles(stashDiffLines)
25442
- : [];
25568
+ // Per-file segmentation for stash diffs reads the LogInkApp-scoped
25569
+ // memo so navigation keys + the input-context derivation share a
25570
+ // single parse pass per stash patch instead of re-walking the
25571
+ // entire patch text on every keystroke.
25572
+ const stashDiffFiles = state.diffSource === 'stash' ? stashDiffParsedFiles : [];
25443
25573
  const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
25444
25574
  const stashDiffSelectedPath = state.diffSource === 'stash'
25445
25575
  ? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
@@ -25536,6 +25666,7 @@ function LogInkApp(deps) {
25536
25666
  rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
25537
25667
  sidebarFocused: state.focus === 'sidebar',
25538
25668
  inspectorFocused: state.focus === 'detail',
25669
+ helpOverlayActive: state.showHelp,
25539
25670
  });
25540
25671
  if (layout.tooSmall) {
25541
25672
  return h(Box, {
@@ -25565,7 +25696,13 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
25565
25696
  ? `PR #${prInfo.number} ${prInfo.isDraft ? 'DRAFT' : prInfo.state}`
25566
25697
  : 'no PR';
25567
25698
  const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
25568
- const loading = isLogInkContextLoading(contextStatus) ? ' loading context' : '';
25699
+ // Boot loading wins over the per-context loading hint because it
25700
+ // tells the user the headline thing they care about (commits aren't
25701
+ // ready yet) — the context fetches finish independently and surface
25702
+ // their own per-section loading copy in the sidebars.
25703
+ const loading = state.bootLoading
25704
+ ? ' loading commits'
25705
+ : isLogInkContextLoading(contextStatus) ? ' loading context' : '';
25569
25706
  const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
25570
25707
  const view = breadcrumb ? ` ${breadcrumb}` : '';
25571
25708
  // Mode indicator (P2.2) — surfaces the current input mode so users
@@ -25872,22 +26009,30 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
25872
26009
  ...(state.historyFetchArgs
25873
26010
  ? [h(Text, { key: 'history-fetch-indicator', dimColor: true }, `filter: ${formatHistoryFetchArgs(state.historyFetchArgs)} (ctrl+u in / to clear)`)]
25874
26011
  : []), ...(pendingNode ? [pendingNode] : []), visible.items.length === 0
25875
- ? h(Text, { dimColor: true }, formatLogInkHistoryEmpty({
25876
- filter: state.filter,
25877
- totalCommits: state.commits.length,
25878
- }))
26012
+ ? h(Text, { dimColor: true }, state.bootLoading
26013
+ ? formatLogInkLoading({ resource: 'commits' })
26014
+ : formatLogInkHistoryEmpty({
26015
+ filter: state.filter,
26016
+ totalCommits: state.commits.length,
26017
+ }))
25879
26018
  : visible.items.map((item, index) => {
25880
26019
  if (item.type === 'graph') {
26020
+ // Graph-only rows are git's lane-closure scaffolding (`|/`,
26021
+ // `|\`, etc.) — they're real topology but visually they look
26022
+ // like blank rows that the user might wonder if they
26023
+ // accidentally skipped a commit on (#831). Render dim-on-dim
26024
+ // so they retreat as connectors rather than competing with
26025
+ // commit rows for the eye's attention.
25881
26026
  if (item.laneSegments && !theme.ascii) {
25882
- return h(Text, { key: `graph-${index}-${item.graph}` }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`));
26027
+ return h(Text, { key: `graph-${index}-${item.graph}`, dimColor: true }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`, { forceDim: true }));
25883
26028
  }
25884
26029
  return h(Text, {
25885
26030
  key: `graph-${index}-${item.graph}`,
25886
26031
  color: theme.noColor ? undefined : theme.colors.muted,
25887
- dimColor: theme.noColor,
25888
- }, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), 140));
26032
+ dimColor: true,
26033
+ }, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), Math.max(8, width - 4)));
25889
26034
  }
25890
- return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, item.laneSegments);
26035
+ return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, item.laneSegments);
25891
26036
  }));
25892
26037
  }
25893
26038
  /**
@@ -25900,7 +26045,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
25900
26045
  * Final padding is appended as its own span so callers do not need to
25901
26046
  * pre-pad the graph string before computing lane segments.
25902
26047
  */
25903
- function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
26048
+ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, options = {}) {
25904
26049
  const muted = theme.noColor ? undefined : theme.colors.muted;
25905
26050
  const elements = [];
25906
26051
  let totalLen = 0;
@@ -25909,7 +26054,12 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
25909
26054
  elements.push(h(Text, {
25910
26055
  key: `${keyPrefix}-${idx}`,
25911
26056
  color: laneColor ?? muted,
25912
- dimColor: theme.noColor && seg.laneId === undefined,
26057
+ // Ink does not cascade dimColor from a parent Text to children,
26058
+ // so the caller's "this whole row should fade" intent has to
26059
+ // travel here as an explicit flag (#831). Used for graph-only
26060
+ // lane-closure rows, where the lane colors otherwise compete
26061
+ // for attention with the commits they connect.
26062
+ dimColor: options.forceDim || (theme.noColor && seg.laneId === undefined),
25913
26063
  }, seg.text));
25914
26064
  totalLen += seg.text.length;
25915
26065
  });
@@ -25930,11 +26080,22 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
25930
26080
  * Truncation is per-segment so the variable-length message field gets
25931
26081
  * the leftover budget after fixed segments are accounted for.
25932
26082
  */
25933
- function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
26083
+ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, laneSegments) {
25934
26084
  const refs = formatInkRefLabels(commit.refs);
25935
- const totalWidth = 140;
26085
+ // Total cells available to the row content. Earlier revisions used a
26086
+ // hardcoded 140 here, which let row content overflow whenever the
26087
+ // panel was narrower than that — Ink would wrap onto a second visual
26088
+ // line and the next commit's graph indicator landed against the wrap
26089
+ // continuation rather than its own commit (#830). Subtracting 4
26090
+ // accounts for the panel's left + right border + 1-cell padding.
26091
+ const totalWidth = Math.max(20, panelWidth - 4);
25936
26092
  const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
25937
- const messageRoom = Math.max(8, totalWidth - fixedWidth - cellWidth(refs));
26093
+ // Refs trail the message and shrink first when the row is narrow:
26094
+ // the user can always see the full ref list in the inspector, so
26095
+ // the headline subject keeps priority over decoration.
26096
+ const refsRoom = Math.max(0, totalWidth - fixedWidth - 8);
26097
+ const refsTrunc = refs ? truncate$1(refs, refsRoom) : '';
26098
+ const messageRoom = Math.max(8, totalWidth - fixedWidth - cellWidth(refsTrunc));
25938
26099
  const message = truncate$1(commit.message, messageRoom);
25939
26100
  const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
25940
26101
  const accent = theme.noColor ? undefined : theme.colors.accent;
@@ -25949,7 +26110,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
25949
26110
  key: `${commit.hash}-${index}`,
25950
26111
  backgroundColor: selectedBg,
25951
26112
  inverse: selected,
25952
- }, ...graphChildren, ' ', h(Text, { color: accent, bold: selected }, commit.shortHash), ' ', h(Text, { dimColor: true }, commit.date), ' ', h(Text, undefined, message), refs ? h(Text, { color: accent }, refs) : null);
26113
+ }, ...graphChildren, ' ', h(Text, { color: accent, bold: selected }, commit.shortHash), ' ', h(Text, { dimColor: true }, commit.date), ' ', h(Text, undefined, message), refsTrunc ? h(Text, { color: accent }, refsTrunc) : null);
25953
26114
  }
25954
26115
  /**
25955
26116
  * Render the synthetic "(+) new commit" affordance shown above the real
@@ -26223,6 +26384,15 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
26223
26384
  : `${localBranches.length}/${sortedAll.length} local | current: ${branches?.currentBranch || '<detached>'}${filterLabel}${sortLabel}`;
26224
26385
  const emptyLabel = formatLogInkBranchesEmpty({ filter: state.filter });
26225
26386
  const loadingLabel = formatLogInkLoading({ resource: 'branches' });
26387
+ // Per-column width derived from the visible window (#833) so columns
26388
+ // align across rows regardless of name length. Padded to the longest
26389
+ // name in view so short rows fill out instead of leaving a gutter;
26390
+ // capped at 40 cells so one runaway long branch name doesn't blow
26391
+ // out the timestamp column entirely (longer names get truncated and
26392
+ // the timestamp stays where the user expects it).
26393
+ const nameColWidth = visible.length === 0
26394
+ ? 28
26395
+ : Math.min(40, Math.max(8, ...visible.map((branch) => branch.shortName.length)));
26226
26396
  const lines = loading
26227
26397
  ? [h(Text, { key: 'branches-loading', dimColor: true }, loadingLabel)]
26228
26398
  : localBranches.length === 0
@@ -26236,18 +26406,18 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
26236
26406
  const lastTouched = formatBranchLastTouched(branch.date, new Date());
26237
26407
  // Split the row into spans so the timestamp stays dim even on the
26238
26408
  // currently-selected (bold) row. The leading marker + name keep
26239
- // their original column widths; the timestamp is right-padded so
26240
- // the divergence column stays aligned across rows.
26241
- const namePadded = branch.shortName.padEnd(28);
26409
+ // their per-window-derived column widths; the timestamp is
26410
+ // right-padded so the divergence column stays aligned across rows.
26411
+ const namePadded = truncate$1(branch.shortName, nameColWidth).padEnd(nameColWidth);
26242
26412
  const timestampPadded = lastTouched.padEnd(8);
26243
26413
  const lineDim = !isSelected && !branch.current;
26244
26414
  const head = `${cursor} ${marker} ${namePadded} `;
26245
26415
  const trailingDivergence = divergence ? ` ${divergence}` : '';
26246
- // Truncate the assembled line cooperatively so we never overflow
26247
- // the panel; the timestamp is short and the divergence is the
26248
- // most expendable, but the existing 140 cap is ample.
26416
+ // Truncate the assembled line to the actual panel width so a
26417
+ // narrow inspector / sidebar focus doesn't push branch rows
26418
+ // onto a second visual line (#830).
26249
26419
  const fullText = `${head}${timestampPadded}${trailingDivergence}`;
26250
- const truncated = truncate$1(fullText, 140);
26420
+ const truncated = truncate$1(fullText, Math.max(20, width - 4));
26251
26421
  // If truncation chopped into the timestamp/divergence portion,
26252
26422
  // fall back to a single Text to keep the visible width honest.
26253
26423
  if (truncated !== fullText) {
@@ -26291,6 +26461,13 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
26291
26461
  : `${tags.length}/${sortedAll.length} tags${filterLabel}${sortLabel}`;
26292
26462
  const emptyLabel = formatLogInkTagsEmpty({ filter: state.filter });
26293
26463
  const loadingLabel = formatLogInkLoading({ resource: 'tags' });
26464
+ // Per-window name column width (#833) so short tags don't leave a
26465
+ // wide gutter and long tags don't push the subject off-screen. Cap
26466
+ // matches the branches surface for visual consistency across the
26467
+ // promoted views.
26468
+ const tagNameColWidth = visible.length === 0
26469
+ ? 20
26470
+ : Math.min(40, Math.max(8, ...visible.map((tag) => tag.name.length)));
26294
26471
  const lines = loading
26295
26472
  ? [h(Text, { key: 'tags-loading', dimColor: true }, loadingLabel)]
26296
26473
  : tags.length === 0
@@ -26304,8 +26481,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
26304
26481
  // formatHyperlink wraps just the tag name, leaving width math
26305
26482
  // intact.
26306
26483
  const url = buildRefUrl(context.provider?.repository, tag.name);
26307
- const namePadded = tag.name.padEnd(20);
26308
- const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`, 140);
26484
+ const namePadded = truncate$1(tag.name, tagNameColWidth).padEnd(tagNameColWidth);
26485
+ const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`, Math.max(20, width - 4));
26309
26486
  if (!url || lineText.indexOf(namePadded) < 0) {
26310
26487
  return h(Text, {
26311
26488
  key: `tag-${index}`,
@@ -26388,6 +26565,17 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
26388
26565
  const headerRight = loading
26389
26566
  ? 'loading worktrees'
26390
26567
  : `${worktrees.length}/${allWorktrees.length} worktrees${filterLabel}`;
26568
+ // Per-window branch column width (#833). Worktrees often track
26569
+ // branches with names varying widely in length (`main` vs.
26570
+ // `feat/tui-something-long`); fixed-width padding either left a
26571
+ // huge gutter on short rows or pushed the path column off-screen on
26572
+ // long ones. Cap matches the other promoted surfaces.
26573
+ const branchColWidth = visible.length === 0
26574
+ ? 28
26575
+ : Math.min(40, Math.max(8, ...visible.map((entry) => {
26576
+ const label = entry.branch ? entry.branch : entry.head || '<detached>';
26577
+ return label.length;
26578
+ })));
26391
26579
  const lines = loading
26392
26580
  ? [h(Text, { key: 'worktrees-loading', dimColor: true }, formatLogInkLoading({ resource: 'worktrees' }))]
26393
26581
  : worktrees.length === 0
@@ -26399,11 +26587,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
26399
26587
  const marker = entry.current ? '*' : ' ';
26400
26588
  const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
26401
26589
  const stateLabel = entry.dirty ? 'dirty' : 'clean';
26590
+ const branchPadded = truncate$1(branchLabel, branchColWidth).padEnd(branchColWidth);
26402
26591
  return h(Text, {
26403
26592
  key: `worktree-${index}`,
26404
26593
  bold: isSelected,
26405
26594
  dimColor: !isSelected && !entry.current,
26406
- }, truncate$1(`${cursor} ${marker} ${branchLabel.padEnd(28)} ${stateLabel.padEnd(6)} ${entry.path}`, width - 4));
26595
+ }, truncate$1(`${cursor} ${marker} ${branchPadded} ${stateLabel.padEnd(6)} ${entry.path}`, width - 4));
26407
26596
  });
26408
26597
  return h(Box, {
26409
26598
  borderColor: focusBorderColor(theme, focused),
@@ -27483,6 +27672,84 @@ function renderFooter(h, components, state, context, theme, idleTip) {
27483
27672
  }, h(Text, { color: theme.colors.muted, dimColor: true }, contextualText), h(Text, { color: theme.colors.muted, dimColor: true }, globalText));
27484
27673
  }
27485
27674
 
27675
+ /**
27676
+ * Per-repo disk cache of the last successful commit-log fetch (#808).
27677
+ * Lets the TUI render an immediate stale-but-useful history view on
27678
+ * subsequent boots while the fresh `git log` runs in the background;
27679
+ * once the fresh data lands the runtime swaps it in transparently.
27680
+ *
27681
+ * Strict best-effort: read failures fall back to "no cache" (boot
27682
+ * shows the loading placeholder), and write failures are swallowed
27683
+ * silently (next boot just doesn't have the cache yet). The cache is
27684
+ * never load-bearing.
27685
+ *
27686
+ * Repos are keyed by a short hash of their absolute path. No PII in
27687
+ * the cache filename, and re-creating a repo at the same path keeps
27688
+ * the same cache.
27689
+ */
27690
+ const CACHE_SCHEMA_VERSION = 1;
27691
+ const CACHE_DIR_NAME = 'overview';
27692
+ /**
27693
+ * Hard cap on rows we'll write per cache entry. The interactive
27694
+ * default limit is 300; this caps growth in case a user opts into a
27695
+ * much larger window. Keeps the cache file under ~200kb on a typical
27696
+ * repo.
27697
+ */
27698
+ const CACHE_ROW_HARD_CAP = 500;
27699
+ function resolveCacheDir() {
27700
+ const xdg = process.env.XDG_CACHE_HOME;
27701
+ if (xdg && xdg.trim().length > 0) {
27702
+ return path__namespace$1.join(xdg, 'coco', CACHE_DIR_NAME);
27703
+ }
27704
+ return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME);
27705
+ }
27706
+ function repoKey(repoPath) {
27707
+ // sha1 here is a non-security cache-key derivation — we just need a
27708
+ // deterministic short identifier for the cache filename so two repos
27709
+ // at different paths never collide. No PII or auth context is hashed
27710
+ // and no collision-resistance against an adversary is required.
27711
+ // DevSkim DS126858 doesn't apply.
27712
+ // DevSkim: ignore DS126858
27713
+ return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
27714
+ }
27715
+ function getOverviewCachePath(repoPath) {
27716
+ return path__namespace$1.join(resolveCacheDir(), `commits.${repoKey(repoPath)}.json`);
27717
+ }
27718
+ function readCachedCommits(repoPath) {
27719
+ try {
27720
+ const raw = fs__namespace$1.readFileSync(getOverviewCachePath(repoPath), 'utf8');
27721
+ const parsed = JSON.parse(raw);
27722
+ if (parsed.version !== CACHE_SCHEMA_VERSION) {
27723
+ // Schema mismatch — quietly drop the stale entry on next write.
27724
+ // Treating it as "no cache" keeps boot behavior predictable
27725
+ // across upgrades.
27726
+ return undefined;
27727
+ }
27728
+ if (!Array.isArray(parsed.rows)) {
27729
+ return undefined;
27730
+ }
27731
+ return parsed.rows;
27732
+ }
27733
+ catch {
27734
+ return undefined;
27735
+ }
27736
+ }
27737
+ function writeCachedCommits(repoPath, rows) {
27738
+ const file = getOverviewCachePath(repoPath);
27739
+ const envelope = {
27740
+ version: CACHE_SCHEMA_VERSION,
27741
+ savedAt: new Date().toISOString(),
27742
+ rows: rows.slice(0, CACHE_ROW_HARD_CAP),
27743
+ };
27744
+ try {
27745
+ fs__namespace$1.mkdirSync(path__namespace$1.dirname(file), { recursive: true });
27746
+ fs__namespace$1.writeFileSync(file, JSON.stringify(envelope));
27747
+ }
27748
+ catch {
27749
+ // Best-effort persistence; swallow.
27750
+ }
27751
+ }
27752
+
27486
27753
  function createLogArgvFromUiArgv(argv) {
27487
27754
  return {
27488
27755
  $0: argv.$0,
@@ -27507,14 +27774,43 @@ function createUiTheme(config, argv) {
27507
27774
  preset: argv.theme,
27508
27775
  };
27509
27776
  }
27777
+ /**
27778
+ * Wrap a fresh-rows loader with the disk-cache write step. Lets the
27779
+ * runtime stay caching-agnostic — it just receives the rows and
27780
+ * doesn't know whether they came from cache or git, while the caller
27781
+ * (which knows the repo path) handles persistence.
27782
+ */
27783
+ function withCacheWrite(repoPath, loader) {
27784
+ return async () => {
27785
+ const rows = await loader();
27786
+ writeCachedCommits(repoPath, rows);
27787
+ return rows;
27788
+ };
27789
+ }
27510
27790
  async function startCocoUiFromLogArgv(logArgv, options = {}) {
27511
27791
  const config = options.config || loadConfig(logArgv);
27512
27792
  const git = options.git || getRepo();
27513
- const rows = options.rows || (await getLogRows(git, logArgv));
27514
- await startInkInteractiveLog(git, rows, {}, {
27793
+ const repoPath = process.cwd();
27794
+ // Three-stage boot (#808):
27795
+ // 1. Read the disk cache and pass cached rows as the initial set
27796
+ // so the user sees the workstation chrome populated with
27797
+ // commits in the first frame.
27798
+ // 2. Mount Ink immediately with those rows (or [] if no cache).
27799
+ // 3. Run loadRows in the background to refresh — when fresh data
27800
+ // lands the runtime swaps it in transparently and we persist
27801
+ // the new rows back to the cache for next boot.
27802
+ // Caller-provided rows skip the lazy path entirely (caller already
27803
+ // has up-to-date data — no point redoing the fetch).
27804
+ const cachedRows = options.rows ? undefined : readCachedCommits(repoPath);
27805
+ const initialRows = options.rows || cachedRows || [];
27806
+ const loadRows = options.rows
27807
+ ? undefined
27808
+ : withCacheWrite(repoPath, () => getLogRows(git, logArgv));
27809
+ await startInkInteractiveLog(git, initialRows, {}, {
27515
27810
  appLabel: 'coco',
27516
27811
  idleTips: config.logTui?.idleTips,
27517
27812
  initialView: 'history',
27813
+ loadRows,
27518
27814
  logArgv,
27519
27815
  theme: config.logTui?.theme,
27520
27816
  });
@@ -27523,11 +27819,15 @@ async function startCocoUi(argv) {
27523
27819
  const config = loadConfig(argv);
27524
27820
  const git = getRepo();
27525
27821
  const logArgv = createLogArgvFromUiArgv(argv);
27526
- const rows = await getLogRows(git, logArgv);
27527
- await startInkInteractiveLog(git, rows, {}, {
27822
+ const repoPath = process.cwd();
27823
+ // Same three-stage boot as startCocoUiFromLogArgv — mount with
27824
+ // cached rows for an instant-paint shell, refresh in background.
27825
+ const cachedRows = readCachedCommits(repoPath);
27826
+ await startInkInteractiveLog(git, cachedRows || [], {}, {
27528
27827
  appLabel: 'coco',
27529
27828
  idleTips: config.logTui?.idleTips,
27530
27829
  initialView: argv.view || 'history',
27830
+ loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
27531
27831
  logArgv,
27532
27832
  theme: createUiTheme(config, argv),
27533
27833
  });
@@ -27637,15 +27937,18 @@ const handler$2 = async (argv) => {
27637
27937
  });
27638
27938
  return;
27639
27939
  }
27640
- const rows = await getLogRows(git, argv);
27940
+ // Interactive path defers the commit log fetch into the runtime
27941
+ // (#808) so the TUI mounts immediately with a "Loading commits…"
27942
+ // placeholder. The non-interactive (stdout) path still needs rows
27943
+ // up-front because the formatter just dumps a static snapshot.
27641
27944
  if (argv.interactive && format === 'table') {
27642
27945
  await startCocoUiFromLogArgv(argv, {
27643
27946
  config,
27644
27947
  git,
27645
- rows,
27646
27948
  });
27647
27949
  return;
27648
27950
  }
27951
+ const rows = await getLogRows(git, argv);
27649
27952
  const result = format === 'json' ? formatLogJson(rows) : formatLogTable(rows);
27650
27953
  await handleResult({
27651
27954
  result,