git-coco 0.39.0 → 0.40.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.esm.mjs +701 -173
- package/dist/index.js +701 -173
- package/package.json +3 -3
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.
|
|
81
|
+
const BUILD_VERSION = "0.40.1";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -2152,11 +2152,21 @@ function formatAuthenticationError(error, logger) {
|
|
|
2152
2152
|
logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
|
|
2153
2153
|
}
|
|
2154
2154
|
/**
|
|
2155
|
-
* Formats a generic error
|
|
2155
|
+
* Formats a generic error.
|
|
2156
|
+
*
|
|
2157
|
+
* The error message prints unconditionally (was previously gated behind
|
|
2158
|
+
* `--verbose`, which left users staring at a "Failed to execute command"
|
|
2159
|
+
* line with no actionable detail when something crashed). The full stack
|
|
2160
|
+
* trace stays under `logger.verbose` so plain output stays focused on the
|
|
2161
|
+
* one-line cause; users running into something they can't diagnose can opt
|
|
2162
|
+
* in with `--verbose` for the trace.
|
|
2156
2163
|
*/
|
|
2157
2164
|
function formatGenericError(error, logger) {
|
|
2158
2165
|
logger.log('\nFailed to execute command', { color: 'yellow' });
|
|
2159
|
-
logger.
|
|
2166
|
+
logger.log(`\nError: ${error.message}`, { color: 'red' });
|
|
2167
|
+
if (error.stack) {
|
|
2168
|
+
logger.verbose(`\n${error.stack}`, { color: 'gray' });
|
|
2169
|
+
}
|
|
2160
2170
|
}
|
|
2161
2171
|
function commandExecutor(handler) {
|
|
2162
2172
|
return async (argv) => {
|
|
@@ -14905,6 +14915,35 @@ function getLogInkWorkflowActions() {
|
|
|
14905
14915
|
kind: 'normal',
|
|
14906
14916
|
requiresConfirmation: false,
|
|
14907
14917
|
},
|
|
14918
|
+
// Status surface group-level batch ops (#791 follow-up). Triggered
|
|
14919
|
+
// by Enter when the cursor is on a status group header
|
|
14920
|
+
// (Staged / Unstaged / Untracked). Empty `key` keeps them
|
|
14921
|
+
// palette-discoverable without registering a global hotkey — the
|
|
14922
|
+
// Enter-on-header path in inkInput is the canonical trigger.
|
|
14923
|
+
{
|
|
14924
|
+
id: 'unstage-all-staged',
|
|
14925
|
+
key: '',
|
|
14926
|
+
label: 'Unstage all staged files',
|
|
14927
|
+
description: 'Unstage every file currently in the staged group.',
|
|
14928
|
+
kind: 'normal',
|
|
14929
|
+
requiresConfirmation: false,
|
|
14930
|
+
},
|
|
14931
|
+
{
|
|
14932
|
+
id: 'stage-all-unstaged',
|
|
14933
|
+
key: '',
|
|
14934
|
+
label: 'Stage all unstaged files',
|
|
14935
|
+
description: 'Stage every modified-but-not-staged file.',
|
|
14936
|
+
kind: 'normal',
|
|
14937
|
+
requiresConfirmation: false,
|
|
14938
|
+
},
|
|
14939
|
+
{
|
|
14940
|
+
id: 'stage-all-untracked',
|
|
14941
|
+
key: '',
|
|
14942
|
+
label: 'Stage all untracked files',
|
|
14943
|
+
description: 'Add every untracked file to the index after confirmation.',
|
|
14944
|
+
kind: 'destructive',
|
|
14945
|
+
requiresConfirmation: true,
|
|
14946
|
+
},
|
|
14908
14947
|
{
|
|
14909
14948
|
id: 'delete-branch',
|
|
14910
14949
|
key: 'D',
|
|
@@ -15922,6 +15961,88 @@ function extractDiffHunk(input) {
|
|
|
15922
15961
|
return { patchText };
|
|
15923
15962
|
}
|
|
15924
15963
|
|
|
15964
|
+
/**
|
|
15965
|
+
* Hardcoded per-entity action lists surfaced inside the right-hand
|
|
15966
|
+
* inspector panel. The inspector used to repeat the repo / branch /
|
|
15967
|
+
* status content the top header and left sidebar already show; we drop
|
|
15968
|
+
* that trailer in favor of an actionable cheat-sheet so the user knows
|
|
15969
|
+
* exactly which keystrokes apply to whatever they have under the cursor.
|
|
15970
|
+
*
|
|
15971
|
+
* Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
|
|
15972
|
+
* - Most per-entity actions live in `inkInput.ts` as direct keystroke
|
|
15973
|
+
* handlers (e.g. `c` cherry-pick, `R` revert) rather than as
|
|
15974
|
+
* globally-registered bindings, so the registry would be a partial
|
|
15975
|
+
* view at best.
|
|
15976
|
+
* - The bindings registry's `contexts` model (normal / search / focus
|
|
15977
|
+
* name) does not cleanly map to inspector entity types like "branch"
|
|
15978
|
+
* or "tag". Filtering it would mean replicating the same per-view
|
|
15979
|
+
* scoping logic the input dispatcher already encodes.
|
|
15980
|
+
* - New per-entity actions are added infrequently — the maintenance
|
|
15981
|
+
* cost of mirroring them here is low and keeps this file the single
|
|
15982
|
+
* source of truth for "what shows in the inspector".
|
|
15983
|
+
*
|
|
15984
|
+
* If you wire up a new per-entity keystroke in `inkInput.ts` — for
|
|
15985
|
+
* example a "create branch from this commit" or "create tag from this
|
|
15986
|
+
* commit" action — add the matching row to the relevant array below so
|
|
15987
|
+
* it shows up in the inspector automatically.
|
|
15988
|
+
*/
|
|
15989
|
+
const HISTORY_COMMIT_ACTIONS = [
|
|
15990
|
+
{ key: 'enter', label: 'Open diff' },
|
|
15991
|
+
{ key: 'c', label: 'Cherry-pick' },
|
|
15992
|
+
{ key: 'R', label: 'Revert', destructive: true },
|
|
15993
|
+
{ key: 'Z', label: 'Reset to commit', destructive: true },
|
|
15994
|
+
{ key: 'i', label: 'Interactive rebase', destructive: true },
|
|
15995
|
+
{ key: 'y', label: 'Yank hash' },
|
|
15996
|
+
{ key: 'Y', label: 'Yank short hash' },
|
|
15997
|
+
{ key: 'O', label: 'Open in browser' },
|
|
15998
|
+
];
|
|
15999
|
+
const BRANCH_ACTIONS = [
|
|
16000
|
+
{ key: 'enter', label: 'Checkout' },
|
|
16001
|
+
{ key: '+', label: 'New branch' },
|
|
16002
|
+
{ key: 'R', label: 'Rename' },
|
|
16003
|
+
{ key: 'u', label: 'Set upstream' },
|
|
16004
|
+
{ key: 'D', label: 'Delete', destructive: true },
|
|
16005
|
+
{ key: 'P', label: 'Push current' },
|
|
16006
|
+
{ key: 'F', label: 'Fetch all' },
|
|
16007
|
+
{ key: 'y', label: 'Yank name' },
|
|
16008
|
+
];
|
|
16009
|
+
const TAG_ACTIONS = [
|
|
16010
|
+
{ key: '+', label: 'New tag' },
|
|
16011
|
+
{ key: 'P', label: 'Push tag' },
|
|
16012
|
+
{ key: 'T', label: 'Delete', destructive: true },
|
|
16013
|
+
{ key: 'R', label: 'Delete remote', destructive: true },
|
|
16014
|
+
{ key: 'y', label: 'Yank name' },
|
|
16015
|
+
];
|
|
16016
|
+
const STASH_ACTIONS = [
|
|
16017
|
+
{ key: 'enter', label: 'Open diff' },
|
|
16018
|
+
{ key: 'a', label: 'Apply' },
|
|
16019
|
+
{ key: 'p', label: 'Pop' },
|
|
16020
|
+
{ key: 'X', label: 'Drop', destructive: true },
|
|
16021
|
+
{ key: 'y', label: 'Yank ref' },
|
|
16022
|
+
];
|
|
16023
|
+
const WORKTREE_ACTIONS = [
|
|
16024
|
+
{ key: 'W', label: 'Remove', destructive: true },
|
|
16025
|
+
{ key: 'y', label: 'Yank path' },
|
|
16026
|
+
];
|
|
16027
|
+
function getInspectorActions(context) {
|
|
16028
|
+
switch (context) {
|
|
16029
|
+
case 'history-commit':
|
|
16030
|
+
return HISTORY_COMMIT_ACTIONS;
|
|
16031
|
+
case 'branch':
|
|
16032
|
+
return BRANCH_ACTIONS;
|
|
16033
|
+
case 'tag':
|
|
16034
|
+
return TAG_ACTIONS;
|
|
16035
|
+
case 'stash':
|
|
16036
|
+
return STASH_ACTIONS;
|
|
16037
|
+
case 'worktree':
|
|
16038
|
+
return WORKTREE_ACTIONS;
|
|
16039
|
+
default: {
|
|
16040
|
+
const exhaustive = context;
|
|
16041
|
+
throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
|
|
16042
|
+
}
|
|
16043
|
+
}
|
|
16044
|
+
}
|
|
16045
|
+
|
|
15925
16046
|
/**
|
|
15926
16047
|
* Sort modes for the promoted views (P4.2).
|
|
15927
16048
|
*
|
|
@@ -16128,6 +16249,7 @@ function withPushedView(state, value) {
|
|
|
16128
16249
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
16129
16250
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
16130
16251
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
16252
|
+
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
16131
16253
|
pendingKey: undefined,
|
|
16132
16254
|
};
|
|
16133
16255
|
}
|
|
@@ -16150,6 +16272,7 @@ function withPoppedView(state) {
|
|
|
16150
16272
|
diffSource: next === 'diff' ? state.diffSource : undefined,
|
|
16151
16273
|
stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
|
|
16152
16274
|
pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
|
|
16275
|
+
statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
|
|
16153
16276
|
pendingKey: undefined,
|
|
16154
16277
|
};
|
|
16155
16278
|
}
|
|
@@ -16167,6 +16290,7 @@ function withReplacedView(state, value) {
|
|
|
16167
16290
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
16168
16291
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
16169
16292
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
16293
|
+
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
16170
16294
|
pendingKey: undefined,
|
|
16171
16295
|
};
|
|
16172
16296
|
}
|
|
@@ -16298,9 +16422,12 @@ function createLogInkState(rows, options = {}) {
|
|
|
16298
16422
|
focus: 'commits',
|
|
16299
16423
|
sidebarTab: 'status',
|
|
16300
16424
|
userSidebarTab: 'status',
|
|
16425
|
+
sidebarHeaderFocused: false,
|
|
16426
|
+
statusGroupHeaderFocused: false,
|
|
16301
16427
|
statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
|
|
16302
16428
|
diffViewMode: 'unified',
|
|
16303
16429
|
inspectorTab: 'inspector',
|
|
16430
|
+
inspectorActionIndex: 0,
|
|
16304
16431
|
};
|
|
16305
16432
|
}
|
|
16306
16433
|
function getSelectedInkCommit(state) {
|
|
@@ -16341,12 +16468,21 @@ function applyLogInkAction(state, action) {
|
|
|
16341
16468
|
return {
|
|
16342
16469
|
...state,
|
|
16343
16470
|
focus: cycleValue(FOCUS_ORDER, state.focus, 1),
|
|
16471
|
+
// Reset header focus when leaving the sidebar so the next
|
|
16472
|
+
// re-entry starts on items rather than mid-flag.
|
|
16473
|
+
sidebarHeaderFocused: false,
|
|
16474
|
+
// Same idea for the status group header — Tab cycling away
|
|
16475
|
+
// from 'commits' should always land back on a real file when
|
|
16476
|
+
// the user returns.
|
|
16477
|
+
statusGroupHeaderFocused: false,
|
|
16344
16478
|
pendingKey: undefined,
|
|
16345
16479
|
};
|
|
16346
16480
|
case 'focusPrevious':
|
|
16347
16481
|
return {
|
|
16348
16482
|
...state,
|
|
16349
16483
|
focus: cycleValue(FOCUS_ORDER, state.focus, -1),
|
|
16484
|
+
sidebarHeaderFocused: false,
|
|
16485
|
+
statusGroupHeaderFocused: false,
|
|
16350
16486
|
pendingKey: undefined,
|
|
16351
16487
|
};
|
|
16352
16488
|
case 'move':
|
|
@@ -16411,6 +16547,9 @@ function applyLogInkAction(state, action) {
|
|
|
16411
16547
|
selectedWorktreeFileIndex: clampIndex$1(state.selectedWorktreeFileIndex + action.delta, action.fileCount),
|
|
16412
16548
|
selectedWorktreeHunkIndex: 0,
|
|
16413
16549
|
worktreeDiffOffset: 0,
|
|
16550
|
+
// Cursor moved to a real file row — drop header focus so the
|
|
16551
|
+
// file Enter handler (open diff) is what fires next.
|
|
16552
|
+
statusGroupHeaderFocused: false,
|
|
16414
16553
|
};
|
|
16415
16554
|
}
|
|
16416
16555
|
case 'moveBranch':
|
|
@@ -16429,10 +16568,40 @@ function applyLogInkAction(state, action) {
|
|
|
16429
16568
|
selectedBranchIndex: 0,
|
|
16430
16569
|
pendingKey: undefined,
|
|
16431
16570
|
};
|
|
16571
|
+
case 'setSidebarHeaderFocused':
|
|
16572
|
+
return {
|
|
16573
|
+
...state,
|
|
16574
|
+
sidebarHeaderFocused: action.value,
|
|
16575
|
+
pendingKey: undefined,
|
|
16576
|
+
};
|
|
16577
|
+
case 'setStatusGroupHeaderFocused':
|
|
16578
|
+
return {
|
|
16579
|
+
...state,
|
|
16580
|
+
statusGroupHeaderFocused: action.value,
|
|
16581
|
+
pendingKey: undefined,
|
|
16582
|
+
};
|
|
16583
|
+
case 'jumpToStatusGroup':
|
|
16584
|
+
// Used by ←/→ on the status surface to land on the first file of
|
|
16585
|
+
// the previous / next non-empty group. Clears header focus so the
|
|
16586
|
+
// user is on a real file after the jump (matches the
|
|
16587
|
+
// sidebar pattern where ←/→ between tabs lands on items, not on
|
|
16588
|
+
// the next tab's header).
|
|
16589
|
+
return {
|
|
16590
|
+
...state,
|
|
16591
|
+
selectedWorktreeFileIndex: Math.max(0, action.targetIndex),
|
|
16592
|
+
selectedWorktreeHunkIndex: 0,
|
|
16593
|
+
worktreeDiffOffset: 0,
|
|
16594
|
+
statusGroupHeaderFocused: false,
|
|
16595
|
+
pendingKey: undefined,
|
|
16596
|
+
};
|
|
16432
16597
|
case 'setInspectorTab':
|
|
16433
16598
|
return {
|
|
16434
16599
|
...state,
|
|
16435
16600
|
inspectorTab: action.value,
|
|
16601
|
+
// Reset the action cursor so a fresh tab visit always starts
|
|
16602
|
+
// on the first action, regardless of where the user left off
|
|
16603
|
+
// in a previous entity context.
|
|
16604
|
+
inspectorActionIndex: 0,
|
|
16436
16605
|
pendingKey: undefined,
|
|
16437
16606
|
};
|
|
16438
16607
|
case 'cycleInspectorTab': {
|
|
@@ -16444,9 +16613,22 @@ function applyLogInkAction(state, action) {
|
|
|
16444
16613
|
return {
|
|
16445
16614
|
...state,
|
|
16446
16615
|
inspectorTab: next,
|
|
16616
|
+
inspectorActionIndex: 0,
|
|
16447
16617
|
pendingKey: undefined,
|
|
16448
16618
|
};
|
|
16449
16619
|
}
|
|
16620
|
+
case 'moveInspectorAction':
|
|
16621
|
+
return {
|
|
16622
|
+
...state,
|
|
16623
|
+
inspectorActionIndex: clampIndex$1(state.inspectorActionIndex + action.delta, action.actionCount),
|
|
16624
|
+
pendingKey: undefined,
|
|
16625
|
+
};
|
|
16626
|
+
case 'resetInspectorActionIndex':
|
|
16627
|
+
return {
|
|
16628
|
+
...state,
|
|
16629
|
+
inspectorActionIndex: 0,
|
|
16630
|
+
pendingKey: undefined,
|
|
16631
|
+
};
|
|
16450
16632
|
case 'moveTag':
|
|
16451
16633
|
return {
|
|
16452
16634
|
...state,
|
|
@@ -16516,6 +16698,10 @@ function applyLogInkAction(state, action) {
|
|
|
16516
16698
|
...state,
|
|
16517
16699
|
statusFilterMask: allOff ? { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK } : next,
|
|
16518
16700
|
selectedWorktreeFileIndex: 0,
|
|
16701
|
+
// Group composition changed — header focus would be ambiguous
|
|
16702
|
+
// (cursor lands on file 0 which may belong to a different
|
|
16703
|
+
// group now). Reset to clear the indicator.
|
|
16704
|
+
statusGroupHeaderFocused: false,
|
|
16519
16705
|
pendingKey: undefined,
|
|
16520
16706
|
};
|
|
16521
16707
|
}
|
|
@@ -16686,6 +16872,13 @@ function applyLogInkAction(state, action) {
|
|
|
16686
16872
|
return {
|
|
16687
16873
|
...state,
|
|
16688
16874
|
focus: action.value,
|
|
16875
|
+
// Reset sidebar header focus when leaving the sidebar so a
|
|
16876
|
+
// re-entry starts on items rather than mid-flag.
|
|
16877
|
+
sidebarHeaderFocused: action.value === 'sidebar' ? state.sidebarHeaderFocused : false,
|
|
16878
|
+
// The status group header lives in the 'commits' focus on
|
|
16879
|
+
// the status view — clear when focus moves away so a
|
|
16880
|
+
// re-entry starts on a real file.
|
|
16881
|
+
statusGroupHeaderFocused: action.value === 'commits' ? state.statusGroupHeaderFocused : false,
|
|
16689
16882
|
pendingKey: undefined,
|
|
16690
16883
|
};
|
|
16691
16884
|
case 'setPendingKey':
|
|
@@ -16887,6 +17080,82 @@ function action(actionValue) {
|
|
|
16887
17080
|
action: actionValue,
|
|
16888
17081
|
};
|
|
16889
17082
|
}
|
|
17083
|
+
/**
|
|
17084
|
+
* Resolve which inspector action context applies for the current
|
|
17085
|
+
* state. Today only history commits expose actions in the inspector
|
|
17086
|
+
* (the renderer hard-coded `'history-commit'`); future PRs can fan
|
|
17087
|
+
* this out to branch / tag / stash / worktree contexts as the
|
|
17088
|
+
* inspector gains entity-aware sections. Returns `undefined` when no
|
|
17089
|
+
* actions section should be shown (so the cursor model stays a
|
|
17090
|
+
* no-op).
|
|
17091
|
+
*/
|
|
17092
|
+
function resolveInspectorActionContext(state) {
|
|
17093
|
+
if (state.activeView === 'history' && !state.pendingCommitFocused) {
|
|
17094
|
+
return 'history-commit';
|
|
17095
|
+
}
|
|
17096
|
+
return undefined;
|
|
17097
|
+
}
|
|
17098
|
+
function getInspectorActionsForState(state) {
|
|
17099
|
+
const ctx = resolveInspectorActionContext(state);
|
|
17100
|
+
return ctx ? getInspectorActions(ctx) : [];
|
|
17101
|
+
}
|
|
17102
|
+
/**
|
|
17103
|
+
* Synthesize the events that fire when the user presses Enter on a
|
|
17104
|
+
* cursored inspector action (#791 follow-up). Mirrors
|
|
17105
|
+
* `getLogInkPaletteExecuteEvents` — each action's `key` field
|
|
17106
|
+
* routes to the same dispatch the corresponding keystroke would
|
|
17107
|
+
* trigger from the history view's commit cursor. Per-key dispatch
|
|
17108
|
+
* (rather than recursively re-running the keystroke through
|
|
17109
|
+
* `getLogInkInputEvents`) avoids the gating problem: most history
|
|
17110
|
+
* keystroke handlers require `state.focus === 'commits'`, but the
|
|
17111
|
+
* inspector executor fires from `state.focus === 'detail'`.
|
|
17112
|
+
*/
|
|
17113
|
+
function getInspectorActionExecuteEvents(inspectorAction, state) {
|
|
17114
|
+
const commit = state.filteredCommits[state.selectedIndex];
|
|
17115
|
+
const requireCommit = (fn) => {
|
|
17116
|
+
if (!commit) {
|
|
17117
|
+
return [action({ type: 'setStatus', value: 'No commit selected' })];
|
|
17118
|
+
}
|
|
17119
|
+
return fn(commit.hash, state.selectedIndex);
|
|
17120
|
+
};
|
|
17121
|
+
switch (inspectorAction.key) {
|
|
17122
|
+
case 'enter':
|
|
17123
|
+
return requireCommit((sha, commitIndex) => [
|
|
17124
|
+
action({ type: 'navigateOpenDiffForCommit', sha, commitIndex }),
|
|
17125
|
+
]);
|
|
17126
|
+
case 'c':
|
|
17127
|
+
return requireCommit(() => [
|
|
17128
|
+
action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' }),
|
|
17129
|
+
]);
|
|
17130
|
+
case 'R':
|
|
17131
|
+
return requireCommit(() => [
|
|
17132
|
+
action({ type: 'setPendingConfirmation', value: 'revert-commit' }),
|
|
17133
|
+
]);
|
|
17134
|
+
case 'Z':
|
|
17135
|
+
return requireCommit(() => [
|
|
17136
|
+
action({
|
|
17137
|
+
type: 'openInputPrompt',
|
|
17138
|
+
kind: 'reset-mode',
|
|
17139
|
+
label: 'Reset mode (soft / mixed / hard)',
|
|
17140
|
+
}),
|
|
17141
|
+
]);
|
|
17142
|
+
case 'i':
|
|
17143
|
+
return requireCommit(() => [
|
|
17144
|
+
action({ type: 'setPendingConfirmation', value: 'interactive-rebase' }),
|
|
17145
|
+
]);
|
|
17146
|
+
case 'y':
|
|
17147
|
+
return requireCommit(() => [{ type: 'yankFromActiveView' }]);
|
|
17148
|
+
case 'Y':
|
|
17149
|
+
return requireCommit(() => [{ type: 'yankFromActiveView', short: true }]);
|
|
17150
|
+
case 'O':
|
|
17151
|
+
return [{ type: 'runWorkflowAction', id: 'open-pr' }];
|
|
17152
|
+
default:
|
|
17153
|
+
return [action({
|
|
17154
|
+
type: 'setStatus',
|
|
17155
|
+
value: `Action ${inspectorAction.key} not yet wired`,
|
|
17156
|
+
})];
|
|
17157
|
+
}
|
|
17158
|
+
}
|
|
16890
17159
|
/**
|
|
16891
17160
|
* Build the events needed to apply the hunk under the diff cursor. The
|
|
16892
17161
|
* runtime workflow handler expects payload format `<target>\n<patch>`
|
|
@@ -17698,11 +17967,74 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17698
17967
|
if (key.rightArrow && state.focus === 'sidebar') {
|
|
17699
17968
|
return [action({ type: 'nextSidebarTab' })];
|
|
17700
17969
|
}
|
|
17970
|
+
// ←/→ on the inspector switch between the [Inspector] / [Actions]
|
|
17971
|
+
// tabs, mirroring the sidebar's left/right tab semantics. `[` and
|
|
17972
|
+
// `]` still work as keyboard alternatives, but the visible hint in
|
|
17973
|
+
// the inspector chrome shows ←/→ because the bracketed `[/]`
|
|
17974
|
+
// notation reads as "press the / key" — which is the global filter
|
|
17975
|
+
// trigger and was making users think the binding was busted.
|
|
17976
|
+
if (key.leftArrow && state.focus === 'detail') {
|
|
17977
|
+
return [action({ type: 'setInspectorTab', value: 'inspector' })];
|
|
17978
|
+
}
|
|
17979
|
+
if (key.rightArrow && state.focus === 'detail') {
|
|
17980
|
+
return [action({ type: 'setInspectorTab', value: 'actions' })];
|
|
17981
|
+
}
|
|
17982
|
+
// ←/→ on the status surface jump between the staged / unstaged /
|
|
17983
|
+
// untracked groups — the horizontal axis is "between groups", the
|
|
17984
|
+
// vertical axis (↑/↓ below) is "within the active group's files".
|
|
17985
|
+
// Lands on the first file of the target group (clears header
|
|
17986
|
+
// focus) so the user is always on a real file after a jump,
|
|
17987
|
+
// mirroring the sidebar's tab-switch landing behavior.
|
|
17988
|
+
if ((key.leftArrow || key.rightArrow) &&
|
|
17989
|
+
state.activeView === 'status' &&
|
|
17990
|
+
state.focus === 'commits' &&
|
|
17991
|
+
context.statusGroups &&
|
|
17992
|
+
context.statusGroups.length > 1) {
|
|
17993
|
+
const groups = context.statusGroups;
|
|
17994
|
+
const currentIndex = groups.findIndex((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
|
|
17995
|
+
state.selectedWorktreeFileIndex < group.startIndex + group.count);
|
|
17996
|
+
const fallback = currentIndex >= 0 ? currentIndex : 0;
|
|
17997
|
+
const delta = key.leftArrow ? -1 : 1;
|
|
17998
|
+
const nextIndex = Math.max(0, Math.min(groups.length - 1, fallback + delta));
|
|
17999
|
+
if (nextIndex !== fallback) {
|
|
18000
|
+
return [action({ type: 'jumpToStatusGroup', targetIndex: groups[nextIndex].startIndex })];
|
|
18001
|
+
}
|
|
18002
|
+
return [];
|
|
18003
|
+
}
|
|
17701
18004
|
if (key.upArrow || inputValue === 'k') {
|
|
18005
|
+
// Inspector Actions tab: ↑/↓ moves the cursor through the
|
|
18006
|
+
// executable action list. Wins over moveDetailFile so a
|
|
18007
|
+
// history-commit explore with both file list AND actions visible
|
|
18008
|
+
// navigates the actions when the user has [/]-toggled to the
|
|
18009
|
+
// actions tab. (#791 follow-up)
|
|
18010
|
+
if (state.focus === 'detail' && state.inspectorTab === 'actions' && context.inspectorActionCount) {
|
|
18011
|
+
return [action({
|
|
18012
|
+
type: 'moveInspectorAction',
|
|
18013
|
+
delta: -1,
|
|
18014
|
+
actionCount: context.inspectorActionCount,
|
|
18015
|
+
})];
|
|
18016
|
+
}
|
|
17702
18017
|
if (state.focus === 'detail' && context.detailFileCount) {
|
|
17703
18018
|
return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
|
|
17704
18019
|
}
|
|
17705
18020
|
if (state.activeView === 'status' && context.worktreeFileCount) {
|
|
18021
|
+
// Already on the group header — ↑ is a no-op (use ←/→ to switch
|
|
18022
|
+
// groups). Mirrors the sidebar's "header is the top of the
|
|
18023
|
+
// hierarchy" behavior.
|
|
18024
|
+
if (state.statusGroupHeaderFocused) {
|
|
18025
|
+
return [];
|
|
18026
|
+
}
|
|
18027
|
+
// Cursor at the first file of its group → promote to the group
|
|
18028
|
+
// header rather than crossing the boundary into the previous
|
|
18029
|
+
// group's last file. Keeps the cursor inside its current
|
|
18030
|
+
// container; ←/→ is the explicit way to move between groups.
|
|
18031
|
+
if (context.statusGroups && context.statusGroups.length > 0) {
|
|
18032
|
+
const currentGroup = context.statusGroups.find((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
|
|
18033
|
+
state.selectedWorktreeFileIndex < group.startIndex + group.count);
|
|
18034
|
+
if (currentGroup && state.selectedWorktreeFileIndex === currentGroup.startIndex) {
|
|
18035
|
+
return [action({ type: 'setStatusGroupHeaderFocused', value: true })];
|
|
18036
|
+
}
|
|
18037
|
+
}
|
|
17706
18038
|
return [action({
|
|
17707
18039
|
type: 'moveWorktreeFile',
|
|
17708
18040
|
delta: -1,
|
|
@@ -17727,6 +18059,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17727
18059
|
previewLineCount: context.previewLineCount,
|
|
17728
18060
|
})];
|
|
17729
18061
|
}
|
|
18062
|
+
// Sidebar header focus: ↑ at item index 0 promotes the cursor
|
|
18063
|
+
// onto the active tab's header. Pressing ↑ again is a no-op
|
|
18064
|
+
// (use ←/→ to switch between tab headers, Enter to drill in).
|
|
18065
|
+
// Only triggers when the sidebar is focused on a content tab —
|
|
18066
|
+
// dedicated promoted views (`g b` etc.) keep the legacy clamp
|
|
18067
|
+
// behavior because they have no header to escape to.
|
|
18068
|
+
if (state.focus === 'sidebar' && !state.sidebarHeaderFocused) {
|
|
18069
|
+
if (state.sidebarTab === 'branches' && state.selectedBranchIndex === 0 && (context.branchCount ?? 0) > 0) {
|
|
18070
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18071
|
+
}
|
|
18072
|
+
if (state.sidebarTab === 'tags' && state.selectedTagIndex === 0 && (context.tagCount ?? 0) > 0) {
|
|
18073
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18074
|
+
}
|
|
18075
|
+
if (state.sidebarTab === 'stashes' && state.selectedStashIndex === 0 && (context.stashCount ?? 0) > 0) {
|
|
18076
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18077
|
+
}
|
|
18078
|
+
if (state.sidebarTab === 'worktrees' && state.selectedWorktreeListIndex === 0 && (context.worktreeListCount ?? 0) > 0) {
|
|
18079
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18080
|
+
}
|
|
18081
|
+
}
|
|
18082
|
+
// Already on the header — ↑ is a no-op (←/→ switches tabs).
|
|
18083
|
+
if (state.focus === 'sidebar' && state.sidebarHeaderFocused) {
|
|
18084
|
+
return [];
|
|
18085
|
+
}
|
|
17730
18086
|
if (isBranchActionTarget(state) && context.branchCount) {
|
|
17731
18087
|
return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
|
|
17732
18088
|
}
|
|
@@ -17761,10 +18117,23 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17761
18117
|
if (state.activeView === 'history' && state.pendingCommitFocused) {
|
|
17762
18118
|
return [action({ type: 'unfocusPendingCommit' })];
|
|
17763
18119
|
}
|
|
18120
|
+
if (state.focus === 'detail' && state.inspectorTab === 'actions' && context.inspectorActionCount) {
|
|
18121
|
+
return [action({
|
|
18122
|
+
type: 'moveInspectorAction',
|
|
18123
|
+
delta: 1,
|
|
18124
|
+
actionCount: context.inspectorActionCount,
|
|
18125
|
+
})];
|
|
18126
|
+
}
|
|
17764
18127
|
if (state.focus === 'detail' && context.detailFileCount) {
|
|
17765
18128
|
return [action({ type: 'moveDetailFile', delta: 1, fileCount: context.detailFileCount })];
|
|
17766
18129
|
}
|
|
17767
18130
|
if (state.activeView === 'status' && context.worktreeFileCount) {
|
|
18131
|
+
// Header focused → ↓ re-enters the group at the cursored file
|
|
18132
|
+
// (which is already the group's first file by construction).
|
|
18133
|
+
// Just clear the flag.
|
|
18134
|
+
if (state.statusGroupHeaderFocused) {
|
|
18135
|
+
return [action({ type: 'setStatusGroupHeaderFocused', value: false })];
|
|
18136
|
+
}
|
|
17768
18137
|
return [action({
|
|
17769
18138
|
type: 'moveWorktreeFile',
|
|
17770
18139
|
delta: 1,
|
|
@@ -17785,6 +18154,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17785
18154
|
previewLineCount: context.previewLineCount,
|
|
17786
18155
|
})];
|
|
17787
18156
|
}
|
|
18157
|
+
// Sidebar header focused: ↓ re-enters the list at index 0.
|
|
18158
|
+
// Clears the header flag and snaps the per-entity selection to 0
|
|
18159
|
+
// (mirrors the existing default selection behavior on first
|
|
18160
|
+
// sidebar focus).
|
|
18161
|
+
if (state.focus === 'sidebar' && state.sidebarHeaderFocused) {
|
|
18162
|
+
return [action({ type: 'setSidebarHeaderFocused', value: false })];
|
|
18163
|
+
}
|
|
17788
18164
|
if (isBranchActionTarget(state) && context.branchCount) {
|
|
17789
18165
|
return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
|
|
17790
18166
|
}
|
|
@@ -17878,6 +18254,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17878
18254
|
];
|
|
17879
18255
|
}
|
|
17880
18256
|
}
|
|
18257
|
+
// Inspector Actions tab: Enter on the cursored action fires its
|
|
18258
|
+
// associated event (cherry-pick / revert / yank / etc.). Wins over
|
|
18259
|
+
// the file-list Enter below when the user has [/]-toggled to the
|
|
18260
|
+
// actions tab. Routes through `getInspectorActionExecuteEvents` so
|
|
18261
|
+
// the per-action dispatch table stays the single source of truth
|
|
18262
|
+
// for what each action does. (#791 follow-up)
|
|
18263
|
+
if (key.return &&
|
|
18264
|
+
state.focus === 'detail' &&
|
|
18265
|
+
state.inspectorTab === 'actions') {
|
|
18266
|
+
const actions = getInspectorActionsForState(state);
|
|
18267
|
+
const cursored = actions[state.inspectorActionIndex];
|
|
18268
|
+
if (cursored) {
|
|
18269
|
+
return getInspectorActionExecuteEvents(cursored, state);
|
|
18270
|
+
}
|
|
18271
|
+
}
|
|
17881
18272
|
// From the inspector / commit-diff detail panel, Enter opens (or refocuses)
|
|
17882
18273
|
// the diff view scoped to the currently-selected commit and file. Lets the
|
|
17883
18274
|
// user drive the explore flow entirely from the right panel: j/k picks a
|
|
@@ -17916,7 +18307,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17916
18307
|
const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
|
|
17917
18308
|
const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
|
|
17918
18309
|
sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
|
|
17919
|
-
|
|
18310
|
+
// Three cases drill into the dedicated view:
|
|
18311
|
+
// 1. The cursor is on the tab header (user pressed ↑ at the
|
|
18312
|
+
// top of the list to escape the items — Enter explicitly
|
|
18313
|
+
// jumps to the dedicated view).
|
|
18314
|
+
// 2. The tab has no in-sidebar primary action defined (status,
|
|
18315
|
+
// tags, worktrees — drilling in is the canonical path).
|
|
18316
|
+
// 3. The tab has zero items (the dedicated view's empty state
|
|
18317
|
+
// tells the user what to do next).
|
|
18318
|
+
if (state.sidebarHeaderFocused || !hasInSidebarPrimaryAction) {
|
|
17920
18319
|
const tabToView = {
|
|
17921
18320
|
status: 'status',
|
|
17922
18321
|
branches: 'branches',
|
|
@@ -17936,6 +18335,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17936
18335
|
// Fall through — per-entity Enter handler below claims the keystroke.
|
|
17937
18336
|
}
|
|
17938
18337
|
if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
|
|
18338
|
+
// Group header focused → fire the group's batch workflow action.
|
|
18339
|
+
// Routed through the workflow runner so the runtime owns the
|
|
18340
|
+
// git invocation + status messaging consistently with the
|
|
18341
|
+
// single-file `space` toggle. The `payload` carries the group's
|
|
18342
|
+
// state ('staged' / 'unstaged' / 'untracked') so the runtime can
|
|
18343
|
+
// resolve which files to act on without re-deriving group state.
|
|
18344
|
+
if (state.statusGroupHeaderFocused && context.statusGroups) {
|
|
18345
|
+
const currentGroup = context.statusGroups.find((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
|
|
18346
|
+
state.selectedWorktreeFileIndex < group.startIndex + group.count);
|
|
18347
|
+
if (currentGroup) {
|
|
18348
|
+
const workflowId = currentGroup.state === 'staged'
|
|
18349
|
+
? 'unstage-all-staged'
|
|
18350
|
+
: currentGroup.state === 'unstaged'
|
|
18351
|
+
? 'stage-all-unstaged'
|
|
18352
|
+
: 'stage-all-untracked';
|
|
18353
|
+
return [{ type: 'runWorkflowAction', id: workflowId, payload: currentGroup.state }];
|
|
18354
|
+
}
|
|
18355
|
+
}
|
|
17939
18356
|
return [action({
|
|
17940
18357
|
type: 'navigateOpenDiffForWorktreeFile',
|
|
17941
18358
|
fileIndex: state.selectedWorktreeFileIndex,
|
|
@@ -20678,6 +21095,35 @@ function parseStashDiffFiles(lines) {
|
|
|
20678
21095
|
}
|
|
20679
21096
|
return files;
|
|
20680
21097
|
}
|
|
21098
|
+
/**
|
|
21099
|
+
* Resolve which stash file *contains* a given line offset — the user's
|
|
21100
|
+
* cursor scrolls through a concatenated multi-file patch, and this is
|
|
21101
|
+
* what powers the "File N/M: <path>" panel header, the inline header
|
|
21102
|
+
* highlighting (#791 follow-up), and the cherry-pick / open-in-editor
|
|
21103
|
+
* dispatchers' "what file is the cursor on" lookup.
|
|
21104
|
+
*
|
|
21105
|
+
* Returns `undefined` when the file list is empty *or* the offset
|
|
21106
|
+
* lands before the very first file's `diff --git` header (e.g. when
|
|
21107
|
+
* `--stat` summary lines lead the patch). Callers fall through to a
|
|
21108
|
+
* "no file selected" state in that case.
|
|
21109
|
+
*/
|
|
21110
|
+
function findStashFileForOffset(files, offset) {
|
|
21111
|
+
if (files.length === 0)
|
|
21112
|
+
return undefined;
|
|
21113
|
+
let current;
|
|
21114
|
+
for (const file of files) {
|
|
21115
|
+
if (file.startLine <= offset) {
|
|
21116
|
+
current = file;
|
|
21117
|
+
}
|
|
21118
|
+
else {
|
|
21119
|
+
break;
|
|
21120
|
+
}
|
|
21121
|
+
}
|
|
21122
|
+
// First file is the canonical fallback — even if the offset lands
|
|
21123
|
+
// before its header (rare), we want the cursor to be "in" something
|
|
21124
|
+
// so the user's actions have a target.
|
|
21125
|
+
return current ?? files[0];
|
|
21126
|
+
}
|
|
20681
21127
|
const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
|
|
20682
21128
|
function parseDiffGitHeader(line) {
|
|
20683
21129
|
const match = line.match(DIFF_GIT_HEADER);
|
|
@@ -20732,6 +21178,25 @@ function revertFile(git, file) {
|
|
|
20732
21178
|
}
|
|
20733
21179
|
return runAction$3(() => git.raw(['restore', '--', file.path]), `Reverted ${file.path}`);
|
|
20734
21180
|
}
|
|
21181
|
+
/**
|
|
21182
|
+
* Group-level batch ops triggered by Enter on a status group header
|
|
21183
|
+
* (staged / unstaged / untracked). Pass the files belonging to that
|
|
21184
|
+
* group; the helpers run a single `git add` / `git restore --staged`
|
|
21185
|
+
* with all paths in one invocation rather than looping per-file —
|
|
21186
|
+
* faster + atomic from the user's point of view.
|
|
21187
|
+
*/
|
|
21188
|
+
function stageAllFiles(git, files) {
|
|
21189
|
+
if (files.length === 0) {
|
|
21190
|
+
return Promise.resolve({ ok: false, message: 'No files to stage' });
|
|
21191
|
+
}
|
|
21192
|
+
return runAction$3(() => git.raw(['add', '--', ...files.map((file) => file.path)]), `Staged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
|
|
21193
|
+
}
|
|
21194
|
+
function unstageAllFiles(git, files) {
|
|
21195
|
+
if (files.length === 0) {
|
|
21196
|
+
return Promise.resolve({ ok: false, message: 'No files to unstage' });
|
|
21197
|
+
}
|
|
21198
|
+
return runAction$3(() => git.raw(['restore', '--staged', '--', ...files.map((file) => file.path)]), `Unstaged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
|
|
21199
|
+
}
|
|
20735
21200
|
|
|
20736
21201
|
function fileState(indexStatus, worktreeStatus) {
|
|
20737
21202
|
if (indexStatus === '?' && worktreeStatus === '?') {
|
|
@@ -20775,6 +21240,22 @@ function applyStatusFilterMask(files, mask) {
|
|
|
20775
21240
|
}
|
|
20776
21241
|
return files.filter((file) => mask[file.state]);
|
|
20777
21242
|
}
|
|
21243
|
+
const WORKTREE_GROUP_ORDER = ['staged', 'unstaged', 'untracked'];
|
|
21244
|
+
function groupWorktreeFiles(files) {
|
|
21245
|
+
const groups = [];
|
|
21246
|
+
let cursor = 0;
|
|
21247
|
+
for (const groupState of WORKTREE_GROUP_ORDER) {
|
|
21248
|
+
const groupFiles = files.filter((file) => file.state === groupState);
|
|
21249
|
+
if (groupFiles.length > 0) {
|
|
21250
|
+
groups.push({ state: groupState, files: groupFiles, startIndex: cursor });
|
|
21251
|
+
cursor += groupFiles.length;
|
|
21252
|
+
}
|
|
21253
|
+
}
|
|
21254
|
+
return groups;
|
|
21255
|
+
}
|
|
21256
|
+
function flattenWorktreeGroups(groups) {
|
|
21257
|
+
return groups.flatMap((group) => group.files);
|
|
21258
|
+
}
|
|
20778
21259
|
|
|
20779
21260
|
function hunkHeader(hunk) {
|
|
20780
21261
|
return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
|
|
@@ -23124,88 +23605,6 @@ function formatPullRequestStateLine(pr) {
|
|
|
23124
23605
|
return parts.join(' · ');
|
|
23125
23606
|
}
|
|
23126
23607
|
|
|
23127
|
-
/**
|
|
23128
|
-
* Hardcoded per-entity action lists surfaced inside the right-hand
|
|
23129
|
-
* inspector panel. The inspector used to repeat the repo / branch /
|
|
23130
|
-
* status content the top header and left sidebar already show; we drop
|
|
23131
|
-
* that trailer in favor of an actionable cheat-sheet so the user knows
|
|
23132
|
-
* exactly which keystrokes apply to whatever they have under the cursor.
|
|
23133
|
-
*
|
|
23134
|
-
* Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
|
|
23135
|
-
* - Most per-entity actions live in `inkInput.ts` as direct keystroke
|
|
23136
|
-
* handlers (e.g. `c` cherry-pick, `R` revert) rather than as
|
|
23137
|
-
* globally-registered bindings, so the registry would be a partial
|
|
23138
|
-
* view at best.
|
|
23139
|
-
* - The bindings registry's `contexts` model (normal / search / focus
|
|
23140
|
-
* name) does not cleanly map to inspector entity types like "branch"
|
|
23141
|
-
* or "tag". Filtering it would mean replicating the same per-view
|
|
23142
|
-
* scoping logic the input dispatcher already encodes.
|
|
23143
|
-
* - New per-entity actions are added infrequently — the maintenance
|
|
23144
|
-
* cost of mirroring them here is low and keeps this file the single
|
|
23145
|
-
* source of truth for "what shows in the inspector".
|
|
23146
|
-
*
|
|
23147
|
-
* If you wire up a new per-entity keystroke in `inkInput.ts` — for
|
|
23148
|
-
* example a "create branch from this commit" or "create tag from this
|
|
23149
|
-
* commit" action — add the matching row to the relevant array below so
|
|
23150
|
-
* it shows up in the inspector automatically.
|
|
23151
|
-
*/
|
|
23152
|
-
const HISTORY_COMMIT_ACTIONS = [
|
|
23153
|
-
{ key: 'enter', label: 'Open diff' },
|
|
23154
|
-
{ key: 'c', label: 'Cherry-pick' },
|
|
23155
|
-
{ key: 'R', label: 'Revert', destructive: true },
|
|
23156
|
-
{ key: 'Z', label: 'Reset to commit', destructive: true },
|
|
23157
|
-
{ key: 'i', label: 'Interactive rebase', destructive: true },
|
|
23158
|
-
{ key: 'y', label: 'Yank hash' },
|
|
23159
|
-
{ key: 'Y', label: 'Yank short hash' },
|
|
23160
|
-
{ key: 'O', label: 'Open in browser' },
|
|
23161
|
-
];
|
|
23162
|
-
const BRANCH_ACTIONS = [
|
|
23163
|
-
{ key: 'enter', label: 'Checkout' },
|
|
23164
|
-
{ key: '+', label: 'New branch' },
|
|
23165
|
-
{ key: 'R', label: 'Rename' },
|
|
23166
|
-
{ key: 'u', label: 'Set upstream' },
|
|
23167
|
-
{ key: 'D', label: 'Delete', destructive: true },
|
|
23168
|
-
{ key: 'P', label: 'Push current' },
|
|
23169
|
-
{ key: 'F', label: 'Fetch all' },
|
|
23170
|
-
{ key: 'y', label: 'Yank name' },
|
|
23171
|
-
];
|
|
23172
|
-
const TAG_ACTIONS = [
|
|
23173
|
-
{ key: '+', label: 'New tag' },
|
|
23174
|
-
{ key: 'P', label: 'Push tag' },
|
|
23175
|
-
{ key: 'T', label: 'Delete', destructive: true },
|
|
23176
|
-
{ key: 'R', label: 'Delete remote', destructive: true },
|
|
23177
|
-
{ key: 'y', label: 'Yank name' },
|
|
23178
|
-
];
|
|
23179
|
-
const STASH_ACTIONS = [
|
|
23180
|
-
{ key: 'enter', label: 'Open diff' },
|
|
23181
|
-
{ key: 'a', label: 'Apply' },
|
|
23182
|
-
{ key: 'p', label: 'Pop' },
|
|
23183
|
-
{ key: 'X', label: 'Drop', destructive: true },
|
|
23184
|
-
{ key: 'y', label: 'Yank ref' },
|
|
23185
|
-
];
|
|
23186
|
-
const WORKTREE_ACTIONS = [
|
|
23187
|
-
{ key: 'W', label: 'Remove', destructive: true },
|
|
23188
|
-
{ key: 'y', label: 'Yank path' },
|
|
23189
|
-
];
|
|
23190
|
-
function getInspectorActions(context) {
|
|
23191
|
-
switch (context) {
|
|
23192
|
-
case 'history-commit':
|
|
23193
|
-
return HISTORY_COMMIT_ACTIONS;
|
|
23194
|
-
case 'branch':
|
|
23195
|
-
return BRANCH_ACTIONS;
|
|
23196
|
-
case 'tag':
|
|
23197
|
-
return TAG_ACTIONS;
|
|
23198
|
-
case 'stash':
|
|
23199
|
-
return STASH_ACTIONS;
|
|
23200
|
-
case 'worktree':
|
|
23201
|
-
return WORKTREE_ACTIONS;
|
|
23202
|
-
default: {
|
|
23203
|
-
const exhaustive = context;
|
|
23204
|
-
throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
|
|
23205
|
-
}
|
|
23206
|
-
}
|
|
23207
|
-
}
|
|
23208
|
-
|
|
23209
23608
|
function sectionLines(title, diff) {
|
|
23210
23609
|
const lines = diff.split('\n').map((line) => line.trimEnd());
|
|
23211
23610
|
return [
|
|
@@ -23761,7 +24160,16 @@ function LogInkApp(deps) {
|
|
|
23761
24160
|
// count, selected-file resolution, and the rendered list all key off
|
|
23762
24161
|
// it so toggles never desync the cursor from the rendered rows.
|
|
23763
24162
|
const visibleWorktreeFiles = React.useMemo(() => applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask), [context.worktree?.files, state.statusFilterMask]);
|
|
23764
|
-
|
|
24163
|
+
// Sectioned view of the visible files (#791 follow-up). Drives the
|
|
24164
|
+
// status surface's three-tier cursor model: ←/→ jumps between
|
|
24165
|
+
// groups, ↑ at index 0 promotes to the group header, Enter on the
|
|
24166
|
+
// header fires the group's batch action. The renderer also consumes
|
|
24167
|
+
// this so the visible file list stays in canonical group order
|
|
24168
|
+
// regardless of whatever order `git status --porcelain` happens to
|
|
24169
|
+
// emit.
|
|
24170
|
+
const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
|
|
24171
|
+
const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
|
|
24172
|
+
const selectedWorktreeFile = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex];
|
|
23765
24173
|
const dispatch = React.useCallback((action) => {
|
|
23766
24174
|
setState((current) => applyLogInkAction(current, action));
|
|
23767
24175
|
}, []);
|
|
@@ -24620,6 +25028,26 @@ function LogInkApp(deps) {
|
|
|
24620
25028
|
return { ok: false, message: 'Comment body required' };
|
|
24621
25029
|
return commentPullRequest(body);
|
|
24622
25030
|
},
|
|
25031
|
+
// Status surface group-level batch ops (#791 follow-up). The
|
|
25032
|
+
// input handler dispatches these when the user presses Enter on a
|
|
25033
|
+
// group header. We re-derive the file list from the live
|
|
25034
|
+
// `context.worktree?.files` rather than trusting a snapshot —
|
|
25035
|
+
// the worktree may have changed since the keystroke fired (rare,
|
|
25036
|
+
// but the cost of re-filtering is negligible compared to the cost
|
|
25037
|
+
// of a stale add). The mask is honored too so a user who's
|
|
25038
|
+
// hidden a category never has it touched by accident.
|
|
25039
|
+
'stage-all-unstaged': async () => {
|
|
25040
|
+
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'unstaged');
|
|
25041
|
+
return stageAllFiles(git, files);
|
|
25042
|
+
},
|
|
25043
|
+
'unstage-all-staged': async () => {
|
|
25044
|
+
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'staged');
|
|
25045
|
+
return unstageAllFiles(git, files);
|
|
25046
|
+
},
|
|
25047
|
+
'stage-all-untracked': async () => {
|
|
25048
|
+
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'untracked');
|
|
25049
|
+
return stageAllFiles(git, files);
|
|
25050
|
+
},
|
|
24623
25051
|
};
|
|
24624
25052
|
const handler = handlers[id];
|
|
24625
25053
|
if (!handler) {
|
|
@@ -24645,7 +25073,7 @@ function LogInkApp(deps) {
|
|
|
24645
25073
|
}
|
|
24646
25074
|
}, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
|
|
24647
25075
|
state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
|
|
24648
|
-
state.tagSort]);
|
|
25076
|
+
state.statusFilterMask, state.tagSort]);
|
|
24649
25077
|
// Resolve the active view's "yank target" (commit hash / branch /
|
|
24650
25078
|
// tag / stash ref / file path) against the live filtered+sorted list,
|
|
24651
25079
|
// copy it to the system clipboard, and surface the result on the
|
|
@@ -24700,7 +25128,7 @@ function LogInkApp(deps) {
|
|
|
24700
25128
|
// Read from the mask-filtered list (#776) so the cursor and the
|
|
24701
25129
|
// yanked path always match what's on screen — yanking a hidden
|
|
24702
25130
|
// row is always a desync bug.
|
|
24703
|
-
const path =
|
|
25131
|
+
const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
|
|
24704
25132
|
if (path) {
|
|
24705
25133
|
value = path;
|
|
24706
25134
|
label = `path ${path}`;
|
|
@@ -24708,7 +25136,7 @@ function LogInkApp(deps) {
|
|
|
24708
25136
|
}
|
|
24709
25137
|
else if (view === 'diff') {
|
|
24710
25138
|
if (state.diffSource === 'worktree') {
|
|
24711
|
-
const path =
|
|
25139
|
+
const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
|
|
24712
25140
|
if (path) {
|
|
24713
25141
|
value = path;
|
|
24714
25142
|
label = `path ${path}`;
|
|
@@ -24718,17 +25146,8 @@ function LogInkApp(deps) {
|
|
|
24718
25146
|
// Walk back to the most recent file header at or before the
|
|
24719
25147
|
// current preview offset — same logic the input-context block
|
|
24720
25148
|
// uses to expose stashDiffSelectedPath.
|
|
24721
|
-
const
|
|
24722
|
-
if (
|
|
24723
|
-
let current = files[0];
|
|
24724
|
-
for (const file of files) {
|
|
24725
|
-
if (file.startLine <= state.diffPreviewOffset) {
|
|
24726
|
-
current = file;
|
|
24727
|
-
}
|
|
24728
|
-
else {
|
|
24729
|
-
break;
|
|
24730
|
-
}
|
|
24731
|
-
}
|
|
25149
|
+
const current = findStashFileForOffset(parseStashDiffFiles(stashDiffLines), state.diffPreviewOffset);
|
|
25150
|
+
if (current) {
|
|
24732
25151
|
value = current.path;
|
|
24733
25152
|
label = `path ${current.path}`;
|
|
24734
25153
|
}
|
|
@@ -24782,7 +25201,7 @@ function LogInkApp(deps) {
|
|
|
24782
25201
|
state.selectedTagIndex,
|
|
24783
25202
|
state.selectedWorktreeFileIndex,
|
|
24784
25203
|
state.tagSort,
|
|
24785
|
-
|
|
25204
|
+
visibleWorktreeFilesGrouped,
|
|
24786
25205
|
]);
|
|
24787
25206
|
React.useEffect(() => {
|
|
24788
25207
|
let active = true;
|
|
@@ -25022,28 +25441,14 @@ function LogInkApp(deps) {
|
|
|
25022
25441
|
? parseStashDiffFiles(stashDiffLines)
|
|
25023
25442
|
: [];
|
|
25024
25443
|
const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
|
|
25025
|
-
const stashDiffSelectedPath =
|
|
25026
|
-
|
|
25027
|
-
|
|
25028
|
-
const offset = state.diffPreviewOffset;
|
|
25029
|
-
// Walk backwards to the most recent file header at or before the
|
|
25030
|
-
// current cursor offset.
|
|
25031
|
-
let current = stashDiffFiles[0];
|
|
25032
|
-
for (const file of stashDiffFiles) {
|
|
25033
|
-
if (file.startLine <= offset) {
|
|
25034
|
-
current = file;
|
|
25035
|
-
}
|
|
25036
|
-
else {
|
|
25037
|
-
break;
|
|
25038
|
-
}
|
|
25039
|
-
}
|
|
25040
|
-
return current.path;
|
|
25041
|
-
})();
|
|
25444
|
+
const stashDiffSelectedPath = state.diffSource === 'stash'
|
|
25445
|
+
? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
|
|
25446
|
+
: undefined;
|
|
25042
25447
|
getLogInkInputEvents(state, inputValue, key, {
|
|
25043
25448
|
detailFileCount: detail?.files.length,
|
|
25044
25449
|
previewLineCount: diffPreviewLineCount,
|
|
25045
25450
|
worktreeDiffLineCount: worktreeDiff?.lines.length,
|
|
25046
|
-
worktreeFileCount:
|
|
25451
|
+
worktreeFileCount: visibleWorktreeFilesGrouped.length,
|
|
25047
25452
|
worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
|
|
25048
25453
|
commitDiffHunkOffsets,
|
|
25049
25454
|
branchCount: branchVisibleCount,
|
|
@@ -25053,7 +25458,13 @@ function LogInkApp(deps) {
|
|
|
25053
25458
|
stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
|
|
25054
25459
|
stashDiffSelectedPath,
|
|
25055
25460
|
worktreeListCount: worktreeVisibleCount,
|
|
25056
|
-
worktreeSelectedPath:
|
|
25461
|
+
worktreeSelectedPath: visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path,
|
|
25462
|
+
statusGroups: visibleWorktreeGroups.map((group) => ({
|
|
25463
|
+
state: group.state,
|
|
25464
|
+
count: group.files.length,
|
|
25465
|
+
startIndex: group.startIndex,
|
|
25466
|
+
})),
|
|
25467
|
+
inspectorActionCount: getInspectorActionsForState(state).length,
|
|
25057
25468
|
commitDiffSelectedPath: state.diffSource === 'commit'
|
|
25058
25469
|
? selectedDetailFile?.path
|
|
25059
25470
|
: undefined,
|
|
@@ -25199,6 +25610,11 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
|
|
|
25199
25610
|
// Accordion layout — every tab's title is visible on its own line, but
|
|
25200
25611
|
// only the active tab expands its content underneath. Switching tabs
|
|
25201
25612
|
// (1-5 / [/]) collapses the previous and expands the next.
|
|
25613
|
+
// When sidebar focus has been promoted to the tab header (#806
|
|
25614
|
+
// follow-up), the active tab's title row gets selection styling
|
|
25615
|
+
// and the items below it render without their cursor highlight
|
|
25616
|
+
// (which now lives on the header).
|
|
25617
|
+
const headerFocused = focused && state.sidebarHeaderFocused;
|
|
25202
25618
|
const tabBlocks = tabs.flatMap((tab, tabIndex) => {
|
|
25203
25619
|
const isActive = tab === state.sidebarTab;
|
|
25204
25620
|
const count = sidebarTabCount(tab, context);
|
|
@@ -25206,6 +25622,7 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
|
|
|
25206
25622
|
? `${sidebarTabLabel(tab)} (${count})`
|
|
25207
25623
|
: sidebarTabLabel(tab);
|
|
25208
25624
|
const headerText = isActive ? `[${labelWithCount}]` : labelWithCount;
|
|
25625
|
+
const headerSelected = isActive && headerFocused;
|
|
25209
25626
|
const blocks = [];
|
|
25210
25627
|
if (tabIndex > 0) {
|
|
25211
25628
|
blocks.push(h(Text, { key: `tab-spacer-${tab}` }, ''));
|
|
@@ -25214,6 +25631,12 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
|
|
|
25214
25631
|
key: `tab-header-${tab}`,
|
|
25215
25632
|
bold: isActive,
|
|
25216
25633
|
dimColor: !isActive,
|
|
25634
|
+
// Selection styling on the header itself when the cursor has
|
|
25635
|
+
// been promoted off the items list. inverse swaps fg/bg so the
|
|
25636
|
+
// highlight reads as "this is the cursor target" identically
|
|
25637
|
+
// to how items render when focused.
|
|
25638
|
+
backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
25639
|
+
inverse: headerSelected,
|
|
25217
25640
|
}, headerText));
|
|
25218
25641
|
if (isActive) {
|
|
25219
25642
|
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
|
|
@@ -25251,7 +25674,11 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
25251
25674
|
// ↑/↓ navigates within the sidebar list and Enter / per-entity keys
|
|
25252
25675
|
// act on the cursored item without needing to drill into the
|
|
25253
25676
|
// dedicated view (#791 follow-up — in-sidebar selection).
|
|
25254
|
-
|
|
25677
|
+
// Items render with the cursor highlight only when the sidebar is
|
|
25678
|
+
// focused on this tab AND the cursor is on items (not promoted to
|
|
25679
|
+
// the tab header). The header-focused branch up in `renderSidebar`
|
|
25680
|
+
// owns the highlight in that case.
|
|
25681
|
+
const focused = state.focus === 'sidebar' && state.sidebarTab === tab && !state.sidebarHeaderFocused;
|
|
25255
25682
|
if (tab === 'branches') {
|
|
25256
25683
|
if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
|
|
25257
25684
|
return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
|
|
@@ -25551,6 +25978,16 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
|
|
|
25551
25978
|
backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
|
|
25552
25979
|
}, truncate$1(label, 140));
|
|
25553
25980
|
}
|
|
25981
|
+
function buildStatusSurfaceRows(groups) {
|
|
25982
|
+
const rows = [];
|
|
25983
|
+
for (const group of groups) {
|
|
25984
|
+
rows.push({ kind: 'header', group });
|
|
25985
|
+
group.files.forEach((file, offset) => {
|
|
25986
|
+
rows.push({ kind: 'file', group, file, flatIndex: group.startIndex + offset });
|
|
25987
|
+
});
|
|
25988
|
+
}
|
|
25989
|
+
return rows;
|
|
25990
|
+
}
|
|
25554
25991
|
function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
25555
25992
|
const { Box, Text } = components;
|
|
25556
25993
|
const focused = state.focus === 'commits';
|
|
@@ -25560,26 +25997,64 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
|
|
|
25560
25997
|
// uses for j/k navigation. `visibleFiles` may be a strict subset of
|
|
25561
25998
|
// worktree.files when the user has narrowed via 1/2/3.
|
|
25562
25999
|
const visibleFiles = applyStatusFilterMask(worktree?.files || [], state.statusFilterMask);
|
|
26000
|
+
// Group + canonical-sort. The runtime + input handler agree on this
|
|
26001
|
+
// order so a `selectedWorktreeFileIndex` of N always points to the
|
|
26002
|
+
// same file across all three (renderer / input / workflow handlers).
|
|
26003
|
+
const visibleGroups = groupWorktreeFiles(visibleFiles);
|
|
26004
|
+
const surfaceRows = buildStatusSurfaceRows(visibleGroups);
|
|
25563
26005
|
const listRows = Math.max(4, bodyRows - 5);
|
|
25564
26006
|
const selectedIndex = state.selectedWorktreeFileIndex;
|
|
26007
|
+
const headerFocused = state.statusGroupHeaderFocused;
|
|
26008
|
+
// Resolve the cursor's row index in the flat (header-and-file) row
|
|
26009
|
+
// list. Used to window the visible slice around the cursor.
|
|
26010
|
+
const cursorRowIndex = (() => {
|
|
26011
|
+
if (!surfaceRows.length)
|
|
26012
|
+
return 0;
|
|
26013
|
+
const currentGroup = visibleGroups.find((group) => selectedIndex >= group.startIndex && selectedIndex < group.startIndex + group.files.length);
|
|
26014
|
+
if (!currentGroup)
|
|
26015
|
+
return 0;
|
|
26016
|
+
if (headerFocused) {
|
|
26017
|
+
const idx = surfaceRows.findIndex((row) => row.kind === 'header' && row.group === currentGroup);
|
|
26018
|
+
return idx >= 0 ? idx : 0;
|
|
26019
|
+
}
|
|
26020
|
+
const idx = surfaceRows.findIndex((row) => row.kind === 'file' && row.flatIndex === selectedIndex);
|
|
26021
|
+
return idx >= 0 ? idx : 0;
|
|
26022
|
+
})();
|
|
25565
26023
|
const cleanHint = formatLogInkStatusEmpty({ hasChanges: Boolean(worktree?.files.length) });
|
|
25566
|
-
const
|
|
26024
|
+
const windowStart = Math.max(0, Math.min(Math.max(0, surfaceRows.length - listRows), cursorRowIndex - Math.floor(listRows / 2)));
|
|
25567
26025
|
const isLoading = isLogInkContextKeyLoading(contextStatus, 'worktree');
|
|
25568
|
-
const
|
|
26026
|
+
const renderedRows = isLoading || !surfaceRows.length
|
|
25569
26027
|
? []
|
|
25570
|
-
:
|
|
25571
|
-
const
|
|
25572
|
-
|
|
26028
|
+
: surfaceRows.slice(windowStart, windowStart + listRows).map((row, offset) => {
|
|
26029
|
+
const rowIndex = windowStart + offset;
|
|
26030
|
+
if (row.kind === 'header') {
|
|
26031
|
+
const groupContainsCursor = selectedIndex >= row.group.startIndex &&
|
|
26032
|
+
selectedIndex < row.group.startIndex + row.group.files.length;
|
|
26033
|
+
const headerSelected = focused && headerFocused && groupContainsCursor;
|
|
26034
|
+
const arrow = theme.ascii ? '>' : '▾';
|
|
26035
|
+
const groupLabel = capitalizeGroupName(row.group.state);
|
|
26036
|
+
const text = ` ${arrow} ${groupLabel} (${row.group.files.length})`;
|
|
26037
|
+
return h(Text, {
|
|
26038
|
+
key: `status-group-${row.group.state}-${rowIndex}`,
|
|
26039
|
+
bold: true,
|
|
26040
|
+
dimColor: !headerSelected && rowIndex > cursorRowIndex,
|
|
26041
|
+
backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
26042
|
+
inverse: headerSelected,
|
|
26043
|
+
}, truncate$1(text, 140));
|
|
26044
|
+
}
|
|
26045
|
+
const isSelected = !headerFocused && row.flatIndex === selectedIndex;
|
|
25573
26046
|
const cursorPart = `${isSelected ? '>' : ' '} `;
|
|
25574
|
-
const dotColor = getStageStatusDotColor(file.state, theme);
|
|
26047
|
+
const dotColor = getStageStatusDotColor(row.file.state, theme);
|
|
25575
26048
|
const useDot = dotColor !== undefined;
|
|
25576
26049
|
const dotCells = useDot ? cellWidth(STAGE_STATUS_DOT) + 1 : 0;
|
|
25577
|
-
const tail = `${file.indexStatus}${file.worktreeStatus} ${
|
|
25578
|
-
const tailTrunc = truncate$1(tail, Math.max(0, 140 - cellWidth(cursorPart) - dotCells));
|
|
26050
|
+
const tail = `${row.file.indexStatus}${row.file.worktreeStatus} ${row.file.path}`;
|
|
26051
|
+
const tailTrunc = truncate$1(tail, Math.max(0, 140 - cellWidth(cursorPart) - dotCells - 2));
|
|
25579
26052
|
return h(Text, {
|
|
25580
|
-
key: `status-row-${
|
|
25581
|
-
dimColor:
|
|
25582
|
-
|
|
26053
|
+
key: `status-file-${row.flatIndex}-${rowIndex}`,
|
|
26054
|
+
dimColor: !isSelected && rowIndex > cursorRowIndex,
|
|
26055
|
+
backgroundColor: isSelected && focused && !theme.noColor ? theme.colors.selection : undefined,
|
|
26056
|
+
inverse: isSelected && focused,
|
|
26057
|
+
}, ` ${cursorPart}`, ...(useDot ? [h(Text, { color: dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
|
|
25583
26058
|
});
|
|
25584
26059
|
// When the mask narrows the list to nothing but the underlying repo
|
|
25585
26060
|
// is non-clean, surface why the panel looks empty so the user can
|
|
@@ -25609,11 +26084,14 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
|
|
|
25609
26084
|
// never touch the filter.
|
|
25610
26085
|
...(isStatusFilterMaskActive(state.statusFilterMask)
|
|
25611
26086
|
? [h(Text, { key: 'status-mask-indicator', dimColor: true }, `filter: ${formatStatusFilterMask(state.statusFilterMask)} (1/2/3 to toggle)`)]
|
|
25612
|
-
: []), ...
|
|
26087
|
+
: []), ...renderedRows, ...fallbackLines.map((line, index) => h(Text, {
|
|
25613
26088
|
key: `status-surface-fallback-${index}`,
|
|
25614
26089
|
dimColor: index > 0,
|
|
25615
26090
|
}, truncate$1(line, 140))));
|
|
25616
26091
|
}
|
|
26092
|
+
function capitalizeGroupName(value) {
|
|
26093
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
26094
|
+
}
|
|
25617
26095
|
function isStatusFilterMaskActive(mask) {
|
|
25618
26096
|
return !mask.staged || !mask.unstaged || !mask.untracked;
|
|
25619
26097
|
}
|
|
@@ -26063,20 +26541,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
26063
26541
|
const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
26064
26542
|
const stashFiles = parseStashDiffFiles(lines);
|
|
26065
26543
|
const fileCount = stashFiles.length;
|
|
26066
|
-
const currentFile = (
|
|
26067
|
-
if (fileCount === 0)
|
|
26068
|
-
return undefined;
|
|
26069
|
-
let current = stashFiles[0];
|
|
26070
|
-
for (const file of stashFiles) {
|
|
26071
|
-
if (file.startLine <= state.diffPreviewOffset) {
|
|
26072
|
-
current = file;
|
|
26073
|
-
}
|
|
26074
|
-
else {
|
|
26075
|
-
break;
|
|
26076
|
-
}
|
|
26077
|
-
}
|
|
26078
|
-
return current;
|
|
26079
|
-
})();
|
|
26544
|
+
const currentFile = findStashFileForOffset(stashFiles, state.diffPreviewOffset);
|
|
26080
26545
|
const currentFileIndex = currentFile
|
|
26081
26546
|
? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
|
|
26082
26547
|
: -1;
|
|
@@ -26103,14 +26568,41 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
26103
26568
|
const headerLines = splitRequestedButTooNarrow
|
|
26104
26569
|
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
26105
26570
|
: baseHeaderLines;
|
|
26571
|
+
// File header anchor map: absolute line index → owning stash file.
|
|
26572
|
+
// Lets the body-render pass restyle each `diff --git` row in O(1)
|
|
26573
|
+
// and decide which one is the *active* file (the one currently
|
|
26574
|
+
// containing `diffPreviewOffset`). The active header gets the
|
|
26575
|
+
// selection background to mark "the file the cursor is inside."
|
|
26576
|
+
const stashFileByStartLine = new Map(stashFiles.map((file) => [file.startLine, file]));
|
|
26577
|
+
const activeStartLine = currentFile?.startLine;
|
|
26106
26578
|
const stashBodyNodes = stashDiffLoading || !lines.length
|
|
26107
26579
|
? []
|
|
26108
26580
|
: splitActive
|
|
26109
26581
|
? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
|
|
26110
|
-
: visibleLines.map((line, index) =>
|
|
26111
|
-
|
|
26112
|
-
|
|
26113
|
-
|
|
26582
|
+
: visibleLines.map((line, index) => {
|
|
26583
|
+
const absoluteIndex = state.diffPreviewOffset + index;
|
|
26584
|
+
const headerFile = stashFileByStartLine.get(absoluteIndex);
|
|
26585
|
+
if (headerFile) {
|
|
26586
|
+
// Replace the verbose `diff --git a/<path> b/<path>` text
|
|
26587
|
+
// with a compact `▾ <path>` marker — the path itself is
|
|
26588
|
+
// the meaningful identifier, not the a/b duplication. The
|
|
26589
|
+
// active file's header gets selection styling so the user
|
|
26590
|
+
// sees at a glance which file the cursor is inside.
|
|
26591
|
+
const isActive = absoluteIndex === activeStartLine;
|
|
26592
|
+
const arrow = theme.ascii ? '> ' : '▾ ';
|
|
26593
|
+
return h(Text, {
|
|
26594
|
+
key: `stash-diff-line-${absoluteIndex}`,
|
|
26595
|
+
bold: true,
|
|
26596
|
+
color: theme.noColor ? undefined : theme.colors.accent,
|
|
26597
|
+
backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
|
|
26598
|
+
inverse: isActive && focused,
|
|
26599
|
+
}, truncate$1(`${arrow}${headerFile.path}`, width - 4));
|
|
26600
|
+
}
|
|
26601
|
+
return h(Text, {
|
|
26602
|
+
key: `stash-diff-line-${absoluteIndex}`,
|
|
26603
|
+
...diffLineProps(line, theme),
|
|
26604
|
+
}, truncate$1(line, width - 4));
|
|
26605
|
+
});
|
|
26114
26606
|
return h(Box, {
|
|
26115
26607
|
borderColor: focusBorderColor(theme, focused),
|
|
26116
26608
|
borderStyle: theme.borderStyle,
|
|
@@ -26293,7 +26785,10 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
26293
26785
|
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
|
|
26294
26786
|
key: `detail-${index}`,
|
|
26295
26787
|
dimColor: index > 1,
|
|
26296
|
-
}, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme
|
|
26788
|
+
}, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26789
|
+
cursorIndex: state.inspectorActionIndex,
|
|
26790
|
+
cursorActive: focused && state.inspectorTab === 'actions',
|
|
26791
|
+
}));
|
|
26297
26792
|
}
|
|
26298
26793
|
const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
|
|
26299
26794
|
// P5.1 — link the commit hash and each ref out to GitHub when we know
|
|
@@ -26330,22 +26825,39 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
26330
26825
|
h(Text, { key: 'detail-spacer-3' }, ''),
|
|
26331
26826
|
h(Text, { key: 'detail-files-title' }, 'Changed files:'),
|
|
26332
26827
|
];
|
|
26828
|
+
// Single-cursor invariant: the file list owns the cursor when the
|
|
26829
|
+
// inspector tab is active; the actions list owns it when the actions
|
|
26830
|
+
// tab is active. Pass `focused` only for the matching tab so users
|
|
26831
|
+
// never see two simultaneous selection highlights inside the panel.
|
|
26832
|
+
const fileListFocused = focused && state.inspectorTab === 'inspector';
|
|
26333
26833
|
const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
|
|
26334
|
-
const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex,
|
|
26335
|
-
//
|
|
26336
|
-
//
|
|
26337
|
-
//
|
|
26338
|
-
//
|
|
26339
|
-
//
|
|
26834
|
+
const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, fileListFocused, fileListMaxRows, width, theme);
|
|
26835
|
+
// Tab indicator. Renders in BOTH tabbed (short-terminal) mode and
|
|
26836
|
+
// tall-stacked mode so the user can always see which tab the cursor
|
|
26837
|
+
// owns and learn the `[/]` toggle. Without this on tall terminals,
|
|
26838
|
+
// the actions list looked like a static cheat-sheet — there was no
|
|
26839
|
+
// visible signal that the cursor could move into it.
|
|
26840
|
+
//
|
|
26841
|
+
// Spacing between tab labels comes from the labels' own padding
|
|
26842
|
+
// (the active label is bracketed `[Inspector]` while the inactive
|
|
26843
|
+
// one is space-padded ` Inspector `, so adjacency reads cleanly).
|
|
26844
|
+
// Earlier revisions stuck a raw `' '` between the Text children to
|
|
26845
|
+
// pad them visually — that crashes Ink at first paint with
|
|
26846
|
+
// "Text string ' ' must be rendered inside <Text> component"
|
|
26847
|
+
// because Box only accepts component children, never bare strings.
|
|
26848
|
+
const activeTab = state.inspectorTab;
|
|
26849
|
+
const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
|
|
26850
|
+
bold: activeTab === 'inspector',
|
|
26851
|
+
dimColor: activeTab !== 'inspector',
|
|
26852
|
+
}, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), h(Text, {
|
|
26853
|
+
bold: activeTab === 'actions',
|
|
26854
|
+
dimColor: activeTab !== 'actions',
|
|
26855
|
+
}, activeTab === 'actions' ? '[Actions]' : ' Actions '), ...(focused
|
|
26856
|
+
? [h(Text, { key: 'inspector-tabs-hint', dimColor: true }, ' · ←/→ switch')]
|
|
26857
|
+
: []));
|
|
26858
|
+
// Tabbed mode (short terminals): render only the active tab's
|
|
26859
|
+
// content under the tab header.
|
|
26340
26860
|
if (tabbed) {
|
|
26341
|
-
const activeTab = state.inspectorTab;
|
|
26342
|
-
const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
|
|
26343
|
-
bold: activeTab === 'inspector',
|
|
26344
|
-
dimColor: activeTab !== 'inspector',
|
|
26345
|
-
}, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), ' ', h(Text, {
|
|
26346
|
-
bold: activeTab === 'actions',
|
|
26347
|
-
dimColor: activeTab !== 'actions',
|
|
26348
|
-
}, activeTab === 'actions' ? '[Actions]' : ' Actions '));
|
|
26349
26861
|
return h(Box, {
|
|
26350
26862
|
borderColor: focusBorderColor(theme, focused),
|
|
26351
26863
|
borderStyle: theme.borderStyle,
|
|
@@ -26354,15 +26866,24 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
26354
26866
|
paddingX: 1,
|
|
26355
26867
|
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...(activeTab === 'inspector'
|
|
26356
26868
|
? [...headerNodes, ...fileListNodes]
|
|
26357
|
-
: renderInspectorActionsSection(h, Text, 'history-commit', width, theme
|
|
26358
|
-
|
|
26869
|
+
: renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26870
|
+
cursorIndex: state.inspectorActionIndex,
|
|
26871
|
+
cursorActive: focused,
|
|
26872
|
+
})));
|
|
26873
|
+
}
|
|
26874
|
+
// Tall mode: stack both sections so the user can read everything at
|
|
26875
|
+
// once, but show the tab header so the active section (and the
|
|
26876
|
+
// `[/]` switch affordance) is visible.
|
|
26359
26877
|
return h(Box, {
|
|
26360
26878
|
borderColor: focusBorderColor(theme, focused),
|
|
26361
26879
|
borderStyle: theme.borderStyle,
|
|
26362
26880
|
flexDirection: 'column',
|
|
26363
26881
|
width,
|
|
26364
26882
|
paddingX: 1,
|
|
26365
|
-
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme
|
|
26883
|
+
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26884
|
+
cursorIndex: state.inspectorActionIndex,
|
|
26885
|
+
cursorActive: focused && state.inspectorTab === 'actions',
|
|
26886
|
+
}));
|
|
26366
26887
|
}
|
|
26367
26888
|
/**
|
|
26368
26889
|
* Render the trailing "Actions:" section that surfaces which keystrokes
|
|
@@ -26375,7 +26896,7 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
26375
26896
|
* minimum from `getLogInkLayout`) so an overflowing label never wraps and
|
|
26376
26897
|
* collides with the next row.
|
|
26377
26898
|
*/
|
|
26378
|
-
function renderInspectorActionsSection(h, Text, context, width, theme) {
|
|
26899
|
+
function renderInspectorActionsSection(h, Text, context, width, theme, options = {}) {
|
|
26379
26900
|
const actions = getInspectorActions(context);
|
|
26380
26901
|
if (!actions.length)
|
|
26381
26902
|
return [];
|
|
@@ -26386,10 +26907,13 @@ function renderInspectorActionsSection(h, Text, context, width, theme) {
|
|
|
26386
26907
|
const GAP = ' ';
|
|
26387
26908
|
const DESTRUCTIVE_SUFFIX = ' [!]';
|
|
26388
26909
|
const labelBudget = Math.max(4, width - 4 /* border + padX */ - KEY_COLUMN - GAP.length - DESTRUCTIVE_SUFFIX.length);
|
|
26910
|
+
const cursorIndex = options.cursorIndex ?? 0;
|
|
26911
|
+
const cursorActive = options.cursorActive ?? false;
|
|
26389
26912
|
const nodes = [
|
|
26390
26913
|
h(Text, { key: 'actions-spacer' }, ''),
|
|
26391
|
-
h(Text, { key: 'actions-title' }, 'Actions:'),
|
|
26914
|
+
h(Text, { key: 'actions-title' }, cursorActive ? '[Actions]' : 'Actions:'),
|
|
26392
26915
|
...actions.map((action, index) => {
|
|
26916
|
+
const isSelected = cursorActive && index === cursorIndex;
|
|
26393
26917
|
const keyCell = action.key.padEnd(KEY_COLUMN);
|
|
26394
26918
|
const label = truncate$1(action.label, labelBudget);
|
|
26395
26919
|
const children = [
|
|
@@ -26407,7 +26931,11 @@ function renderInspectorActionsSection(h, Text, context, width, theme) {
|
|
|
26407
26931
|
dimColor: false,
|
|
26408
26932
|
}, DESTRUCTIVE_SUFFIX));
|
|
26409
26933
|
}
|
|
26410
|
-
return h(Text, {
|
|
26934
|
+
return h(Text, {
|
|
26935
|
+
key: `actions-${index}`,
|
|
26936
|
+
backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
26937
|
+
inverse: isSelected,
|
|
26938
|
+
}, ...children);
|
|
26411
26939
|
}),
|
|
26412
26940
|
];
|
|
26413
26941
|
return nodes;
|