git-coco 0.62.1 → 0.62.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
61
61
  /**
62
62
  * Current build version from package.json
63
63
  */
64
- const BUILD_VERSION = "0.62.1";
64
+ const BUILD_VERSION = "0.62.3";
65
65
 
66
66
  const isInteractive = (config) => {
67
67
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -25162,12 +25162,14 @@ function formatSortIndicator(mode, options = {}) {
25162
25162
  }
25163
25163
 
25164
25164
  /**
25165
- * True when `pending` (a `state.pendingDeletion`) targets this exact row.
25166
- * Shared by every deletable surface + the sidebar so the spinner-swap
25167
- * test is identical everywhere. Takes the field value (not the whole
25168
- * state) so it can live next to the type without a forward reference.
25165
+ * True when `pending` (a `state.pendingItemAction`) targets this exact
25166
+ * row. Action-agnostic on purpose — every surface + the sidebar render
25167
+ * the same spinner whether the row is being deleted or checked out, so
25168
+ * the spinner-swap test stays identical everywhere. Takes the field
25169
+ * value (not the whole state) so it can live next to the type without a
25170
+ * forward reference.
25169
25171
  */
25170
- function isPendingDeletion(pending, kind, id) {
25172
+ function isPendingItemAction(pending, kind, id) {
25171
25173
  return pending?.kind === kind && pending.id === id;
25172
25174
  }
25173
25175
  const DEFAULT_CHANGELOG_VIEW_STATE = {
@@ -26375,10 +26377,11 @@ function applyLogInkAction(state, action) {
26375
26377
  workflowActionId: action.value ? undefined : state.workflowActionId,
26376
26378
  pendingKey: undefined,
26377
26379
  };
26378
- case 'setPendingDeletion':
26379
- // Pure marker for the in-flight delete; touches nothing else so the
26380
- // list keeps rendering normally underneath the one spinner'd row.
26381
- return { ...state, pendingDeletion: action.value };
26380
+ case 'setPendingItemAction':
26381
+ // Pure marker for the in-flight row action (delete / checkout);
26382
+ // touches nothing else so the list keeps rendering normally
26383
+ // underneath the one spinner'd row.
26384
+ return { ...state, pendingItemAction: action.value };
26382
26385
  case 'toggleFilterMode':
26383
26386
  return {
26384
26387
  ...state,
@@ -31001,6 +31004,29 @@ function deleteBranch(git, branch, force = false) {
31001
31004
  function isBranchNotFullyMergedError(message) {
31002
31005
  return /not fully merged/i.test(message || '');
31003
31006
  }
31007
+ /**
31008
+ * True when a branch delete was rejected because the branch is checked
31009
+ * out in a worktree. Unlike "not fully merged" there's no force escape
31010
+ * hatch — git refuses `git branch -D` on a worktree-checked-out branch
31011
+ * too — so the UI should surface a clear "free up the worktree first"
31012
+ * message rather than offering a force-delete that would fail the same
31013
+ * way. Matches git's wording: `Cannot delete branch 'x' checked out at
31014
+ * '<path>'` / `used by worktree at '<path>'`.
31015
+ */
31016
+ function isBranchCheckedOutElsewhereError(message) {
31017
+ return /checked out at|used by worktree/i.test(message || '');
31018
+ }
31019
+ /**
31020
+ * Pull the worktree path out of git's "checked out at '<path>'" /
31021
+ * "used by worktree at '<path>'" rejection so the UI can name where the
31022
+ * branch is still in use. Returns undefined when the message doesn't
31023
+ * carry a path (older git phrasings) so callers can fall back to a
31024
+ * generic message.
31025
+ */
31026
+ function parseCheckedOutWorktreePath(message) {
31027
+ const match = /(?:checked out at|used by worktree at) '([^']+)'/i.exec(message || '');
31028
+ return match?.[1];
31029
+ }
31004
31030
  function fetchRemotes(git) {
31005
31031
  return runAction$5(() => git.raw(['fetch', '--all', '--prune']), 'Fetched all remotes');
31006
31032
  }
@@ -34325,7 +34351,13 @@ function sidebarTabLabel(tab) {
34325
34351
  * Sliding window keeps the cursor in view as the user navigates a long
34326
34352
  * list; truncation hints surface the count of hidden rows.
34327
34353
  */
34328
- function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount) {
34354
+ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount,
34355
+ // Optional per-row foreground colour (e.g. the current branch in
34356
+ // green). Applied only when the row is NOT the active selection —
34357
+ // the selection's inverse/background styling owns the row's colour in
34358
+ // that case, and layering a foreground under `inverse` would swap it
34359
+ // into an unexpected background tint.
34360
+ rowColor) {
34329
34361
  if (items.length === 0)
34330
34362
  return [];
34331
34363
  const window = getSidebarVisibleWindow(items.length, selectedIndex, visibleCount);
@@ -34342,10 +34374,12 @@ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, wid
34342
34374
  break;
34343
34375
  const isSelected = focused && index === selectedIndex;
34344
34376
  const text = toRowText(items[index], index);
34377
+ const color = isSelected ? undefined : rowColor?.(items[index], index);
34345
34378
  elements.push(h(Text, {
34346
34379
  key: `${keyPrefix}-row-${index}`,
34347
34380
  backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
34348
34381
  inverse: isSelected,
34382
+ color,
34349
34383
  }, truncateCells(` ${text}`, width - 4)));
34350
34384
  }
34351
34385
  if (window.truncatedBelow > 0) {
@@ -34412,7 +34446,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34412
34446
  // shows this spinner in place of its leading marker (branches /
34413
34447
  // worktrees) or appended to the row (tags / stashes, which have no
34414
34448
  // leading status icon). `pending` is the single in-flight target.
34415
- const pending = state.pendingDeletion;
34449
+ const pending = state.pendingItemAction;
34416
34450
  const spin = inlineSpinnerGlyph(spinnerFrame, theme.ascii);
34417
34451
  // Available rows for the active tab's list. The sidebar chrome
34418
34452
  // takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
@@ -34451,11 +34485,15 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34451
34485
  return [
34452
34486
  ...headerRows,
34453
34487
  ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => {
34454
- const glyph = isPendingDeletion(pending, 'branch', branch.shortName)
34488
+ const glyph = isPendingItemAction(pending, 'branch', branch.shortName)
34455
34489
  ? spin
34456
34490
  : branchRowMarker(branch, { ascii: theme.ascii }).glyph;
34457
34491
  return `${glyph} ${branch.shortName}`;
34458
- }, 'tab-branches', visibleListCount),
34492
+ }, 'tab-branches', visibleListCount,
34493
+ // Paint the checked-out branch green so "where am I?" reads at a
34494
+ // glance, matching the green HEAD marker the branches surface
34495
+ // already uses. NO_COLOR themes fall back to the `*` glyph alone.
34496
+ (branch) => (branch.current && !theme.noColor ? theme.colors.success : undefined)),
34459
34497
  ];
34460
34498
  }
34461
34499
  if (tab === 'tags') {
@@ -34470,7 +34508,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34470
34508
  const base = `${truncateCells(tag.name, 16)} ${tag.subject}`;
34471
34509
  // Tags have no leading status icon, so the pending spinner is
34472
34510
  // appended to the row instead of replacing a glyph.
34473
- return isPendingDeletion(pending, 'tag', tag.name) ? `${base} ${spin}` : base;
34511
+ return isPendingItemAction(pending, 'tag', tag.name) ? `${base} ${spin}` : base;
34474
34512
  }, 'tab-tags', visibleListCount);
34475
34513
  }
34476
34514
  if (tab === 'stashes') {
@@ -34485,7 +34523,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34485
34523
  const base = `@{${index}} ${stash.message || '(no message)'}`;
34486
34524
  // `@{N}` is the stash ref, not a status icon, so append the
34487
34525
  // spinner rather than replacing it.
34488
- return isPendingDeletion(pending, 'stash', stash.ref) ? `${base} ${spin}` : base;
34526
+ return isPendingItemAction(pending, 'stash', stash.ref) ? `${base} ${spin}` : base;
34489
34527
  }, 'tab-stashes', visibleListCount);
