git-coco 0.40.1 → 0.41.1

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.esm.mjs +569 -138
  2. package/dist/index.js +569 -138
  3. package/package.json +1 -1
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.1";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -14976,6 +14976,19 @@ function getLogInkWorkflowActions() {
14976
14976
  kind: 'destructive',
14977
14977
  requiresConfirmation: true,
14978
14978
  },
14979
+ {
14980
+ // Per-view-only — the inkInput handler scopes this to the
14981
+ // worktrees surface so the global `D` keystroke (delete-branch)
14982
+ // keeps working from elsewhere. The empty `key` keeps the
14983
+ // workflow palette-discoverable but does not register a global
14984
+ // hotkey that would collide with delete-branch.
14985
+ id: 'remove-worktree-and-branch',
14986
+ key: '',
14987
+ label: 'Remove worktree + delete branch',
14988
+ description: 'Remove the selected worktree and delete the branch it was tracking after confirmation.',
14989
+ kind: 'destructive',
14990
+ requiresConfirmation: true,
14991
+ },
14979
14992
  {
14980
14993
  id: 'abort-operation',
14981
14994
  key: 'A',
@@ -16338,6 +16351,11 @@ function replaceRows(state, rows) {
16338
16351
  selectedFileIndex: 0,
16339
16352
  pendingCommitFocused: false,
16340
16353
  pendingKey: undefined,
16354
+ // Rows just landed — clear the boot-loading flag so the history
16355
+ // surface drops the "Loading commits…" placeholder. Safe to clear
16356
+ // unconditionally because `replaceRows` only fires after a real
16357
+ // git log returns.
16358
+ bootLoading: false,
16341
16359
  };
16342
16360
  }
16343
16361
  function appendRows(state, rows) {
@@ -16428,6 +16446,7 @@ function createLogInkState(rows, options = {}) {
16428
16446
  diffViewMode: 'unified',
16429
16447
  inspectorTab: 'inspector',
16430
16448
  inspectorActionIndex: 0,
16449
+ bootLoading: options.bootLoading ?? false,
16431
16450
  };
16432
16451
  }
