git-coco 0.61.0 → 0.62.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.
@@ -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.61.0";
64
+ const BUILD_VERSION = "0.62.1";
65
65
 
66
66
  const isInteractive = (config) => {
67
67
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -22433,6 +22433,18 @@ function getLogInkWorkflowActions() {
22433
22433
  kind: 'destructive',
22434
22434
  requiresConfirmation: true,
22435
22435
  },
22436
+ {
22437
+ // No key binding — this is raised by the runtime as a second
22438
+ // confirmation when a safe `delete-branch` (`git branch -d`) is
22439
+ // rejected for an unmerged branch. Reachable from the `:` palette
22440
+ // too, as an explicit force-delete that still gates on y-confirm.
22441
+ id: 'force-delete-branch',
22442
+ key: '',
22443
+ label: 'Force-delete branch',
22444
+ description: 'Force-delete the selected branch even if it is not fully merged (git branch -D).',
22445
+ kind: 'destructive',
22446
+ requiresConfirmation: true,
22447
+ },
22436
22448
  {
22437
22449
  id: 'delete-tag',
22438
22450
  key: 'T',
@@ -25149,6 +25161,15 @@ function formatSortIndicator(mode, options = {}) {
25149
25161
  return `${options.ascii ? 'v' : '▼'} ${mode}`;
25150
25162
  }
25151
25163
 
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.
25169
+ */
25170
+ function isPendingDeletion(pending, kind, id) {
25171
+ return pending?.kind === kind && pending.id === id;
25172
+ }
25152
25173
  const DEFAULT_CHANGELOG_VIEW_STATE = {
25153
25174
  status: 'idle',
25154
25175
  scrollOffset: 0,
@@ -26354,6 +26375,10 @@ function applyLogInkAction(state, action) {
26354
26375
  workflowActionId: action.value ? undefined : state.workflowActionId,
26355
26376
  pendingKey: undefined,
26356
26377
  };
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 };
26357
26382
  case 'toggleFilterMode':
26358
26383
  return {
26359
26384
  ...state,
@@ -29978,6 +30003,24 @@ const SPINNER_TICK_MS = 80;
29978
30003
  function pickSpinnerFrame(tick) {
29979
30004
  return SPINNER_FRAMES[Math.max(0, tick) % SPINNER_FRAMES.length];
29980
30005
  }
30006
+ /**
30007
+ * ASCII-safe spinner frames for `NO_COLOR` / ASCII terminals where the
30008
+ * braille dots either don't render or look like noise. The four-frame
30009
+ * `|/-\` cycle is the classic terminal spinner and reads as motion in
30010
+ * any encoding.
30011
+ */
30012
+ const ASCII_SPINNER_FRAMES = ['|', '/', '-', '\\'];
30013
+ /**
30014
+ * Inline per-item pending glyph — used in place of (or appended to) a
30015
+ * list row's status icon while that row's mutation (a delete) is in
30016
+ * flight. Braille spinner normally; the ASCII cycle under `ascii`
30017
+ * themes so the indicator survives `NO_COLOR` / dumb terminals.
30018
+ */
30019
+ function inlineSpinnerGlyph(tick, ascii) {
30020
+ return ascii
30021
+ ? ASCII_SPINNER_FRAMES[Math.max(0, tick) % ASCII_SPINNER_FRAMES.length]
30022
+ : pickSpinnerFrame(tick);
30023
+ }
29981
30024
 
29982
30025
  /**
29983
30026
  * Build the initial `LogInkContextStatus` for a freshly-created frame
@@ -30932,7 +30975,7 @@ function createBranch(git, branchName, startPoint) {
30932
30975
  function renameBranch(git, oldName, newName) {
30933
30976
  return runAction$5(() => git.raw(['branch', '-m', oldName, newName]), `Renamed ${oldName} to ${newName}`);
30934
30977
  }
30935
- function deleteBranch(git, branch) {
30978
+ function deleteBranch(git, branch, force = false) {
30936
30979
  if (branch.type !== 'local') {
30937
30980
  return Promise.resolve({
30938
30981
  ok: false,
@@ -30945,7 +30988,18 @@ function deleteBranch(git, branch) {
30945
30988
  message: 'Cannot delete the current branch.',
30946
30989
  });
30947
30990
  }
30948
- return runAction$5(() => git.raw(['branch', '-d', branch.shortName]), `Deleted branch ${branch.shortName}`);
30991
+ // `-d` is the safe delete (refuses unmerged branches); `-D` forces it.
30992
+ // The TUI starts with `-d` and only escalates to `-D` after the user
30993
+ // confirms a second time on the "not fully merged" error.
30994
+ return runAction$5(() => git.raw(['branch', force ? '-D' : '-d', branch.shortName]), force ? `Force-deleted branch ${branch.shortName}` : `Deleted branch ${branch.shortName}`);
30995
+ }
30996
+ /**
30997
+ * True when a failed `git branch -d` was rejected specifically because the
30998
+ * branch isn't fully merged (the one case worth offering a force-delete
30999
+ * for). Matches git's wording across versions ("not fully merged").
31000
+ */
31001
+ function isBranchNotFullyMergedError(message) {
31002
+ return /not fully merged/i.test(message || '');
30949
31003
  }
30950
31004
  function fetchRemotes(git) {
30951
31005
  return runAction$5(() => git.raw(['fetch', '--all', '--prune']), 'Fetched all remotes');
@@ -34353,7 +34407,13 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
34353
34407
  * rows so they read as the same severity scale used in the main status
34354
34408
  * surface; every other tab falls through to selectable rows.
34355
34409
  */
34356
- function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme) {
34410
+ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme, spinnerFrame) {
34411
+ // Inline pending-delete glyph: while a row's delete is in flight it
34412
+ // shows this spinner in place of its leading marker (branches /
34413
+ // worktrees) or appended to the row (tags / stashes, which have no
34414
+ // leading status icon). `pending` is the single in-flight target.
34415
+ const pending = state.pendingDeletion;
34416
+ const spin = inlineSpinnerGlyph(spinnerFrame, theme.ascii);
34357
34417
  // Available rows for the active tab's list. The sidebar chrome
34358
34418
  // takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
34359
34419
  // spacers); the branches tab eats 3 more for its summary header
@@ -34390,7 +34450,12 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34390
34450
  ];
34391
34451
  return [
34392
34452
  ...headerRows,
34393
- ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii }).glyph} ${branch.shortName}`, 'tab-branches', visibleListCount),
34453
+ ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => {
34454
+ const glyph = isPendingDeletion(pending, 'branch', branch.shortName)
34455
+ ? spin
34456
+ : branchRowMarker(branch, { ascii: theme.ascii }).glyph;
34457
+ return `${glyph} ${branch.shortName}`;
34458
+ }, 'tab-branches', visibleListCount),
34394
34459
  ];
34395
34460
  }
34396
34461
  if (tab === 'tags') {
@@ -34401,7 +34466,12 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34401
34466
  if (tags.length === 0) {
34402
34467
  return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
34403
34468
  }
34404
- return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncateCells(tag.name, 16)} ${tag.subject}`, 'tab-tags', visibleListCount);
34469
+ return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => {
34470
+ const base = `${truncateCells(tag.name, 16)} ${tag.subject}`;
34471
+ // Tags have no leading status icon, so the pending spinner is
34472
+ // appended to the row instead of replacing a glyph.
34473
+ return isPendingDeletion(pending, 'tag', tag.name) ? `${base} ${spin}` : base;
34474
+ }, 'tab-tags', visibleListCount);
34405
34475
  }