34490
34528
  }
34491
34529
  // worktrees
@@ -34497,7 +34535,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34497
34535
  return [h(Text, { key: 'tab-worktrees-empty', dimColor: true }, ' No linked worktrees')];
34498
34536
  }
34499
34537
  return renderSelectableSidebarRows(h, Text, worktrees, state.selectedWorktreeListIndex, focused, width, theme, (worktree) => {
34500
- const marker = isPendingDeletion(pending, 'worktree', worktree.path)
34538
+ const marker = isPendingItemAction(pending, 'worktree', worktree.path)
34501
34539
  ? spin
34502
34540
  : worktree.current ? '*' : ' ';
34503
34541
  const wstate = worktree.dirty ? 'dirty' : 'clean';
@@ -34933,7 +34971,7 @@ function renderBranchesSurface(ctx, spinnerFrame = 0) {
34933
34971
  // While this branch's delete is in flight, its sync-state marker
34934
34972
  // is replaced by an inline spinner (accent-coloured) so the row
34935
34973
  // reads as "deleting" until it vanishes on refresh.
34936
- const deleting = isPendingDeletion(state.pendingDeletion, 'branch', branch.shortName);
34974
+ const deleting = isPendingItemAction(state.pendingItemAction, 'branch', branch.shortName);
34937
34975
  const glyph = deleting ? inlineSpinnerGlyph(spinnerFrame, theme.ascii) : marker.glyph;
34938
34976
  const glyphColor = deleting
34939
34977
  ? (theme.noColor ? undefined : theme.colors.accent)
@@ -34956,11 +34994,16 @@ function renderBranchesSurface(ctx, spinnerFrame = 0) {
34956
34994
  const truncated = truncateCells(fullText, Math.max(20, width - 4));
34957
34995
  // If truncation chopped into the timestamp/divergence portion,
34958
34996
  // fall back to a single Text to keep the visible width honest.
34997
+ // The checked-out branch is painted green (matching its green
34998
+ // HEAD marker) so "where am I?" reads at a glance. NO_COLOR
34999
+ // themes fall back to the `*` glyph alone.
35000
+ const currentColor = branch.current && !theme.noColor ? theme.colors.success : undefined;
34959
35001
  if (truncated !== fullText) {
34960
35002
  return h(Text, {
34961
35003
  key: `branch-${index}`,
34962
35004
  bold: isSelected,
34963
35005
  dimColor: lineDim,
35006
+ color: currentColor,
34964
35007
  }, truncated);
34965
35008
  }
34966
35009
  return h(Text, {
@@ -34975,7 +35018,12 @@ function renderBranchesSurface(ctx, spinnerFrame = 0) {
34975
35018
  // no-upstream kinds return undefined from
34976
35019
  // `getBranchRowMarkerColor`, so those markers inherit the
34977
35020
  // row's dim and read as quiet chrome.
34978
- h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
35021
+ h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph),
35022
+ // Name span: green for the current branch (dimColor:false keeps
35023
+ // it bright), otherwise it inherits the row's normal styling.
35024
+ currentColor
35025
+ ? h(Text, { color: currentColor, dimColor: false }, trailingName)
35026
+ : trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
34979
35027
  });
34980
35028
  // Scroll indicators — same "N more above/below" pattern as the
34981
35029
  // sidebar and help overlay so the user knows the list continues.
@@ -38774,6 +38822,15 @@ function renderReflogSurface(ctx) {
38774
38822
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
38775
38823
  * of #890. No behavior change.
38776
38824
  */
38825
+ const GAP = 2; // cells between columns
38826
+ /** Truncate to `w` cells, then pad to `w` (left = padEnd, right = padStart). */
38827
+ function cell(value, w, align = 'left') {
38828
+ const t = truncateCells(value, w);
38829
+ // padStart/padEnd count code units; refs / ages / counts / branch
38830
+ // names are ASCII in practice, matching the branches surface's
38831
+ // padEnd-based column alignment.
38832
+ return align === 'right' ? t.padStart(w) : t.padEnd(w);
38833
+ }
38777
38834
  function renderStashSurface(ctx, spinnerFrame = 0) {
38778
38835
  const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
38779
38836
  const { Box, Text } = components;
@@ -38784,7 +38841,9 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
38784
38841
  ? allStashes.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
38785
38842
  : allStashes;
38786
38843
  const selected = Math.max(0, Math.min(state.selectedStashIndex, Math.max(0, stashes.length - 1)));
38787
- const listRows = Math.max(4, bodyRows - 4);
38844
+ // One extra row reserved (vs the other surfaces' `- 4`) for the column
38845
+ // header row below.
38846
+ const listRows = Math.max(4, bodyRows - 5);
38788
38847
  const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
38789
38848
  const visible = stashes.slice(startIndex, startIndex + listRows);
38790
38849
  const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
@@ -38794,10 +38853,67 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
38794
38853
  const emptyLabel = formatLogInkStashEmpty({ filter: state.filter });
38795
38854
  const loadingLabel = formatLogInkLoading({ resource: 'stashes' });
38796
38855
  const now = getRenderNow();
38797
- // Available width for a row: box width minus the 2-cell horizontal
38798
- // padding. Truncate to it (with a small floor) instead of a magic 140
38799
- // so the richer meta degrades gracefully on narrow terminals.
38800
- const rowWidth = Math.max(20, width - 2);
38856
+ // Usable interior width: panel width minus the border (2) and the
38857
+ // paddingX:1 (2). The previous `width - 2` under-counted the border
38858
+ // and made every near-full row overflow by 2 cells and wrap — the
38859
+ // single biggest readability hit in the old list.
38860
+ const contentWidth = Math.max(20, width - 4);
38861
+ const ageOf = (s) => formatCompactRelativeDate(s.date, now);
38862
+ // Column widths derived from the visible window (#833 pattern) so rows
38863
+ // align without re-measuring the whole list. Each column is at least
38864
+ // as wide as its header label so the header never truncates ("age",
38865
+ // "branch", "files"); age tops out at 5 ("today" is the longest value
38866
+ // formatCompactRelativeDate emits).
38867
+ const refCol = visible.length
38868
+ ? Math.min(11, Math.max(3, ...visible.map((s) => cellWidth(s.ref))))
38869
+ : 9;
38870
+ const ageCol = visible.length
38871
+ ? Math.min(5, Math.max(3, ...visible.map((s) => cellWidth(ageOf(s)))))
38872
+ : 3;
38873
+ const branchColMax = visible.length
38874
+ ? Math.max(0, ...visible.map((s) => cellWidth(s.branch || '')))
38875
+ : 0;
38876
+ const filesCol = visible.length
38877
+ ? Math.max(5, ...visible.map((s) => String(s.files.length).length))
38878
+ : 5;
38879
+ // Responsive degradation. Keep ref + message always; when the message
38880
+ // floor is threatened, shed columns the preview pane already shows —
38881
+ // branch first, then age, then the file count — before squeezing the
38882
+ // message.
38883
+ // Widen to fit the "branch" header when any stash carries a branch;
38884
+ // stays 0 (column dropped) when none do.
38885
+ let branchCol = branchColMax > 0 ? Math.min(18, Math.max(6, branchColMax)) : 0;
38886
+ let showAge = true;
38887
+ let showFiles = true;
38888
+ const fixedWidth = () => 2 + refCol +
38889
+ (showAge ? GAP + ageCol : 0) +
38890
+ (branchCol > 0 ? GAP + branchCol : 0) +
38891
+ (showFiles ? GAP + filesCol : 0) +
38892
+ GAP; // gap before the message column
38893
+ let messageWidth = contentWidth - fixedWidth();
38894
+ if (messageWidth < 24 && branchCol > 0) {
38895
+ branchCol = 0;
38896
+ messageWidth = contentWidth - fixedWidth();
38897
+ }
38898
+ if (messageWidth < 16 && showAge) {
38899
+ showAge = false;
38900
+ messageWidth = contentWidth - fixedWidth();
38901
+ }
38902
+ if (messageWidth < 12 && showFiles) {
38903
+ showFiles = false;
38904
+ messageWidth = contentWidth - fixedWidth();
38905
+ }
38906
+ messageWidth = Math.max(8, messageWidth);
38907
+ // Column header. Right-aligned labels over the right-aligned numeric
38908
+ // columns (age, files) so header and data share an edge.
38909
+ let headerText = ` ${cell('ref', refCol)}`;
38910
+ if (showAge)
38911
+ headerText += `${' '.repeat(GAP)}${cell('age', ageCol, 'right')}`;
38912
+ if (branchCol > 0)
38913
+ headerText += `${' '.repeat(GAP)}${cell('branch', branchCol)}`;
38914
+ if (showFiles)
38915
+ headerText += `${' '.repeat(GAP)}${cell('files', filesCol, 'right')}`;
38916
+ headerText += `${' '.repeat(GAP)}message`;
38801
38917
  const lines = loading
38802
38918
  ? [h(Text, { key: 'stash-loading', dimColor: true }, loadingLabel)]
38803
38919
  : stashes.length === 0
@@ -38806,32 +38922,22 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
38806
38922
  const index = startIndex + offset;
38807
38923
  const isSelected = index === selected;
38808
38924
  const cursor = isSelected ? '>' : ' ';
38809
- // Surface the metadata the StashEntry already carries — origin
38810
- // branch, file count, and relative age — between the ref and the
38811
- // message, so the list answers "which stash is this?" without an
38812
- // Enter→diff round trip.
38813
- const age = formatCompactRelativeDate(stash.date, now);
38814
- const fileCount = stash.files.length;
38815
- const meta = [
38816
- stash.branch ? `on ${stash.branch}` : '',
38817
- fileCount > 0 ? `${fileCount} file${fileCount === 1 ? '' : 's'}` : '',
38818
- age,
38819
- ].filter(Boolean).join(' · ');
38820
- const rowText = meta
38821
- ? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
38822
- : `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
38925
+ const deleting = isPendingItemAction(state.pendingItemAction, 'stash', stash.ref);
38823
38926
  // The `stash@{N}` ref is an identifier, not a status icon, so a
38824
38927
  // delete-in-flight appends an accent spinner at the row's end
38825
- // (2 cells reserved from the width budget).
38826
- const deleting = isPendingDeletion(state.pendingDeletion, 'stash', stash.ref);
38928
+ // (2 cells reserved from the message budget).
38827
38929
  const spinnerSpan = deleting
38828
38930
  ? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
38829
38931
  : null;
38932
+ const message = truncateCells(stash.message, messageWidth - (deleting ? 2 : 0));
38933
+ // ref + message read as primary; age / branch / file-count are
38934
+ // dim metadata (kept dim even on the bold selected row so the
38935
+ // message stays the focal point).
38830
38936
  return h(Text, {
38831
38937
  key: `stash-${index}`,
38832
38938
  bold: isSelected,
38833
38939
  dimColor: !isSelected,
38834
- }, truncateCells(rowText, rowWidth - (deleting ? 2 : 0)), spinnerSpan);
38940
+ }, `${cursor} `, cell(stash.ref, refCol), showAge ? h(Text, { dimColor: true }, `${' '.repeat(GAP)}${cell(ageOf(stash), ageCol, 'right')}`) : null, branchCol > 0 ? h(Text, { dimColor: true }, `${' '.repeat(GAP)}${cell(stash.branch || '', branchCol)}`) : null, showFiles ? h(Text, { dimColor: true }, `${' '.repeat(GAP)}${cell(String(stash.files.length), filesCol, 'right')}`) : null, `${' '.repeat(GAP)}${message}`, spinnerSpan);
38835
38941
  });
38836
38942
  const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
38837
38943
  const stashHasMoreBelow = startIndex + listRows < stashes.length;
@@ -38842,7 +38948,11 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
38842
38948
  flexShrink: 0,
38843
38949
  paddingX: 1,
38844
38950
  width,
38845
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...(stashHasMoreAbove
38951
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme),
38952
+ // Column header — only when there are rows to label.
38953
+ ...(!loading && stashes.length > 0
38954
+ ? [h(Text, { key: 'stash-col-header', dimColor: true }, truncateCells(headerText, contentWidth))]
38955
+ : []), ...(stashHasMoreAbove
38846
38956
  ? [h(Text, { key: 'stash-more-above', dimColor: true }, ` ↑ ${startIndex} more above`)]
38847
38957
  : []), ...lines, ...(stashHasMoreBelow
38848
38958
  ? [h(Text, { key: 'stash-more-below', dimColor: true }, ` ↓ ${stashes.length - (startIndex + listRows)} more below`)]
@@ -39223,7 +39333,7 @@ function renderTagsSurface(ctx, spinnerFrame = 0) {
39223
39333
  // Tags have no leading status icon, so a delete-in-flight appends
39224
39334
  // an accent spinner at the row's end. Reserve its 2 cells from the
39225
39335
  // truncation budget so it never pushes the row past the panel.
39226
- const deleting = isPendingDeletion(state.pendingDeletion, 'tag', tag.name);
39336
+ const deleting = isPendingItemAction(state.pendingItemAction, 'tag', tag.name);
39227
39337
  const spinnerSpan = deleting
39228
39338
  ? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
39229
39339
  : null;
@@ -39305,7 +39415,7 @@ function renderWorktreesSurface(ctx, spinnerFrame = 0) {
39305
39415
  const index = startIndex + offset;
39306
39416
  const isSelected = index === selected;
39307
39417
  const cursor = isSelected ? '>' : ' ';
39308
- const marker = isPendingDeletion(state.pendingDeletion, 'worktree', entry.path)
39418
+ const marker = isPendingItemAction(state.pendingItemAction, 'worktree', entry.path)
39309
39419
  ? inlineSpinnerGlyph(spinnerFrame, theme.ascii)
39310
39420
  : entry.current ? '*' : ' ';
39311
39421
  const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
@@ -40774,15 +40884,28 @@ const REMOTE_OP_LOADERS = {
40774
40884
  * Returns `undefined` for non-delete workflows (and when nothing is
40775
40885
  * selected), which the runner treats as "no pending marker".
40776
40886
  */
40777
- function resolvePendingDeletion(id, state, context) {
40887
+ function resolvePendingItemAction(id, state, context) {
40778
40888
  const { filter } = state;
40889
+ // Checking out a branch gets the same inline spinner on its row as a
40890
+ // delete does — the action just runs `git checkout` instead of
40891
+ // `git branch -d`. Resolved the same way as the delete branch case
40892
+ // (and identically to the checkout-branch handler) so the spinner
40893
+ // lands on exactly the row the user pressed enter on.
40894
+ if (id === 'checkout-branch') {
40895
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
40896
+ const visible = filter
40897
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], filter))
40898
+ : all;
40899
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
40900
+ return branch ? { kind: 'branch', id: branch.shortName, action: 'checkout' } : undefined;
40901
+ }
40779
40902
  if (id === 'delete-branch' || id === 'force-delete-branch') {
40780
40903
  const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
40781
40904
  const visible = filter
40782
40905
  ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], filter))
40783
40906
  : all;
40784
40907
  const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
40785
- return branch ? { kind: 'branch', id: branch.shortName } : undefined;
40908
+ return branch ? { kind: 'branch', id: branch.shortName, action: 'delete' } : undefined;
40786
40909
  }
40787
40910
  if (id === 'delete-tag') {
40788
40911
  const all = sortTags(context.tags?.tags || [], state.tagSort);
@@ -40790,7 +40913,7 @@ function resolvePendingDeletion(id, state, context) {
40790
40913
  ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], filter))
40791
40914
  : all;
40792
40915
  const tag = visible[Math.min(state.selectedTagIndex, visible.length - 1)];
40793
- return tag ? { kind: 'tag', id: tag.name } : undefined;
40916
+ return tag ? { kind: 'tag', id: tag.name, action: 'delete' } : undefined;
40794
40917
  }
40795
40918
  if (id === 'drop-stash') {
40796
40919
  const all = context.stashes?.stashes || [];
@@ -40798,7 +40921,7 @@ function resolvePendingDeletion(id, state, context) {
40798
40921
  ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], filter))
40799
40922
  : all;
40800
40923
  const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
40801
- return stash ? { kind: 'stash', id: stash.ref } : undefined;
40924
+ return stash ? { kind: 'stash', id: stash.ref, action: 'delete' } : undefined;
40802
40925
  }
40803
40926
  if (id === 'remove-worktree') {
40804
40927
  const all = context.worktreeList?.worktrees || [];
@@ -40808,7 +40931,7 @@ function resolvePendingDeletion(id, state, context) {
40808
40931
  const wt = visible.length
40809
40932
  ? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
40810
40933
  : all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
40811
- return wt ? { kind: 'worktree', id: wt.path } : undefined;
40934
+ return wt ? { kind: 'worktree', id: wt.path, action: 'delete' } : undefined;
40812
40935
  }
40813
40936
  return undefined;
40814
40937
  }
@@ -41129,9 +41252,10 @@ function LogInkApp(deps) {
41129
41252
  state.commitCompose.loading ||
41130
41253
  Boolean(state.remoteOp) ||
41131
41254
  Boolean(state.statusLoading) ||
41132
- // Keep the shared spinner ticking while a list-item delete is in
41133
- // flight so its inline pending glyph animates instead of freezing.
41134
- Boolean(state.pendingDeletion);
41255
+ // Keep the shared spinner ticking while a list-item action (delete
41256
+ // or checkout) is in flight so its inline pending glyph animates
41257
+ // instead of freezing.
41258
+ Boolean(state.pendingItemAction);
41135
41259
  React.useEffect(() => {
41136
41260
  if (!anyLoading) {
41137
41261
  // Reset to 0 so the next loading state starts from a known
@@ -44071,14 +44195,15 @@ function LogInkApp(deps) {
44071
44195
  if (remoteOp) {
44072
44196
  dispatch({ type: 'setRemoteOp', value: remoteOp });
44073
44197
  }
44074
- // Mark the cursored row as deleting so it shows an inline pending
44075
- // spinner while the git call runs. Cleared in `finally` after the
44076
- // refresh, so a successful delete hands straight off to the row
44077
- // vanishing, and a failed one (e.g. an unmerged branch) restores
44078
- // the row's normal icon alongside the error status.
44079
- const pendingDeletion = resolvePendingDeletion(id, state, context);
44080
- if (pendingDeletion) {
44081
- dispatch({ type: 'setPendingDeletion', value: pendingDeletion });
44198
+ // Mark the cursored row as busy so it shows an inline pending
44199
+ // spinner while the git call runs (delete or checkout). Cleared in
44200
+ // `finally` after the refresh, so a successful delete hands straight
44201
+ // off to the row vanishing, a checkout to the sidebar repainting
44202
+ // with the new current branch, and a failure (e.g. an unmerged
44203
+ // branch) restores the row's normal icon alongside the error status.
44204
+ const pendingItemAction = resolvePendingItemAction(id, state, context);
44205
+ if (pendingItemAction) {
44206
+ dispatch({ type: 'setPendingItemAction', value: pendingItemAction });
44082
44207
  }
44083
44208
  try {
44084
44209
  const result = await handler();
@@ -44091,6 +44216,24 @@ function LogInkApp(deps) {
44091
44216
  if (id === 'delete-branch' && !result?.ok && isBranchNotFullyMergedError(result?.message)) {
44092
44217
  dispatch({ type: 'setPendingConfirmation', value: 'force-delete-branch' });
44093
44218
  }
44219
+ // A branch checked out in a worktree can't be deleted — and unlike
44220
+ // the unmerged case, `git branch -D` won't force it either, so we
44221
+ // don't offer a confirmation. Replace git's raw rejection with a
44222
+ // clear "free up the worktree first" message that names where the
44223
+ // branch is still in use.
44224
+ if ((id === 'delete-branch' || id === 'force-delete-branch') &&
44225
+ !result?.ok &&
44226
+ isBranchCheckedOutElsewhereError(result?.message)) {
44227
+ const worktreePath = parseCheckedOutWorktreePath(result?.message);
44228
+ const branchName = pendingItemAction?.id;
44229
+ dispatch({
44230
+ type: 'setStatus',
44231
+ value: worktreePath
44232
+ ? `Can't delete ${branchName ? `'${branchName}'` : 'branch'} — checked out in worktree ${worktreePath}. Switch that worktree off the branch or remove it first.`
44233
+ : `Can't delete ${branchName ? `'${branchName}'` : 'branch'} — it's checked out in another worktree. Switch that worktree off the branch or remove it first.`,
44234
+ kind: 'warning',
44235
+ });
44236
+ }
44094
44237
  // Refresh history rows AS WELL when the workflow could have
44095
44238
  // changed the commits the user sees (#945 follow-up). The
44096
44239
  // workflow IDs below all either create/rewrite local commits or
@@ -44126,15 +44269,18 @@ function LogInkApp(deps) {
44126
44269
  if (result?.ok && historyMutatingIds.has(id)) {
44127
44270
  await refreshHistoryRows();
44128
44271
  }
44129
- // Checkout-branch is the one workflow where we want a *visible*
44130
- // refresh so the user sees the branches sidebar repaint with the
44131
- // new current branch (per #806 follow-up). Snap the cursor to
44132
- // position 0 first so when the refresh completes and the new
44133
- // current branch lands at the top (per #809's pin-current rule),
44134
- // the cursor is already there waiting.
44272
+ // Checkout-branch snaps the cursor to position 0 first so when the
44273
+ // refresh completes and the new current branch lands at the top
44274
+ // (per #809's pin-current rule), the cursor is already there
44275
+ // waiting. The refresh is *silent*: the loud refresh used to blank
44276
+ // every branch name behind a "loading branches…" placeholder (#806),
44277
+ // but the in-flight row now carries its own inline pending spinner
44278
+ // (resolvePendingItemAction → action 'checkout'), so a silent
44279
+ // stale-while-revalidate swap keeps the list readable and just
44280
+ // repaints the current-branch marker once the new context lands.
44135
44281
  if (id === 'checkout-branch' && result?.ok) {
44136
44282
  dispatch({ type: 'resetBranchSelection' });
44137
- await refreshContext();
44283
+ await refreshContext({ silent: true });
44138
44284
  }
44139
44285
  else {
44140
44286
  // Silent refresh so the deleted item disappears from the list
@@ -44189,11 +44335,11 @@ function LogInkApp(deps) {
44189
44335
  if (remoteOp) {
44190
44336
  dispatch({ type: 'setRemoteOp', value: undefined });
44191
44337
  }
44192
- // Same guarantee for the per-row delete spinner: clear it whether
44193
- // the delete succeeded, failed, or the refresh threw, so no row is
44194
- // left spinning forever.
44195
- if (pendingDeletion) {
44196
- dispatch({ type: 'setPendingDeletion', value: undefined });
44338
+ // Same guarantee for the per-row pending spinner (delete or
44339
+ // checkout): clear it whether the action succeeded, failed, or the
44340
+ // refresh threw, so no row is left spinning forever.
44341
+ if (pendingItemAction) {
44342
+ dispatch({ type: 'setPendingItemAction', value: undefined });
44197
44343
  }
44198
44344
  }
44199
44345
  }, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
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.62.1";
81
+ const BUILD_VERSION = "0.62.3";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -25179,12 +25179,14 @@ function formatSortIndicator(mode, options = {}) {
25179
25179
  }
25180
25180
 
25181
25181
  /**
25182
- * True when `pending` (a `state.pendingDeletion`) targets this exact row.
25183
- * Shared by every deletable surface + the sidebar so the spinner-swap
25184
- * test is identical everywhere. Takes the field value (not the whole
25185
- * state) so it can live next to the type without a forward reference.
25182
+ * True when `pending` (a `state.pendingItemAction`) targets this exact
25183
+ * row. Action-agnostic on purpose — every surface + the sidebar render
25184
+ * the same spinner whether the row is being deleted or checked out, so
25185
+ * the spinner-swap test stays identical everywhere. Takes the field
25186
+ * value (not the whole state) so it can live next to the type without a
25187
+ * forward reference.
25186
25188
  */
25187
- function isPendingDeletion(pending, kind, id) {
25189
+ function isPendingItemAction(pending, kind, id) {
25188
25190
  return pending?.kind === kind && pending.id === id;
25189
25191
  }
25190
25192
  const DEFAULT_CHANGELOG_VIEW_STATE = {
@@ -26392,10 +26394,11 @@ function applyLogInkAction(state, action) {
26392
26394
  workflowActionId: action.value ? undefined : state.workflowActionId,
26393
26395
  pendingKey: undefined,
26394
26396
  };
26395
- case 'setPendingDeletion':
26396
- // Pure marker for the in-flight delete; touches nothing else so the
26397
- // list keeps rendering normally underneath the one spinner'd row.
26398
- return { ...state, pendingDeletion: action.value };
26397
+ case 'setPendingItemAction':
26398
+ // Pure marker for the in-flight row action (delete / checkout);
26399
+ // touches nothing else so the list keeps rendering normally
26400
+ // underneath the one spinner'd row.
26401
+ return { ...state, pendingItemAction: action.value };
26399
26402
  case 'toggleFilterMode':
26400
26403
  return {
26401
26404
  ...state,
@@ -31018,6 +31021,29 @@ function deleteBranch(git, branch, force = false) {
31018
31021
  function isBranchNotFullyMergedError(message) {
31019
31022
  return /not fully merged/i.test(message || '');
31020
31023
  }
31024
+ /**
31025
+ * True when a branch delete was rejected because the branch is checked
31026
+ * out in a worktree. Unlike "not fully merged" there's no force escape
31027
+ * hatch — git refuses `git branch -D` on a worktree-checked-out branch
31028
+ * too — so the UI should surface a clear "free up the worktree first"
31029
+ * message rather than offering a force-delete that would fail the same
31030
+ * way. Matches git's wording: `Cannot delete branch 'x' checked out at
31031
+ * '<path>'` / `used by worktree at '<path>'`.
31032
+ */
31033
+ function isBranchCheckedOutElsewhereError(message) {
31034
+ return /checked out at|used by worktree/i.test(message || '');
31035
+ }
31036
+ /**
31037
+ * Pull the worktree path out of git's "checked out at '<path>'" /
31038
+ * "used by worktree at '<path>'" rejection so the UI can name where the
31039
+ * branch is still in use. Returns undefined when the message doesn't
31040
+ * carry a path (older git phrasings) so callers can fall back to a
31041
+ * generic message.
31042
+ */
31043
+ function parseCheckedOutWorktreePath(message) {
31044
+ const match = /(?:checked out at|used by worktree at) '([^']+)'/i.exec(message || '');
31045
+ return match?.[1];
31046
+ }
31021
31047
  function fetchRemotes(git) {
31022
31048
  return runAction$5(() => git.raw(['fetch', '--all', '--prune']), 'Fetched all remotes');
31023
31049
  }
@@ -34342,7 +34368,13 @@ function sidebarTabLabel(tab) {
34342
34368
  * Sliding window keeps the cursor in view as the user navigates a long
34343
34369
  * list; truncation hints surface the count of hidden rows.
34344
34370
  */
34345
- function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount) {
34371
+ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount,
34372
+ // Optional per-row foreground colour (e.g. the current branch in
34373
+ // green). Applied only when the row is NOT the active selection —
34374
+ // the selection's inverse/background styling owns the row's colour in
34375
+ // that case, and layering a foreground under `inverse` would swap it
34376
+ // into an unexpected background tint.
34377
+ rowColor) {
34346
34378
  if (items.length === 0)
34347
34379
  return [];
34348
34380
  const window = getSidebarVisibleWindow(items.length, selectedIndex, visibleCount);
@@ -34359,10 +34391,12 @@ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, wid
34359
34391
  break;
34360
34392
  const isSelected = focused && index === selectedIndex;
34361
34393
  const text = toRowText(items[index], index);
34394
+ const color = isSelected ? undefined : rowColor?.(items[index], index);
34362
34395
  elements.push(h(Text, {
34363
34396
  key: `${keyPrefix}-row-${index}`,
34364
34397
  backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
34365
34398
  inverse: isSelected,
34399
+ color,
34366
34400
  }, truncateCells(` ${text}`, width - 4)));
34367
34401
  }
34368
34402
  if (window.truncatedBelow > 0) {
@@ -34429,7 +34463,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34429
34463
  // shows this spinner in place of its leading marker (branches /
34430
34464
  // worktrees) or appended to the row (tags / stashes, which have no
34431
34465
  // leading status icon). `pending` is the single in-flight target.
34432
- const pending = state.pendingDeletion;
34466
+ const pending = state.pendingItemAction;
34433
34467
  const spin = inlineSpinnerGlyph(spinnerFrame, theme.ascii);
34434
34468
  // Available rows for the active tab's list. The sidebar chrome
34435
34469
  // takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
@@ -34468,11 +34502,15 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34468
34502
  return [
34469
34503
  ...headerRows,
34470
34504
  ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => {
34471
- const glyph = isPendingDeletion(pending, 'branch', branch.shortName)
34505
+ const glyph = isPendingItemAction(pending, 'branch', branch.shortName)
34472
34506
  ? spin
34473
34507
  : branchRowMarker(branch, { ascii: theme.ascii }).glyph;
34474
34508
  return `${glyph} ${branch.shortName}`;
34475
- }, 'tab-branches', visibleListCount),
34509
+ }, 'tab-branches', visibleListCount,
34510
+ // Paint the checked-out branch green so "where am I?" reads at a
34511
+ // glance, matching the green HEAD marker the branches surface
34512
+ // already uses. NO_COLOR themes fall back to the `*` glyph alone.
34513
+ (branch) => (branch.current && !theme.noColor ? theme.colors.success : undefined)),
34476
34514
  ];
34477
34515
  }
34478
34516
  if (tab === 'tags') {
@@ -34487,7 +34525,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34487
34525
  const base = `${truncateCells(tag.name, 16)} ${tag.subject}`;
34488
34526
  // Tags have no leading status icon, so the pending spinner is
34489
34527
  // appended to the row instead of replacing a glyph.
34490
- return isPendingDeletion(pending, 'tag', tag.name) ? `${base} ${spin}` : base;
34528
+ return isPendingItemAction(pending, 'tag', tag.name) ? `${base} ${spin}` : base;
34491
34529
  }, 'tab-tags', visibleListCount);
34492
34530
  }
34493
34531
  if (tab === 'stashes') {
@@ -34502,7 +34540,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34502
34540
  const base = `@{${index}} ${stash.message || '(no message)'}`;
34503
34541
  // `@{N}` is the stash ref, not a status icon, so append the
34504
34542
  // spinner rather than replacing it.
34505
- return isPendingDeletion(pending, 'stash', stash.ref) ? `${base} ${spin}` : base;
34543
+ return isPendingItemAction(pending, 'stash', stash.ref) ? `${base} ${spin}` : base;
34506
34544
  }, 'tab-stashes', visibleListCount);
34507
34545
  }
