git-coco 0.62.2 → 0.62.4
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 +242 -38
- package/dist/index.js +242 -38
- 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.4";
|
|
65
65
|
|
|
66
66
|
const isInteractive = (config) => {
|
|
67
67
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -26368,6 +26368,8 @@ function applyLogInkAction(state, action) {
|
|
|
26368
26368
|
pendingMutationConfirmation: action.value ? undefined : state.pendingMutationConfirmation,
|
|
26369
26369
|
pendingKey: undefined,
|
|
26370
26370
|
};
|
|
26371
|
+
case 'setWorktreeCheckoutConflict':
|
|
26372
|
+
return { ...state, worktreeCheckoutConflict: action.value, pendingKey: undefined };
|
|
26371
26373
|
case 'setPendingMutationConfirmation':
|
|
26372
26374
|
return {
|
|
26373
26375
|
...state,
|
|
@@ -27740,7 +27742,45 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27740
27742
|
return [];
|
|
27741
27743
|
}
|
|
27742
27744
|
if (state.pendingConfirmationId) {
|
|
27745
|
+
// Worktree-conflict removal options (#1175): alongside the y-switch,
|
|
27746
|
+
// `r` removes the conflicting worktree and checks the branch out
|
|
27747
|
+
// here, `x` removes the worktree AND deletes the branch. Both defer
|
|
27748
|
+
// to the runtime (it owns the git ops + the conflict context); the
|
|
27749
|
+
// runtime clears the conflict state once it resolves.
|
|
27750
|
+
if (state.pendingConfirmationId === 'switch-to-conflicting-worktree' && state.worktreeCheckoutConflict) {
|
|
27751
|
+
if (inputValue === 'r') {
|
|
27752
|
+
return [
|
|
27753
|
+
{ type: 'runWorkflowAction', id: 'conflict-remove-worktree-checkout' },
|
|
27754
|
+
action({ type: 'setPendingConfirmation', value: undefined }),
|
|
27755
|
+
];
|
|
27756
|
+
}
|
|
27757
|
+
if (inputValue === 'x') {
|
|
27758
|
+
return [
|
|
27759
|
+
{ type: 'runWorkflowAction', id: 'conflict-remove-worktree-branch' },
|
|
27760
|
+
action({ type: 'setPendingConfirmation', value: undefined }),
|
|
27761
|
+
];
|
|
27762
|
+
}
|
|
27763
|
+
}
|
|
27743
27764
|
if (inputValue === 'y') {
|
|
27765
|
+
// Worktree-conflict switch (#1175): the branch is already checked
|
|
27766
|
+
// out elsewhere, so "switch" just opens that worktree as a nested
|
|
27767
|
+
// repo frame (same mechanism as drilling into a submodule) — no
|
|
27768
|
+
// git mutation, hence handled here rather than via the runtime.
|
|
27769
|
+
if (state.pendingConfirmationId === 'switch-to-conflicting-worktree') {
|
|
27770
|
+
const conflict = state.worktreeCheckoutConflict;
|
|
27771
|
+
if (conflict) {
|
|
27772
|
+
return [
|
|
27773
|
+
action({ type: 'pushRepoFrame', label: conflict.branch, workdir: conflict.worktreePath }),
|
|
27774
|
+
action({ type: 'setStatus', value: `Switched to worktree ${conflict.worktreePath} (${conflict.branch})` }),
|
|
27775
|
+
action({ type: 'setPendingConfirmation', value: undefined }),
|
|
27776
|
+
action({ type: 'setWorktreeCheckoutConflict', value: undefined }),
|
|
27777
|
+
];
|
|
27778
|
+
}
|
|
27779
|
+
return [
|
|
27780
|
+
action({ type: 'setPendingConfirmation', value: undefined }),
|
|
27781
|
+
action({ type: 'setWorktreeCheckoutConflict', value: undefined }),
|
|
27782
|
+
];
|
|
27783
|
+
}
|
|
27744
27784
|
const workflowAction = getLogInkWorkflowActionById(state.pendingConfirmationId);
|
|
27745
27785
|
if (workflowAction?.id === 'ai-commit-summary') {
|
|
27746
27786
|
return [
|
|
@@ -27766,6 +27806,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27766
27806
|
if (inputValue === 'n' || key.escape) {
|
|
27767
27807
|
return [
|
|
27768
27808
|
action({ type: 'setPendingConfirmation', value: undefined }),
|
|
27809
|
+
// Drop any worktree-conflict context so the prompt doesn't
|
|
27810
|
+
// linger after the user declines to switch.
|
|
27811
|
+
...(state.worktreeCheckoutConflict
|
|
27812
|
+
? [action({ type: 'setWorktreeCheckoutConflict', value: undefined })]
|
|
27813
|
+
: []),
|
|
27769
27814
|
action({ type: 'setStatus', value: 'workflow action cancelled' }),
|
|
27770
27815
|
];
|
|
27771
27816
|
}
|
|
@@ -34351,7 +34396,13 @@ function sidebarTabLabel(tab) {
|
|
|
34351
34396
|
* Sliding window keeps the cursor in view as the user navigates a long
|
|
34352
34397
|
* list; truncation hints surface the count of hidden rows.
|
|
34353
34398
|
*/
|
|
34354
|
-
function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount
|
|
34399
|
+
function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount,
|
|
34400
|
+
// Optional per-row foreground colour (e.g. the current branch in
|
|
34401
|
+
// green). Applied only when the row is NOT the active selection —
|
|
34402
|
+
// the selection's inverse/background styling owns the row's colour in
|
|
34403
|
+
// that case, and layering a foreground under `inverse` would swap it
|
|
34404
|
+
// into an unexpected background tint.
|
|
34405
|
+
rowColor) {
|
|
34355
34406
|
if (items.length === 0)
|
|
34356
34407
|
return [];
|
|
34357
34408
|
const window = getSidebarVisibleWindow(items.length, selectedIndex, visibleCount);
|
|
@@ -34368,10 +34419,12 @@ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, wid
|
|
|
34368
34419
|
break;
|
|
34369
34420
|
const isSelected = focused && index === selectedIndex;
|
|
34370
34421
|
const text = toRowText(items[index], index);
|
|
34422
|
+
const color = isSelected ? undefined : rowColor?.(items[index], index);
|
|
34371
34423
|
elements.push(h(Text, {
|
|
34372
34424
|
key: `${keyPrefix}-row-${index}`,
|
|
34373
34425
|
backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
34374
34426
|
inverse: isSelected,
|
|
34427
|
+
color,
|
|
34375
34428
|
}, truncateCells(` ${text}`, width - 4)));
|
|
34376
34429
|
}
|
|
34377
34430
|
if (window.truncatedBelow > 0) {
|
|
@@ -34481,7 +34534,11 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
34481
34534
|
? spin
|
|
34482
34535
|
: branchRowMarker(branch, { ascii: theme.ascii }).glyph;
|
|
34483
34536
|
return `${glyph} ${branch.shortName}`;
|
|
34484
|
-
}, 'tab-branches', visibleListCount
|
|
34537
|
+
}, 'tab-branches', visibleListCount,
|
|
34538
|
+
// Paint the checked-out branch green so "where am I?" reads at a
|
|
34539
|
+
// glance, matching the green HEAD marker the branches surface
|
|
34540
|
+
// already uses. NO_COLOR themes fall back to the `*` glyph alone.
|
|
34541
|
+
(branch) => (branch.current && !theme.noColor ? theme.colors.success : undefined)),
|
|
34485
34542
|
];
|
|
34486
34543
|
}
|
|
34487
34544
|
if (tab === 'tags') {
|
|
@@ -34982,11 +35039,16 @@ function renderBranchesSurface(ctx, spinnerFrame = 0) {
|
|
|
34982
35039
|
const truncated = truncateCells(fullText, Math.max(20, width - 4));
|
|
34983
35040
|
// If truncation chopped into the timestamp/divergence portion,
|
|
34984
35041
|
// fall back to a single Text to keep the visible width honest.
|
|
35042
|
+
// The checked-out branch is painted green (matching its green
|
|
35043
|
+
// HEAD marker) so "where am I?" reads at a glance. NO_COLOR
|
|
35044
|
+
// themes fall back to the `*` glyph alone.
|
|
35045
|
+
const currentColor = branch.current && !theme.noColor ? theme.colors.success : undefined;
|
|
34985
35046
|
if (truncated !== fullText) {
|
|
34986
35047
|
return h(Text, {
|
|
34987
35048
|
key: `branch-${index}`,
|
|
34988
35049
|
bold: isSelected,
|
|
34989
35050
|
dimColor: lineDim,
|
|
35051
|
+
color: currentColor,
|
|
34990
35052
|
}, truncated);
|
|
34991
35053
|
}
|
|
34992
35054
|
return h(Text, {
|
|
@@ -35001,7 +35063,12 @@ function renderBranchesSurface(ctx, spinnerFrame = 0) {
|
|
|
35001
35063
|
// no-upstream kinds return undefined from
|
|
35002
35064
|
// `getBranchRowMarkerColor`, so those markers inherit the
|
|
35003
35065
|
// row's dim and read as quiet chrome.
|
|
35004
|
-
h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph),
|
|
35066
|
+
h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph),
|
|
35067
|
+
// Name span: green for the current branch (dimColor:false keeps
|
|
35068
|
+
// it bright), otherwise it inherits the row's normal styling.
|
|
35069
|
+
currentColor
|
|
35070
|
+
? h(Text, { color: currentColor, dimColor: false }, trailingName)
|
|
35071
|
+
: trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
|
|
35005
35072
|
});
|
|
35006
35073
|
// Scroll indicators — same "N more above/below" pattern as the
|
|
35007
35074
|
// sidebar and help overlay so the user knows the list continues.
|
|
@@ -37618,25 +37685,36 @@ function renderConfirmationPanel(h, components, state, width, theme, focused) {
|
|
|
37618
37685
|
: state.pendingMutationConfirmation === 'discard-draft'
|
|
37619
37686
|
? 'Quit and discard the in-progress commit draft'
|
|
37620
37687
|
: undefined;
|
|
37621
|
-
|
|
37688
|
+
// Worktree-conflict switch (#1175): a checkout was rejected because
|
|
37689
|
+
// the branch is checked out elsewhere — name the branch + worktree so
|
|
37690
|
+
// the prompt explains what "y" does (jump into that worktree).
|
|
37691
|
+
const conflict = state.worktreeCheckoutConflict;
|
|
37692
|
+
const isWorktreeConflict = state.pendingConfirmationId === 'switch-to-conflicting-worktree';
|
|
37693
|
+
const label = isWorktreeConflict && conflict
|
|
37694
|
+
? `Switch to the worktree where '${conflict.branch}' is checked out?`
|
|
37695
|
+
: action?.label || mutationLabel || 'Workflow action';
|
|
37622
37696
|
const warning = state.pendingMutationConfirmation === 'discard-draft'
|
|
37623
37697
|
? 'You have an unsaved commit draft. Press y to discard it and quit.'
|
|
37624
37698
|
: state.pendingMutationConfirmation
|
|
37625
37699
|
? 'This discards local changes and cannot be undone by Coco.'
|
|
37626
|
-
|
|
37627
|
-
|
|
37628
|
-
|
|
37629
|
-
|
|
37630
|
-
:
|
|
37631
|
-
?
|
|
37632
|
-
:
|
|
37700
|
+
: isWorktreeConflict && conflict
|
|
37701
|
+
? `'${conflict.branch}' is checked out at ${conflict.worktreePath}.${conflict.dirty ? ' That worktree has uncommitted changes — removal will be refused until it is clean or stashed.' : ''}`
|
|
37702
|
+
// Second-stage confirm raised when a safe delete hit an unmerged
|
|
37703
|
+
// branch — name the reason so the force isn't a blind "y again".
|
|
37704
|
+
: state.pendingConfirmationId === 'force-delete-branch'
|
|
37705
|
+
? 'Not fully merged. Force-delete (git branch -D) is irreversible.'
|
|
37706
|
+
: action?.kind === 'ai'
|
|
37707
|
+
? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
|
|
37708
|
+
: 'Destructive Git action requires confirmation.';
|
|
37633
37709
|
return h(Box, {
|
|
37634
37710
|
borderColor: focusBorderColor(theme, focused),
|
|
37635
37711
|
borderStyle: theme.borderStyle,
|
|
37636
37712
|
flexDirection: 'column',
|
|
37637
37713
|
width,
|
|
37638
37714
|
paddingX: 1,
|
|
37639
|
-
}, h(Text, { bold: true }, panelTitle('Confirm', focused)), h(Text, undefined, truncateCells(label, width - 4)), h(Text, { dimColor: true }, truncateCells(warning, width - 4)), h(Text, undefined, ''), h(Text, undefined,
|
|
37715
|
+
}, h(Text, { bold: true }, panelTitle('Confirm', focused)), h(Text, undefined, truncateCells(label, width - 4)), h(Text, { dimColor: true }, truncateCells(warning, width - 4)), h(Text, undefined, ''), h(Text, undefined, truncateCells(isWorktreeConflict
|
|
37716
|
+
? 'y switch · r remove worktree & check out here · x remove worktree & delete branch · n/Esc cancel'
|
|
37717
|
+
: 'Press y to confirm or n/Esc to cancel.', width - 4)));
|
|
37640
37718
|
}
|
|
37641
37719
|
/**
|
|
37642
37720
|
* First-launch onboarding overlay (P1.3). Shown once per machine, gated
|
|
@@ -38800,6 +38878,15 @@ function renderReflogSurface(ctx) {
|
|
|
38800
38878
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
38801
38879
|
* of #890. No behavior change.
|
|
38802
38880
|
*/
|
|
38881
|
+
const GAP = 2; // cells between columns
|
|
38882
|
+
/** Truncate to `w` cells, then pad to `w` (left = padEnd, right = padStart). */
|
|
38883
|
+
function cell(value, w, align = 'left') {
|
|
38884
|
+
const t = truncateCells(value, w);
|
|
38885
|
+
// padStart/padEnd count code units; refs / ages / counts / branch
|
|
38886
|
+
// names are ASCII in practice, matching the branches surface's
|
|
38887
|
+
// padEnd-based column alignment.
|
|
38888
|
+
return align === 'right' ? t.padStart(w) : t.padEnd(w);
|
|
38889
|
+
}
|
|
38803
38890
|
function renderStashSurface(ctx, spinnerFrame = 0) {
|
|
38804
38891
|
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38805
38892
|
const { Box, Text } = components;
|
|
@@ -38810,7 +38897,9 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
|
|
|
38810
38897
|
? allStashes.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
|
|
38811
38898
|
: allStashes;
|
|
38812
38899
|
const selected = Math.max(0, Math.min(state.selectedStashIndex, Math.max(0, stashes.length - 1)));
|
|
38813
|
-
|
|
38900
|
+
// One extra row reserved (vs the other surfaces' `- 4`) for the column
|
|
38901
|
+
// header row below.
|
|
38902
|
+
const listRows = Math.max(4, bodyRows - 5);
|
|
38814
38903
|
const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
|
|
38815
38904
|
const visible = stashes.slice(startIndex, startIndex + listRows);
|
|
38816
38905
|
const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
|
|
@@ -38820,10 +38909,67 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
|
|
|
38820
38909
|
const emptyLabel = formatLogInkStashEmpty({ filter: state.filter });
|
|
38821
38910
|
const loadingLabel = formatLogInkLoading({ resource: 'stashes' });
|
|
38822
38911
|
const now = getRenderNow();
|
|
38823
|
-
//
|
|
38824
|
-
//
|
|
38825
|
-
//
|
|
38826
|
-
|
|
38912
|
+
// Usable interior width: panel width minus the border (2) and the
|
|
38913
|
+
// paddingX:1 (2). The previous `width - 2` under-counted the border
|
|
38914
|
+
// and made every near-full row overflow by 2 cells and wrap — the
|
|
38915
|
+
// single biggest readability hit in the old list.
|
|
38916
|
+
const contentWidth = Math.max(20, width - 4);
|
|
38917
|
+
const ageOf = (s) => formatCompactRelativeDate(s.date, now);
|
|
38918
|
+
// Column widths derived from the visible window (#833 pattern) so rows
|
|
38919
|
+
// align without re-measuring the whole list. Each column is at least
|
|
38920
|
+
// as wide as its header label so the header never truncates ("age",
|
|
38921
|
+
// "branch", "files"); age tops out at 5 ("today" is the longest value
|
|
38922
|
+
// formatCompactRelativeDate emits).
|
|
38923
|
+
const refCol = visible.length
|
|
38924
|
+
? Math.min(11, Math.max(3, ...visible.map((s) => cellWidth(s.ref))))
|
|
38925
|
+
: 9;
|
|
38926
|
+
const ageCol = visible.length
|
|
38927
|
+
? Math.min(5, Math.max(3, ...visible.map((s) => cellWidth(ageOf(s)))))
|
|
38928
|
+
: 3;
|
|
38929
|
+
const branchColMax = visible.length
|
|
38930
|
+
? Math.max(0, ...visible.map((s) => cellWidth(s.branch || '')))
|
|
38931
|
+
: 0;
|
|
38932
|
+
const filesCol = visible.length
|
|
38933
|
+
? Math.max(5, ...visible.map((s) => String(s.files.length).length))
|
|
38934
|
+
: 5;
|
|
38935
|
+
// Responsive degradation. Keep ref + message always; when the message
|
|
38936
|
+
// floor is threatened, shed columns the preview pane already shows —
|
|
38937
|
+
// branch first, then age, then the file count — before squeezing the
|
|
38938
|
+
// message.
|
|
38939
|
+
// Widen to fit the "branch" header when any stash carries a branch;
|
|
38940
|
+
// stays 0 (column dropped) when none do.
|
|
38941
|
+
let branchCol = branchColMax > 0 ? Math.min(18, Math.max(6, branchColMax)) : 0;
|
|
38942
|
+
let showAge = true;
|
|
38943
|
+
let showFiles = true;
|
|
38944
|
+
const fixedWidth = () => 2 + refCol +
|
|
38945
|
+
(showAge ? GAP + ageCol : 0) +
|
|
38946
|
+
(branchCol > 0 ? GAP + branchCol : 0) +
|
|
38947
|
+
(showFiles ? GAP + filesCol : 0) +
|
|
38948
|
+
GAP; // gap before the message column
|
|
38949
|
+
let messageWidth = contentWidth - fixedWidth();
|
|
38950
|
+
if (messageWidth < 24 && branchCol > 0) {
|
|
38951
|
+
branchCol = 0;
|
|
38952
|
+
messageWidth = contentWidth - fixedWidth();
|
|
38953
|
+
}
|
|
38954
|
+
if (messageWidth < 16 && showAge) {
|
|
38955
|
+
showAge = false;
|
|
38956
|
+
messageWidth = contentWidth - fixedWidth();
|
|
38957
|
+
}
|
|
38958
|
+
if (messageWidth < 12 && showFiles) {
|
|
38959
|
+
showFiles = false;
|
|
38960
|
+
messageWidth = contentWidth - fixedWidth();
|
|
38961
|
+
}
|
|
38962
|
+
messageWidth = Math.max(8, messageWidth);
|
|
38963
|
+
// Column header. Right-aligned labels over the right-aligned numeric
|
|
38964
|
+
// columns (age, files) so header and data share an edge.
|
|
38965
|
+
let headerText = ` ${cell('ref', refCol)}`;
|
|
38966
|
+
if (showAge)
|
|
38967
|
+
headerText += `${' '.repeat(GAP)}${cell('age', ageCol, 'right')}`;
|
|
38968
|
+
if (branchCol > 0)
|
|
38969
|
+
headerText += `${' '.repeat(GAP)}${cell('branch', branchCol)}`;
|
|
38970
|
+
if (showFiles)
|
|
38971
|
+
headerText += `${' '.repeat(GAP)}${cell('files', filesCol, 'right')}`;
|
|
38972
|
+
headerText += `${' '.repeat(GAP)}message`;
|
|
38827
38973
|
const lines = loading
|
|
38828
38974
|
? [h(Text, { key: 'stash-loading', dimColor: true }, loadingLabel)]
|
|
38829
38975
|
: stashes.length === 0
|
|
@@ -38832,32 +38978,22 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
|
|
|
38832
38978
|
const index = startIndex + offset;
|
|
38833
38979
|
const isSelected = index === selected;
|
|
38834
38980
|
const cursor = isSelected ? '>' : ' ';
|
|
38835
|
-
|
|
38836
|
-
// branch, file count, and relative age — between the ref and the
|
|
38837
|
-
// message, so the list answers "which stash is this?" without an
|
|
38838
|
-
// Enter→diff round trip.
|
|
38839
|
-
const age = formatCompactRelativeDate(stash.date, now);
|
|
38840
|
-
const fileCount = stash.files.length;
|
|
38841
|
-
const meta = [
|
|
38842
|
-
stash.branch ? `on ${stash.branch}` : '',
|
|
38843
|
-
fileCount > 0 ? `${fileCount} file${fileCount === 1 ? '' : 's'}` : '',
|
|
38844
|
-
age,
|
|
38845
|
-
].filter(Boolean).join(' · ');
|
|
38846
|
-
const rowText = meta
|
|
38847
|
-
? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
|
|
38848
|
-
: `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
|
|
38981
|
+
const deleting = isPendingItemAction(state.pendingItemAction, 'stash', stash.ref);
|
|
38849
38982
|
// The `stash@{N}` ref is an identifier, not a status icon, so a
|
|
38850
38983
|
// delete-in-flight appends an accent spinner at the row's end
|
|
38851
|
-
// (2 cells reserved from the
|
|
38852
|
-
const deleting = isPendingItemAction(state.pendingItemAction, 'stash', stash.ref);
|
|
38984
|
+
// (2 cells reserved from the message budget).
|
|
38853
38985
|
const spinnerSpan = deleting
|
|
38854
38986
|
? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
|
|
38855
38987
|
: null;
|
|
38988
|
+
const message = truncateCells(stash.message, messageWidth - (deleting ? 2 : 0));
|
|
38989
|
+
// ref + message read as primary; age / branch / file-count are
|
|
38990
|
+
// dim metadata (kept dim even on the bold selected row so the
|
|
38991
|
+
// message stays the focal point).
|
|
38856
38992
|
return h(Text, {
|
|
38857
38993
|
key: `stash-${index}`,
|
|
38858
38994
|
bold: isSelected,
|
|
38859
38995
|
dimColor: !isSelected,
|
|
38860
|
-
},
|
|
38996
|
+
}, `${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);
|
|
38861
38997
|
});
|
|
38862
38998
|
const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
|
|
38863
38999
|
const stashHasMoreBelow = startIndex + listRows < stashes.length;
|
|
@@ -38868,7 +39004,11 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
|
|
|
38868
39004
|
flexShrink: 0,
|
|
38869
39005
|
paddingX: 1,
|
|
38870
39006
|
width,
|
|
38871
|
-
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme),
|
|
39007
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme),
|
|
39008
|
+
// Column header — only when there are rows to label.
|
|
39009
|
+
...(!loading && stashes.length > 0
|
|
39010
|
+
? [h(Text, { key: 'stash-col-header', dimColor: true }, truncateCells(headerText, contentWidth))]
|
|
39011
|
+
: []), ...(stashHasMoreAbove
|
|
38872
39012
|
? [h(Text, { key: 'stash-more-above', dimColor: true }, ` ↑ ${startIndex} more above`)]
|
|
38873
39013
|
: []), ...lines, ...(stashHasMoreBelow
|
|
38874
39014
|
? [h(Text, { key: 'stash-more-below', dimColor: true }, ` ↓ ${stashes.length - (startIndex + listRows)} more below`)]
|
|
@@ -43688,6 +43828,42 @@ function LogInkApp(deps) {
|
|
|
43688
43828
|
// path on the wrong target.
|
|
43689
43829
|
return removeWorktreeAndBranch(git, cursorTarget, context.branches?.localBranches || []);
|
|
43690
43830
|
},
|
|
43831
|
+
// Worktree-checkout-conflict resolutions (#1175). Unlike the
|
|
43832
|
+
// cursor-targeted handlers above, these act on the worktree
|
|
43833
|
+
// captured in `state.worktreeCheckoutConflict` (the one git named
|
|
43834
|
+
// when it refused the checkout), not the worktrees-view cursor.
|
|
43835
|
+
'conflict-remove-worktree-checkout': async () => {
|
|
43836
|
+
const conflict = state.worktreeCheckoutConflict;
|
|
43837
|
+
dispatch({ type: 'setWorktreeCheckoutConflict', value: undefined });
|
|
43838
|
+
if (!conflict)
|
|
43839
|
+
return { ok: false, message: 'No worktree conflict to resolve.' };
|
|
43840
|
+
const worktree = context.worktreeList?.worktrees?.find((w) => w.path === conflict.worktreePath);
|
|
43841
|
+
if (!worktree)
|
|
43842
|
+
return { ok: false, message: `Worktree ${conflict.worktreePath} not found.` };
|
|
43843
|
+
// removeWorktree refuses a dirty / current worktree and returns
|
|
43844
|
+
// a clear message — surface it rather than forcing.
|
|
43845
|
+
const removed = await removeWorktree(git, worktree);
|
|
43846
|
+
if (!removed.ok)
|
|
43847
|
+
return removed;
|
|
43848
|
+
const branch = (context.branches?.localBranches || []).find((b) => b.type === 'local' && b.shortName === conflict.branch);
|
|
43849
|
+
if (!branch) {
|
|
43850
|
+
return { ok: true, message: `Removed worktree ${worktree.path}; branch ${conflict.branch} not found to check out.` };
|
|
43851
|
+
}
|
|
43852
|
+
const checkout = await checkoutBranch(git, branch);
|
|
43853
|
+
return checkout.ok
|
|
43854
|
+
? { ok: true, message: `Removed worktree ${worktree.path} and checked out ${conflict.branch}` }
|
|
43855
|
+
: { ok: false, message: `Removed worktree ${worktree.path}, but checkout failed: ${checkout.message}` };
|
|
43856
|
+
},
|
|
43857
|
+
'conflict-remove-worktree-branch': async () => {
|
|
43858
|
+
const conflict = state.worktreeCheckoutConflict;
|
|
43859
|
+
dispatch({ type: 'setWorktreeCheckoutConflict', value: undefined });
|
|
43860
|
+
if (!conflict)
|
|
43861
|
+
return { ok: false, message: 'No worktree conflict to resolve.' };
|
|
43862
|
+
const worktree = context.worktreeList?.worktrees?.find((w) => w.path === conflict.worktreePath);
|
|
43863
|
+
if (!worktree)
|
|
43864
|
+
return { ok: false, message: `Worktree ${conflict.worktreePath} not found.` };
|
|
43865
|
+
return removeWorktreeAndBranch(git, worktree, context.branches?.localBranches || []);
|
|
43866
|
+
},
|
|
43691
43867
|
'abort-operation': async () => {
|
|
43692
43868
|
const operation = context.operation?.operation;
|
|
43693
43869
|
if (!operation) {
|
|
@@ -44150,6 +44326,30 @@ function LogInkApp(deps) {
|
|
|
44150
44326
|
kind: 'warning',
|
|
44151
44327
|
});
|
|
44152
44328
|
}
|
|
44329
|
+
// Checking out a branch that's already checked out in another
|
|
44330
|
+
// worktree is rejected by git ("already checked out at <path>").
|
|
44331
|
+
// Rather than dead-end on that, capture the conflict and raise a
|
|
44332
|
+
// y-confirm offering to switch into that worktree — the branch IS
|
|
44333
|
+
// checked out, just elsewhere (#1175).
|
|
44334
|
+
if (id === 'checkout-branch' && !result?.ok && isBranchCheckedOutElsewhereError(result?.message)) {
|
|
44335
|
+
const worktreePath = parseCheckedOutWorktreePath(result?.message);
|
|
44336
|
+
const branchName = pendingItemAction?.id;
|
|
44337
|
+
if (worktreePath && branchName) {
|
|
44338
|
+
const worktree = context.worktreeList?.worktrees?.find((w) => w.path === worktreePath);
|
|
44339
|
+
dispatch({
|
|
44340
|
+
type: 'setWorktreeCheckoutConflict',
|
|
44341
|
+
value: { branch: branchName, worktreePath, dirty: worktree?.dirty ?? false },
|
|
44342
|
+
});
|
|
44343
|
+
dispatch({ type: 'setPendingConfirmation', value: 'switch-to-conflicting-worktree' });
|
|
44344
|
+
}
|
|
44345
|
+
else {
|
|
44346
|
+
dispatch({
|
|
44347
|
+
type: 'setStatus',
|
|
44348
|
+
value: `'${branchName ?? 'branch'}' is already checked out in another worktree.`,
|
|
44349
|
+
kind: 'warning',
|
|
44350
|
+
});
|
|
44351
|
+
}
|
|
44352
|
+
}
|
|
44153
44353
|
// Refresh history rows AS WELL when the workflow could have
|
|
44154
44354
|
// changed the commits the user sees (#945 follow-up). The
|
|
44155
44355
|
// workflow IDs below all either create/rewrite local commits or
|
|
@@ -44159,6 +44359,10 @@ function LogInkApp(deps) {
|
|
|
44159
44359
|
// metadata-only mutations (delete-tag, set-upstream, etc.).
|
|
44160
44360
|
const historyMutatingIds = new Set([
|
|
44161
44361
|
'checkout-branch',
|
|
44362
|
+
// Resolving a checkout conflict changes HEAD (checkout) and/or the
|
|
44363
|
+
// ref set (branch delete), so the graph needs a refresh.
|
|
44364
|
+
'conflict-remove-worktree-checkout',
|
|
44365
|
+
'conflict-remove-worktree-branch',
|
|
44162
44366
|
'continue-operation',
|
|
44163
44367
|
'pull-current-branch',
|
|
44164
44368
|
// Fetch / pull / push bring in new commits and move
|
|
@@ -44194,7 +44398,7 @@ function LogInkApp(deps) {
|
|
|
44194
44398
|
// (resolvePendingItemAction → action 'checkout'), so a silent
|
|
44195
44399
|
// stale-while-revalidate swap keeps the list readable and just
|
|
44196
44400
|
// repaints the current-branch marker once the new context lands.
|
|
44197
|
-
if (id === 'checkout-branch' && result?.ok) {
|
|
44401
|
+
if ((id === 'checkout-branch' || id === 'conflict-remove-worktree-checkout') && result?.ok) {
|
|
44198
44402
|
dispatch({ type: 'resetBranchSelection' });
|
|
44199
44403
|
await refreshContext({ silent: true });
|
|
44200
44404
|
}
|
|
@@ -44261,7 +44465,7 @@ function LogInkApp(deps) {
|
|
|
44261
44465
|
}, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
|
|
44262
44466
|
state.branchSort, state.filter, state.selectedBranchIndex,
|
|
44263
44467
|
state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
|
|
44264
|
-
state.statusFilterMask, state.tagSort]);
|
|
44468
|
+
state.statusFilterMask, state.tagSort, state.worktreeCheckoutConflict]);
|
|
44265
44469
|
// Resolve the active view's "yank target" (commit hash / branch /
|
|
44266
44470
|
// tag / stash ref / file path) against the live filtered+sorted list,
|
|
44267
44471
|
// copy it to the system clipboard, and surface the result on the
|
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.4";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -26385,6 +26385,8 @@ function applyLogInkAction(state, action) {
|
|
|
26385
26385
|
pendingMutationConfirmation: action.value ? undefined : state.pendingMutationConfirmation,
|
|
26386
26386
|
pendingKey: undefined,
|
|
26387
26387
|
};
|
|
26388
|
+
case 'setWorktreeCheckoutConflict':
|
|
26389
|
+
return { ...state, worktreeCheckoutConflict: action.value, pendingKey: undefined };
|
|
26388
26390
|
case 'setPendingMutationConfirmation':
|
|
26389
26391
|
return {
|
|
26390
26392
|
...state,
|
|
@@ -27757,7 +27759,45 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27757
27759
|
return [];
|
|
27758
27760
|
}
|
|
27759
27761
|
if (state.pendingConfirmationId) {
|
|
27762
|
+
// Worktree-conflict removal options (#1175): alongside the y-switch,
|
|
27763
|
+
// `r` removes the conflicting worktree and checks the branch out
|
|
27764
|
+
// here, `x` removes the worktree AND deletes the branch. Both defer
|
|
27765
|
+
// to the runtime (it owns the git ops + the conflict context); the
|
|
27766
|
+
// runtime clears the conflict state once it resolves.
|
|
27767
|
+
if (state.pendingConfirmationId === 'switch-to-conflicting-worktree' && state.worktreeCheckoutConflict) {
|
|
27768
|
+
if (inputValue === 'r') {
|
|
27769
|
+
return [
|
|
27770
|
+
{ type: 'runWorkflowAction', id: 'conflict-remove-worktree-checkout' },
|
|
27771
|
+
action({ type: 'setPendingConfirmation', value: undefined }),
|
|
27772
|
+
];
|
|
27773
|
+
}
|
|
27774
|
+
if (inputValue === 'x') {
|
|
27775
|
+
return [
|
|
27776
|
+
{ type: 'runWorkflowAction', id: 'conflict-remove-worktree-branch' },
|
|
27777
|
+
action({ type: 'setPendingConfirmation', value: undefined }),
|
|
27778
|
+
];
|
|
27779
|
+
}
|
|
27780
|
+
}
|
|
27760
27781
|
if (inputValue === 'y') {
|
|
27782
|
+
// Worktree-conflict switch (#1175): the branch is already checked
|
|
27783
|
+
// out elsewhere, so "switch" just opens that worktree as a nested
|
|
27784
|
+
// repo frame (same mechanism as drilling into a submodule) — no
|
|
27785
|
+
// git mutation, hence handled here rather than via the runtime.
|
|
27786
|
+
if (state.pendingConfirmationId === 'switch-to-conflicting-worktree') {
|
|
27787
|
+
const conflict = state.worktreeCheckoutConflict;
|
|
27788
|
+
if (conflict) {
|
|
27789
|
+
return [
|
|
27790
|
+
action({ type: 'pushRepoFrame', label: conflict.branch, workdir: conflict.worktreePath }),
|
|
27791
|
+
action({ type: 'setStatus', value: `Switched to worktree ${conflict.worktreePath} (${conflict.branch})` }),
|
|
27792
|
+
action({ type: 'setPendingConfirmation', value: undefined }),
|
|
27793
|
+
action({ type: 'setWorktreeCheckoutConflict', value: undefined }),
|
|
27794
|
+
];
|
|
27795
|
+
}
|
|
27796
|
+
return [
|
|
27797
|
+
action({ type: 'setPendingConfirmation', value: undefined }),
|
|
27798
|
+
action({ type: 'setWorktreeCheckoutConflict', value: undefined }),
|
|
27799
|
+
];
|
|
27800
|
+
}
|
|
27761
27801
|
const workflowAction = getLogInkWorkflowActionById(state.pendingConfirmationId);
|
|
27762
27802
|
if (workflowAction?.id === 'ai-commit-summary') {
|
|
27763
27803
|
return [
|
|
@@ -27783,6 +27823,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27783
27823
|
if (inputValue === 'n' || key.escape) {
|
|
27784
27824
|
return [
|
|
27785
27825
|
action({ type: 'setPendingConfirmation', value: undefined }),
|
|
27826
|
+
// Drop any worktree-conflict context so the prompt doesn't
|
|
27827
|
+
// linger after the user declines to switch.
|
|
27828
|
+
...(state.worktreeCheckoutConflict
|
|
27829
|
+
? [action({ type: 'setWorktreeCheckoutConflict', value: undefined })]
|
|
27830
|
+
: []),
|
|
27786
27831
|
action({ type: 'setStatus', value: 'workflow action cancelled' }),
|
|
27787
27832
|
];
|
|
27788
27833
|
}
|
|
@@ -34368,7 +34413,13 @@ function sidebarTabLabel(tab) {
|
|
|
34368
34413
|
* Sliding window keeps the cursor in view as the user navigates a long
|
|
34369
34414
|
* list; truncation hints surface the count of hidden rows.
|
|
34370
34415
|
*/
|
|
34371
|
-
function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount
|
|
34416
|
+
function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount,
|
|
34417
|
+
// Optional per-row foreground colour (e.g. the current branch in
|
|
34418
|
+
// green). Applied only when the row is NOT the active selection —
|
|
34419
|
+
// the selection's inverse/background styling owns the row's colour in
|
|
34420
|
+
// that case, and layering a foreground under `inverse` would swap it
|
|
34421
|
+
// into an unexpected background tint.
|
|
34422
|
+
rowColor) {
|
|
34372
34423
|
if (items.length === 0)
|
|
34373
34424
|
return [];
|
|
34374
34425
|
const window = getSidebarVisibleWindow(items.length, selectedIndex, visibleCount);
|
|
@@ -34385,10 +34436,12 @@ function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, wid
|
|
|
34385
34436
|
break;
|
|
34386
34437
|
const isSelected = focused && index === selectedIndex;
|
|
34387
34438
|
const text = toRowText(items[index], index);
|
|
34439
|
+
const color = isSelected ? undefined : rowColor?.(items[index], index);
|
|
34388
34440
|
elements.push(h(Text, {
|
|
34389
34441
|
key: `${keyPrefix}-row-${index}`,
|
|
34390
34442
|
backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
34391
34443
|
inverse: isSelected,
|
|
34444
|
+
color,
|
|
34392
34445
|
}, truncateCells(` ${text}`, width - 4)));
|
|
34393
34446
|
}
|
|
34394
34447
|
if (window.truncatedBelow > 0) {
|
|
@@ -34498,7 +34551,11 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
34498
34551
|
? spin
|
|
34499
34552
|
: branchRowMarker(branch, { ascii: theme.ascii }).glyph;
|
|
34500
34553
|
return `${glyph} ${branch.shortName}`;
|
|
34501
|
-
}, 'tab-branches', visibleListCount
|
|
34554
|
+
}, 'tab-branches', visibleListCount,
|
|
34555
|
+
// Paint the checked-out branch green so "where am I?" reads at a
|
|
34556
|
+
// glance, matching the green HEAD marker the branches surface
|
|
34557
|
+
// already uses. NO_COLOR themes fall back to the `*` glyph alone.
|
|
34558
|
+
(branch) => (branch.current && !theme.noColor ? theme.colors.success : undefined)),
|
|
34502
34559
|
];
|
|
34503
34560
|
}
|
|
34504
34561
|
if (tab === 'tags') {
|
|
@@ -34999,11 +35056,16 @@ function renderBranchesSurface(ctx, spinnerFrame = 0) {
|
|
|
34999
35056
|
const truncated = truncateCells(fullText, Math.max(20, width - 4));
|
|
35000
35057
|
// If truncation chopped into the timestamp/divergence portion,
|
|
35001
35058
|
// fall back to a single Text to keep the visible width honest.
|
|
35059
|
+
// The checked-out branch is painted green (matching its green
|
|
35060
|
+
// HEAD marker) so "where am I?" reads at a glance. NO_COLOR
|
|
35061
|
+
// themes fall back to the `*` glyph alone.
|
|
35062
|
+
const currentColor = branch.current && !theme.noColor ? theme.colors.success : undefined;
|
|
35002
35063
|
if (truncated !== fullText) {
|
|
35003
35064
|
return h(Text, {
|
|
35004
35065
|
key: `branch-${index}`,
|
|
35005
35066
|
bold: isSelected,
|
|
35006
35067
|
dimColor: lineDim,
|
|
35068
|
+
color: currentColor,
|
|
35007
35069
|
}, truncated);
|
|
35008
35070
|
}
|
|
35009
35071
|
return h(Text, {
|
|
@@ -35018,7 +35080,12 @@ function renderBranchesSurface(ctx, spinnerFrame = 0) {
|
|
|
35018
35080
|
// no-upstream kinds return undefined from
|
|
35019
35081
|
// `getBranchRowMarkerColor`, so those markers inherit the
|
|
35020
35082
|
// row's dim and read as quiet chrome.
|
|
35021
|
-
h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph),
|
|
35083
|
+
h(Text, { color: glyphColor, dimColor: glyphColor ? false : undefined }, glyph),
|
|
35084
|
+
// Name span: green for the current branch (dimColor:false keeps
|
|
35085
|
+
// it bright), otherwise it inherits the row's normal styling.
|
|
35086
|
+
currentColor
|
|
35087
|
+
? h(Text, { color: currentColor, dimColor: false }, trailingName)
|
|
35088
|
+
: trailingName, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
|
|
35022
35089
|
});
|
|
35023
35090
|
// Scroll indicators — same "N more above/below" pattern as the
|
|
35024
35091
|
// sidebar and help overlay so the user knows the list continues.
|
|
@@ -37635,25 +37702,36 @@ function renderConfirmationPanel(h, components, state, width, theme, focused) {
|
|
|
37635
37702
|
: state.pendingMutationConfirmation === 'discard-draft'
|
|
37636
37703
|
? 'Quit and discard the in-progress commit draft'
|
|
37637
37704
|
: undefined;
|
|
37638
|
-
|
|
37705
|
+
// Worktree-conflict switch (#1175): a checkout was rejected because
|
|
37706
|
+
// the branch is checked out elsewhere — name the branch + worktree so
|
|
37707
|
+
// the prompt explains what "y" does (jump into that worktree).
|
|
37708
|
+
const conflict = state.worktreeCheckoutConflict;
|
|
37709
|
+
const isWorktreeConflict = state.pendingConfirmationId === 'switch-to-conflicting-worktree';
|
|
37710
|
+
const label = isWorktreeConflict && conflict
|
|
37711
|
+
? `Switch to the worktree where '${conflict.branch}' is checked out?`
|
|
37712
|
+
: action?.label || mutationLabel || 'Workflow action';
|
|
37639
37713
|
const warning = state.pendingMutationConfirmation === 'discard-draft'
|
|
37640
37714
|
? 'You have an unsaved commit draft. Press y to discard it and quit.'
|
|
37641
37715
|
: state.pendingMutationConfirmation
|
|
37642
37716
|
? 'This discards local changes and cannot be undone by Coco.'
|
|
37643
|
-
|
|
37644
|
-
|
|
37645
|
-
|
|
37646
|
-
|
|
37647
|
-
:
|
|
37648
|
-
?
|
|
37649
|
-
:
|
|
37717
|
+
: isWorktreeConflict && conflict
|
|
37718
|
+
? `'${conflict.branch}' is checked out at ${conflict.worktreePath}.${conflict.dirty ? ' That worktree has uncommitted changes — removal will be refused until it is clean or stashed.' : ''}`
|
|
37719
|
+
// Second-stage confirm raised when a safe delete hit an unmerged
|
|
37720
|
+
// branch — name the reason so the force isn't a blind "y again".
|
|
37721
|
+
: state.pendingConfirmationId === 'force-delete-branch'
|
|
37722
|
+
? 'Not fully merged. Force-delete (git branch -D) is irreversible.'
|
|
37723
|
+
: action?.kind === 'ai'
|
|
37724
|
+
? `AI action requires confirmation. Estimated ${action.estimatedTokens || '<unknown>'} tokens.`
|
|
37725
|
+
: 'Destructive Git action requires confirmation.';
|
|
37650
37726
|
return h(Box, {
|
|
37651
37727
|
borderColor: focusBorderColor(theme, focused),
|
|
37652
37728
|
borderStyle: theme.borderStyle,
|
|
37653
37729
|
flexDirection: 'column',
|
|
37654
37730
|
width,
|
|
37655
37731
|
paddingX: 1,
|
|
37656
|
-
}, h(Text, { bold: true }, panelTitle('Confirm', focused)), h(Text, undefined, truncateCells(label, width - 4)), h(Text, { dimColor: true }, truncateCells(warning, width - 4)), h(Text, undefined, ''), h(Text, undefined,
|
|
37732
|
+
}, h(Text, { bold: true }, panelTitle('Confirm', focused)), h(Text, undefined, truncateCells(label, width - 4)), h(Text, { dimColor: true }, truncateCells(warning, width - 4)), h(Text, undefined, ''), h(Text, undefined, truncateCells(isWorktreeConflict
|
|
37733
|
+
? 'y switch · r remove worktree & check out here · x remove worktree & delete branch · n/Esc cancel'
|
|
37734
|
+
: 'Press y to confirm or n/Esc to cancel.', width - 4)));
|
|
37657
37735
|
}
|
|
37658
37736
|
/**
|
|
37659
37737
|
* First-launch onboarding overlay (P1.3). Shown once per machine, gated
|
|
@@ -38817,6 +38895,15 @@ function renderReflogSurface(ctx) {
|
|
|
38817
38895
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
38818
38896
|
* of #890. No behavior change.
|
|
38819
38897
|
*/
|
|
38898
|
+
const GAP = 2; // cells between columns
|
|
38899
|
+
/** Truncate to `w` cells, then pad to `w` (left = padEnd, right = padStart). */
|
|
38900
|
+
function cell(value, w, align = 'left') {
|
|
38901
|
+
const t = truncateCells(value, w);
|
|
38902
|
+
// padStart/padEnd count code units; refs / ages / counts / branch
|
|
38903
|
+
// names are ASCII in practice, matching the branches surface's
|
|
38904
|
+
// padEnd-based column alignment.
|
|
38905
|
+
return align === 'right' ? t.padStart(w) : t.padEnd(w);
|
|
38906
|
+
}
|
|
38820
38907
|
function renderStashSurface(ctx, spinnerFrame = 0) {
|
|
38821
38908
|
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38822
38909
|
const { Box, Text } = components;
|
|
@@ -38827,7 +38914,9 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
|
|
|
38827
38914
|
? allStashes.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
|
|
38828
38915
|
: allStashes;
|
|
38829
38916
|
const selected = Math.max(0, Math.min(state.selectedStashIndex, Math.max(0, stashes.length - 1)));
|
|
38830
|
-
|
|
38917
|
+
// One extra row reserved (vs the other surfaces' `- 4`) for the column
|
|
38918
|
+
// header row below.
|
|
38919
|
+
const listRows = Math.max(4, bodyRows - 5);
|
|
38831
38920
|
const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
|
|
38832
38921
|
const visible = stashes.slice(startIndex, startIndex + listRows);
|
|
38833
38922
|
const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
|
|
@@ -38837,10 +38926,67 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
|
|
|
38837
38926
|
const emptyLabel = formatLogInkStashEmpty({ filter: state.filter });
|
|
38838
38927
|
const loadingLabel = formatLogInkLoading({ resource: 'stashes' });
|
|
38839
38928
|
const now = getRenderNow();
|
|
38840
|
-
//
|
|
38841
|
-
//
|
|
38842
|
-
//
|
|
38843
|
-
|
|
38929
|
+
// Usable interior width: panel width minus the border (2) and the
|
|
38930
|
+
// paddingX:1 (2). The previous `width - 2` under-counted the border
|
|
38931
|
+
// and made every near-full row overflow by 2 cells and wrap — the
|
|
38932
|
+
// single biggest readability hit in the old list.
|
|
38933
|
+
const contentWidth = Math.max(20, width - 4);
|
|
38934
|
+
const ageOf = (s) => formatCompactRelativeDate(s.date, now);
|
|
38935
|
+
// Column widths derived from the visible window (#833 pattern) so rows
|
|
38936
|
+
// align without re-measuring the whole list. Each column is at least
|
|
38937
|
+
// as wide as its header label so the header never truncates ("age",
|
|
38938
|
+
// "branch", "files"); age tops out at 5 ("today" is the longest value
|
|
38939
|
+
// formatCompactRelativeDate emits).
|
|
38940
|
+
const refCol = visible.length
|
|
38941
|
+
? Math.min(11, Math.max(3, ...visible.map((s) => cellWidth(s.ref))))
|
|
38942
|
+
: 9;
|
|
38943
|
+
const ageCol = visible.length
|
|
38944
|
+
? Math.min(5, Math.max(3, ...visible.map((s) => cellWidth(ageOf(s)))))
|
|
38945
|
+
: 3;
|
|
38946
|
+
const branchColMax = visible.length
|
|
38947
|
+
? Math.max(0, ...visible.map((s) => cellWidth(s.branch || '')))
|
|
38948
|
+
: 0;
|
|
38949
|
+
const filesCol = visible.length
|
|
38950
|
+
? Math.max(5, ...visible.map((s) => String(s.files.length).length))
|
|
38951
|
+
: 5;
|
|
38952
|
+
// Responsive degradation. Keep ref + message always; when the message
|
|
38953
|
+
// floor is threatened, shed columns the preview pane already shows —
|
|
38954
|
+
// branch first, then age, then the file count — before squeezing the
|
|
38955
|
+
// message.
|
|
38956
|
+
// Widen to fit the "branch" header when any stash carries a branch;
|
|
38957
|
+
// stays 0 (column dropped) when none do.
|
|
38958
|
+
let branchCol = branchColMax > 0 ? Math.min(18, Math.max(6, branchColMax)) : 0;
|
|
38959
|
+
let showAge = true;
|
|
38960
|
+
let showFiles = true;
|
|
38961
|
+
const fixedWidth = () => 2 + refCol +
|
|
38962
|
+
(showAge ? GAP + ageCol : 0) +
|
|
38963
|
+
(branchCol > 0 ? GAP + branchCol : 0) +
|
|
38964
|
+
(showFiles ? GAP + filesCol : 0) +
|
|
38965
|
+
GAP; // gap before the message column
|
|
38966
|
+
let messageWidth = contentWidth - fixedWidth();
|
|
38967
|
+
if (messageWidth < 24 && branchCol > 0) {
|
|
38968
|
+
branchCol = 0;
|
|
38969
|
+
messageWidth = contentWidth - fixedWidth();
|
|
38970
|
+
}
|
|
38971
|
+
if (messageWidth < 16 && showAge) {
|
|
38972
|
+
showAge = false;
|
|
38973
|
+
messageWidth = contentWidth - fixedWidth();
|
|
38974
|
+
}
|
|
38975
|
+
if (messageWidth < 12 && showFiles) {
|
|
38976
|
+
showFiles = false;
|
|
38977
|
+
messageWidth = contentWidth - fixedWidth();
|
|
38978
|
+
}
|
|
38979
|
+
messageWidth = Math.max(8, messageWidth);
|
|
38980
|
+
// Column header. Right-aligned labels over the right-aligned numeric
|
|
38981
|
+
// columns (age, files) so header and data share an edge.
|
|
38982
|
+
let headerText = ` ${cell('ref', refCol)}`;
|
|
38983
|
+
if (showAge)
|
|
38984
|
+
headerText += `${' '.repeat(GAP)}${cell('age', ageCol, 'right')}`;
|
|
38985
|
+
if (branchCol > 0)
|
|
38986
|
+
headerText += `${' '.repeat(GAP)}${cell('branch', branchCol)}`;
|
|
38987
|
+
if (showFiles)
|
|
38988
|
+
headerText += `${' '.repeat(GAP)}${cell('files', filesCol, 'right')}`;
|
|
38989
|
+
headerText += `${' '.repeat(GAP)}message`;
|
|
38844
38990
|
const lines = loading
|
|
38845
38991
|
? [h(Text, { key: 'stash-loading', dimColor: true }, loadingLabel)]
|
|
38846
38992
|
: stashes.length === 0
|
|
@@ -38849,32 +38995,22 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
|
|
|
38849
38995
|
const index = startIndex + offset;
|
|
38850
38996
|
const isSelected = index === selected;
|
|
38851
38997
|
const cursor = isSelected ? '>' : ' ';
|
|
38852
|
-
|
|
38853
|
-
// branch, file count, and relative age — between the ref and the
|
|
38854
|
-
// message, so the list answers "which stash is this?" without an
|
|
38855
|
-
// Enter→diff round trip.
|
|
38856
|
-
const age = formatCompactRelativeDate(stash.date, now);
|
|
38857
|
-
const fileCount = stash.files.length;
|
|
38858
|
-
const meta = [
|
|
38859
|
-
stash.branch ? `on ${stash.branch}` : '',
|
|
38860
|
-
fileCount > 0 ? `${fileCount} file${fileCount === 1 ? '' : 's'}` : '',
|
|
38861
|
-
age,
|
|
38862
|
-
].filter(Boolean).join(' · ');
|
|
38863
|
-
const rowText = meta
|
|
38864
|
-
? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
|
|
38865
|
-
: `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
|
|
38998
|
+
const deleting = isPendingItemAction(state.pendingItemAction, 'stash', stash.ref);
|
|
38866
38999
|
// The `stash@{N}` ref is an identifier, not a status icon, so a
|
|
38867
39000
|
// delete-in-flight appends an accent spinner at the row's end
|
|
38868
|
-
// (2 cells reserved from the
|
|
38869
|
-
const deleting = isPendingItemAction(state.pendingItemAction, 'stash', stash.ref);
|
|
39001
|
+
// (2 cells reserved from the message budget).
|
|
38870
39002
|
const spinnerSpan = deleting
|
|
38871
39003
|
? h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: false }, ` ${inlineSpinnerGlyph(spinnerFrame, theme.ascii)}`)
|
|
38872
39004
|
: null;
|
|
39005
|
+
const message = truncateCells(stash.message, messageWidth - (deleting ? 2 : 0));
|
|
39006
|
+
// ref + message read as primary; age / branch / file-count are
|
|
39007
|
+
// dim metadata (kept dim even on the bold selected row so the
|
|
39008
|
+
// message stays the focal point).
|
|
38873
39009
|
return h(Text, {
|
|
38874
39010
|
key: `stash-${index}`,
|
|
38875
39011
|
bold: isSelected,
|
|
38876
39012
|
dimColor: !isSelected,
|
|
38877
|
-
},
|
|
39013
|
+
}, `${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);
|
|
38878
39014
|
});
|
|
38879
39015
|
const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
|
|
38880
39016
|
const stashHasMoreBelow = startIndex + listRows < stashes.length;
|
|
@@ -38885,7 +39021,11 @@ function renderStashSurface(ctx, spinnerFrame = 0) {
|
|
|
38885
39021
|
flexShrink: 0,
|
|
38886
39022
|
paddingX: 1,
|
|
38887
39023
|
width,
|
|
38888
|
-
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme),
|
|
39024
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme),
|
|
39025
|
+
// Column header — only when there are rows to label.
|
|
39026
|
+
...(!loading && stashes.length > 0
|
|
39027
|
+
? [h(Text, { key: 'stash-col-header', dimColor: true }, truncateCells(headerText, contentWidth))]
|
|
39028
|
+
: []), ...(stashHasMoreAbove
|
|
38889
39029
|
? [h(Text, { key: 'stash-more-above', dimColor: true }, ` ↑ ${startIndex} more above`)]
|
|
38890
39030
|
: []), ...lines, ...(stashHasMoreBelow
|
|
38891
39031
|
? [h(Text, { key: 'stash-more-below', dimColor: true }, ` ↓ ${stashes.length - (startIndex + listRows)} more below`)]
|
|
@@ -43705,6 +43845,42 @@ function LogInkApp(deps) {
|
|
|
43705
43845
|
// path on the wrong target.
|
|
43706
43846
|
return removeWorktreeAndBranch(git, cursorTarget, context.branches?.localBranches || []);
|
|
43707
43847
|
},
|
|
43848
|
+
// Worktree-checkout-conflict resolutions (#1175). Unlike the
|
|
43849
|
+
// cursor-targeted handlers above, these act on the worktree
|
|
43850
|
+
// captured in `state.worktreeCheckoutConflict` (the one git named
|
|
43851
|
+
// when it refused the checkout), not the worktrees-view cursor.
|
|
43852
|
+
'conflict-remove-worktree-checkout': async () => {
|
|
43853
|
+
const conflict = state.worktreeCheckoutConflict;
|
|
43854
|
+
dispatch({ type: 'setWorktreeCheckoutConflict', value: undefined });
|
|
43855
|
+
if (!conflict)
|
|
43856
|
+
return { ok: false, message: 'No worktree conflict to resolve.' };
|
|
43857
|
+
const worktree = context.worktreeList?.worktrees?.find((w) => w.path === conflict.worktreePath);
|
|
43858
|
+
if (!worktree)
|
|
43859
|
+
return { ok: false, message: `Worktree ${conflict.worktreePath} not found.` };
|
|
43860
|
+
// removeWorktree refuses a dirty / current worktree and returns
|
|
43861
|
+
// a clear message — surface it rather than forcing.
|
|
43862
|
+
const removed = await removeWorktree(git, worktree);
|
|
43863
|
+
if (!removed.ok)
|
|
43864
|
+
return removed;
|
|
43865
|
+
const branch = (context.branches?.localBranches || []).find((b) => b.type === 'local' && b.shortName === conflict.branch);
|
|
43866
|
+
if (!branch) {
|
|
43867
|
+
return { ok: true, message: `Removed worktree ${worktree.path}; branch ${conflict.branch} not found to check out.` };
|
|
43868
|
+
}
|
|
43869
|
+
const checkout = await checkoutBranch(git, branch);
|
|
43870
|
+
return checkout.ok
|
|
43871
|
+
? { ok: true, message: `Removed worktree ${worktree.path} and checked out ${conflict.branch}` }
|
|
43872
|
+
: { ok: false, message: `Removed worktree ${worktree.path}, but checkout failed: ${checkout.message}` };
|
|
43873
|
+
},
|
|
43874
|
+
'conflict-remove-worktree-branch': async () => {
|
|
43875
|
+
const conflict = state.worktreeCheckoutConflict;
|
|
43876
|
+
dispatch({ type: 'setWorktreeCheckoutConflict', value: undefined });
|
|
43877
|
+
if (!conflict)
|
|
43878
|
+
return { ok: false, message: 'No worktree conflict to resolve.' };
|
|
43879
|
+
const worktree = context.worktreeList?.worktrees?.find((w) => w.path === conflict.worktreePath);
|
|
43880
|
+
if (!worktree)
|
|
43881
|
+
return { ok: false, message: `Worktree ${conflict.worktreePath} not found.` };
|
|
43882
|
+
return removeWorktreeAndBranch(git, worktree, context.branches?.localBranches || []);
|
|
43883
|
+
},
|
|
43708
43884
|
'abort-operation': async () => {
|
|
43709
43885
|
const operation = context.operation?.operation;
|
|
43710
43886
|
if (!operation) {
|
|
@@ -44167,6 +44343,30 @@ function LogInkApp(deps) {
|
|
|
44167
44343
|
kind: 'warning',
|
|
44168
44344
|
});
|
|
44169
44345
|
}
|
|
44346
|
+
// Checking out a branch that's already checked out in another
|
|
44347
|
+
// worktree is rejected by git ("already checked out at <path>").
|
|
44348
|
+
// Rather than dead-end on that, capture the conflict and raise a
|
|
44349
|
+
// y-confirm offering to switch into that worktree — the branch IS
|
|
44350
|
+
// checked out, just elsewhere (#1175).
|
|
44351
|
+
if (id === 'checkout-branch' && !result?.ok && isBranchCheckedOutElsewhereError(result?.message)) {
|
|
44352
|
+
const worktreePath = parseCheckedOutWorktreePath(result?.message);
|
|
44353
|
+
const branchName = pendingItemAction?.id;
|
|
44354
|
+
if (worktreePath && branchName) {
|
|
44355
|
+
const worktree = context.worktreeList?.worktrees?.find((w) => w.path === worktreePath);
|
|
44356
|
+
dispatch({
|
|
44357
|
+
type: 'setWorktreeCheckoutConflict',
|
|
44358
|
+
value: { branch: branchName, worktreePath, dirty: worktree?.dirty ?? false },
|
|
44359
|
+
});
|
|
44360
|
+
dispatch({ type: 'setPendingConfirmation', value: 'switch-to-conflicting-worktree' });
|
|
44361
|
+
}
|
|
44362
|
+
else {
|
|
44363
|
+
dispatch({
|
|
44364
|
+
type: 'setStatus',
|
|
44365
|
+
value: `'${branchName ?? 'branch'}' is already checked out in another worktree.`,
|
|
44366
|
+
kind: 'warning',
|
|
44367
|
+
});
|
|
44368
|
+
}
|
|
44369
|
+
}
|
|
44170
44370
|
// Refresh history rows AS WELL when the workflow could have
|
|
44171
44371
|
// changed the commits the user sees (#945 follow-up). The
|
|
44172
44372
|
// workflow IDs below all either create/rewrite local commits or
|
|
@@ -44176,6 +44376,10 @@ function LogInkApp(deps) {
|
|
|
44176
44376
|
// metadata-only mutations (delete-tag, set-upstream, etc.).
|
|
44177
44377
|
const historyMutatingIds = new Set([
|
|
44178
44378
|
'checkout-branch',
|
|
44379
|
+
// Resolving a checkout conflict changes HEAD (checkout) and/or the
|
|
44380
|
+
// ref set (branch delete), so the graph needs a refresh.
|
|
44381
|
+
'conflict-remove-worktree-checkout',
|
|
44382
|
+
'conflict-remove-worktree-branch',
|
|
44179
44383
|
'continue-operation',
|
|
44180
44384
|
'pull-current-branch',
|
|
44181
44385
|
// Fetch / pull / push bring in new commits and move
|
|
@@ -44211,7 +44415,7 @@ function LogInkApp(deps) {
|
|
|
44211
44415
|
// (resolvePendingItemAction → action 'checkout'), so a silent
|
|
44212
44416
|
// stale-while-revalidate swap keeps the list readable and just
|
|
44213
44417
|
// repaints the current-branch marker once the new context lands.
|
|
44214
|
-
if (id === 'checkout-branch' && result?.ok) {
|
|
44418
|
+
if ((id === 'checkout-branch' || id === 'conflict-remove-worktree-checkout') && result?.ok) {
|
|
44215
44419
|
dispatch({ type: 'resetBranchSelection' });
|
|
44216
44420
|
await refreshContext({ silent: true });
|
|
44217
44421
|
}
|
|
@@ -44278,7 +44482,7 @@ function LogInkApp(deps) {
|
|
|
44278
44482
|
}, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
|
|
44279
44483
|
state.branchSort, state.filter, state.selectedBranchIndex,
|
|
44280
44484
|
state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
|
|
44281
|
-
state.statusFilterMask, state.tagSort]);
|
|
44485
|
+
state.statusFilterMask, state.tagSort, state.worktreeCheckoutConflict]);
|
|
44282
44486
|
// Resolve the active view's "yank target" (commit hash / branch /
|
|
44283
44487
|
// tag / stash ref / file path) against the live filtered+sorted list,
|
|
44284
44488
|
// copy it to the system clipboard, and surface the result on the
|