16433
16452
  function getSelectedInkCommit(state) {
@@ -16629,6 +16648,12 @@ function applyLogInkAction(state, action) {
16629
16648
  inspectorActionIndex: 0,
16630
16649
  pendingKey: undefined,
16631
16650
  };
16651
+ case 'setBootLoading':
16652
+ return {
16653
+ ...state,
16654
+ bootLoading: action.value,
16655
+ pendingKey: undefined,
16656
+ };
16632
16657
  case 'moveTag':
16633
16658
  return {
16634
16659
  ...state,
@@ -18428,6 +18453,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18428
18453
  if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
18429
18454
  return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
18430
18455
  }
18456
+ // Per-view worktree action: `D` removes the worktree AND deletes
18457
+ // the branch it was tracking (#838). Scoped to the worktrees
18458
+ // surface so it intercepts BEFORE the global workflow-by-key
18459
+ // dispatcher would otherwise route `D` to delete-branch (which
18460
+ // would silently target whatever was last cursored on the branches
18461
+ // surface instead of acting on the worktree under the cursor here).
18462
+ // `W` keeps its existing "remove worktree only" semantics.
18463
+ if (inputValue === 'D' && isWorktreeActionTarget(state) && context.worktreeListCount) {
18464
+ return [action({ type: 'setPendingConfirmation', value: 'remove-worktree-and-branch' })];
18465
+ }
18431
18466
  // #783 — full PR action panel keys, scoped to the pull-request view.
18432
18467
  // All five wrap a `gh pr <verb>` invocation; merge / request-changes /
18433
18468
  // comment open prompts first, the rest route through the y-confirm
@@ -18705,7 +18740,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18705
18740
  * fall back to "already seen" so we never block startup.
18706
18741
  */
18707
18742
  const MARKER_BASENAME = 'onboarding.seen';
18708
- function resolveCacheDir$2() {
18743
+ function resolveCacheDir$3() {
18709
18744
  const xdg = process.env.XDG_CACHE_HOME;
18710
18745
  if (xdg && xdg.trim().length > 0) {
18711
18746
  return path__namespace$1.join(xdg, 'coco');
@@ -18713,7 +18748,7 @@ function resolveCacheDir$2() {
18713
18748
  return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
18714
18749
  }
18715
18750
  function getOnboardingMarkerPath() {
18716
- return path__namespace$1.join(resolveCacheDir$2(), MARKER_BASENAME);
18751
+ return path__namespace$1.join(resolveCacheDir$3(), MARKER_BASENAME);
18717
18752
  }
18718
18753
  function hasSeenOnboarding() {
18719
18754
  try {
@@ -18744,14 +18779,14 @@ function markOnboardingSeen() {
18744
18779
  * settings: best-effort, XDG-friendly, no PII in the cache filename.
18745
18780
  */
18746
18781
  const VALID_MODES = ['unified', 'split'];
18747
- function resolveCacheDir$1() {
18782
+ function resolveCacheDir$2() {
18748
18783
  const xdg = process.env.XDG_CACHE_HOME;
18749
18784
  if (xdg && xdg.trim().length > 0) {
18750
18785
  return path__namespace$1.join(xdg, 'coco');
18751
18786
  }
18752
18787
  return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
18753
18788
  }
18754
- function repoKey$1(repoPath) {
18789
+ function repoKey$2(repoPath) {
18755
18790
  // sha1 is used here as a non-security cache-key derivation — we just
18756
18791
  // need a deterministic short identifier for the marker filename. No
18757
18792
  // PII or auth context is hashed.
@@ -18759,7 +18794,7 @@ function repoKey$1(repoPath) {
18759
18794
  return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
18760
18795
  }
18761
18796
  function getDiffViewModeMarkerPath(repoPath) {
18762
- return path__namespace$1.join(resolveCacheDir$1(), `diff-view-mode.${repoKey$1(repoPath)}`);
18797
+ return path__namespace$1.join(resolveCacheDir$2(), `diff-view-mode.${repoKey$2(repoPath)}`);
18763
18798
  }
18764
18799
  function getSavedDiffViewMode(repoPath) {
18765
18800
  try {
@@ -18801,14 +18836,14 @@ const VALID_TABS = [
18801
18836
  'stashes',
18802
18837
  'worktrees',
18803
18838
  ];
18804
- function resolveCacheDir() {
18839
+ function resolveCacheDir$1() {
18805
18840
  const xdg = process.env.XDG_CACHE_HOME;
18806
18841
  if (xdg && xdg.trim().length > 0) {
18807
18842
  return path__namespace$1.join(xdg, 'coco');
18808
18843
  }
18809
18844
  return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
18810
18845
  }
18811
- function repoKey(repoPath) {
18846
+ function repoKey$1(repoPath) {
18812
18847
  // sha1 is used here as a non-security cache-key derivation — we just
18813
18848
  // need a deterministic short identifier for the marker filename so
18814
18849
  // re-creating a repo at the same path keeps the same preference.
@@ -18818,7 +18853,7 @@ function repoKey(repoPath) {
18818
18853
  return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
18819
18854
  }
18820
18855
  function getSidebarTabMarkerPath(repoPath) {
18821
- return path__namespace$1.join(resolveCacheDir(), `sidebar-tab.${repoKey(repoPath)}`);
18856
+ return path__namespace$1.join(resolveCacheDir$1(), `sidebar-tab.${repoKey$1(repoPath)}`);
18822
18857
  }
18823
18858
  function getSavedSidebarTab(repoPath) {
18824
18859
  try {
@@ -19245,9 +19280,16 @@ function getLogInkLayout(input) {
19245
19280
  // graph dominant; focus expansion gives the inspector room for long
19246
19281
  // commit bodies / file lists / action labels. Mirrors the sidebar
19247
19282
  // 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)));
19283
+ //
19284
+ // Help overlay overrides both — it borrows ~50% of the terminal so
19285
+ // hotkey descriptions render in full instead of truncating to
19286
+ // "Move focus...". Capped at 100 cells so a wide terminal doesn't
19287
+ // waste an absurd amount of horizontal space on the cheat sheet.
19288
+ const detailWidth = input.helpOverlayActive
19289
+ ? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
19290
+ : input.inspectorFocused
19291
+ ? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
19292
+ : Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
19251
19293
  // Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
19252
19294
  // (~36% of width). The transition is instant per render — focus tab to
19253
19295
  // expand, focus away to collapse.
@@ -21491,6 +21533,56 @@ function worktreePathAction(worktree) {
21491
21533
  message: `Worktree path: ${worktree.path}`,
21492
21534
  };
21493
21535
  }
21536
+ /**
21537
+ * Remove a worktree AND delete the branch it was tracking (#838). The
21538
+ * canonical "I'm done with this side branch" wind-down: removes the
21539
+ * worktree directory, then runs `git branch -d` on the previously
21540
+ * checked-out branch.
21541
+ *
21542
+ * Both pre-flight guards inherit from the underlying helpers:
21543
+ * - removeWorktree refuses the current worktree and dirty worktrees
21544
+ * - deleteBranch refuses the current branch and uses `-d` (safe
21545
+ * delete, refuses unmerged commits)
21546
+ *
21547
+ * Aborts cleanly at any failure point and surfaces a message that
21548
+ * names which step broke. When the worktree had no branch (detached
21549
+ * HEAD) the branch step is silently skipped — there's nothing to
21550
+ * delete and the worktree removal alone counts as success.
21551
+ */
21552
+ async function removeWorktreeAndBranch(git, worktree, branchRefs) {
21553
+ const removeResult = await removeWorktree(git, worktree);
21554
+ if (!removeResult.ok) {
21555
+ return removeResult;
21556
+ }
21557
+ const branchName = worktree.branch;
21558
+ if (!branchName) {
21559
+ return {
21560
+ ok: true,
21561
+ message: `Removed worktree ${worktree.path} (no branch to delete)`,
21562
+ };
21563
+ }
21564
+ // Look up the local BranchRef for the branch this worktree was on.
21565
+ // deleteBranch needs the full ref (not just the name) so its
21566
+ // current-branch and local-only guards apply correctly.
21567
+ const branch = branchRefs.find((entry) => entry.type === 'local' && entry.shortName === branchName);
21568
+ if (!branch) {
21569
+ return {
21570
+ ok: true,
21571
+ message: `Removed worktree ${worktree.path} (branch ${branchName} not found in local branches)`,
21572
+ };
21573
+ }
21574
+ const deleteResult = await deleteBranch(git, branch);
21575
+ if (!deleteResult.ok) {
21576
+ return {
21577
+ ok: false,
21578
+ message: `Removed worktree ${worktree.path}, but branch delete failed: ${deleteResult.message}`,
21579
+ };
21580
+ }
21581
+ return {
21582
+ ok: true,
21583
+ message: `Removed worktree ${worktree.path} and deleted branch ${branchName}`,
21584
+ };
21585
+ }
21494
21586
 
21495
21587
  function shortBranch(branch) {
21496
21588
  return branch?.replace(/^refs\/heads\//, '');
@@ -23739,15 +23831,20 @@ async function loadLogInkContext(git) {
23739
23831
  };
23740
23832
  }
23741
23833
  function loadLogInkContextEntries(git) {
23834
+ // Boot-time per-key fetches. Each load() runs in parallel from
23835
+ // `LogInkApp`'s mount effect. `pullRequest` is intentionally
23836
+ // omitted (#808) — its `gh pr view --json` call duplicates the
23837
+ // slim PR fetch already happening inside `getProviderOverview`,
23838
+ // and the only consumer that needs the *full* enriched response is
23839
+ // the dedicated PR view (`g p`). Lazy-loaded by a separate effect
23840
+ // when the user actually navigates there. Header / yank / workflow
23841
+ // paths read the slim version off `provider.currentPullRequest` so
23842
+ // the chrome stays populated immediately on boot.
23742
23843
  return [
23743
23844
  {
23744
23845
  key: 'branches',
23745
23846
  load: () => safe(getBranchOverview(git)),
23746
23847
  },
23747
- {
23748
- key: 'pullRequest',
23749
- load: () => safe(getPullRequestOverview(git)),
23750
- },
23751
23848
  {
23752
23849
  key: 'tags',
23753
23850
  load: () => safe(getTagOverview(git)),
@@ -23960,8 +24057,15 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
23960
24057
  const input = streams.input || process.stdin;
23961
24058
  const output = streams.output || process.stdout;
23962
24059
  const error = streams.error || process.stderr;
24060
+ // Non-TTY fallback (CI logs, piped output) needs the rows up-front
24061
+ // because the renderer just dumps a static snapshot. Run the
24062
+ // deferred loader synchronously here when present so callers get
24063
+ // the same shape regardless of the entry path.
23963
24064
  if (!canStartLogInkTui(input, output)) {
23964
- await startInteractiveLog(git, rows, {
24065
+ const fallbackRows = options.loadRows && rows.length === 0
24066
+ ? await options.loadRows()
24067
+ : rows;
24068
+ await startInteractiveLog(git, fallbackRows, {
23965
24069
  appLabel: options.appLabel,
23966
24070
  input,
23967
24071
  output,
@@ -23980,6 +24084,7 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
23980
24084
  ink,
23981
24085
  initialView: options.initialView || 'history',
23982
24086
  logArgv: options.logArgv,
24087
+ loadRows: options.loadRows,
23983
24088
  React,
23984
24089
  rows,
23985
24090
  theme: createLogInkTheme(options.theme),
@@ -24076,7 +24181,7 @@ function enrichFilterActionWithRectification(action, state, context) {
24076
24181
  }
24077
24182
  }
24078
24183
  function LogInkApp(deps) {
24079
- const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, logArgv, React, resumeRef, rows, theme } = deps;
24184
+ const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
24080
24185
  const { Box, Text, useApp, useInput, useWindowSize } = ink;
24081
24186
  const h = React.createElement;
24082
24187
  const { exit } = useApp();
@@ -24097,9 +24202,26 @@ function LogInkApp(deps) {
24097
24202
  // user's cache dir so the tip never reappears once dismissed. Lazy
24098
24203
  // initializer so the fs check only runs on mount, not every render.
24099
24204
  const [showOnboarding, setShowOnboarding] = React.useState(() => !hasSeenOnboarding());
24100
- const [state, setState] = React.useState(() => createLogInkState(rows, { activeView: initialView }));
24205
+ const [state, setState] = React.useState(() => createLogInkState(rows, {
24206
+ activeView: initialView,
24207
+ // Boot loader is in flight whenever the caller passed
24208
+ // `loadRows`, regardless of whether `rows` was empty or
24209
+ // pre-populated from the disk cache (#808). The history
24210
+ // surface only shows the "Loading commits…" placeholder when
24211
+ // there are zero visible commits, so cached data renders
24212
+ // immediately while the chrome still flags the refresh.
24213
+ bootLoading: Boolean(loadRows),
24214
+ }));
24101
24215
  const [context, setContext] = React.useState({});
24102
- const [contextStatus, setContextStatus] = React.useState(() => createLogInkContextStatus('loading'));
24216
+ const [contextStatus, setContextStatus] = React.useState(() => {
24217
+ // Boot starts every fetched key in 'loading' so the surfaces show
24218
+ // their loading hints immediately. `pullRequest` is the exception
24219
+ // (#808) — it isn't part of the boot fetch entries; it lazy-loads
24220
+ // when the user enters the PR view. Marking it 'idle' avoids a
24221
+ // permanent "loading" flag in the chrome and lets the dedicated
24222
+ // PR view's own load effect drive its loading state.
24223
+ return updateLogInkContextStatus(createLogInkContextStatus('loading'), 'pullRequest', 'idle');
24224
+ });
24103
24225
  const [detail, setDetail] = React.useState(undefined);
24104
24226
  const [detailLoading, setDetailLoading] = React.useState(false);
24105
24227
  const [filePreview, setFilePreview] = React.useState(undefined);
@@ -24170,9 +24292,78 @@ function LogInkApp(deps) {
24170
24292
  const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
24171
24293
  const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
24172
24294
  const selectedWorktreeFile = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex];
24295
+ // Stash patch per-file segmentation (#808). Hoisted out of the
24296
+ // useInput callback (was running on every keystroke), the yank
24297
+ // handler (was running per `y` press), and renderDiffSurface (was
24298
+ // running per paint) into a single LogInkApp-scoped memo. When the
24299
+ // active stash diff has hundreds of files, the prior fan-out was
24300
+ // re-walking the entire patch text 2-3x per keystroke for no
24301
+ // observable reason — the parsed list is purely a function of the
24302
+ // line array, which only changes when the user opens a different
24303
+ // stash.
24304
+ const stashDiffParsedFiles = React.useMemo(() => stashDiffLines ? parseStashDiffFiles(stashDiffLines) : [], [stashDiffLines]);
24305
+ // Filtered promoted-view lists (#808). These were recomputed inline
24306
+ // inside useInput on every keystroke — for a repo with hundreds of
24307
+ // branches / tags and an active filter, that's hundreds of regex
24308
+ // matches per arrow-key press. Memoizing on (raw list, filter)
24309
+ // collapses the work to one pass per filter / data change.
24310
+ const filteredBranchList = React.useMemo(() => {
24311
+ const all = context.branches?.localBranches || [];
24312
+ if (!state.filter)
24313
+ return all;
24314
+ return all.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter));
24315
+ }, [context.branches?.localBranches, state.filter]);
24316
+ const filteredTagList = React.useMemo(() => {
24317
+ const all = context.tags?.tags || [];
24318
+ if (!state.filter)
24319
+ return all;
24320
+ return all.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter));
24321
+ }, [context.tags?.tags, state.filter]);
24322
+ const filteredStashList = React.useMemo(() => {
24323
+ const all = context.stashes?.stashes || [];
24324
+ if (!state.filter)
24325
+ return all;
24326
+ return all.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter));
24327
+ }, [context.stashes?.stashes, state.filter]);
24328
+ const filteredWorktreeList = React.useMemo(() => {
24329
+ const all = context.worktreeList?.worktrees || [];
24330
+ if (!state.filter)
24331
+ return all;
24332
+ return all.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || ''], state.filter));
24333
+ }, [context.worktreeList?.worktrees, state.filter]);
24173
24334
  const dispatch = React.useCallback((action) => {
24174
24335
  setState((current) => applyLogInkAction(current, action));
24175
24336
  }, []);
24337
+ // Deferred commit-log loader (#808). Runs once on mount when the
24338
+ // caller opted into the lazy boot path. The Ink tree is already on
24339
+ // screen at this point — without this the user stares at a black
24340
+ // terminal during the synchronous git log pre-mount fetch. The
24341
+ // mounted-ref guard prevents a late-resolving promise from
24342
+ // dispatching after the user `q` quits before rows arrive.
24343
+ React.useEffect(() => {
24344
+ if (!loadRows)
24345
+ return;
24346
+ let cancelled = false;
24347
+ void loadRows()
24348
+ .then((nextRows) => {
24349
+ if (cancelled || !mountedRef.current)
24350
+ return;
24351
+ dispatch({ type: 'replaceRows', rows: nextRows });
24352
+ })
24353
+ .catch((error) => {
24354
+ if (cancelled || !mountedRef.current)
24355
+ return;
24356
+ const message = error instanceof Error ? error.message : String(error);
24357
+ dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}` });
24358
+ dispatch({ type: 'setBootLoading', value: false });
24359
+ });
24360
+ return () => {
24361
+ cancelled = true;
24362
+ };
24363
+ // Intentionally one-shot — re-running the boot load on hot
24364
+ // dispatch / loader changes would refetch the entire log on every
24365
+ // re-render. The loader fires once per app mount and that's it.
24366
+ }, []);
24176
24367
  // Auto-dismiss status messages after a short window so transient
24177
24368
  // confirmations ("Pulled current branch", "Edited foo.ts") don't
24178
24369
  // linger forever. Each new message resets the timer; clearing the
@@ -24394,6 +24585,34 @@ function LogInkApp(deps) {
24394
24585
  active = false;
24395
24586
  };
24396
24587
  }, [git]);
24588
+ // Lazy-load the full pullRequest overview (#808). Only fires when
24589
+ // the user actually navigates to the PR view, and only when we
24590
+ // don't already have data (so a workflow-triggered refresh that
24591
+ // hydrated `pullRequest` doesn't re-fetch on view entry). The
24592
+ // dedicated PR view shows its own loading state while this is in
24593
+ // flight; everywhere else (header glyph, yank, workflow runner)
24594
+ // already falls through to the slim `provider.currentPullRequest`
24595
+ // so the chrome stays populated immediately on boot.
24596
+ React.useEffect(() => {
24597
+ if (state.activeView !== 'pull-request')
24598
+ return;
24599
+ if (context.pullRequest)
24600
+ return;
24601
+ let active = true;
24602
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'));
24603
+ void safe(getPullRequestOverview(git)).then((value) => {
24604
+ if (!active)
24605
+ return;
24606
+ setContext((current) => ({
24607
+ ...current,
24608
+ pullRequest: value,
24609
+ }));
24610
+ setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'));
24611
+ });
24612
+ return () => {
24613
+ active = false;
24614
+ };
24615
+ }, [git, state.activeView, context.pullRequest]);
24397
24616
  React.useEffect(() => {
24398
24617
  let active = true;
24399
24618
  async function loadDetail() {
@@ -24415,12 +24634,27 @@ function LogInkApp(deps) {
24415
24634
  }, [git, selected?.hash]);
24416
24635
  // #806 follow-up — auto-jump the history view to whichever branch /
24417
24636
  // tag the user is currently cursoring in the sidebar (or the
24418
- // dedicated branches / tags view). Debounced so cursor-scrolling
24419
- // through a long branch list doesn't dispatch on every keystroke.
24637
+ // dedicated branches / tags view).
24638
+ //
24639
+ // Originally this fired on a 150ms trailing-edge debounce. The user
24640
+ // reported the sync feeling inconsistent (#839) — the trailing
24641
+ // pattern means a fast scroll through a long branch list cancels
24642
+ // the timer on every keystroke and only fires once on release; the
24643
+ // user never sees the cursor follow their navigation. Switched to
24644
+ // synchronous fire-on-effect so each cursor move snaps the history
24645
+ // graph immediately. The dispatch is cheap (O(n) findIndex on the
24646
+ // filtered commits + a state spread); React batches the re-renders
24647
+ // so even rapid scroll only paints the final position. Tracks the
24648
+ // last-dispatched hash via a ref so we don't fire setStatus
24649
+ // repeatedly when several adjacent branches all point at the same
24650
+ // commit (very common with squash-merged feature branches that all
24651
+ // converge on `main`'s tip).
24652
+ //
24420
24653
  // No-op when the cursored ref's tip isn't in the loaded commit
24421
24654
  // window (under compact mode the cursored branch's tip may not be
24422
24655
  // fetched yet); a status hint surfaces in that case so the user
24423
24656
  // knows to toggle full graph or load older commits.
24657
+ const lastSyncedHashRef = React.useRef(undefined);
24424
24658
  React.useEffect(() => {
24425
24659
  const onBranchTab = state.activeView === 'branches' ||
24426
24660
  (state.focus === 'sidebar' && state.sidebarTab === 'branches');
@@ -24428,58 +24662,58 @@ function LogInkApp(deps) {
24428
24662
  (state.focus === 'sidebar' && state.sidebarTab === 'tags');
24429
24663
  if (!onBranchTab && !onTagTab)
24430
24664
  return;
24431
- let cancelled = false;
24432
- const timer = setTimeout(() => {
24433
- if (cancelled)
24434
- return;
24435
- let targetHash;
24436
- let targetLabel;
24437
- if (onBranchTab) {
24438
- const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
24439
- const visible = state.filter
24440
- ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
24441
- : all;
24442
- const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
24443
- if (branch) {
24444
- targetHash = branch.hash;
24445
- targetLabel = `branch ${branch.shortName}`;
24446
- }
24447
- }
24448
- else if (onTagTab) {
24449
- const all = sortTags(context.tags?.tags || [], state.tagSort);
24450
- const visible = state.filter
24451
- ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
24452
- : all;
24453
- const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
24454
- if (tag) {
24455
- targetHash = tag.hash;
24456
- targetLabel = `tag ${tag.name}`;
24457
- }
24458
- }
24459
- if (!targetHash)
24460
- return;
24461
- const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
24462
- if (loaded) {
24463
- dispatch({ type: 'selectCommitByHash', hash: targetHash });
24464
- // Confirmation status message so the user gets feedback even
24465
- // when the dedicated branches / tags view is occupying the
24466
- // main panel and the history cursor moves invisibly behind it.
24467
- dispatch({
24468
- type: 'setStatus',
24469
- value: `Synced history to ${targetLabel} tip`,
24470
- });
24665
+ let targetHash;
24666
+ let targetLabel;
24667
+ if (onBranchTab) {
24668
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
24669
+ const visible = state.filter
24670
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
24671
+ : all;
24672
+ const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
24673
+ if (branch) {
24674
+ targetHash = branch.hash;
24675
+ targetLabel = `branch ${branch.shortName}`;
24471
24676
  }
24472
- else {
24473
- dispatch({
24474
- type: 'setStatus',
24475
- value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
24476
- });
24677
+ }
24678
+ else if (onTagTab) {
24679
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
24680
+ const visible = state.filter
24681
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
24682
+ : all;
24683
+ const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
24684
+ if (tag) {
24685
+ targetHash = tag.hash;
24686
+ targetLabel = `tag ${tag.name}`;
24477
24687
  }
24478
- }, 150);
24479
- return () => {
24480
- cancelled = true;
24481
- clearTimeout(timer);
24482
- };
24688
+ }
24689
+ if (!targetHash)
24690
+ return;
24691
+ // Skip the dispatch + status churn when the cursor hasn't
24692
+ // actually changed which commit it's targeting (the case for
24693
+ // rapid navigation through a cluster of branches that all point
24694
+ // at the same commit). Without this guard the user sees a stream
24695
+ // of "Synced history to <branch> tip" status messages even
24696
+ // though the history cursor never moved.
24697
+ if (targetHash === lastSyncedHashRef.current)
24698
+ return;
24699
+ const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
24700
+ if (loaded) {
24701
+ lastSyncedHashRef.current = targetHash;
24702
+ dispatch({ type: 'selectCommitByHash', hash: targetHash });
24703
+ // Confirmation status message so the user gets feedback even
24704
+ // when the dedicated branches / tags view is occupying the
24705
+ // main panel and the history cursor moves invisibly behind it.
24706
+ dispatch({
24707
+ type: 'setStatus',
24708
+ value: `Synced history to ${targetLabel} tip`,
24709
+ });
24710
+ }
24711
+ else {
24712
+ dispatch({
24713
+ type: 'setStatus',
24714
+ value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
24715
+ });
24716
+ }
24483
24717
  }, [
24484
24718
  dispatch, context.branches, context.tags,
24485
24719
  state.activeView, state.focus, state.sidebarTab,
@@ -24487,6 +24721,18 @@ function LogInkApp(deps) {
24487
24721
  state.branchSort, state.tagSort, state.filter,
24488
24722
  state.filteredCommits,
24489
24723
  ]);
24724
+ // Reset the dedup ref when the user moves focus away from the
24725
+ // sidebar branches / tags tab so re-entering re-fires the sync
24726
+ // even if the cursored branch is the same as before.
24727
+ React.useEffect(() => {
24728
+ const onBranchTab = state.activeView === 'branches' ||
24729
+ (state.focus === 'sidebar' && state.sidebarTab === 'branches');
24730
+ const onTagTab = state.activeView === 'tags' ||
24731
+ (state.focus === 'sidebar' && state.sidebarTab === 'tags');
24732
+ if (!onBranchTab && !onTagTab) {
24733
+ lastSyncedHashRef.current = undefined;
24734
+ }
24735
+ }, [state.activeView, state.focus, state.sidebarTab]);
24490
24736
  React.useEffect(() => {
24491
24737
  let active = true;
24492
24738
  async function loadWorktreeDiff() {
@@ -24926,6 +25172,29 @@ function LogInkApp(deps) {
24926
25172
  }
24927
25173
  return removeWorktree(git, cursorTarget);
24928
25174
  },
25175
+ 'remove-worktree-and-branch': async () => {
25176
+ const all = context.worktreeList?.worktrees || [];
25177
+ const visible = state.filter
25178
+ ? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
25179
+ : all;
25180
+ const cursorTarget = visible.length
25181
+ ? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
25182
+ : all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
25183
+ if (!cursorTarget)
25184
+ return { ok: false, message: 'No worktree selected' };
25185
+ if (cursorTarget.current) {
25186
+ return {
25187
+ ok: false,
25188
+ message: 'Cannot remove the current worktree — switch to another worktree first.',
25189
+ };
25190
+ }
25191
+ // The chained helper handles the worktree removal AND the
25192
+ // safe branch delete in one call. Branch refs come from the
25193
+ // live context so the underlying deleteBranch helper sees
25194
+ // the current/local flags it needs to refuse the destructive
25195
+ // path on the wrong target.
25196
+ return removeWorktreeAndBranch(git, cursorTarget, context.branches?.localBranches || []);
25197
+ },
24929
25198
  'abort-operation': async () => {
24930
25199
  const operation = context.operation?.operation;
24931
25200
  if (!operation) {
@@ -25145,8 +25414,9 @@ function LogInkApp(deps) {
25145
25414
  else if (state.diffSource === 'stash' && stashDiffLines) {
25146
25415
  // Walk back to the most recent file header at or before the
25147
25416
  // current preview offset — same logic the input-context block
25148
- // uses to expose stashDiffSelectedPath.
25149
- const current = findStashFileForOffset(parseStashDiffFiles(stashDiffLines), state.diffPreviewOffset);
25417
+ // uses to expose stashDiffSelectedPath. Reads the memoized
25418
+ // parse so the yank handler doesn't re-walk the entire patch.
25419
+ const current = findStashFileForOffset(stashDiffParsedFiles, state.diffPreviewOffset);
25150
25420
  if (current) {
25151
25421
  value = current.path;
25152
25422
  label = `path ${current.path}`;
@@ -25189,6 +25459,7 @@ function LogInkApp(deps) {
25189
25459
  selected,
25190
25460
  selectedDetailFile,
25191
25461
  stashDiffLines,
25462
+ stashDiffParsedFiles,
25192
25463
  state.activeView,
25193
25464
  state.branchSort,
25194
25465
  state.diffPreviewOffset,
@@ -25403,43 +25674,25 @@ function LogInkApp(deps) {
25403
25674
  // P4.5: navigation in branches/tags/stash uses the FILTERED list
25404
25675
  // length when a filter is active so j/k stay live instead of getting
25405
25676
  // 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;
25677
+ // screen. The filtered lists are memoized at LogInkApp scope (#808
25678
+ // perf pass) — reading them here is O(1) instead of O(branches +
25679
+ // tags + stashes + worktrees) per keystroke.
25680
+ const branchVisibleCount = filteredBranchList.length;
25681
+ const tagVisibleCount = filteredTagList.length;
25682
+ const stashVisibleCount = filteredStashList.length;
25683
+ const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
25684
+ const worktreeVisibleCount = filteredWorktreeList.length;
25431
25685
  // When the diff view is showing a stash patch, swap the previewLineCount
25432
25686
  // to the stash diff length so the existing pageDetailPreview path
25433
25687
  // (j/k, PgUp/PgDn) scrolls through it without a parallel pipeline.
25434
25688
  const diffPreviewLineCount = state.diffSource === 'stash'
25435
25689
  ? stashDiffLines?.length
25436
25690
  : 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
- : [];
25691
+ // Per-file segmentation for stash diffs reads the LogInkApp-scoped
25692
+ // memo so navigation keys + the input-context derivation share a
25693
+ // single parse pass per stash patch instead of re-walking the
25694
+ // entire patch text on every keystroke.
25695
+ const stashDiffFiles = state.diffSource === 'stash' ? stashDiffParsedFiles : [];
25443
25696
  const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
25444
25697
  const stashDiffSelectedPath = state.diffSource === 'stash'
25445
25698
  ? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
@@ -25536,6 +25789,7 @@ function LogInkApp(deps) {
25536
25789
  rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
25537
25790
  sidebarFocused: state.focus === 'sidebar',
25538
25791
  inspectorFocused: state.focus === 'detail',
25792
+ helpOverlayActive: state.showHelp,
25539
25793
  });
25540
25794
  if (layout.tooSmall) {
25541
25795
  return h(Box, {
@@ -25565,7 +25819,13 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
25565
25819
  ? `PR #${prInfo.number} ${prInfo.isDraft ? 'DRAFT' : prInfo.state}`
25566
25820
  : 'no PR';
25567
25821
  const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
25568
- const loading = isLogInkContextLoading(contextStatus) ? ' loading context' : '';
25822
+ // Boot loading wins over the per-context loading hint because it
25823
+ // tells the user the headline thing they care about (commits aren't
25824
+ // ready yet) — the context fetches finish independently and surface
25825
+ // their own per-section loading copy in the sidebars.
25826
+ const loading = state.bootLoading
25827
+ ? ' loading commits'
25828
+ : isLogInkContextLoading(contextStatus) ? ' loading context' : '';
25569
25829
  const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
25570
25830
  const view = breadcrumb ? ` ${breadcrumb}` : '';
25571
25831
  // Mode indicator (P2.2) — surfaces the current input mode so users
@@ -25872,22 +26132,30 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
25872
26132
  ...(state.historyFetchArgs
25873
26133
  ? [h(Text, { key: 'history-fetch-indicator', dimColor: true }, `filter: ${formatHistoryFetchArgs(state.historyFetchArgs)} (ctrl+u in / to clear)`)]
25874
26134
  : []), ...(pendingNode ? [pendingNode] : []), visible.items.length === 0
25875
- ? h(Text, { dimColor: true }, formatLogInkHistoryEmpty({
25876
- filter: state.filter,
25877
- totalCommits: state.commits.length,
25878
- }))
26135
+ ? h(Text, { dimColor: true }, state.bootLoading
26136
+ ? formatLogInkLoading({ resource: 'commits' })
26137
+ : formatLogInkHistoryEmpty({
26138
+ filter: state.filter,
26139
+ totalCommits: state.commits.length,
26140
+ }))
25879
26141
  : visible.items.map((item, index) => {
25880
26142
  if (item.type === 'graph') {
26143
+ // Graph-only rows are git's lane-closure scaffolding (`|/`,
26144
+ // `|\`, etc.) — they're real topology but visually they look
26145
+ // like blank rows that the user might wonder if they
26146
+ // accidentally skipped a commit on (#831). Render dim-on-dim
26147
+ // so they retreat as connectors rather than competing with
26148
+ // commit rows for the eye's attention.
25881
26149
  if (item.laneSegments && !theme.ascii) {
25882
- return h(Text, { key: `graph-${index}-${item.graph}` }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`));
26150
+ return h(Text, { key: `graph-${index}-${item.graph}`, dimColor: true }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`, { forceDim: true }));
25883
26151
  }
25884
26152
  return h(Text, {
25885
26153
  key: `graph-${index}-${item.graph}`,
25886
26154
  color: theme.noColor ? undefined : theme.colors.muted,
25887
- dimColor: theme.noColor,
25888
- }, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), 140));
26155
+ dimColor: true,
26156
+ }, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), Math.max(8, width - 4)));
25889
26157
  }
25890
- return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, item.laneSegments);
26158
+ return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, item.laneSegments);
25891
26159
  }));
25892
26160
  }
25893
26161
  /**
@@ -25900,7 +26168,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
25900
26168
  * Final padding is appended as its own span so callers do not need to
25901
26169
  * pre-pad the graph string before computing lane segments.
25902
26170
  */
25903
- function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
26171
+ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, options = {}) {
25904
26172
  const muted = theme.noColor ? undefined : theme.colors.muted;
25905
26173
  const elements = [];
25906
26174
  let totalLen = 0;
@@ -25909,7 +26177,12 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
25909
26177
  elements.push(h(Text, {
25910
26178
  key: `${keyPrefix}-${idx}`,
25911
26179
  color: laneColor ?? muted,
25912
- dimColor: theme.noColor && seg.laneId === undefined,
26180
+ // Ink does not cascade dimColor from a parent Text to children,
26181
+ // so the caller's "this whole row should fade" intent has to
26182
+ // travel here as an explicit flag (#831). Used for graph-only
26183
+ // lane-closure rows, where the lane colors otherwise compete
26184
+ // for attention with the commits they connect.
26185
+ dimColor: options.forceDim || (theme.noColor && seg.laneId === undefined),
25913
26186
  }, seg.text));
25914
26187
  totalLen += seg.text.length;
25915
26188
  });
@@ -25930,11 +26203,22 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
25930
26203
  * Truncation is per-segment so the variable-length message field gets
25931
26204
  * the leftover budget after fixed segments are accounted for.
25932
26205
  */
25933
- function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
26206
+ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, laneSegments) {
25934
26207
  const refs = formatInkRefLabels(commit.refs);
25935
- const totalWidth = 140;
26208
+ // Total cells available to the row content. Earlier revisions used a
26209
+ // hardcoded 140 here, which let row content overflow whenever the
26210
+ // panel was narrower than that — Ink would wrap onto a second visual
26211
+ // line and the next commit's graph indicator landed against the wrap
26212
+ // continuation rather than its own commit (#830). Subtracting 4
26213
+ // accounts for the panel's left + right border + 1-cell padding.
26214
+ const totalWidth = Math.max(20, panelWidth - 4);
25936
26215
  const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
25937
- const messageRoom = Math.max(8, totalWidth - fixedWidth - cellWidth(refs));
26216
+ // Refs trail the message and shrink first when the row is narrow:
26217
+ // the user can always see the full ref list in the inspector, so
26218
+ // the headline subject keeps priority over decoration.
26219
+ const refsRoom = Math.max(0, totalWidth - fixedWidth - 8);
26220
+ const refsTrunc = refs ? truncate$1(refs, refsRoom) : '';
26221
+ const messageRoom = Math.max(8, totalWidth - fixedWidth - cellWidth(refsTrunc));
25938
26222
  const message = truncate$1(commit.message, messageRoom);
25939
26223
  const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
25940
26224
  const accent = theme.noColor ? undefined : theme.colors.accent;
@@ -25949,7 +26233,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
25949
26233
  key: `${commit.hash}-${index}`,
25950
26234
  backgroundColor: selectedBg,
25951
26235
  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);
26236
+ }, ...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
26237
  }
25954
26238
  /**
25955
26239
  * Render the synthetic "(+) new commit" affordance shown above the real
@@ -26223,6 +26507,15 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
26223
26507
  : `${localBranches.length}/${sortedAll.length} local | current: ${branches?.currentBranch || '<detached>'}${filterLabel}${sortLabel}`;
26224
26508
  const emptyLabel = formatLogInkBranchesEmpty({ filter: state.filter });
26225
26509
  const loadingLabel = formatLogInkLoading({ resource: 'branches' });
26510
+ // Per-column width derived from the visible window (#833) so columns
26511
+ // align across rows regardless of name length. Padded to the longest
26512
+ // name in view so short rows fill out instead of leaving a gutter;
26513
+ // capped at 40 cells so one runaway long branch name doesn't blow
26514
+ // out the timestamp column entirely (longer names get truncated and
26515
+ // the timestamp stays where the user expects it).
26516
+ const nameColWidth = visible.length === 0
26517
+ ? 28
26518
+ : Math.min(40, Math.max(8, ...visible.map((branch) => branch.shortName.length)));
26226
26519
  const lines = loading
26227
26520
  ? [h(Text, { key: 'branches-loading', dimColor: true }, loadingLabel)]
26228
26521
  : localBranches.length === 0
@@ -26236,18 +26529,18 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
26236
26529
  const lastTouched = formatBranchLastTouched(branch.date, new Date());
26237
26530
  // Split the row into spans so the timestamp stays dim even on the
26238
26531
  // 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);
26532
+ // their per-window-derived column widths; the timestamp is
26533
+ // right-padded so the divergence column stays aligned across rows.
26534
+ const namePadded = truncate$1(branch.shortName, nameColWidth).padEnd(nameColWidth);
26242
26535
  const timestampPadded = lastTouched.padEnd(8);
26243
26536
  const lineDim = !isSelected && !branch.current;
26244
26537
  const head = `${cursor} ${marker} ${namePadded} `;
26245
26538
  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.
26539
+ // Truncate the assembled line to the actual panel width so a
26540
+ // narrow inspector / sidebar focus doesn't push branch rows
26541
+ // onto a second visual line (#830).
26249
26542
  const fullText = `${head}${timestampPadded}${trailingDivergence}`;
26250
- const truncated = truncate$1(fullText, 140);
26543
+ const truncated = truncate$1(fullText, Math.max(20, width - 4));
26251
26544
  // If truncation chopped into the timestamp/divergence portion,
26252
26545
  // fall back to a single Text to keep the visible width honest.
26253
26546
  if (truncated !== fullText) {
@@ -26291,6 +26584,13 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
26291
26584
  : `${tags.length}/${sortedAll.length} tags${filterLabel}${sortLabel}`;
26292
26585
  const emptyLabel = formatLogInkTagsEmpty({ filter: state.filter });
26293
26586
  const loadingLabel = formatLogInkLoading({ resource: 'tags' });
26587
+ // Per-window name column width (#833) so short tags don't leave a
26588
+ // wide gutter and long tags don't push the subject off-screen. Cap
26589
+ // matches the branches surface for visual consistency across the
26590
+ // promoted views.
26591
+ const tagNameColWidth = visible.length === 0
26592
+ ? 20
26593
+ : Math.min(40, Math.max(8, ...visible.map((tag) => tag.name.length)));
26294
26594
  const lines = loading
26295
26595
  ? [h(Text, { key: 'tags-loading', dimColor: true }, loadingLabel)]
26296
26596
  : tags.length === 0
@@ -26304,8 +26604,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
26304
26604
  // formatHyperlink wraps just the tag name, leaving width math
26305
26605
  // intact.
26306
26606
  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);
26607
+ const namePadded = truncate$1(tag.name, tagNameColWidth).padEnd(tagNameColWidth);
26608
+ const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`, Math.max(20, width - 4));
26309
26609
  if (!url || lineText.indexOf(namePadded) < 0) {
26310
26610
  return h(Text, {
26311
26611
  key: `tag-${index}`,
@@ -26388,6 +26688,17 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
26388
26688
  const headerRight = loading
26389
26689
  ? 'loading worktrees'
26390
26690
  : `${worktrees.length}/${allWorktrees.length} worktrees${filterLabel}`;
26691
+ // Per-window branch column width (#833). Worktrees often track
26692
+ // branches with names varying widely in length (`main` vs.
26693
+ // `feat/tui-something-long`); fixed-width padding either left a
26694
+ // huge gutter on short rows or pushed the path column off-screen on
26695
+ // long ones. Cap matches the other promoted surfaces.
26696
+ const branchColWidth = visible.length === 0
26697
+ ? 28
26698
+ : Math.min(40, Math.max(8, ...visible.map((entry) => {
26699
+ const label = entry.branch ? entry.branch : entry.head || '<detached>';
26700
+ return label.length;
26701
+ })));
26391
26702
  const lines = loading
26392
26703
  ? [h(Text, { key: 'worktrees-loading', dimColor: true }, formatLogInkLoading({ resource: 'worktrees' }))]
26393
26704
  : worktrees.length === 0
@@ -26399,11 +26710,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
26399
26710
  const marker = entry.current ? '*' : ' ';
26400
26711
  const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
26401
26712
  const stateLabel = entry.dirty ? 'dirty' : 'clean';
26713
+ const branchPadded = truncate$1(branchLabel, branchColWidth).padEnd(branchColWidth);
26402
26714
  return h(Text, {
26403
26715
  key: `worktree-${index}`,
26404
26716
  bold: isSelected,
26405
26717
  dimColor: !isSelected && !entry.current,
26406
- }, truncate$1(`${cursor} ${marker} ${branchLabel.padEnd(28)} ${stateLabel.padEnd(6)} ${entry.path}`, width - 4));
26718
+ }, truncate$1(`${cursor} ${marker} ${branchPadded} ${stateLabel.padEnd(6)} ${entry.path}`, width - 4));
26407
26719
  });
26408
26720
  return h(Box, {
26409
26721
  borderColor: focusBorderColor(theme, focused),
@@ -27061,7 +27373,12 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
27061
27373
  }, truncate$1(theme.ascii ? '[...] AI draft in progress' : '⏳ AI draft in progress', width - 4))]
27062
27374
  : []), ...(stagedFiles.length
27063
27375
  ? [
27064
- h(Text, { key: 'compose-context-staged-title', bold: true }, 'Staged'),
27376
+ // Section header carries the total count to match the status
27377
+ // surface's "▾ Staged (n)" treatment (#840). The visible
27378
+ // file list is sliced at 12 rows; using `worktree.stagedCount`
27379
+ // (the total) avoids a misleading "Staged (12)" label when
27380
+ // there are actually more staged files below the slice.
27381
+ h(Text, { key: 'compose-context-staged-title', bold: true }, `Staged (${worktree?.stagedCount ?? stagedFiles.length})`),
27065
27382
  ...stagedFiles.map((file, index) => h(Text, {
27066
27383
  key: `compose-context-staged-${index}`,
27067
27384
  color: theme.noColor ? undefined : theme.colors.gitAdded,
@@ -27070,7 +27387,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
27070
27387
  ]
27071
27388
  : []), ...(unstagedFiles.length
27072
27389
  ? [
27073
- h(Text, { key: 'compose-context-unstaged-title', bold: true }, 'Unstaged'),
27390
+ h(Text, { key: 'compose-context-unstaged-title', bold: true }, `Unstaged (${worktree?.unstagedCount ?? unstagedFiles.length})`),
27074
27391
  ...unstagedFiles.map((file, index) => h(Text, {
27075
27392
  key: `compose-context-unstaged-${index}`,
27076
27393
  color: theme.noColor ? undefined : theme.colors.gitModified,
@@ -27483,6 +27800,84 @@ function renderFooter(h, components, state, context, theme, idleTip) {
27483
27800
  }, h(Text, { color: theme.colors.muted, dimColor: true }, contextualText), h(Text, { color: theme.colors.muted, dimColor: true }, globalText));
27484
27801
  }
27485
27802
 
27803
+ /**
27804
+ * Per-repo disk cache of the last successful commit-log fetch (#808).
27805
+ * Lets the TUI render an immediate stale-but-useful history view on
27806
+ * subsequent boots while the fresh `git log` runs in the background;
27807
+ * once the fresh data lands the runtime swaps it in transparently.
27808
+ *
27809
+ * Strict best-effort: read failures fall back to "no cache" (boot
27810
+ * shows the loading placeholder), and write failures are swallowed
27811
+ * silently (next boot just doesn't have the cache yet). The cache is
27812
+ * never load-bearing.
27813
+ *
27814
+ * Repos are keyed by a short hash of their absolute path. No PII in
27815
+ * the cache filename, and re-creating a repo at the same path keeps
27816
+ * the same cache.
27817
+ */
27818
+ const CACHE_SCHEMA_VERSION = 1;
27819
+ const CACHE_DIR_NAME = 'overview';
27820
+ /**
27821
+ * Hard cap on rows we'll write per cache entry. The interactive
27822
+ * default limit is 300; this caps growth in case a user opts into a
27823
+ * much larger window. Keeps the cache file under ~200kb on a typical
27824
+ * repo.
27825
+ */
27826
+ const CACHE_ROW_HARD_CAP = 500;
27827
+ function resolveCacheDir() {
27828
+ const xdg = process.env.XDG_CACHE_HOME;
27829
+ if (xdg && xdg.trim().length > 0) {
27830
+ return path__namespace$1.join(xdg, 'coco', CACHE_DIR_NAME);
27831
+ }
27832
+ return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME);
27833
+ }
27834
+ function repoKey(repoPath) {
27835
+ // sha1 here is a non-security cache-key derivation — we just need a
27836
+ // deterministic short identifier for the cache filename so two repos
27837
+ // at different paths never collide. No PII or auth context is hashed
27838
+ // and no collision-resistance against an adversary is required.
27839
+ // DevSkim DS126858 doesn't apply.
27840
+ // DevSkim: ignore DS126858
27841
+ return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
27842
+ }
27843
+ function getOverviewCachePath(repoPath) {
27844
+ return path__namespace$1.join(resolveCacheDir(), `commits.${repoKey(repoPath)}.json`);
27845
+ }
27846
+ function readCachedCommits(repoPath) {
27847
+ try {
27848
+ const raw = fs__namespace$1.readFileSync(getOverviewCachePath(repoPath), 'utf8');
27849
+ const parsed = JSON.parse(raw);
27850
+ if (parsed.version !== CACHE_SCHEMA_VERSION) {
27851
+ // Schema mismatch — quietly drop the stale entry on next write.
27852
+ // Treating it as "no cache" keeps boot behavior predictable
27853
+ // across upgrades.
27854
+ return undefined;
27855
+ }
27856
+ if (!Array.isArray(parsed.rows)) {
27857
+ return undefined;
27858
+ }
27859
+ return parsed.rows;
27860
+ }
27861
+ catch {
27862
+ return undefined;
27863
+ }
27864
+ }
27865
+ function writeCachedCommits(repoPath, rows) {
27866
+ const file = getOverviewCachePath(repoPath);
27867
+ const envelope = {
27868
+ version: CACHE_SCHEMA_VERSION,
27869
+ savedAt: new Date().toISOString(),
27870
+ rows: rows.slice(0, CACHE_ROW_HARD_CAP),
27871
+ };
27872
+ try {
27873
+ fs__namespace$1.mkdirSync(path__namespace$1.dirname(file), { recursive: true });
27874
+ fs__namespace$1.writeFileSync(file, JSON.stringify(envelope));
27875
+ }
27876
+ catch {
27877
+ // Best-effort persistence; swallow.
27878
+ }
27879
+ }
27880
+
27486
27881
  function createLogArgvFromUiArgv(argv) {
27487
27882
  return {
27488
27883
  $0: argv.$0,
@@ -27507,14 +27902,43 @@ function createUiTheme(config, argv) {
27507
27902
  preset: argv.theme,
27508
27903
  };
27509
27904
  }
27905
+ /**
27906
+ * Wrap a fresh-rows loader with the disk-cache write step. Lets the
27907
+ * runtime stay caching-agnostic — it just receives the rows and
27908
+ * doesn't know whether they came from cache or git, while the caller
27909
+ * (which knows the repo path) handles persistence.
27910
+ */
27911
+ function withCacheWrite(repoPath, loader) {
27912
+ return async () => {
27913
+ const rows = await loader();
27914
+ writeCachedCommits(repoPath, rows);
27915
+ return rows;
27916
+ };
27917
+ }
27510
27918
  async function startCocoUiFromLogArgv(logArgv, options = {}) {
27511
27919
  const config = options.config || loadConfig(logArgv);
27512
27920
  const git = options.git || getRepo();
27513
- const rows = options.rows || (await getLogRows(git, logArgv));
27514
- await startInkInteractiveLog(git, rows, {}, {
27921
+ const repoPath = process.cwd();
27922
+ // Three-stage boot (#808):
27923
+ // 1. Read the disk cache and pass cached rows as the initial set
27924
+ // so the user sees the workstation chrome populated with
27925
+ // commits in the first frame.
27926
+ // 2. Mount Ink immediately with those rows (or [] if no cache).
27927
+ // 3. Run loadRows in the background to refresh — when fresh data
27928
+ // lands the runtime swaps it in transparently and we persist
27929
+ // the new rows back to the cache for next boot.
27930
+ // Caller-provided rows skip the lazy path entirely (caller already
27931
+ // has up-to-date data — no point redoing the fetch).
27932
+ const cachedRows = options.rows ? undefined : readCachedCommits(repoPath);
27933
+ const initialRows = options.rows || cachedRows || [];
27934
+ const loadRows = options.rows
27935
+ ? undefined
27936
+ : withCacheWrite(repoPath, () => getLogRows(git, logArgv));
27937
+ await startInkInteractiveLog(git, initialRows, {}, {
27515
27938
  appLabel: 'coco',
27516
27939
  idleTips: config.logTui?.idleTips,
27517
27940
  initialView: 'history',
27941
+ loadRows,
27518
27942
  logArgv,
27519
27943
  theme: config.logTui?.theme,
27520
27944
  });
@@ -27523,11 +27947,15 @@ async function startCocoUi(argv) {
27523
27947
  const config = loadConfig(argv);
27524
27948
  const git = getRepo();
27525
27949
  const logArgv = createLogArgvFromUiArgv(argv);
27526
- const rows = await getLogRows(git, logArgv);
27527
- await startInkInteractiveLog(git, rows, {}, {
27950
+ const repoPath = process.cwd();
27951
+ // Same three-stage boot as startCocoUiFromLogArgv — mount with
27952
+ // cached rows for an instant-paint shell, refresh in background.
27953
+ const cachedRows = readCachedCommits(repoPath);
27954
+ await startInkInteractiveLog(git, cachedRows || [], {}, {
27528
27955
  appLabel: 'coco',
27529
27956
  idleTips: config.logTui?.idleTips,
27530
27957
  initialView: argv.view || 'history',
27958
+ loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
27531
27959
  logArgv,
27532
27960
  theme: createUiTheme(config, argv),
27533
27961
  });
@@ -27637,15 +28065,18 @@ const handler$2 = async (argv) => {
27637
28065
  });
27638
28066
  return;
27639
28067
  }
27640
- const rows = await getLogRows(git, argv);
28068
+ // Interactive path defers the commit log fetch into the runtime
28069
+ // (#808) so the TUI mounts immediately with a "Loading commits…"
28070
+ // placeholder. The non-interactive (stdout) path still needs rows
28071
+ // up-front because the formatter just dumps a static snapshot.
27641
28072
  if (argv.interactive && format === 'table') {
27642
28073
  await startCocoUiFromLogArgv(argv, {
27643
28074
  config,
27644
28075
  git,
27645
- rows,
27646
28076
  });
27647
28077
  return;
27648
28078
  }
28079
+ const rows = await getLogRows(git, argv);
27649
28080
  const result = format === 'json' ? formatLogJson(rows) : formatLogTable(rows);
27650
28081
  await handleResult({
27651
28082
  result,