34406
34476
  if (tab === 'stashes') {
34407
34477
  if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
@@ -34411,7 +34481,12 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34411
34481
  if (stashes.length === 0) {
34412
34482
  return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
34413
34483
  }
34414
- return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes', visibleListCount);
34484
+ return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => {
34485
+ const base = `@{${index}} ${stash.message || '(no message)'}`;
34486
+ // `@{N}` is the stash ref, not a status icon, so append the
34487
+ // spinner rather than replacing it.
34488
+ return isPendingDeletion(pending, 'stash', stash.ref) ? `${base} ${spin}` : base;
34489
+ }, 'tab-stashes', visibleListCount);
34415
34490
  }
34416
34491
  // worktrees
34417
34492
  if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
@@ -34422,12 +34497,14 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34422
34497
  return [h(Text, { key: 'tab-worktrees-empty', dimColor: true }, ' No linked worktrees')];
34423
34498
  }
34424
34499
  return renderSelectableSidebarRows(h, Text, worktrees, state.selectedWorktreeListIndex, focused, width, theme, (worktree) => {
34425
- const marker = worktree.current ? '*' : ' ';
34500
+ const marker = isPendingDeletion(pending, 'worktree', worktree.path)
34501
+ ? spin
34502
+ : worktree.current ? '*' : ' ';
34426
34503
  const wstate = worktree.dirty ? 'dirty' : 'clean';
34427
34504
  return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
34428
34505
  }, 'tab-worktrees', visibleListCount);
34429
34506
  }
34430
- function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme) {
34507
+ function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme, spinnerFrame = 0) {
34431
34508
  const { Box, Text } = components;
34432
34509
  const focused = state.focus === 'sidebar';
34433
34510
  const tabs = getLogInkSidebarTabs();
@@ -34463,7 +34540,7 @@ function renderSidebar$1(h, components, state, context, contextStatus, width, bo
34463
34540
  inverse: headerSelected,
34464
34541
  }, headerText));
34465
34542
  if (isActive) {
34466
- blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
34543
+ blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme, spinnerFrame));
34467
34544
  }
34468
34545
  return blocks;
34469
34546
  });