34508
34546
  // worktrees
@@ -34514,7 +34552,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34514
34552
  return [h(Text, { key: 'tab-worktrees-empty', dimColor: true }, ' No linked worktrees')];
34515
34553
  }
34516
34554
  return renderSelectableSidebarRows(h, Text, worktrees, state.selectedWorktreeListIndex, focused, width, theme, (worktree) => {
34517
- const marker = isPendingDeletion(pending, 'worktree', worktree.path)
34555
+ const marker = isPendingItemAction(pending, 'worktree', worktree.path)
34518
34556
  ? spin
34519
34557
  : worktree.current ? '*' : ' ';
34520
34558
  const wstate = worktree.dirty ? 'dirty' : 'clean';
@@ -34950,7 +34988,7 @@ function renderBranchesSurface(ctx, spinnerFrame = 0) {
34950
34988
  // While this branch's delete is in flight, its sync-state marker
34951
34989
  // is replaced by an inline spinner (accent-coloured) so the row
34952
34990
  // reads as "deleting" until it vanishes on refresh.
34953
- const deleting = isPendingDeletion(state.pendingDeletion, 'branch', branch.shortName);
34991
+ const deleting = isPendingItemAction(state.pendingItemAction, 'branch', branch.shortName);
34954
34992
  const glyph = deleting ? inlineSpinnerGlyph(spinnerFrame, theme.ascii) : marker.glyph;
34955
34993
  const glyphColor = deleting
34956
34994
  ? (theme.noColor ? undefined : theme.colors.accent)
@@ -34973,11 +35011,16 @@ function renderBranchesSurface(ctx, spinnerFrame = 0) {
34973
35011
  const truncated = truncateCells(fullText, Math.max(20, width - 4));
34974
35012
  // If truncation chopped into the timestamp/divergence portion,
34975
35013
  // fall back to a single Text to keep the visible width honest.
35014
+ // The checked-out branch is painted green (matching its green
35015
+ // HEAD marker) so "where am I?" reads at a glance. NO_COLOR
35016
+ // themes fall back to the `*` glyph alone.
35017
+ const currentColor = branch.current && !theme.noColor ? theme.colors.success : undefined;
34976
35018
  if (truncated !== fullText) {
34977
35019
  return h(Text, {
34978
35020
  key: `branch-${index}`,
34979
35021
  bold: isSelected,
34980
35022
  dimColor: lineDim,
35023
+ color: currentColor,
34981
35024
  }, truncated);
34982
35025
  }
34983
35026
  return h(Text, {
@@ -34992,7 +35035,12 @@ function renderBranchesSurface(ctx, spinnerFrame = 0) {
34992
35035
  // no-upstream kinds return undefined from
34993
35036
  // `getBranchRowMarkerColor`, so those markers inherit the
34994
35037
  // row's dim and read as quiet chrome.
34995
- h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
35038
+ h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph),
35039
+ // Name span: green for the current branch (dimColor:false keeps
35040
+ // it bright), otherwise it inherits the row's normal styling.
35041
+ currentColor
35042
+ ? h(Text, { color: currentColor, dimColor: false }, trailingName)
35043
+ : trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
34996
35044
  });
34997
35045
  // Scroll indicators — same "N more above/below" pattern as the
34998
35046
  // sidebar and help overlay so the user knows the list continues.
@@ -38791,6 +38839,15 @@ function renderReflogSurface(ctx) {
38791
38839
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
38792
38840
  * of #890. No behavior change.
38793
38841
  */
38842
+ const GAP = 2; // cells between columns
38843
+ /** Truncate to `w` cells, then pad to `w` (left = padEnd, right = padStart). */
38844
+ function cell(value, w, align = 'left') {
38845
+ const t = truncateCells(value, w);
38846
+ // padStart/padEnd count code units; refs / ages / counts / branch
38847
+ // names are ASCII in practice, matching the branches surface's
38848
+ // padEnd-based column alignment.
38849
+ return align === 'right' ? t.padStart(w) : t.padEnd(w);
38850
+ }
38794
38851
  function renderStashSurface(ctx, spinnerFrame = 0) {
38795
38852
  const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
38796
38853
  const { Box, Text } = components;
@@ -38801,7 +38858,9 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
38801
38858
  ? allStashes.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
38802
38859
  : allStashes;
38803
38860
  const selected = Math.max(0, Math.min(state.selectedStashIndex, Math.max(0, stashes.length - 1)));
38804
- const listRows = Math.max(4, bodyRows - 4);
38861
+ // One extra row reserved (vs the other surfaces' `- 4`) for the column
38862
+ // header row below.
38863
+ const listRows = Math.max(4, bodyRows - 5);
38805
38864
  const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
38806
38865
  const visible = stashes.slice(startIndex, startIndex + listRows);
38807
38866
  const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
@@ -38811,10 +38870,67 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
38811
38870
  const emptyLabel = formatLogInkStashEmpty({ filter: state.filter });
38812
38871
  const loadingLabel = formatLogInkLoading({ resource: 'stashes' });
38813
38872
  const now = getRenderNow();
38814
- // Available width for a row: box width minus the 2-cell horizontal
38815
- // padding. Truncate to it (with a small floor) instead of a magic 140
38816
- // so the richer meta degrades gracefully on narrow terminals.
38817
- const rowWidth = Math.max(20, width - 2);
38873
+ // Usable interior width: panel width minus the border (2) and the
38874
+ // paddingX:1 (2). The previous `width - 2` under-counted the border
38875
+ // and made every near-full row overflow by 2 cells and wrap — the
38876
+ // single biggest readability hit in the old list.
38877
+ const contentWidth = Math.max(20, width - 4);
38878
+ const ageOf = (s) => formatCompactRelativeDate(s.date, now);
38879
+ // Column widths derived from the visible window (#833 pattern) so rows
38880
+ // align without re-measuring the whole list. Each column is at least
38881
+ // as wide as its header label so the header never truncates ("age",
38882
+ // "branch", "files"); age tops out at 5 ("today" is the longest value
38883
+ // formatCompactRelativeDate emits).
38884
+ const refCol = visible.length
38885
+ ? Math.min(11, Math.max(3, ...visible.map((s) => cellWidth(s.ref))))
38886
+ : 9;
38887
+ const ageCol = visible.length
38888
+ ? Math.min(5, Math.max(3, ...visible.map((s) => cellWidth(ageOf(s)))))
38889
+ : 3;
38890
+ const branchColMax = visible.length
38891
+ ? Math.max(0, ...visible.map((s) => cellWidth(s.branch || '')))
38892
+ : 0;
38893
+ const filesCol = visible.length
38894
+ ? Math.max(5, ...visible.map((s) => String(s.files.length).length))
38895
+ : 5;
38896
+ // Responsive degradation. Keep ref + message always; when the message
38897
+ // floor is threatened, shed columns the preview pane already shows —
38898
+ // branch first, then age, then the file count — before squeezing the
38899
+ // message.
38900
+ // Widen to fit the "branch" header when any stash carries a branch;
38901
+ // stays 0 (column dropped) when none do.
38902
+ let branchCol = branchColMax > 0 ? Math.min(18, Math.max(6, branchColMax)) : 0;
38903
+ let showAge = true;
38904
+ let showFiles = true;
38905
+ const fixedWidth = () => 2 + refCol +
38906
+ (showAge ? GAP + ageCol : 0) +
38907
+ (branchCol > 0 ? GAP + branchCol : 0) +
38908
+ (showFiles ? GAP + filesCol : 0) +
38909
+ GAP; // gap before the message column
38910
+ let messageWidth = contentWidth - fixedWidth();
38911
+ if (messageWidth < 24 && branchCol > 0) {
38912
+ branchCol = 0;
38913
+ messageWidth = contentWidth - fixedWidth();
38914
+ }
38915
+ if (messageWidth < 16 && showAge) {
38916
+ showAge = false;
38917
+ messageWidth = contentWidth - fixedWidth();
38918
+ }
38919
+ if (messageWidth < 12 && showFiles) {
38920
+ showFiles = false;
38921
+ messageWidth = contentWidth - fixedWidth();
38922
+ }
38923
+ messageWidth = Math.max(8, messageWidth);
38924
+ // Column header. Right-aligned labels over the right-aligned numeric
38925
+ // columns (age, files) so header and data share an edge.
38926
+ let headerText = ` ${cell('ref', refCol)}`;
38927
+ if (showAge)
38928
+ headerText += `${' '.repeat(GAP)}${cell('age', ageCol, 'right')}`;
38929
+ if (branchCol > 0)
38930
+ headerText += `${' '.repeat(GAP)}${cell('branch', branchCol)}`;
38931
+ if (showFiles)
38932
+ headerText += `${' '.repeat(GAP)}${cell('files', filesCol, 'right')}`;
38933
+ headerText += `${' '.repeat(GAP)}message`;
38818
38934
  const lines = loading
38819
38935
  ? [h(Text, { key: 'stash-loading', dimColor: true }, loadingLabel)]
38820
38936
  : stashes.length === 0
@@ -38823,32 +38939,22 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
38823
38939
  const index = startIndex + offset;
38824
38940
  const isSelected = index === selected;
38825
38941
  const cursor = isSelected ? '>' : ' ';
38826
- // Surface the metadata the StashEntry already carries — origin
38827
- // branch, file count, and relative age — between the ref and the
38828
- // message, so the list answers "which stash is this?" without an
38829
- // Enter→diff round trip.
38830
- const age = formatCompactRelativeDate(stash.date, now);
38831
- const fileCount = stash.files.length;
38832
- const meta = [
38833
- stash.branch ? `on ${stash.branch}` : '',
38834
- fileCount > 0 ? `${fileCount} file${fileCount === 1 ? '' : 's'}` : '',
38835
- age,
38836
- ].filter(Boolean).join(' · ');
38837
- const rowText = meta
38838
- ? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
38839
- : `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
38942
+ const deleting = isPendingItemAction(state.pendingItemAction, 'stash', stash.ref);
38840
38943
  // The `stash@{N}` ref is an identifier, not a status icon, so a
38841
38944
  // delete-in-flight appends an accent spinner at the row's end
38842
- // (2 cells reserved from the width budget).
38843
- const deleting = isPendingDeletion(state.pendingDeletion, 'stash', stash.ref);
38945
+ // (2 cells reserved from the message budget).
38844
38946
  const spinnerSpan = deleting
38845
38947
  ? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
38846
38948
  : null;
38949
+ const message = truncateCells(stash.message, messageWidth - (deleting ? 2 : 0));
38950
+ // ref + message read as primary; age / branch / file-count are
38951
+ // dim metadata (kept dim even on the bold selected row so the
38952
+ // message stays the focal point).
38847
38953
  return h(Text, {
38848
38954
  key: `stash-${index}`,
38849
38955
  bold: isSelected,
38850
38956
  dimColor: !isSelected,
38851
- }, truncateCells(rowText, rowWidth - (deleting ? 2 : 0)), spinnerSpan);
38957
+ }, `${cursor} `, cell(stash.ref, refCol), showAge ? h(Text, { dimColor: true }, `${' '.repeat(GAP)}${cell(ageOf(stash), ageCol, 'right')}`) : null, branchCol > 0 ? h(Text, { dimColor: true }, `${' '.repeat(GAP)}${cell(stash.branch || '', branchCol)}`) : null, showFiles ? h(Text, { dimColor: true }, `${' '.repeat(GAP)}${cell(String(stash.files.length), filesCol, 'right')}`) : null, `${' '.repeat(GAP)}${message}`, spinnerSpan);
38852
38958
  });
38853
38959
  const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
38854
38960
  const stashHasMoreBelow = startIndex + listRows < stashes.length;
@@ -38859,7 +38965,11 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
38859
38965
  flexShrink: 0,
38860
38966
  paddingX: 1,
38861
38967
  width,
38862
- }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...(stashHasMoreAbove
38968
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme),
38969
+ // Column header — only when there are rows to label.
38970
+ ...(!loading && stashes.length > 0
38971
+ ? [h(Text, { key: 'stash-col-header', dimColor: true }, truncateCells(headerText, contentWidth))]
38972
+ : []), ...(stashHasMoreAbove
38863
38973
  ? [h(Text, { key: 'stash-more-above', dimColor: true }, ` ↑ ${startIndex} more above`)]
38864
38974
  : []), ...lines, ...(stashHasMoreBelow
38865
38975
  ? [h(Text, { key: 'stash-more-below', dimColor: true }, ` ↓ ${stashes.length - (startIndex + listRows)} more below`)]
@@ -39240,7 +39350,7 @@ function renderTagsSurface(ctx, spinnerFrame = 0) {
39240
39350
  // Tags have no leading status icon, so a delete-in-flight appends
39241
39351
  // an accent spinner at the row's end. Reserve its 2 cells from the
39242
39352
  // truncation budget so it never pushes the row past the panel.
39243
- const deleting = isPendingDeletion(state.pendingDeletion, 'tag', tag.name);
39353
+ const deleting = isPendingItemAction(state.pendingItemAction, 'tag', tag.name);
39244
39354
  const spinnerSpan = deleting
39245
39355
  ? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
39246
39356
  : null;
@@ -39322,7 +39432,7 @@ function renderWorktreesSurface(ctx, spinnerFrame = 0) {
39322
39432
  const index = startIndex + offset;
39323
39433
  const isSelected = index === selected;
39324
39434
  const cursor = isSelected ? '>' : ' ';
39325
- const marker = isPendingDeletion(state.pendingDeletion, 'worktree', entry.path)
39435
+ const marker = isPendingItemAction(state.pendingItemAction, 'worktree', entry.path)
39326
39436
  ? inlineSpinnerGlyph(spinnerFrame, theme.ascii)
39327
39437
  : entry.current ? '*' : ' ';
39328
39438
  const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
@@ -40791,15 +40901,28 @@ const REMOTE_OP_LOADERS = {
40791
40901
  * Returns `undefined` for non-delete workflows (and when nothing is
40792
40902
  * selected), which the runner treats as "no pending marker".
40793
40903
  */
40794
- function resolvePendingDeletion(id, state, context) {
40904
+ function resolvePendingItemAction(id, state, context) {
40795
40905
  const { filter } = state;
40906
+ // Checking out a branch gets the same inline spinner on its row as a
40907
+ // delete does — the action just runs `git checkout` instead of
40908
+ // `git branch -d`. Resolved the same way as the delete branch case
40909
+ // (and identically to the checkout-branch handler) so the spinner
40910
+ // lands on exactly the row the user pressed enter on.
40911
+ if (id === 'checkout-branch') {
40912
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
40913
+ const visible = filter
40914
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], filter))
40915
+ : all;
40916
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
40917
+ return branch ? { kind: 'branch', id: branch.shortName, action: 'checkout' } : undefined;
40918
+ }
40796
40919
  if (id === 'delete-branch' || id === 'force-delete-branch') {
40797
40920
  const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
40798
40921
  const visible = filter
40799
40922
  ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], filter))
40800
40923
  : all;
40801
40924
  const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
40802
- return branch ? { kind: 'branch', id: branch.shortName } : undefined;
40925
+ return branch ? { kind: 'branch', id: branch.shortName, action: 'delete' } : undefined;
40803
40926
  }
40804
40927
  if (id === 'delete-tag') {
40805
40928
  const all = sortTags(context.tags?.tags || [], state.tagSort);
@@ -40807,7 +40930,7 @@ function resolvePendingDeletion(id, state, context) {
40807
40930
  ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], filter))
40808
40931
  : all;
40809
40932
  const tag = visible[Math.min(state.selectedTagIndex, visible.length - 1)];
40810
- return tag ? { kind: 'tag', id: tag.name } : undefined;
40933
+ return tag ? { kind: 'tag', id: tag.name, action: 'delete' } : undefined;
40811
40934
  }
40812
40935
  if (id === 'drop-stash') {
40813
40936
  const all = context.stashes?.stashes || [];
@@ -40815,7 +40938,7 @@ function resolvePendingDeletion(id, state, context) {
40815
40938
  ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], filter))
40816
40939
  : all;
40817
40940
  const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
40818
- return stash ? { kind: 'stash', id: stash.ref } : undefined;
40941
+ return stash ? { kind: 'stash', id: stash.ref, action: 'delete' } : undefined;
40819
40942
  }
40820
40943
  if (id === 'remove-worktree') {
40821
40944
  const all = context.worktreeList?.worktrees || [];
@@ -40825,7 +40948,7 @@ function resolvePendingDeletion(id, state, context) {
40825
40948
  const wt = visible.length
40826
40949
  ? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
40827
40950
  : all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
40828
- return wt ? { kind: 'worktree', id: wt.path } : undefined;
40951
+ return wt ? { kind: 'worktree', id: wt.path, action: 'delete' } : undefined;
40829
40952
  }
40830
40953
  return undefined;
40831
40954
  }
@@ -41146,9 +41269,10 @@ function LogInkApp(deps) {
41146
41269
  state.commitCompose.loading ||
41147
41270
  Boolean(state.remoteOp) ||
41148
41271
  Boolean(state.statusLoading) ||
41149
- // Keep the shared spinner ticking while a list-item delete is in
41150
- // flight so its inline pending glyph animates instead of freezing.
41151
- Boolean(state.pendingDeletion);
41272
+ // Keep the shared spinner ticking while a list-item action (delete
41273
+ // or checkout) is in flight so its inline pending glyph animates
41274
+ // instead of freezing.
41275
+ Boolean(state.pendingItemAction);
41152
41276
  React.useEffect(() => {
41153
41277
  if (!anyLoading) {
41154
41278
  // Reset to 0 so the next loading state starts from a known
@@ -44088,14 +44212,15 @@ function LogInkApp(deps) {
44088
44212
  if (remoteOp) {
44089
44213
  dispatch({ type: 'setRemoteOp', value: remoteOp });
44090
44214
  }
44091
- // Mark the cursored row as deleting so it shows an inline pending
44092
- // spinner while the git call runs. Cleared in `finally` after the
44093
- // refresh, so a successful delete hands straight off to the row
44094
- // vanishing, and a failed one (e.g. an unmerged branch) restores
44095
- // the row's normal icon alongside the error status.
44096
- const pendingDeletion = resolvePendingDeletion(id, state, context);
44097
- if (pendingDeletion) {
44098
- dispatch({ type: 'setPendingDeletion', value: pendingDeletion });
44215
+ // Mark the cursored row as busy so it shows an inline pending
44216
+ // spinner while the git call runs (delete or checkout). Cleared in
44217
+ // `finally` after the refresh, so a successful delete hands straight
44218
+ // off to the row vanishing, a checkout to the sidebar repainting
44219
+ // with the new current branch, and a failure (e.g. an unmerged
44220
+ // branch) restores the row's normal icon alongside the error status.
44221
+ const pendingItemAction = resolvePendingItemAction(id, state, context);
44222
+ if (pendingItemAction) {
44223
+ dispatch({ type: 'setPendingItemAction', value: pendingItemAction });
44099
44224
  }
44100
44225
  try {
44101
44226
  const result = await handler();
@@ -44108,6 +44233,24 @@ function LogInkApp(deps) {
44108
44233
  if (id === 'delete-branch' && !result?.ok && isBranchNotFullyMergedError(result?.message)) {
44109
44234
  dispatch({ type: 'setPendingConfirmation', value: 'force-delete-branch' });
44110
44235
  }
44236
+ // A branch checked out in a worktree can't be deleted — and unlike
44237
+ // the unmerged case, `git branch -D` won't force it either, so we
44238
+ // don't offer a confirmation. Replace git's raw rejection with a
44239
+ // clear "free up the worktree first" message that names where the
44240
+ // branch is still in use.
44241
+ if ((id === 'delete-branch' || id === 'force-delete-branch') &&
44242
+ !result?.ok &&
44243
+ isBranchCheckedOutElsewhereError(result?.message)) {
44244
+ const worktreePath = parseCheckedOutWorktreePath(result?.message);
44245
+ const branchName = pendingItemAction?.id;
44246
+ dispatch({
44247
+ type: 'setStatus',
44248
+ value: worktreePath
44249
+ ? `Can't delete ${branchName ? `'${branchName}'` : 'branch'} — checked out in worktree ${worktreePath}. Switch that worktree off the branch or remove it first.`
44250
+ : `Can't delete ${branchName ? `'${branchName}'` : 'branch'} — it's checked out in another worktree. Switch that worktree off the branch or remove it first.`,
44251
+ kind: 'warning',
44252
+ });
44253
+ }
44111
44254
  // Refresh history rows AS WELL when the workflow could have
44112
44255
  // changed the commits the user sees (#945 follow-up). The
44113
44256
  // workflow IDs below all either create/rewrite local commits or
@@ -44143,15 +44286,18 @@ function LogInkApp(deps) {
44143
44286
  if (result?.ok && historyMutatingIds.has(id)) {
44144
44287
  await refreshHistoryRows();
44145
44288
  }
44146
- // Checkout-branch is the one workflow where we want a *visible*
44147
- // refresh so the user sees the branches sidebar repaint with the
44148
- // new current branch (per #806 follow-up). Snap the cursor to
44149
- // position 0 first so when the refresh completes and the new
44150
- // current branch lands at the top (per #809's pin-current rule),
44151
- // the cursor is already there waiting.
44289
+ // Checkout-branch snaps the cursor to position 0 first so when the
44290
+ // refresh completes and the new current branch lands at the top
44291
+ // (per #809's pin-current rule), the cursor is already there
44292
+ // waiting. The refresh is *silent*: the loud refresh used to blank
44293
+ // every branch name behind a "loading branches…" placeholder (#806),
44294
+ // but the in-flight row now carries its own inline pending spinner
44295
+ // (resolvePendingItemAction → action 'checkout'), so a silent
44296
+ // stale-while-revalidate swap keeps the list readable and just
44297
+ // repaints the current-branch marker once the new context lands.
44152
44298
  if (id === 'checkout-branch' && result?.ok) {
44153
44299
  dispatch({ type: 'resetBranchSelection' });
44154
- await refreshContext();
44300
+ await refreshContext({ silent: true });
44155
44301
  }
44156
44302
  else {
44157
44303
  // Silent refresh so the deleted item disappears from the list
@@ -44206,11 +44352,11 @@ function LogInkApp(deps) {
44206
44352
  if (remoteOp) {
44207
44353
  dispatch({ type: 'setRemoteOp', value: undefined });
44208
44354
  }
44209
- // Same guarantee for the per-row delete spinner: clear it whether
44210
- // the delete succeeded, failed, or the refresh threw, so no row is
44211
- // left spinning forever.
44212
- if (pendingDeletion) {
44213
- dispatch({ type: 'setPendingDeletion', value: undefined });
44355
+ // Same guarantee for the per-row pending spinner (delete or
44356
+ // checkout): clear it whether the action succeeded, failed, or the
44357
+ // refresh threw, so no row is left spinning forever.
44358
+ if (pendingItemAction) {
44359
+ dispatch({ type: 'setPendingItemAction', value: undefined });
44214
44360
  }
44215
44361
  }
44216
44362
  }, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-coco",
3
- "version": "0.62.1",
3
+ "version": "0.62.3",
4
4
  "description": "zero-effort git commits with coco.",
5
5
  "author": "gfargo <ghfargo@gmail.com>",
6
6
  "license": "MIT",