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.
- package/dist/index.esm.mjs +218 -72
- package/dist/index.js +218 -72
- package/package.json +1 -1
package/dist/index.esm.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
25166
|
-
*
|
|
25167
|
-
*
|
|
25168
|
-
*
|
|
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
|
|
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 '
|
|
26379
|
-
// Pure marker for the in-flight
|
|
26380
|
-
//
|
|
26381
|
-
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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),
|
|
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
|
-
|
|
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
|
-
//
|
|
38798
|
-
//
|
|
38799
|
-
//
|
|
38800
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
},
|
|
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),
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
41133
|
-
// flight so its inline pending glyph animates
|
|
41134
|
-
|
|
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
|
|
44075
|
-
// spinner while the git call runs. Cleared in
|
|
44076
|
-
// refresh, so a successful delete hands straight
|
|
44077
|
-
// vanishing,
|
|
44078
|
-
// the
|
|
44079
|
-
|
|
44080
|
-
|
|
44081
|
-
|
|
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
|
|
44130
|
-
// refresh
|
|
44131
|
-
//
|
|
44132
|
-
//
|
|
44133
|
-
//
|
|
44134
|
-
// the
|
|
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
|
|
44193
|
-
// the
|
|
44194
|
-
// left spinning forever.
|
|
44195
|
-
if (
|
|
44196
|
-
dispatch({ type: '
|
|
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.
|
|
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.
|
|
25183
|
-
*
|
|
25184
|
-
*
|
|
25185
|
-
*
|
|
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
|
|
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 '
|
|
26396
|
-
// Pure marker for the in-flight
|
|
26397
|
-
//
|
|
26398
|
-
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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),
|
|
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
|
-
|
|
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
|
-
//
|
|
38815
|
-
//
|
|
38816
|
-
//
|
|
38817
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
},
|
|
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),
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
41150
|
-
// flight so its inline pending glyph animates
|
|
41151
|
-
|
|
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
|
|
44092
|
-
// spinner while the git call runs. Cleared in
|
|
44093
|
-
// refresh, so a successful delete hands straight
|
|
44094
|
-
// vanishing,
|
|
44095
|
-
// the
|
|
44096
|
-
|
|
44097
|
-
|
|
44098
|
-
|
|
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
|
|
44147
|
-
// refresh
|
|
44148
|
-
//
|
|
44149
|
-
//
|
|
44150
|
-
//
|
|
44151
|
-
// the
|
|
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
|
|
44210
|
-
// the
|
|
44211
|
-
// left spinning forever.
|
|
44212
|
-
if (
|
|
44213
|
-
dispatch({ type: '
|
|
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,
|