@@ -34814,7 +34891,7 @@ function formatLogInkGitHubNoRemote({ resource, }) {
34814
34891
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
34815
34892
  * of #890. No behavior change.
34816
34893
  */
34817
- function renderBranchesSurface(ctx) {
34894
+ function renderBranchesSurface(ctx, spinnerFrame = 0) {
34818
34895
  const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
34819
34896
  const { Box, Text } = components;
34820
34897
  const focused = state.focus === 'commits';
@@ -34853,7 +34930,14 @@ function renderBranchesSurface(ctx) {
34853
34930
  const isSelected = index === selected;
34854
34931
  const cursor = isSelected ? '>' : ' ';
34855
34932
  const marker = branchRowMarker(branch, { ascii: theme.ascii });
34856
- const markerColor = getBranchRowMarkerColor(marker.kind, theme);
34933
+ // While this branch's delete is in flight, its sync-state marker
34934
+ // is replaced by an inline spinner (accent-coloured) so the row
34935
+ // reads as "deleting" until it vanishes on refresh.
34936
+ const deleting = isPendingDeletion(state.pendingDeletion, 'branch', branch.shortName);
34937
+ const glyph = deleting ? inlineSpinnerGlyph(spinnerFrame, theme.ascii) : marker.glyph;
34938
+ const glyphColor = deleting
34939
+ ? (theme.noColor ? undefined : theme.colors.accent)
34940
+ : getBranchRowMarkerColor(marker.kind, theme);
34857
34941
  const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
34858
34942
  const lastTouched = formatBranchLastTouched(branch.date, getRenderNow());
34859
34943
  // Split the row into spans so the timestamp stays dim even on the
@@ -34868,7 +34952,7 @@ function renderBranchesSurface(ctx) {
34868
34952
  // Truncate the assembled line to the actual panel width so a
34869
34953
  // narrow inspector / sidebar focus doesn't push branch rows
34870
34954
  // onto a second visual line (#830).
34871
- const fullText = `${cursorAndPad}${marker.glyph}${trailingName}${timestampPadded}${trailingDivergence}`;
34955
+ const fullText = `${cursorAndPad}${glyph}${trailingName}${timestampPadded}${trailingDivergence}`;
34872
34956
  const truncated = truncateCells(fullText, Math.max(20, width - 4));
34873
34957
  // If truncation chopped into the timestamp/divergence portion,
34874
34958
  // fall back to a single Text to keep the visible width honest.
@@ -34891,7 +34975,7 @@ function renderBranchesSurface(ctx) {
34891
34975
  // no-upstream kinds return undefined from
34892
34976
  // `getBranchRowMarkerColor`, so those markers inherit the
34893
34977
  // row's dim and read as quiet chrome.
34894
- h(Text, { color: markerColor, dimColor: markerColor ? false : undefined }, marker.glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
34978
+ h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
34895
34979
  });
34896
34980
  // Scroll indicators — same "N more above/below" pattern as the
34897
34981
  // sidebar and help overlay so the user knows the list continues.
@@ -37513,9 +37597,13 @@ function renderConfirmationPanel(h, components, state, width, theme, focused) {
37513
37597
  ? 'You have an unsaved commit draft. Press y to discard it and quit.'
37514
37598
  : state.pendingMutationConfirmation
37515
37599
  ? 'This discards local changes and cannot be undone by Coco.'
37516
- : action?.kind === 'ai'
37517
- ? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
37518
- : 'Destructive Git action requires confirmation.';
37600
+ // Second-stage confirm raised when a safe delete hit an unmerged
37601
+ // branch name the reason so the force isn't a blind "y again".
37602
+ : state.pendingConfirmationId === 'force-delete-branch'
37603
+ ? 'Not fully merged. Force-delete (git branch -D) is irreversible.'
37604
+ : action?.kind === 'ai'
37605
+ ? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
37606
+ : 'Destructive Git action requires confirmation.';
37519
37607
  return h(Box, {
37520
37608
  borderColor: focusBorderColor(theme, focused),
37521
37609
  borderStyle: theme.borderStyle,
@@ -38686,7 +38774,7 @@ function renderReflogSurface(ctx) {
38686
38774
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
38687
38775
  * of #890. No behavior change.
38688
38776
  */
38689
- function renderStashSurface(ctx) {
38777
+ function renderStashSurface(ctx, spinnerFrame = 0) {
38690
38778
  const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
38691
38779
  const { Box, Text } = components;
38692
38780
  const focused = state.focus === 'commits';
@@ -38732,11 +38820,18 @@ function renderStashSurface(ctx) {
38732
38820
  const rowText = meta
38733
38821
  ? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
38734
38822
  : `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
38823
+ // The `stash@{N}` ref is an identifier, not a status icon, so a
38824
+ // 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);
38827
+ const spinnerSpan = deleting
38828
+ ? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
38829
+ : null;
38735
38830
  return h(Text, {
38736
38831
  key: `stash-${index}`,
38737
38832
  bold: isSelected,
38738
38833
  dimColor: !isSelected,
38739
- }, truncateCells(rowText, rowWidth));
38834
+ }, truncateCells(rowText, rowWidth - (deleting ? 2 : 0)), spinnerSpan);
38740
38835
  });
38741
38836
  const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
38742
38837
  const stashHasMoreBelow = startIndex + listRows < stashes.length;
@@ -39084,7 +39179,7 @@ function formatHyperlink(text, url, env = process.env) {
39084
39179
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
39085
39180
  * of #890. No behavior change.
39086
39181
  */
39087
- function renderTagsSurface(ctx) {
39182
+ function renderTagsSurface(ctx, spinnerFrame = 0) {
39088
39183
  const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
39089
39184
  const { Box, Text } = components;
39090
39185
  const focused = state.focus === 'commits';
@@ -39125,13 +39220,20 @@ function renderTagsSurface(ctx) {
39125
39220
  // intact.
39126
39221
  const url = buildRefUrl(context.provider?.repository, tag.name);
39127
39222
  const namePadded = truncateCells(tag.name, tagNameColWidth).padEnd(tagNameColWidth);
39128
- const lineText = truncateCells(`${cursor} ${namePadded} ${tag.subject}`, Math.max(20, width - 4));
39223
+ // Tags have no leading status icon, so a delete-in-flight appends
39224
+ // an accent spinner at the row's end. Reserve its 2 cells from the
39225
+ // truncation budget so it never pushes the row past the panel.
39226
+ const deleting = isPendingDeletion(state.pendingDeletion, 'tag', tag.name);
39227
+ const spinnerSpan = deleting
39228
+ ? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
39229
+ : null;
39230
+ const lineText = truncateCells(`${cursor} ${namePadded} ${tag.subject}`, Math.max(20, width - 4 - (deleting ? 2 : 0)));
39129
39231
  if (!url || lineText.indexOf(namePadded) < 0) {
39130
39232
  return h(Text, {
39131
39233
  key: `tag-${index}`,
39132
39234
  bold: isSelected,
39133
39235
  dimColor: !isSelected,
39134
- }, lineText);
39236
+ }, lineText, spinnerSpan);
39135
39237
  }
39136
39238
  const linkStart = lineText.indexOf(namePadded);
39137
39239
  const before = lineText.slice(0, linkStart);
@@ -39140,7 +39242,7 @@ function renderTagsSurface(ctx) {
39140
39242
  key: `tag-${index}`,
39141
39243
  bold: isSelected,
39142
39244
  dimColor: !isSelected,
39143
- }, before, formatHyperlink(namePadded, url), after);
39245
+ }, before, formatHyperlink(namePadded, url), after, spinnerSpan);
39144
39246
  });
39145
39247
  const tagsHasMoreAbove = startIndex > 0 && tags.length > 0;
39146
39248
  const tagsHasMoreBelow = startIndex + listRows < tags.length;
@@ -39167,7 +39269,7 @@ function renderTagsSurface(ctx) {
39167
39269
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
39168
39270
  * of #890. No behavior change.
39169
39271
  */
39170
- function renderWorktreesSurface(ctx) {
39272
+ function renderWorktreesSurface(ctx, spinnerFrame = 0) {
39171
39273
  const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
39172
39274
  const { Box, Text } = components;
39173
39275
  const focused = state.focus === 'commits';
@@ -39203,7 +39305,9 @@ function renderWorktreesSurface(ctx) {
39203
39305
  const index = startIndex + offset;
39204
39306
  const isSelected = index === selected;
39205
39307
  const cursor = isSelected ? '>' : ' ';
39206
- const marker = entry.current ? '*' : ' ';
39308
+ const marker = isPendingDeletion(state.pendingDeletion, 'worktree', entry.path)
39309
+ ? inlineSpinnerGlyph(spinnerFrame, theme.ascii)
39310
+ : entry.current ? '*' : ' ';
39207
39311
  const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
39208
39312
  const stateLabel = entry.dirty ? 'dirty' : 'clean';
39209
39313
  const branchPadded = truncateCells(branchLabel, branchColWidth).padEnd(branchColWidth);
@@ -39273,10 +39377,10 @@ function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHun
39273
39377
  return renderComposeSurface(surface, spinnerFrame);
39274
39378
  }
39275
39379
  if (state.activeView === 'branches') {
39276
- return renderBranchesSurface(surface);
39380
+ return renderBranchesSurface(surface, spinnerFrame);
39277
39381
  }
39278
39382
  if (state.activeView === 'tags') {
39279
- return renderTagsSurface(surface);
39383
+ return renderTagsSurface(surface, spinnerFrame);
39280
39384
  }
39281
39385
  if (state.activeView === 'reflog') {
39282
39386
  return renderReflogSurface(surface);
@@ -39285,10 +39389,10 @@ function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHun
39285
39389
  return renderBisectSurface(surface, bisectCandidateDetail, bisectCandidateLoading);
39286
39390
  }
39287
39391
  if (state.activeView === 'stash') {
39288
- return renderStashSurface(surface);
39392
+ return renderStashSurface(surface, spinnerFrame);
39289
39393
  }
39290
39394
  if (state.activeView === 'worktrees') {
39291
- return renderWorktreesSurface(surface);
39395
+ return renderWorktreesSurface(surface, spinnerFrame);
39292
39396
  }
39293
39397
  if (state.activeView === 'submodules') {
39294
39398
  return renderSubmodulesSurface(surface);
@@ -40661,6 +40765,53 @@ const REMOTE_OP_LOADERS = {
40661
40765
  'pull-selected-branch': { kind: 'pull', label: 'Pulling branch from remote…' },
40662
40766
  'push-selected-branch': { kind: 'push', label: 'Pushing branch to remote…' },
40663
40767
  };
40768
+ /**
40769
+ * Resolve which list row a delete workflow is about to act on, so the
40770
+ * runner can mark it pending (inline spinner) for the duration of the
40771
+ * git call. Mirrors the cursored-target resolution inside each delete
40772
+ * handler exactly — same sort, same promoted-filter, same selection
40773
+ * index — so the spinner lands on the row that actually gets deleted.
40774
+ * Returns `undefined` for non-delete workflows (and when nothing is
40775
+ * selected), which the runner treats as "no pending marker".
40776
+ */
40777
+ function resolvePendingDeletion(id, state, context) {
40778
+ const { filter } = state;
40779
+ if (id === 'delete-branch' || id === 'force-delete-branch') {
40780
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
40781
+ const visible = filter
40782
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], filter))
40783
+ : all;
40784
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
40785
+ return branch ? { kind: 'branch', id: branch.shortName } : undefined;
40786
+ }
40787
+ if (id === 'delete-tag') {
40788
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
40789
+ const visible = filter
40790
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], filter))
40791
+ : all;
40792
+ const tag = visible[Math.min(state.selectedTagIndex, visible.length - 1)];
40793
+ return tag ? { kind: 'tag', id: tag.name } : undefined;
40794
+ }
40795
+ if (id === 'drop-stash') {
40796
+ const all = context.stashes?.stashes || [];
40797
+ const visible = filter
40798
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], filter))
40799
+ : all;
40800
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
40801
+ return stash ? { kind: 'stash', id: stash.ref } : undefined;
40802
+ }
40803
+ if (id === 'remove-worktree') {
40804
+ const all = context.worktreeList?.worktrees || [];
40805
+ const visible = filter
40806
+ ? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], filter))
40807
+ : all;
40808
+ const wt = visible.length
40809
+ ? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
40810
+ : all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
40811
+ return wt ? { kind: 'worktree', id: wt.path } : undefined;
40812
+ }
40813
+ return undefined;
40814
+ }
40664
40815
  function predictNextFilter(action, currentFilter) {
40665
40816
  switch (action.type) {
40666
40817
  case 'appendFilter':
@@ -40977,7 +41128,10 @@ function LogInkApp(deps) {
40977
41128
  state.changelogView.status === 'loading' ||
40978
41129
  state.commitCompose.loading ||
40979
41130
  Boolean(state.remoteOp) ||
40980
- Boolean(state.statusLoading);
41131
+ 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);
40981
41135
  React.useEffect(() => {
40982
41136
  if (!anyLoading) {
40983
41137
  // Reset to 0 so the next loading state starts from a known
@@ -43134,6 +43288,16 @@ function LogInkApp(deps) {
43134
43288
  return { ok: false, message: 'No branch selected' };
43135
43289
  return deleteBranch(git, branch);
43136
43290
  },
43291
+ 'force-delete-branch': async () => {
43292
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
43293
+ const visible = state.filter
43294
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
43295
+ : all;
43296
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
43297
+ if (!branch)
43298
+ return { ok: false, message: 'No branch selected' };
43299
+ return deleteBranch(git, branch, true);
43300
+ },
43137
43301
  'delete-tag': async () => {
43138
43302
  const all = sortTags(context.tags?.tags || [], state.tagSort);
43139
43303
  const visible = state.filter
@@ -43907,9 +44071,26 @@ function LogInkApp(deps) {
43907
44071
  if (remoteOp) {
43908
44072
  dispatch({ type: 'setRemoteOp', value: remoteOp });
43909
44073
  }
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 });
44082
+ }
43910
44083
  try {
43911
44084
  const result = await handler();
43912
44085
  dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
44086
+ // A safe `delete-branch` (`git branch -d`) refuses branches that
44087
+ // aren't fully merged. Rather than dead-end on git's raw error, raise
44088
+ // a second y-confirm offering the force-delete (`git branch -D`). The
44089
+ // cursor hasn't moved (the delete failed), so the force handler
44090
+ // re-resolves the same branch.
44091
+ if (id === 'delete-branch' && !result?.ok && isBranchNotFullyMergedError(result?.message)) {
44092
+ dispatch({ type: 'setPendingConfirmation', value: 'force-delete-branch' });
44093
+ }
43913
44094
  // Refresh history rows AS WELL when the workflow could have
43914
44095
  // changed the commits the user sees (#945 follow-up). The
43915
44096
  // workflow IDs below all either create/rewrite local commits or
@@ -44008,6 +44189,12 @@ function LogInkApp(deps) {
44008
44189
  if (remoteOp) {
44009
44190
  dispatch({ type: 'setRemoteOp', value: undefined });
44010
44191
  }
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 });
44197
+ }
44011
44198
  }
44012
44199
  }, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
44013
44200
  state.branchSort, state.filter, state.selectedBranchIndex,
@@ -44867,7 +45054,7 @@ function LogInkApp(deps) {
44867
45054
  // Panel renderers are thunks so single-pane mode can build only the
44868
45055
  // visible pane — the main-panel render in particular is expensive, so
44869
45056
  // we don't want to invoke the two hidden ones just to drop them.
44870
- const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme);
45057
+ const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme, spinnerFrame);
44871
45058
  const mainSurface = {
44872
45059
  h,
44873
45060
  components: { Box, Text },
@@ -45178,12 +45365,23 @@ function createLogArgvFromUiArgv(argv) {
45178
45365
  // starting state. `coco ui --no-all` opts back to
45179
45366
  // current-branch-only.
45180
45367
  //
45368
+ // `?? true` re-asserts that default for the synthetic-argv path:
45369
+ // bare `coco` routes through defaultRouteHandler → buildSyntheticArgv,
45370
+ // which bypasses yargs and so never applies the `default: true` from
45371
+ // ui/config.ts — leaving `argv.all` undefined (#1169). Without the
45372
+ // fallback the workstation booted in compact (`--first-parent
45373
+ // --no-merges`) mode: fewer commits, branches "ahead" of HEAD hidden,
45374
+ // and cursoring/checking-out a branch whose tip wasn't in the compact
45375
+ // window triggered an anchored-context append that looped the graph
45376
+ // back to the initial commit. Explicit `--no-all` (argv.all === false)
45377
+ // is preserved because `false ?? true === false`.
45378
+ //
45181
45379
  // Note: passing `--branch foo` does NOT automatically scope away
45182
45380
  // from --all. If the user wants strictly that branch, they pass
45183
45381
  // `coco ui --branch foo --no-all`. We considered the implicit
45184
45382
  // scope-narrowing but it surprises users who pass `--branch` as
45185
45383
  // a "highlight this branch in the all-refs view" hint.
45186
- all: argv.all,
45384
+ all: argv.all ?? true,
45187
45385
  branch: argv.branch,
45188
45386
  format: 'table',
45189
45387
  interactive: true,
@@ -50197,6 +50395,11 @@ async function probeIsGitRepo() {
50197
50395
  * `InitArgv`, `UiArgv`, `WorkspaceArgv`) so we can't just spread the
50198
50396
  * raw default argv — we have to project the shared fields and let
50199
50397
  * the handler fill in command-specific defaults.
50398
+ *
50399
+ * Exported for testing: this is the path bare `coco` takes to the
50400
+ * workstation, and it intentionally does NOT set `all` — yargs's
50401
+ * `default: true` for `coco ui` never runs here, so the ui→log argv
50402
+ * mapping has to re-assert that default (#1169).
50200
50403
  */
50201
50404
  function buildSyntheticArgv(argv) {
50202
50405
  return {
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.61.0";
81
+ const BUILD_VERSION = "0.62.1";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -22450,6 +22450,18 @@ function getLogInkWorkflowActions() {
22450
22450
  kind: 'destructive',
22451
22451
  requiresConfirmation: true,
22452
22452
  },
22453
+ {
22454
+ // No key binding — this is raised by the runtime as a second
22455
+ // confirmation when a safe `delete-branch` (`git branch -d`) is
22456
+ // rejected for an unmerged branch. Reachable from the `:` palette
22457
+ // too, as an explicit force-delete that still gates on y-confirm.
22458
+ id: 'force-delete-branch',
22459
+ key: '',
22460
+ label: 'Force-delete branch',
22461
+ description: 'Force-delete the selected branch even if it is not fully merged (git branch -D).',
22462
+ kind: 'destructive',
22463
+ requiresConfirmation: true,
22464
+ },
22453
22465
  {
22454
22466
  id: 'delete-tag',
22455
22467
  key: 'T',
@@ -25166,6 +25178,15 @@ function formatSortIndicator(mode, options = {}) {
25166
25178
  return `${options.ascii ? 'v' : '▼'} ${mode}`;
25167
25179
  }
25168
25180
 
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.
25186
+ */
25187
+ function isPendingDeletion(pending, kind, id) {
25188
+ return pending?.kind === kind && pending.id === id;
25189
+ }
25169
25190
  const DEFAULT_CHANGELOG_VIEW_STATE = {
25170
25191
  status: 'idle',
25171
25192
  scrollOffset: 0,
@@ -26371,6 +26392,10 @@ function applyLogInkAction(state, action) {
26371
26392
  workflowActionId: action.value ? undefined : state.workflowActionId,
26372
26393
  pendingKey: undefined,
26373
26394
  };
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 };
26374
26399
  case 'toggleFilterMode':
26375
26400
  return {
26376
26401
  ...state,
@@ -29995,6 +30020,24 @@ const SPINNER_TICK_MS = 80;
29995
30020
  function pickSpinnerFrame(tick) {
29996
30021
  return SPINNER_FRAMES[Math.max(0, tick) % SPINNER_FRAMES.length];
29997
30022
  }
30023
+ /**
30024
+ * ASCII-safe spinner frames for `NO_COLOR` / ASCII terminals where the
30025
+ * braille dots either don't render or look like noise. The four-frame
30026
+ * `|/-\` cycle is the classic terminal spinner and reads as motion in
30027
+ * any encoding.
30028
+ */
30029
+ const ASCII_SPINNER_FRAMES = ['|', '/', '-', '\\'];
30030
+ /**
30031
+ * Inline per-item pending glyph — used in place of (or appended to) a
30032
+ * list row's status icon while that row's mutation (a delete) is in
30033
+ * flight. Braille spinner normally; the ASCII cycle under `ascii`
30034
+ * themes so the indicator survives `NO_COLOR` / dumb terminals.
30035
+ */
30036
+ function inlineSpinnerGlyph(tick, ascii) {
30037
+ return ascii
30038
+ ? ASCII_SPINNER_FRAMES[Math.max(0, tick) % ASCII_SPINNER_FRAMES.length]
30039
+ : pickSpinnerFrame(tick);
30040
+ }
29998
30041
 
29999
30042
  /**
30000
30043
  * Build the initial `LogInkContextStatus` for a freshly-created frame
@@ -30949,7 +30992,7 @@ function createBranch(git, branchName, startPoint) {
30949
30992
  function renameBranch(git, oldName, newName) {
30950
30993
  return runAction$5(() => git.raw(['branch', '-m', oldName, newName]), `Renamed ${oldName} to ${newName}`);
30951
30994
  }
30952
- function deleteBranch(git, branch) {
30995
+ function deleteBranch(git, branch, force = false) {
30953
30996
  if (branch.type !== 'local') {
30954
30997
  return Promise.resolve({
30955
30998
  ok: false,
@@ -30962,7 +31005,18 @@ function deleteBranch(git, branch) {
30962
31005
  message: 'Cannot delete the current branch.',
30963
31006
  });
30964
31007
  }
30965
- return runAction$5(() => git.raw(['branch', '-d', branch.shortName]), `Deleted branch ${branch.shortName}`);
31008
+ // `-d` is the safe delete (refuses unmerged branches); `-D` forces it.
31009
+ // The TUI starts with `-d` and only escalates to `-D` after the user
31010
+ // confirms a second time on the "not fully merged" error.
31011
+ return runAction$5(() => git.raw(['branch', force ? '-D' : '-d', branch.shortName]), force ? `Force-deleted branch ${branch.shortName}` : `Deleted branch ${branch.shortName}`);
31012
+ }
31013
+ /**
31014
+ * True when a failed `git branch -d` was rejected specifically because the
31015
+ * branch isn't fully merged (the one case worth offering a force-delete
31016
+ * for). Matches git's wording across versions ("not fully merged").
31017
+ */
31018
+ function isBranchNotFullyMergedError(message) {
31019
+ return /not fully merged/i.test(message || '');
30966
31020
  }
30967
31021
  function fetchRemotes(git) {
30968
31022
  return runAction$5(() => git.raw(['fetch', '--all', '--prune']), 'Fetched all remotes');
@@ -34370,7 +34424,13 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
34370
34424
  * rows so they read as the same severity scale used in the main status
34371
34425
  * surface; every other tab falls through to selectable rows.
34372
34426
  */
34373
- function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme) {
34427
+ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme, spinnerFrame) {
34428
+ // Inline pending-delete glyph: while a row's delete is in flight it
34429
+ // shows this spinner in place of its leading marker (branches /
34430
+ // worktrees) or appended to the row (tags / stashes, which have no
34431
+ // leading status icon). `pending` is the single in-flight target.
34432
+ const pending = state.pendingDeletion;
34433
+ const spin = inlineSpinnerGlyph(spinnerFrame, theme.ascii);
34374
34434
  // Available rows for the active tab's list. The sidebar chrome
34375
34435
  // takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
34376
34436
  // spacers); the branches tab eats 3 more for its summary header
@@ -34407,7 +34467,12 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34407
34467
  ];
34408
34468
  return [
34409
34469
  ...headerRows,
34410
- ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii }).glyph} ${branch.shortName}`, 'tab-branches', visibleListCount),
34470
+ ...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => {
34471
+ const glyph = isPendingDeletion(pending, 'branch', branch.shortName)
34472
+ ? spin
34473
+ : branchRowMarker(branch, { ascii: theme.ascii }).glyph;
34474
+ return `${glyph} ${branch.shortName}`;
34475
+ }, 'tab-branches', visibleListCount),
34411
34476
  ];
34412
34477
  }
34413
34478
  if (tab === 'tags') {
@@ -34418,7 +34483,12 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34418
34483
  if (tags.length === 0) {
34419
34484
  return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
34420
34485
  }
34421
- return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncateCells(tag.name, 16)} ${tag.subject}`, 'tab-tags', visibleListCount);
34486
+ return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => {
34487
+ const base = `${truncateCells(tag.name, 16)} ${tag.subject}`;
34488
+ // Tags have no leading status icon, so the pending spinner is
34489
+ // appended to the row instead of replacing a glyph.
34490
+ return isPendingDeletion(pending, 'tag', tag.name) ? `${base} ${spin}` : base;
34491
+ }, 'tab-tags', visibleListCount);
34422
34492
  }
34423
34493
  if (tab === 'stashes') {
34424
34494
  if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
@@ -34428,7 +34498,12 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34428
34498
  if (stashes.length === 0) {
34429
34499
  return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
34430
34500
  }
34431
- return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes', visibleListCount);
34501
+ return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => {
34502
+ const base = `@{${index}} ${stash.message || '(no message)'}`;
34503
+ // `@{N}` is the stash ref, not a status icon, so append the
34504
+ // spinner rather than replacing it.
34505
+ return isPendingDeletion(pending, 'stash', stash.ref) ? `${base} ${spin}` : base;
34506
+ }, 'tab-stashes', visibleListCount);
34432
34507
  }
34433
34508
  // worktrees
34434
34509
  if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
@@ -34439,12 +34514,14 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
34439
34514
  return [h(Text, { key: 'tab-worktrees-empty', dimColor: true }, ' No linked worktrees')];
34440
34515
  }
34441
34516
  return renderSelectableSidebarRows(h, Text, worktrees, state.selectedWorktreeListIndex, focused, width, theme, (worktree) => {
34442
- const marker = worktree.current ? '*' : ' ';
34517
+ const marker = isPendingDeletion(pending, 'worktree', worktree.path)
34518
+ ? spin
34519
+ : worktree.current ? '*' : ' ';
34443
34520
  const wstate = worktree.dirty ? 'dirty' : 'clean';
34444
34521
  return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
34445
34522
  }, 'tab-worktrees', visibleListCount);
34446
34523
  }
34447
- function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme) {
34524
+ function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme, spinnerFrame = 0) {
34448
34525
  const { Box, Text } = components;
34449
34526
  const focused = state.focus === 'sidebar';
34450
34527
  const tabs = getLogInkSidebarTabs();
@@ -34480,7 +34557,7 @@ function renderSidebar$1(h, components, state, context, contextStatus, width, bo
34480
34557
  inverse: headerSelected,
34481
34558
  }, headerText));
34482
34559
  if (isActive) {
34483
- blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
34560
+ blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme, spinnerFrame));
34484
34561
  }
34485
34562
  return blocks;
34486
34563
  });
@@ -34831,7 +34908,7 @@ function formatLogInkGitHubNoRemote({ resource, }) {
34831
34908
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
34832
34909
  * of #890. No behavior change.
34833
34910
  */
34834
- function renderBranchesSurface(ctx) {
34911
+ function renderBranchesSurface(ctx, spinnerFrame = 0) {
34835
34912
  const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
34836
34913
  const { Box, Text } = components;
34837
34914
  const focused = state.focus === 'commits';
@@ -34870,7 +34947,14 @@ function renderBranchesSurface(ctx) {
34870
34947
  const isSelected = index === selected;
34871
34948
  const cursor = isSelected ? '>' : ' ';
34872
34949
  const marker = branchRowMarker(branch, { ascii: theme.ascii });
34873
- const markerColor = getBranchRowMarkerColor(marker.kind, theme);
34950
+ // While this branch's delete is in flight, its sync-state marker
34951
+ // is replaced by an inline spinner (accent-coloured) so the row
34952
+ // reads as "deleting" until it vanishes on refresh.
34953
+ const deleting = isPendingDeletion(state.pendingDeletion, 'branch', branch.shortName);
34954
+ const glyph = deleting ? inlineSpinnerGlyph(spinnerFrame, theme.ascii) : marker.glyph;
34955
+ const glyphColor = deleting
34956
+ ? (theme.noColor ? undefined : theme.colors.accent)
34957
+ : getBranchRowMarkerColor(marker.kind, theme);
34874
34958
  const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
34875
34959
  const lastTouched = formatBranchLastTouched(branch.date, getRenderNow());
34876
34960
  // Split the row into spans so the timestamp stays dim even on the
@@ -34885,7 +34969,7 @@ function renderBranchesSurface(ctx) {
34885
34969
  // Truncate the assembled line to the actual panel width so a
34886
34970
  // narrow inspector / sidebar focus doesn't push branch rows
34887
34971
  // onto a second visual line (#830).
34888
- const fullText = `${cursorAndPad}${marker.glyph}${trailingName}${timestampPadded}${trailingDivergence}`;
34972
+ const fullText = `${cursorAndPad}${glyph}${trailingName}${timestampPadded}${trailingDivergence}`;
34889
34973
  const truncated = truncateCells(fullText, Math.max(20, width - 4));
34890
34974
  // If truncation chopped into the timestamp/divergence portion,
34891
34975
  // fall back to a single Text to keep the visible width honest.
@@ -34908,7 +34992,7 @@ function renderBranchesSurface(ctx) {
34908
34992
  // no-upstream kinds return undefined from
34909
34993
  // `getBranchRowMarkerColor`, so those markers inherit the
34910
34994
  // row's dim and read as quiet chrome.
34911
- h(Text, { color: markerColor, dimColor: markerColor ? false : undefined }, marker.glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
34995
+ h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph), trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
34912
34996
  });
34913
34997
  // Scroll indicators — same "N more above/below" pattern as the
34914
34998
  // sidebar and help overlay so the user knows the list continues.
@@ -37530,9 +37614,13 @@ function renderConfirmationPanel(h, components, state, width, theme, focused) {
37530
37614
  ? 'You have an unsaved commit draft. Press y to discard it and quit.'
37531
37615
  : state.pendingMutationConfirmation
37532
37616
  ? 'This discards local changes and cannot be undone by Coco.'
37533
- : action?.kind === 'ai'
37534
- ? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
37535
- : 'Destructive Git action requires confirmation.';
37617
+ // Second-stage confirm raised when a safe delete hit an unmerged
37618
+ // branch name the reason so the force isn't a blind "y again".
37619
+ : state.pendingConfirmationId === 'force-delete-branch'
37620
+ ? 'Not fully merged. Force-delete (git branch -D) is irreversible.'
37621
+ : action?.kind === 'ai'
37622
+ ? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
37623
+ : 'Destructive Git action requires confirmation.';
37536
37624
  return h(Box, {
37537
37625
  borderColor: focusBorderColor(theme, focused),
37538
37626
  borderStyle: theme.borderStyle,
@@ -38703,7 +38791,7 @@ function renderReflogSurface(ctx) {
38703
38791
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
38704
38792
  * of #890. No behavior change.
38705
38793
  */
38706
- function renderStashSurface(ctx) {
38794
+ function renderStashSurface(ctx, spinnerFrame = 0) {
38707
38795
  const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
38708
38796
  const { Box, Text } = components;
38709
38797
  const focused = state.focus === 'commits';
@@ -38749,11 +38837,18 @@ function renderStashSurface(ctx) {
38749
38837
  const rowText = meta
38750
38838
  ? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
38751
38839
  : `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
38840
+ // The `stash@{N}` ref is an identifier, not a status icon, so a
38841
+ // 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);
38844
+ const spinnerSpan = deleting
38845
+ ? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
38846
+ : null;
38752
38847
  return h(Text, {
38753
38848
  key: `stash-${index}`,
38754
38849
  bold: isSelected,
38755
38850
  dimColor: !isSelected,
38756
- }, truncateCells(rowText, rowWidth));
38851
+ }, truncateCells(rowText, rowWidth - (deleting ? 2 : 0)), spinnerSpan);
38757
38852
  });
38758
38853
  const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
38759
38854
  const stashHasMoreBelow = startIndex + listRows < stashes.length;
@@ -39101,7 +39196,7 @@ function formatHyperlink(text, url, env = process.env) {
39101
39196
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
39102
39197
  * of #890. No behavior change.
39103
39198
  */
39104
- function renderTagsSurface(ctx) {
39199
+ function renderTagsSurface(ctx, spinnerFrame = 0) {
39105
39200
  const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
39106
39201
  const { Box, Text } = components;
39107
39202
  const focused = state.focus === 'commits';
@@ -39142,13 +39237,20 @@ function renderTagsSurface(ctx) {
39142
39237
  // intact.
39143
39238
  const url = buildRefUrl(context.provider?.repository, tag.name);
39144
39239
  const namePadded = truncateCells(tag.name, tagNameColWidth).padEnd(tagNameColWidth);
39145
- const lineText = truncateCells(`${cursor} ${namePadded} ${tag.subject}`, Math.max(20, width - 4));
39240
+ // Tags have no leading status icon, so a delete-in-flight appends
39241
+ // an accent spinner at the row's end. Reserve its 2 cells from the
39242
+ // truncation budget so it never pushes the row past the panel.
39243
+ const deleting = isPendingDeletion(state.pendingDeletion, 'tag', tag.name);
39244
+ const spinnerSpan = deleting
39245
+ ? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
39246
+ : null;
39247
+ const lineText = truncateCells(`${cursor} ${namePadded} ${tag.subject}`, Math.max(20, width - 4 - (deleting ? 2 : 0)));
39146
39248
  if (!url || lineText.indexOf(namePadded) < 0) {
39147
39249
  return h(Text, {
39148
39250
  key: `tag-${index}`,
39149
39251
  bold: isSelected,
39150
39252
  dimColor: !isSelected,
39151
- }, lineText);
39253
+ }, lineText, spinnerSpan);
39152
39254
  }
39153
39255
  const linkStart = lineText.indexOf(namePadded);
39154
39256
  const before = lineText.slice(0, linkStart);
@@ -39157,7 +39259,7 @@ function renderTagsSurface(ctx) {
39157
39259
  key: `tag-${index}`,
39158
39260
  bold: isSelected,
39159
39261
  dimColor: !isSelected,
39160
- }, before, formatHyperlink(namePadded, url), after);
39262
+ }, before, formatHyperlink(namePadded, url), after, spinnerSpan);
39161
39263
  });
39162
39264
  const tagsHasMoreAbove = startIndex > 0 && tags.length > 0;
39163
39265
  const tagsHasMoreBelow = startIndex + listRows < tags.length;
@@ -39184,7 +39286,7 @@ function renderTagsSurface(ctx) {
39184
39286
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
39185
39287
  * of #890. No behavior change.
39186
39288
  */
39187
- function renderWorktreesSurface(ctx) {
39289
+ function renderWorktreesSurface(ctx, spinnerFrame = 0) {
39188
39290
  const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
39189
39291
  const { Box, Text } = components;
39190
39292
  const focused = state.focus === 'commits';
@@ -39220,7 +39322,9 @@ function renderWorktreesSurface(ctx) {
39220
39322
  const index = startIndex + offset;
39221
39323
  const isSelected = index === selected;
39222
39324
  const cursor = isSelected ? '>' : ' ';
39223
- const marker = entry.current ? '*' : ' ';
39325
+ const marker = isPendingDeletion(state.pendingDeletion, 'worktree', entry.path)
39326
+ ? inlineSpinnerGlyph(spinnerFrame, theme.ascii)
39327
+ : entry.current ? '*' : ' ';
39224
39328
  const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
39225
39329
  const stateLabel = entry.dirty ? 'dirty' : 'clean';
39226
39330
  const branchPadded = truncateCells(branchLabel, branchColWidth).padEnd(branchColWidth);
@@ -39290,10 +39394,10 @@ function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHun
39290
39394
  return renderComposeSurface(surface, spinnerFrame);
39291
39395
  }
39292
39396
  if (state.activeView === 'branches') {
39293
- return renderBranchesSurface(surface);
39397
+ return renderBranchesSurface(surface, spinnerFrame);
39294
39398
  }
39295
39399
  if (state.activeView === 'tags') {
39296
- return renderTagsSurface(surface);
39400
+ return renderTagsSurface(surface, spinnerFrame);
39297
39401
  }
39298
39402
  if (state.activeView === 'reflog') {
39299
39403
  return renderReflogSurface(surface);
@@ -39302,10 +39406,10 @@ function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHun
39302
39406
  return renderBisectSurface(surface, bisectCandidateDetail, bisectCandidateLoading);
39303
39407
  }
39304
39408
  if (state.activeView === 'stash') {
39305
- return renderStashSurface(surface);
39409
+ return renderStashSurface(surface, spinnerFrame);
39306
39410
  }
39307
39411
  if (state.activeView === 'worktrees') {
39308
- return renderWorktreesSurface(surface);
39412
+ return renderWorktreesSurface(surface, spinnerFrame);
39309
39413
  }
39310
39414
  if (state.activeView === 'submodules') {
39311
39415
  return renderSubmodulesSurface(surface);
@@ -40678,6 +40782,53 @@ const REMOTE_OP_LOADERS = {
40678
40782
  'pull-selected-branch': { kind: 'pull', label: 'Pulling branch from remote…' },
40679
40783
  'push-selected-branch': { kind: 'push', label: 'Pushing branch to remote…' },
40680
40784
  };
40785
+ /**
40786
+ * Resolve which list row a delete workflow is about to act on, so the
40787
+ * runner can mark it pending (inline spinner) for the duration of the
40788
+ * git call. Mirrors the cursored-target resolution inside each delete
40789
+ * handler exactly — same sort, same promoted-filter, same selection
40790
+ * index — so the spinner lands on the row that actually gets deleted.
40791
+ * Returns `undefined` for non-delete workflows (and when nothing is
40792
+ * selected), which the runner treats as "no pending marker".
40793
+ */
40794
+ function resolvePendingDeletion(id, state, context) {
40795
+ const { filter } = state;
40796
+ if (id === 'delete-branch' || id === 'force-delete-branch') {
40797
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
40798
+ const visible = filter
40799
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], filter))
40800
+ : all;
40801
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
40802
+ return branch ? { kind: 'branch', id: branch.shortName } : undefined;
40803
+ }
40804
+ if (id === 'delete-tag') {
40805
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
40806
+ const visible = filter
40807
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], filter))
40808
+ : all;
40809
+ const tag = visible[Math.min(state.selectedTagIndex, visible.length - 1)];
40810
+ return tag ? { kind: 'tag', id: tag.name } : undefined;
40811
+ }
40812
+ if (id === 'drop-stash') {
40813
+ const all = context.stashes?.stashes || [];
40814
+ const visible = filter
40815
+ ? all.filter((s) => matchesPromotedFilter([s.ref, s.message], filter))
40816
+ : all;
40817
+ const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
40818
+ return stash ? { kind: 'stash', id: stash.ref } : undefined;
40819
+ }
40820
+ if (id === 'remove-worktree') {
40821
+ const all = context.worktreeList?.worktrees || [];
40822
+ const visible = filter
40823
+ ? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], filter))
40824
+ : all;
40825
+ const wt = visible.length
40826
+ ? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
40827
+ : all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
40828
+ return wt ? { kind: 'worktree', id: wt.path } : undefined;
40829
+ }
40830
+ return undefined;
40831
+ }
40681
40832
  function predictNextFilter(action, currentFilter) {
40682
40833
  switch (action.type) {
40683
40834
  case 'appendFilter':
@@ -40994,7 +41145,10 @@ function LogInkApp(deps) {
40994
41145
  state.changelogView.status === 'loading' ||
40995
41146
  state.commitCompose.loading ||
40996
41147
  Boolean(state.remoteOp) ||
40997
- Boolean(state.statusLoading);
41148
+ 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);
40998
41152
  React.useEffect(() => {
40999
41153
  if (!anyLoading) {
41000
41154
  // Reset to 0 so the next loading state starts from a known
@@ -43151,6 +43305,16 @@ function LogInkApp(deps) {
43151
43305
  return { ok: false, message: 'No branch selected' };
43152
43306
  return deleteBranch(git, branch);
43153
43307
  },
43308
+ 'force-delete-branch': async () => {
43309
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
43310
+ const visible = state.filter
43311
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
43312
+ : all;
43313
+ const branch = visible[Math.min(state.selectedBranchIndex, visible.length - 1)];
43314
+ if (!branch)
43315
+ return { ok: false, message: 'No branch selected' };
43316
+ return deleteBranch(git, branch, true);
43317
+ },
43154
43318
  'delete-tag': async () => {
43155
43319
  const all = sortTags(context.tags?.tags || [], state.tagSort);
43156
43320
  const visible = state.filter
@@ -43924,9 +44088,26 @@ function LogInkApp(deps) {
43924
44088
  if (remoteOp) {
43925
44089
  dispatch({ type: 'setRemoteOp', value: remoteOp });
43926
44090
  }
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 });
44099
+ }
43927
44100
  try {
43928
44101
  const result = await handler();
43929
44102
  dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
44103
+ // A safe `delete-branch` (`git branch -d`) refuses branches that
44104
+ // aren't fully merged. Rather than dead-end on git's raw error, raise
44105
+ // a second y-confirm offering the force-delete (`git branch -D`). The
44106
+ // cursor hasn't moved (the delete failed), so the force handler
44107
+ // re-resolves the same branch.
44108
+ if (id === 'delete-branch' && !result?.ok && isBranchNotFullyMergedError(result?.message)) {
44109
+ dispatch({ type: 'setPendingConfirmation', value: 'force-delete-branch' });
44110
+ }
43930
44111
  // Refresh history rows AS WELL when the workflow could have
43931
44112
  // changed the commits the user sees (#945 follow-up). The
43932
44113
  // workflow IDs below all either create/rewrite local commits or
@@ -44025,6 +44206,12 @@ function LogInkApp(deps) {
44025
44206
  if (remoteOp) {
44026
44207
  dispatch({ type: 'setRemoteOp', value: undefined });
44027
44208
  }
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 });
44214
+ }
44028
44215
  }
44029
44216
  }, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
44030
44217
  state.branchSort, state.filter, state.selectedBranchIndex,
@@ -44884,7 +45071,7 @@ function LogInkApp(deps) {
44884
45071
  // Panel renderers are thunks so single-pane mode can build only the
44885
45072
  // visible pane — the main-panel render in particular is expensive, so
44886
45073
  // we don't want to invoke the two hidden ones just to drop them.
44887
- const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme);
45074
+ const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme, spinnerFrame);
44888
45075
  const mainSurface = {
44889
45076
  h,
44890
45077
  components: { Box, Text },
@@ -45195,12 +45382,23 @@ function createLogArgvFromUiArgv(argv) {
45195
45382
  // starting state. `coco ui --no-all` opts back to
45196
45383
  // current-branch-only.
45197
45384
  //
45385
+ // `?? true` re-asserts that default for the synthetic-argv path:
45386
+ // bare `coco` routes through defaultRouteHandler → buildSyntheticArgv,
45387
+ // which bypasses yargs and so never applies the `default: true` from
45388
+ // ui/config.ts — leaving `argv.all` undefined (#1169). Without the
45389
+ // fallback the workstation booted in compact (`--first-parent
45390
+ // --no-merges`) mode: fewer commits, branches "ahead" of HEAD hidden,
45391
+ // and cursoring/checking-out a branch whose tip wasn't in the compact
45392
+ // window triggered an anchored-context append that looped the graph
45393
+ // back to the initial commit. Explicit `--no-all` (argv.all === false)
45394
+ // is preserved because `false ?? true === false`.
45395
+ //
45198
45396
  // Note: passing `--branch foo` does NOT automatically scope away
45199
45397
  // from --all. If the user wants strictly that branch, they pass
45200
45398
  // `coco ui --branch foo --no-all`. We considered the implicit
45201
45399
  // scope-narrowing but it surprises users who pass `--branch` as
45202
45400
  // a "highlight this branch in the all-refs view" hint.
45203
- all: argv.all,
45401
+ all: argv.all ?? true,
45204
45402
  branch: argv.branch,
45205
45403
  format: 'table',
45206
45404
  interactive: true,
@@ -50214,6 +50412,11 @@ async function probeIsGitRepo() {
50214
50412
  * `InitArgv`, `UiArgv`, `WorkspaceArgv`) so we can't just spread the
50215
50413
  * raw default argv — we have to project the shared fields and let
50216
50414
  * the handler fill in command-specific defaults.
50415
+ *
50416
+ * Exported for testing: this is the path bare `coco` takes to the
50417
+ * workstation, and it intentionally does NOT set `all` — yargs's
50418
+ * `default: true` for `coco ui` never runs here, so the ui→log argv
50419
+ * mapping has to re-assert that default (#1169).
50217
50420
  */
50218
50421
  function buildSyntheticArgv(argv) {
50219
50422
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-coco",
3
- "version": "0.61.0",
3
+ "version": "0.62.1",
4
4
  "description": "zero-effort git commits with coco.",
5
5
  "author": "gfargo <ghfargo@gmail.com>",
6
6
  "license": "MIT",