git-coco 0.39.0 → 0.40.0
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 +642 -156
- package/dist/index.js +642 -156
- package/package.json +1 -1
package/dist/index.esm.mjs
CHANGED
|
@@ -53,7 +53,7 @@ import { pathToFileURL } from 'url';
|
|
|
53
53
|
/**
|
|
54
54
|
* Current build version from package.json
|
|
55
55
|
*/
|
|
56
|
-
const BUILD_VERSION = "0.
|
|
56
|
+
const BUILD_VERSION = "0.40.0";
|
|
57
57
|
|
|
58
58
|
const isInteractive = (config) => {
|
|
59
59
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -14880,6 +14880,35 @@ function getLogInkWorkflowActions() {
|
|
|
14880
14880
|
kind: 'normal',
|
|
14881
14881
|
requiresConfirmation: false,
|
|
14882
14882
|
},
|
|
14883
|
+
// Status surface group-level batch ops (#791 follow-up). Triggered
|
|
14884
|
+
// by Enter when the cursor is on a status group header
|
|
14885
|
+
// (Staged / Unstaged / Untracked). Empty `key` keeps them
|
|
14886
|
+
// palette-discoverable without registering a global hotkey — the
|
|
14887
|
+
// Enter-on-header path in inkInput is the canonical trigger.
|
|
14888
|
+
{
|
|
14889
|
+
id: 'unstage-all-staged',
|
|
14890
|
+
key: '',
|
|
14891
|
+
label: 'Unstage all staged files',
|
|
14892
|
+
description: 'Unstage every file currently in the staged group.',
|
|
14893
|
+
kind: 'normal',
|
|
14894
|
+
requiresConfirmation: false,
|
|
14895
|
+
},
|
|
14896
|
+
{
|
|
14897
|
+
id: 'stage-all-unstaged',
|
|
14898
|
+
key: '',
|
|
14899
|
+
label: 'Stage all unstaged files',
|
|
14900
|
+
description: 'Stage every modified-but-not-staged file.',
|
|
14901
|
+
kind: 'normal',
|
|
14902
|
+
requiresConfirmation: false,
|
|
14903
|
+
},
|
|
14904
|
+
{
|
|
14905
|
+
id: 'stage-all-untracked',
|
|
14906
|
+
key: '',
|
|
14907
|
+
label: 'Stage all untracked files',
|
|
14908
|
+
description: 'Add every untracked file to the index after confirmation.',
|
|
14909
|
+
kind: 'destructive',
|
|
14910
|
+
requiresConfirmation: true,
|
|
14911
|
+
},
|
|
14883
14912
|
{
|
|
14884
14913
|
id: 'delete-branch',
|
|
14885
14914
|
key: 'D',
|
|
@@ -15897,6 +15926,88 @@ function extractDiffHunk(input) {
|
|
|
15897
15926
|
return { patchText };
|
|
15898
15927
|
}
|
|
15899
15928
|
|
|
15929
|
+
/**
|
|
15930
|
+
* Hardcoded per-entity action lists surfaced inside the right-hand
|
|
15931
|
+
* inspector panel. The inspector used to repeat the repo / branch /
|
|
15932
|
+
* status content the top header and left sidebar already show; we drop
|
|
15933
|
+
* that trailer in favor of an actionable cheat-sheet so the user knows
|
|
15934
|
+
* exactly which keystrokes apply to whatever they have under the cursor.
|
|
15935
|
+
*
|
|
15936
|
+
* Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
|
|
15937
|
+
* - Most per-entity actions live in `inkInput.ts` as direct keystroke
|
|
15938
|
+
* handlers (e.g. `c` cherry-pick, `R` revert) rather than as
|
|
15939
|
+
* globally-registered bindings, so the registry would be a partial
|
|
15940
|
+
* view at best.
|
|
15941
|
+
* - The bindings registry's `contexts` model (normal / search / focus
|
|
15942
|
+
* name) does not cleanly map to inspector entity types like "branch"
|
|
15943
|
+
* or "tag". Filtering it would mean replicating the same per-view
|
|
15944
|
+
* scoping logic the input dispatcher already encodes.
|
|
15945
|
+
* - New per-entity actions are added infrequently — the maintenance
|
|
15946
|
+
* cost of mirroring them here is low and keeps this file the single
|
|
15947
|
+
* source of truth for "what shows in the inspector".
|
|
15948
|
+
*
|
|
15949
|
+
* If you wire up a new per-entity keystroke in `inkInput.ts` — for
|
|
15950
|
+
* example a "create branch from this commit" or "create tag from this
|
|
15951
|
+
* commit" action — add the matching row to the relevant array below so
|
|
15952
|
+
* it shows up in the inspector automatically.
|
|
15953
|
+
*/
|
|
15954
|
+
const HISTORY_COMMIT_ACTIONS = [
|
|
15955
|
+
{ key: 'enter', label: 'Open diff' },
|
|
15956
|
+
{ key: 'c', label: 'Cherry-pick' },
|
|
15957
|
+
{ key: 'R', label: 'Revert', destructive: true },
|
|
15958
|
+
{ key: 'Z', label: 'Reset to commit', destructive: true },
|
|
15959
|
+
{ key: 'i', label: 'Interactive rebase', destructive: true },
|
|
15960
|
+
{ key: 'y', label: 'Yank hash' },
|
|
15961
|
+
{ key: 'Y', label: 'Yank short hash' },
|
|
15962
|
+
{ key: 'O', label: 'Open in browser' },
|
|
15963
|
+
];
|
|
15964
|
+
const BRANCH_ACTIONS = [
|
|
15965
|
+
{ key: 'enter', label: 'Checkout' },
|
|
15966
|
+
{ key: '+', label: 'New branch' },
|
|
15967
|
+
{ key: 'R', label: 'Rename' },
|
|
15968
|
+
{ key: 'u', label: 'Set upstream' },
|
|
15969
|
+
{ key: 'D', label: 'Delete', destructive: true },
|
|
15970
|
+
{ key: 'P', label: 'Push current' },
|
|
15971
|
+
{ key: 'F', label: 'Fetch all' },
|
|
15972
|
+
{ key: 'y', label: 'Yank name' },
|
|
15973
|
+
];
|
|
15974
|
+
const TAG_ACTIONS = [
|
|
15975
|
+
{ key: '+', label: 'New tag' },
|
|
15976
|
+
{ key: 'P', label: 'Push tag' },
|
|
15977
|
+
{ key: 'T', label: 'Delete', destructive: true },
|
|
15978
|
+
{ key: 'R', label: 'Delete remote', destructive: true },
|
|
15979
|
+
{ key: 'y', label: 'Yank name' },
|
|
15980
|
+
];
|
|
15981
|
+
const STASH_ACTIONS = [
|
|
15982
|
+
{ key: 'enter', label: 'Open diff' },
|
|
15983
|
+
{ key: 'a', label: 'Apply' },
|
|
15984
|
+
{ key: 'p', label: 'Pop' },
|
|
15985
|
+
{ key: 'X', label: 'Drop', destructive: true },
|
|
15986
|
+
{ key: 'y', label: 'Yank ref' },
|
|
15987
|
+
];
|
|
15988
|
+
const WORKTREE_ACTIONS = [
|
|
15989
|
+
{ key: 'W', label: 'Remove', destructive: true },
|
|
15990
|
+
{ key: 'y', label: 'Yank path' },
|
|
15991
|
+
];
|
|
15992
|
+
function getInspectorActions(context) {
|
|
15993
|
+
switch (context) {
|
|
15994
|
+
case 'history-commit':
|
|
15995
|
+
return HISTORY_COMMIT_ACTIONS;
|
|
15996
|
+
case 'branch':
|
|
15997
|
+
return BRANCH_ACTIONS;
|
|
15998
|
+
case 'tag':
|
|
15999
|
+
return TAG_ACTIONS;
|
|
16000
|
+
case 'stash':
|
|
16001
|
+
return STASH_ACTIONS;
|
|
16002
|
+
case 'worktree':
|
|
16003
|
+
return WORKTREE_ACTIONS;
|
|
16004
|
+
default: {
|
|
16005
|
+
const exhaustive = context;
|
|
16006
|
+
throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
|
|
16007
|
+
}
|
|
16008
|
+
}
|
|
16009
|
+
}
|
|
16010
|
+
|
|
15900
16011
|
/**
|
|
15901
16012
|
* Sort modes for the promoted views (P4.2).
|
|
15902
16013
|
*
|
|
@@ -16103,6 +16214,7 @@ function withPushedView(state, value) {
|
|
|
16103
16214
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
16104
16215
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
16105
16216
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
16217
|
+
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
16106
16218
|
pendingKey: undefined,
|
|
16107
16219
|
};
|
|
16108
16220
|
}
|
|
@@ -16125,6 +16237,7 @@ function withPoppedView(state) {
|
|
|
16125
16237
|
diffSource: next === 'diff' ? state.diffSource : undefined,
|
|
16126
16238
|
stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
|
|
16127
16239
|
pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
|
|
16240
|
+
statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
|
|
16128
16241
|
pendingKey: undefined,
|
|
16129
16242
|
};
|
|
16130
16243
|
}
|
|
@@ -16142,6 +16255,7 @@ function withReplacedView(state, value) {
|
|
|
16142
16255
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
16143
16256
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
16144
16257
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
16258
|
+
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
16145
16259
|
pendingKey: undefined,
|
|
16146
16260
|
};
|
|
16147
16261
|
}
|
|
@@ -16273,9 +16387,12 @@ function createLogInkState(rows, options = {}) {
|
|
|
16273
16387
|
focus: 'commits',
|
|
16274
16388
|
sidebarTab: 'status',
|
|
16275
16389
|
userSidebarTab: 'status',
|
|
16390
|
+
sidebarHeaderFocused: false,
|
|
16391
|
+
statusGroupHeaderFocused: false,
|
|
16276
16392
|
statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
|
|
16277
16393
|
diffViewMode: 'unified',
|
|
16278
16394
|
inspectorTab: 'inspector',
|
|
16395
|
+
inspectorActionIndex: 0,
|
|
16279
16396
|
};
|
|
16280
16397
|
}
|
|
16281
16398
|
function getSelectedInkCommit(state) {
|
|
@@ -16316,12 +16433,21 @@ function applyLogInkAction(state, action) {
|
|
|
16316
16433
|
return {
|
|
16317
16434
|
...state,
|
|
16318
16435
|
focus: cycleValue(FOCUS_ORDER, state.focus, 1),
|
|
16436
|
+
// Reset header focus when leaving the sidebar so the next
|
|
16437
|
+
// re-entry starts on items rather than mid-flag.
|
|
16438
|
+
sidebarHeaderFocused: false,
|
|
16439
|
+
// Same idea for the status group header — Tab cycling away
|
|
16440
|
+
// from 'commits' should always land back on a real file when
|
|
16441
|
+
// the user returns.
|
|
16442
|
+
statusGroupHeaderFocused: false,
|
|
16319
16443
|
pendingKey: undefined,
|
|
16320
16444
|
};
|
|
16321
16445
|
case 'focusPrevious':
|
|
16322
16446
|
return {
|
|
16323
16447
|
...state,
|
|
16324
16448
|
focus: cycleValue(FOCUS_ORDER, state.focus, -1),
|
|
16449
|
+
sidebarHeaderFocused: false,
|
|
16450
|
+
statusGroupHeaderFocused: false,
|
|
16325
16451
|
pendingKey: undefined,
|
|
16326
16452
|
};
|
|
16327
16453
|
case 'move':
|
|
@@ -16386,6 +16512,9 @@ function applyLogInkAction(state, action) {
|
|
|
16386
16512
|
selectedWorktreeFileIndex: clampIndex$1(state.selectedWorktreeFileIndex + action.delta, action.fileCount),
|
|
16387
16513
|
selectedWorktreeHunkIndex: 0,
|
|
16388
16514
|
worktreeDiffOffset: 0,
|
|
16515
|
+
// Cursor moved to a real file row — drop header focus so the
|
|
16516
|
+
// file Enter handler (open diff) is what fires next.
|
|
16517
|
+
statusGroupHeaderFocused: false,
|
|
16389
16518
|
};
|
|
16390
16519
|
}
|
|
16391
16520
|
case 'moveBranch':
|
|
@@ -16404,10 +16533,40 @@ function applyLogInkAction(state, action) {
|
|
|
16404
16533
|
selectedBranchIndex: 0,
|
|
16405
16534
|
pendingKey: undefined,
|
|
16406
16535
|
};
|
|
16536
|
+
case 'setSidebarHeaderFocused':
|
|
16537
|
+
return {
|
|
16538
|
+
...state,
|
|
16539
|
+
sidebarHeaderFocused: action.value,
|
|
16540
|
+
pendingKey: undefined,
|
|
16541
|
+
};
|
|
16542
|
+
case 'setStatusGroupHeaderFocused':
|
|
16543
|
+
return {
|
|
16544
|
+
...state,
|
|
16545
|
+
statusGroupHeaderFocused: action.value,
|
|
16546
|
+
pendingKey: undefined,
|
|
16547
|
+
};
|
|
16548
|
+
case 'jumpToStatusGroup':
|
|
16549
|
+
// Used by ←/→ on the status surface to land on the first file of
|
|
16550
|
+
// the previous / next non-empty group. Clears header focus so the
|
|
16551
|
+
// user is on a real file after the jump (matches the
|
|
16552
|
+
// sidebar pattern where ←/→ between tabs lands on items, not on
|
|
16553
|
+
// the next tab's header).
|
|
16554
|
+
return {
|
|
16555
|
+
...state,
|
|
16556
|
+
selectedWorktreeFileIndex: Math.max(0, action.targetIndex),
|
|
16557
|
+
selectedWorktreeHunkIndex: 0,
|
|
16558
|
+
worktreeDiffOffset: 0,
|
|
16559
|
+
statusGroupHeaderFocused: false,
|
|
16560
|
+
pendingKey: undefined,
|
|
16561
|
+
};
|
|
16407
16562
|
case 'setInspectorTab':
|
|
16408
16563
|
return {
|
|
16409
16564
|
...state,
|
|
16410
16565
|
inspectorTab: action.value,
|
|
16566
|
+
// Reset the action cursor so a fresh tab visit always starts
|
|
16567
|
+
// on the first action, regardless of where the user left off
|
|
16568
|
+
// in a previous entity context.
|
|
16569
|
+
inspectorActionIndex: 0,
|
|
16411
16570
|
pendingKey: undefined,
|
|
16412
16571
|
};
|
|
16413
16572
|
case 'cycleInspectorTab': {
|
|
@@ -16419,9 +16578,22 @@ function applyLogInkAction(state, action) {
|
|
|
16419
16578
|
return {
|
|
16420
16579
|
...state,
|
|
16421
16580
|
inspectorTab: next,
|
|
16581
|
+
inspectorActionIndex: 0,
|
|
16422
16582
|
pendingKey: undefined,
|
|
16423
16583
|
};
|
|
16424
16584
|
}
|
|
16585
|
+
case 'moveInspectorAction':
|
|
16586
|
+
return {
|
|
16587
|
+
...state,
|
|
16588
|
+
inspectorActionIndex: clampIndex$1(state.inspectorActionIndex + action.delta, action.actionCount),
|
|
16589
|
+
pendingKey: undefined,
|
|
16590
|
+
};
|
|
16591
|
+
case 'resetInspectorActionIndex':
|
|
16592
|
+
return {
|
|
16593
|
+
...state,
|
|
16594
|
+
inspectorActionIndex: 0,
|
|
16595
|
+
pendingKey: undefined,
|
|
16596
|
+
};
|
|
16425
16597
|
case 'moveTag':
|
|
16426
16598
|
return {
|
|
16427
16599
|
...state,
|
|
@@ -16491,6 +16663,10 @@ function applyLogInkAction(state, action) {
|
|
|
16491
16663
|
...state,
|
|
16492
16664
|
statusFilterMask: allOff ? { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK } : next,
|
|
16493
16665
|
selectedWorktreeFileIndex: 0,
|
|
16666
|
+
// Group composition changed — header focus would be ambiguous
|
|
16667
|
+
// (cursor lands on file 0 which may belong to a different
|
|
16668
|
+
// group now). Reset to clear the indicator.
|
|
16669
|
+
statusGroupHeaderFocused: false,
|
|
16494
16670
|
pendingKey: undefined,
|
|
16495
16671
|
};
|
|
16496
16672
|
}
|
|
@@ -16661,6 +16837,13 @@ function applyLogInkAction(state, action) {
|
|
|
16661
16837
|
return {
|
|
16662
16838
|
...state,
|
|
16663
16839
|
focus: action.value,
|
|
16840
|
+
// Reset sidebar header focus when leaving the sidebar so a
|
|
16841
|
+
// re-entry starts on items rather than mid-flag.
|
|
16842
|
+
sidebarHeaderFocused: action.value === 'sidebar' ? state.sidebarHeaderFocused : false,
|
|
16843
|
+
// The status group header lives in the 'commits' focus on
|
|
16844
|
+
// the status view — clear when focus moves away so a
|
|
16845
|
+
// re-entry starts on a real file.
|
|
16846
|
+
statusGroupHeaderFocused: action.value === 'commits' ? state.statusGroupHeaderFocused : false,
|
|
16664
16847
|
pendingKey: undefined,
|
|
16665
16848
|
};
|
|
16666
16849
|
case 'setPendingKey':
|
|
@@ -16862,6 +17045,82 @@ function action(actionValue) {
|
|
|
16862
17045
|
action: actionValue,
|
|
16863
17046
|
};
|
|
16864
17047
|
}
|
|
17048
|
+
/**
|
|
17049
|
+
* Resolve which inspector action context applies for the current
|
|
17050
|
+
* state. Today only history commits expose actions in the inspector
|
|
17051
|
+
* (the renderer hard-coded `'history-commit'`); future PRs can fan
|
|
17052
|
+
* this out to branch / tag / stash / worktree contexts as the
|
|
17053
|
+
* inspector gains entity-aware sections. Returns `undefined` when no
|
|
17054
|
+
* actions section should be shown (so the cursor model stays a
|
|
17055
|
+
* no-op).
|
|
17056
|
+
*/
|
|
17057
|
+
function resolveInspectorActionContext(state) {
|
|
17058
|
+
if (state.activeView === 'history' && !state.pendingCommitFocused) {
|
|
17059
|
+
return 'history-commit';
|
|
17060
|
+
}
|
|
17061
|
+
return undefined;
|
|
17062
|
+
}
|
|
17063
|
+
function getInspectorActionsForState(state) {
|
|
17064
|
+
const ctx = resolveInspectorActionContext(state);
|
|
17065
|
+
return ctx ? getInspectorActions(ctx) : [];
|
|
17066
|
+
}
|
|
17067
|
+
/**
|
|
17068
|
+
* Synthesize the events that fire when the user presses Enter on a
|
|
17069
|
+
* cursored inspector action (#791 follow-up). Mirrors
|
|
17070
|
+
* `getLogInkPaletteExecuteEvents` — each action's `key` field
|
|
17071
|
+
* routes to the same dispatch the corresponding keystroke would
|
|
17072
|
+
* trigger from the history view's commit cursor. Per-key dispatch
|
|
17073
|
+
* (rather than recursively re-running the keystroke through
|
|
17074
|
+
* `getLogInkInputEvents`) avoids the gating problem: most history
|
|
17075
|
+
* keystroke handlers require `state.focus === 'commits'`, but the
|
|
17076
|
+
* inspector executor fires from `state.focus === 'detail'`.
|
|
17077
|
+
*/
|
|
17078
|
+
function getInspectorActionExecuteEvents(inspectorAction, state) {
|
|
17079
|
+
const commit = state.filteredCommits[state.selectedIndex];
|
|
17080
|
+
const requireCommit = (fn) => {
|
|
17081
|
+
if (!commit) {
|
|
17082
|
+
return [action({ type: 'setStatus', value: 'No commit selected' })];
|
|
17083
|
+
}
|
|
17084
|
+
return fn(commit.hash, state.selectedIndex);
|
|
17085
|
+
};
|
|
17086
|
+
switch (inspectorAction.key) {
|
|
17087
|
+
case 'enter':
|
|
17088
|
+
return requireCommit((sha, commitIndex) => [
|
|
17089
|
+
action({ type: 'navigateOpenDiffForCommit', sha, commitIndex }),
|
|
17090
|
+
]);
|
|
17091
|
+
case 'c':
|
|
17092
|
+
return requireCommit(() => [
|
|
17093
|
+
action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' }),
|
|
17094
|
+
]);
|
|
17095
|
+
case 'R':
|
|
17096
|
+
return requireCommit(() => [
|
|
17097
|
+
action({ type: 'setPendingConfirmation', value: 'revert-commit' }),
|
|
17098
|
+
]);
|
|
17099
|
+
case 'Z':
|
|
17100
|
+
return requireCommit(() => [
|
|
17101
|
+
action({
|
|
17102
|
+
type: 'openInputPrompt',
|
|
17103
|
+
kind: 'reset-mode',
|
|
17104
|
+
label: 'Reset mode (soft / mixed / hard)',
|
|
17105
|
+
}),
|
|
17106
|
+
]);
|
|
17107
|
+
case 'i':
|
|
17108
|
+
return requireCommit(() => [
|
|
17109
|
+
action({ type: 'setPendingConfirmation', value: 'interactive-rebase' }),
|
|
17110
|
+
]);
|
|
17111
|
+
case 'y':
|
|
17112
|
+
return requireCommit(() => [{ type: 'yankFromActiveView' }]);
|
|
17113
|
+
case 'Y':
|
|
17114
|
+
return requireCommit(() => [{ type: 'yankFromActiveView', short: true }]);
|
|
17115
|
+
case 'O':
|
|
17116
|
+
return [{ type: 'runWorkflowAction', id: 'open-pr' }];
|
|
17117
|
+
default:
|
|
17118
|
+
return [action({
|
|
17119
|
+
type: 'setStatus',
|
|
17120
|
+
value: `Action ${inspectorAction.key} not yet wired`,
|
|
17121
|
+
})];
|
|
17122
|
+
}
|
|
17123
|
+
}
|
|
16865
17124
|
/**
|
|
16866
17125
|
* Build the events needed to apply the hunk under the diff cursor. The
|
|
16867
17126
|
* runtime workflow handler expects payload format `<target>\n<patch>`
|
|
@@ -17673,11 +17932,62 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17673
17932
|
if (key.rightArrow && state.focus === 'sidebar') {
|
|
17674
17933
|
return [action({ type: 'nextSidebarTab' })];
|
|
17675
17934
|
}
|
|
17935
|
+
// ←/→ on the status surface jump between the staged / unstaged /
|
|
17936
|
+
// untracked groups — the horizontal axis is "between groups", the
|
|
17937
|
+
// vertical axis (↑/↓ below) is "within the active group's files".
|
|
17938
|
+
// Lands on the first file of the target group (clears header
|
|
17939
|
+
// focus) so the user is always on a real file after a jump,
|
|
17940
|
+
// mirroring the sidebar's tab-switch landing behavior.
|
|
17941
|
+
if ((key.leftArrow || key.rightArrow) &&
|
|
17942
|
+
state.activeView === 'status' &&
|
|
17943
|
+
state.focus === 'commits' &&
|
|
17944
|
+
context.statusGroups &&
|
|
17945
|
+
context.statusGroups.length > 1) {
|
|
17946
|
+
const groups = context.statusGroups;
|
|
17947
|
+
const currentIndex = groups.findIndex((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
|
|
17948
|
+
state.selectedWorktreeFileIndex < group.startIndex + group.count);
|
|
17949
|
+
const fallback = currentIndex >= 0 ? currentIndex : 0;
|
|
17950
|
+
const delta = key.leftArrow ? -1 : 1;
|
|
17951
|
+
const nextIndex = Math.max(0, Math.min(groups.length - 1, fallback + delta));
|
|
17952
|
+
if (nextIndex !== fallback) {
|
|
17953
|
+
return [action({ type: 'jumpToStatusGroup', targetIndex: groups[nextIndex].startIndex })];
|
|
17954
|
+
}
|
|
17955
|
+
return [];
|
|
17956
|
+
}
|
|
17676
17957
|
if (key.upArrow || inputValue === 'k') {
|
|
17958
|
+
// Inspector Actions tab: ↑/↓ moves the cursor through the
|
|
17959
|
+
// executable action list. Wins over moveDetailFile so a
|
|
17960
|
+
// history-commit explore with both file list AND actions visible
|
|
17961
|
+
// navigates the actions when the user has [/]-toggled to the
|
|
17962
|
+
// actions tab. (#791 follow-up)
|
|
17963
|
+
if (state.focus === 'detail' && state.inspectorTab === 'actions' && context.inspectorActionCount) {
|
|
17964
|
+
return [action({
|
|
17965
|
+
type: 'moveInspectorAction',
|
|
17966
|
+
delta: -1,
|
|
17967
|
+
actionCount: context.inspectorActionCount,
|
|
17968
|
+
})];
|
|
17969
|
+
}
|
|
17677
17970
|
if (state.focus === 'detail' && context.detailFileCount) {
|
|
17678
17971
|
return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
|
|
17679
17972
|
}
|
|
17680
17973
|
if (state.activeView === 'status' && context.worktreeFileCount) {
|
|
17974
|
+
// Already on the group header — ↑ is a no-op (use ←/→ to switch
|
|
17975
|
+
// groups). Mirrors the sidebar's "header is the top of the
|
|
17976
|
+
// hierarchy" behavior.
|
|
17977
|
+
if (state.statusGroupHeaderFocused) {
|
|
17978
|
+
return [];
|
|
17979
|
+
}
|
|
17980
|
+
// Cursor at the first file of its group → promote to the group
|
|
17981
|
+
// header rather than crossing the boundary into the previous
|
|
17982
|
+
// group's last file. Keeps the cursor inside its current
|
|
17983
|
+
// container; ←/→ is the explicit way to move between groups.
|
|
17984
|
+
if (context.statusGroups && context.statusGroups.length > 0) {
|
|
17985
|
+
const currentGroup = context.statusGroups.find((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
|
|
17986
|
+
state.selectedWorktreeFileIndex < group.startIndex + group.count);
|
|
17987
|
+
if (currentGroup && state.selectedWorktreeFileIndex === currentGroup.startIndex) {
|
|
17988
|
+
return [action({ type: 'setStatusGroupHeaderFocused', value: true })];
|
|
17989
|
+
}
|
|
17990
|
+
}
|
|
17681
17991
|
return [action({
|
|
17682
17992
|
type: 'moveWorktreeFile',
|
|
17683
17993
|
delta: -1,
|
|
@@ -17702,6 +18012,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17702
18012
|
previewLineCount: context.previewLineCount,
|
|
17703
18013
|
})];
|
|
17704
18014
|
}
|
|
18015
|
+
// Sidebar header focus: ↑ at item index 0 promotes the cursor
|
|
18016
|
+
// onto the active tab's header. Pressing ↑ again is a no-op
|
|
18017
|
+
// (use ←/→ to switch between tab headers, Enter to drill in).
|
|
18018
|
+
// Only triggers when the sidebar is focused on a content tab —
|
|
18019
|
+
// dedicated promoted views (`g b` etc.) keep the legacy clamp
|
|
18020
|
+
// behavior because they have no header to escape to.
|
|
18021
|
+
if (state.focus === 'sidebar' && !state.sidebarHeaderFocused) {
|
|
18022
|
+
if (state.sidebarTab === 'branches' && state.selectedBranchIndex === 0 && (context.branchCount ?? 0) > 0) {
|
|
18023
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18024
|
+
}
|
|
18025
|
+
if (state.sidebarTab === 'tags' && state.selectedTagIndex === 0 && (context.tagCount ?? 0) > 0) {
|
|
18026
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18027
|
+
}
|
|
18028
|
+
if (state.sidebarTab === 'stashes' && state.selectedStashIndex === 0 && (context.stashCount ?? 0) > 0) {
|
|
18029
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18030
|
+
}
|
|
18031
|
+
if (state.sidebarTab === 'worktrees' && state.selectedWorktreeListIndex === 0 && (context.worktreeListCount ?? 0) > 0) {
|
|
18032
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18033
|
+
}
|
|
18034
|
+
}
|
|
18035
|
+
// Already on the header — ↑ is a no-op (←/→ switches tabs).
|
|
18036
|
+
if (state.focus === 'sidebar' && state.sidebarHeaderFocused) {
|
|
18037
|
+
return [];
|
|
18038
|
+
}
|
|
17705
18039
|
if (isBranchActionTarget(state) && context.branchCount) {
|
|
17706
18040
|
return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
|
|
17707
18041
|
}
|
|
@@ -17736,10 +18070,23 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17736
18070
|
if (state.activeView === 'history' && state.pendingCommitFocused) {
|
|
17737
18071
|
return [action({ type: 'unfocusPendingCommit' })];
|
|
17738
18072
|
}
|
|
18073
|
+
if (state.focus === 'detail' && state.inspectorTab === 'actions' && context.inspectorActionCount) {
|
|
18074
|
+
return [action({
|
|
18075
|
+
type: 'moveInspectorAction',
|
|
18076
|
+
delta: 1,
|
|
18077
|
+
actionCount: context.inspectorActionCount,
|
|
18078
|
+
})];
|
|
18079
|
+
}
|
|
17739
18080
|
if (state.focus === 'detail' && context.detailFileCount) {
|
|
17740
18081
|
return [action({ type: 'moveDetailFile', delta: 1, fileCount: context.detailFileCount })];
|
|
17741
18082
|
}
|
|
17742
18083
|
if (state.activeView === 'status' && context.worktreeFileCount) {
|
|
18084
|
+
// Header focused → ↓ re-enters the group at the cursored file
|
|
18085
|
+
// (which is already the group's first file by construction).
|
|
18086
|
+
// Just clear the flag.
|
|
18087
|
+
if (state.statusGroupHeaderFocused) {
|
|
18088
|
+
return [action({ type: 'setStatusGroupHeaderFocused', value: false })];
|
|
18089
|
+
}
|
|
17743
18090
|
return [action({
|
|
17744
18091
|
type: 'moveWorktreeFile',
|
|
17745
18092
|
delta: 1,
|
|
@@ -17760,6 +18107,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17760
18107
|
previewLineCount: context.previewLineCount,
|
|
17761
18108
|
})];
|
|
17762
18109
|
}
|
|
18110
|
+
// Sidebar header focused: ↓ re-enters the list at index 0.
|
|
18111
|
+
// Clears the header flag and snaps the per-entity selection to 0
|
|
18112
|
+
// (mirrors the existing default selection behavior on first
|
|
18113
|
+
// sidebar focus).
|
|
18114
|
+
if (state.focus === 'sidebar' && state.sidebarHeaderFocused) {
|
|
18115
|
+
return [action({ type: 'setSidebarHeaderFocused', value: false })];
|
|
18116
|
+
}
|
|
17763
18117
|
if (isBranchActionTarget(state) && context.branchCount) {
|
|
17764
18118
|
return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
|
|
17765
18119
|
}
|
|
@@ -17853,6 +18207,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17853
18207
|
];
|
|
17854
18208
|
}
|
|
17855
18209
|
}
|
|
18210
|
+
// Inspector Actions tab: Enter on the cursored action fires its
|
|
18211
|
+
// associated event (cherry-pick / revert / yank / etc.). Wins over
|
|
18212
|
+
// the file-list Enter below when the user has [/]-toggled to the
|
|
18213
|
+
// actions tab. Routes through `getInspectorActionExecuteEvents` so
|
|
18214
|
+
// the per-action dispatch table stays the single source of truth
|
|
18215
|
+
// for what each action does. (#791 follow-up)
|
|
18216
|
+
if (key.return &&
|
|
18217
|
+
state.focus === 'detail' &&
|
|
18218
|
+
state.inspectorTab === 'actions') {
|
|
18219
|
+
const actions = getInspectorActionsForState(state);
|
|
18220
|
+
const cursored = actions[state.inspectorActionIndex];
|
|
18221
|
+
if (cursored) {
|
|
18222
|
+
return getInspectorActionExecuteEvents(cursored, state);
|
|
18223
|
+
}
|
|
18224
|
+
}
|
|
17856
18225
|
// From the inspector / commit-diff detail panel, Enter opens (or refocuses)
|
|
17857
18226
|
// the diff view scoped to the currently-selected commit and file. Lets the
|
|
17858
18227
|
// user drive the explore flow entirely from the right panel: j/k picks a
|
|
@@ -17891,7 +18260,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17891
18260
|
const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
|
|
17892
18261
|
const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
|
|
17893
18262
|
sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
|
|
17894
|
-
|
|
18263
|
+
// Three cases drill into the dedicated view:
|
|
18264
|
+
// 1. The cursor is on the tab header (user pressed ↑ at the
|
|
18265
|
+
// top of the list to escape the items — Enter explicitly
|
|
18266
|
+
// jumps to the dedicated view).
|
|
18267
|
+
// 2. The tab has no in-sidebar primary action defined (status,
|
|
18268
|
+
// tags, worktrees — drilling in is the canonical path).
|
|
18269
|
+
// 3. The tab has zero items (the dedicated view's empty state
|
|
18270
|
+
// tells the user what to do next).
|
|
18271
|
+
if (state.sidebarHeaderFocused || !hasInSidebarPrimaryAction) {
|
|
17895
18272
|
const tabToView = {
|
|
17896
18273
|
status: 'status',
|
|
17897
18274
|
branches: 'branches',
|
|
@@ -17911,6 +18288,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17911
18288
|
// Fall through — per-entity Enter handler below claims the keystroke.
|
|
17912
18289
|
}
|
|
17913
18290
|
if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
|
|
18291
|
+
// Group header focused → fire the group's batch workflow action.
|
|
18292
|
+
// Routed through the workflow runner so the runtime owns the
|
|
18293
|
+
// git invocation + status messaging consistently with the
|
|
18294
|
+
// single-file `space` toggle. The `payload` carries the group's
|
|
18295
|
+
// state ('staged' / 'unstaged' / 'untracked') so the runtime can
|
|
18296
|
+
// resolve which files to act on without re-deriving group state.
|
|
18297
|
+
if (state.statusGroupHeaderFocused && context.statusGroups) {
|
|
18298
|
+
const currentGroup = context.statusGroups.find((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
|
|
18299
|
+
state.selectedWorktreeFileIndex < group.startIndex + group.count);
|
|
18300
|
+
if (currentGroup) {
|
|
18301
|
+
const workflowId = currentGroup.state === 'staged'
|
|
18302
|
+
? 'unstage-all-staged'
|
|
18303
|
+
: currentGroup.state === 'unstaged'
|
|
18304
|
+
? 'stage-all-unstaged'
|
|
18305
|
+
: 'stage-all-untracked';
|
|
18306
|
+
return [{ type: 'runWorkflowAction', id: workflowId, payload: currentGroup.state }];
|
|
18307
|
+
}
|
|
18308
|
+
}
|
|
17914
18309
|
return [action({
|
|
17915
18310
|
type: 'navigateOpenDiffForWorktreeFile',
|
|
17916
18311
|
fileIndex: state.selectedWorktreeFileIndex,
|
|
@@ -20653,6 +21048,35 @@ function parseStashDiffFiles(lines) {
|
|
|
20653
21048
|
}
|
|
20654
21049
|
return files;
|
|
20655
21050
|
}
|
|
21051
|
+
/**
|
|
21052
|
+
* Resolve which stash file *contains* a given line offset — the user's
|
|
21053
|
+
* cursor scrolls through a concatenated multi-file patch, and this is
|
|
21054
|
+
* what powers the "File N/M: <path>" panel header, the inline header
|
|
21055
|
+
* highlighting (#791 follow-up), and the cherry-pick / open-in-editor
|
|
21056
|
+
* dispatchers' "what file is the cursor on" lookup.
|
|
21057
|
+
*
|
|
21058
|
+
* Returns `undefined` when the file list is empty *or* the offset
|
|
21059
|
+
* lands before the very first file's `diff --git` header (e.g. when
|
|
21060
|
+
* `--stat` summary lines lead the patch). Callers fall through to a
|
|
21061
|
+
* "no file selected" state in that case.
|
|
21062
|
+
*/
|
|
21063
|
+
function findStashFileForOffset(files, offset) {
|
|
21064
|
+
if (files.length === 0)
|
|
21065
|
+
return undefined;
|
|
21066
|
+
let current;
|
|
21067
|
+
for (const file of files) {
|
|
21068
|
+
if (file.startLine <= offset) {
|
|
21069
|
+
current = file;
|
|
21070
|
+
}
|
|
21071
|
+
else {
|
|
21072
|
+
break;
|
|
21073
|
+
}
|
|
21074
|
+
}
|
|
21075
|
+
// First file is the canonical fallback — even if the offset lands
|
|
21076
|
+
// before its header (rare), we want the cursor to be "in" something
|
|
21077
|
+
// so the user's actions have a target.
|
|
21078
|
+
return current ?? files[0];
|
|
21079
|
+
}
|
|
20656
21080
|
const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
|
|
20657
21081
|
function parseDiffGitHeader(line) {
|
|
20658
21082
|
const match = line.match(DIFF_GIT_HEADER);
|
|
@@ -20707,6 +21131,25 @@ function revertFile(git, file) {
|
|
|
20707
21131
|
}
|
|
20708
21132
|
return runAction$3(() => git.raw(['restore', '--', file.path]), `Reverted ${file.path}`);
|
|
20709
21133
|
}
|
|
21134
|
+
/**
|
|
21135
|
+
* Group-level batch ops triggered by Enter on a status group header
|
|
21136
|
+
* (staged / unstaged / untracked). Pass the files belonging to that
|
|
21137
|
+
* group; the helpers run a single `git add` / `git restore --staged`
|
|
21138
|
+
* with all paths in one invocation rather than looping per-file —
|
|
21139
|
+
* faster + atomic from the user's point of view.
|
|
21140
|
+
*/
|
|
21141
|
+
function stageAllFiles(git, files) {
|
|
21142
|
+
if (files.length === 0) {
|
|
21143
|
+
return Promise.resolve({ ok: false, message: 'No files to stage' });
|
|
21144
|
+
}
|
|
21145
|
+
return runAction$3(() => git.raw(['add', '--', ...files.map((file) => file.path)]), `Staged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
|
|
21146
|
+
}
|
|
21147
|
+
function unstageAllFiles(git, files) {
|
|
21148
|
+
if (files.length === 0) {
|
|
21149
|
+
return Promise.resolve({ ok: false, message: 'No files to unstage' });
|
|
21150
|
+
}
|
|
21151
|
+
return runAction$3(() => git.raw(['restore', '--staged', '--', ...files.map((file) => file.path)]), `Unstaged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
|
|
21152
|
+
}
|
|
20710
21153
|
|
|
20711
21154
|
function fileState(indexStatus, worktreeStatus) {
|
|
20712
21155
|
if (indexStatus === '?' && worktreeStatus === '?') {
|
|
@@ -20750,6 +21193,22 @@ function applyStatusFilterMask(files, mask) {
|
|
|
20750
21193
|
}
|
|
20751
21194
|
return files.filter((file) => mask[file.state]);
|
|
20752
21195
|
}
|
|
21196
|
+
const WORKTREE_GROUP_ORDER = ['staged', 'unstaged', 'untracked'];
|
|
21197
|
+
function groupWorktreeFiles(files) {
|
|
21198
|
+
const groups = [];
|
|
21199
|
+
let cursor = 0;
|
|
21200
|
+
for (const groupState of WORKTREE_GROUP_ORDER) {
|
|
21201
|
+
const groupFiles = files.filter((file) => file.state === groupState);
|
|
21202
|
+
if (groupFiles.length > 0) {
|
|
21203
|
+
groups.push({ state: groupState, files: groupFiles, startIndex: cursor });
|
|
21204
|
+
cursor += groupFiles.length;
|
|
21205
|
+
}
|
|
21206
|
+
}
|
|
21207
|
+
return groups;
|
|
21208
|
+
}
|
|
21209
|
+
function flattenWorktreeGroups(groups) {
|
|
21210
|
+
return groups.flatMap((group) => group.files);
|
|
21211
|
+
}
|
|
20753
21212
|
|
|
20754
21213
|
function hunkHeader(hunk) {
|
|
20755
21214
|
return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
|
|
@@ -23099,88 +23558,6 @@ function formatPullRequestStateLine(pr) {
|
|
|
23099
23558
|
return parts.join(' · ');
|
|
23100
23559
|
}
|
|
23101
23560
|
|
|
23102
|
-
/**
|
|
23103
|
-
* Hardcoded per-entity action lists surfaced inside the right-hand
|
|
23104
|
-
* inspector panel. The inspector used to repeat the repo / branch /
|
|
23105
|
-
* status content the top header and left sidebar already show; we drop
|
|
23106
|
-
* that trailer in favor of an actionable cheat-sheet so the user knows
|
|
23107
|
-
* exactly which keystrokes apply to whatever they have under the cursor.
|
|
23108
|
-
*
|
|
23109
|
-
* Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
|
|
23110
|
-
* - Most per-entity actions live in `inkInput.ts` as direct keystroke
|
|
23111
|
-
* handlers (e.g. `c` cherry-pick, `R` revert) rather than as
|
|
23112
|
-
* globally-registered bindings, so the registry would be a partial
|
|
23113
|
-
* view at best.
|
|
23114
|
-
* - The bindings registry's `contexts` model (normal / search / focus
|
|
23115
|
-
* name) does not cleanly map to inspector entity types like "branch"
|
|
23116
|
-
* or "tag". Filtering it would mean replicating the same per-view
|
|
23117
|
-
* scoping logic the input dispatcher already encodes.
|
|
23118
|
-
* - New per-entity actions are added infrequently — the maintenance
|
|
23119
|
-
* cost of mirroring them here is low and keeps this file the single
|
|
23120
|
-
* source of truth for "what shows in the inspector".
|
|
23121
|
-
*
|
|
23122
|
-
* If you wire up a new per-entity keystroke in `inkInput.ts` — for
|
|
23123
|
-
* example a "create branch from this commit" or "create tag from this
|
|
23124
|
-
* commit" action — add the matching row to the relevant array below so
|
|
23125
|
-
* it shows up in the inspector automatically.
|
|
23126
|
-
*/
|
|
23127
|
-
const HISTORY_COMMIT_ACTIONS = [
|
|
23128
|
-
{ key: 'enter', label: 'Open diff' },
|
|
23129
|
-
{ key: 'c', label: 'Cherry-pick' },
|
|
23130
|
-
{ key: 'R', label: 'Revert', destructive: true },
|
|
23131
|
-
{ key: 'Z', label: 'Reset to commit', destructive: true },
|
|
23132
|
-
{ key: 'i', label: 'Interactive rebase', destructive: true },
|
|
23133
|
-
{ key: 'y', label: 'Yank hash' },
|
|
23134
|
-
{ key: 'Y', label: 'Yank short hash' },
|
|
23135
|
-
{ key: 'O', label: 'Open in browser' },
|
|
23136
|
-
];
|
|
23137
|
-
const BRANCH_ACTIONS = [
|
|
23138
|
-
{ key: 'enter', label: 'Checkout' },
|
|
23139
|
-
{ key: '+', label: 'New branch' },
|
|
23140
|
-
{ key: 'R', label: 'Rename' },
|
|
23141
|
-
{ key: 'u', label: 'Set upstream' },
|
|
23142
|
-
{ key: 'D', label: 'Delete', destructive: true },
|
|
23143
|
-
{ key: 'P', label: 'Push current' },
|
|
23144
|
-
{ key: 'F', label: 'Fetch all' },
|
|
23145
|
-
{ key: 'y', label: 'Yank name' },
|
|
23146
|
-
];
|
|
23147
|
-
const TAG_ACTIONS = [
|
|
23148
|
-
{ key: '+', label: 'New tag' },
|
|
23149
|
-
{ key: 'P', label: 'Push tag' },
|
|
23150
|
-
{ key: 'T', label: 'Delete', destructive: true },
|
|
23151
|
-
{ key: 'R', label: 'Delete remote', destructive: true },
|
|
23152
|
-
{ key: 'y', label: 'Yank name' },
|
|
23153
|
-
];
|
|
23154
|
-
const STASH_ACTIONS = [
|
|
23155
|
-
{ key: 'enter', label: 'Open diff' },
|
|
23156
|
-
{ key: 'a', label: 'Apply' },
|
|
23157
|
-
{ key: 'p', label: 'Pop' },
|
|
23158
|
-
{ key: 'X', label: 'Drop', destructive: true },
|
|
23159
|
-
{ key: 'y', label: 'Yank ref' },
|
|
23160
|
-
];
|
|
23161
|
-
const WORKTREE_ACTIONS = [
|
|
23162
|
-
{ key: 'W', label: 'Remove', destructive: true },
|
|
23163
|
-
{ key: 'y', label: 'Yank path' },
|
|
23164
|
-
];
|
|
23165
|
-
function getInspectorActions(context) {
|
|
23166
|
-
switch (context) {
|
|
23167
|
-
case 'history-commit':
|
|
23168
|
-
return HISTORY_COMMIT_ACTIONS;
|
|
23169
|
-
case 'branch':
|
|
23170
|
-
return BRANCH_ACTIONS;
|
|
23171
|
-
case 'tag':
|
|
23172
|
-
return TAG_ACTIONS;
|
|
23173
|
-
case 'stash':
|
|
23174
|
-
return STASH_ACTIONS;
|
|
23175
|
-
case 'worktree':
|
|
23176
|
-
return WORKTREE_ACTIONS;
|
|
23177
|
-
default: {
|
|
23178
|
-
const exhaustive = context;
|
|
23179
|
-
throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
|
|
23180
|
-
}
|
|
23181
|
-
}
|
|
23182
|
-
}
|
|
23183
|
-
|
|
23184
23561
|
function sectionLines(title, diff) {
|
|
23185
23562
|
const lines = diff.split('\n').map((line) => line.trimEnd());
|
|
23186
23563
|
return [
|
|
@@ -23736,7 +24113,16 @@ function LogInkApp(deps) {
|
|
|
23736
24113
|
// count, selected-file resolution, and the rendered list all key off
|
|
23737
24114
|
// it so toggles never desync the cursor from the rendered rows.
|
|
23738
24115
|
const visibleWorktreeFiles = React.useMemo(() => applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask), [context.worktree?.files, state.statusFilterMask]);
|
|
23739
|
-
|
|
24116
|
+
// Sectioned view of the visible files (#791 follow-up). Drives the
|
|
24117
|
+
// status surface's three-tier cursor model: ←/→ jumps between
|
|
24118
|
+
// groups, ↑ at index 0 promotes to the group header, Enter on the
|
|
24119
|
+
// header fires the group's batch action. The renderer also consumes
|
|
24120
|
+
// this so the visible file list stays in canonical group order
|
|
24121
|
+
// regardless of whatever order `git status --porcelain` happens to
|
|
24122
|
+
// emit.
|
|
24123
|
+
const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
|
|
24124
|
+
const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
|
|
24125
|
+
const selectedWorktreeFile = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex];
|
|
23740
24126
|
const dispatch = React.useCallback((action) => {
|
|
23741
24127
|
setState((current) => applyLogInkAction(current, action));
|
|
23742
24128
|
}, []);
|
|
@@ -24595,6 +24981,26 @@ function LogInkApp(deps) {
|
|
|
24595
24981
|
return { ok: false, message: 'Comment body required' };
|
|
24596
24982
|
return commentPullRequest(body);
|
|
24597
24983
|
},
|
|
24984
|
+
// Status surface group-level batch ops (#791 follow-up). The
|
|
24985
|
+
// input handler dispatches these when the user presses Enter on a
|
|
24986
|
+
// group header. We re-derive the file list from the live
|
|
24987
|
+
// `context.worktree?.files` rather than trusting a snapshot —
|
|
24988
|
+
// the worktree may have changed since the keystroke fired (rare,
|
|
24989
|
+
// but the cost of re-filtering is negligible compared to the cost
|
|
24990
|
+
// of a stale add). The mask is honored too so a user who's
|
|
24991
|
+
// hidden a category never has it touched by accident.
|
|
24992
|
+
'stage-all-unstaged': async () => {
|
|
24993
|
+
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'unstaged');
|
|
24994
|
+
return stageAllFiles(git, files);
|
|
24995
|
+
},
|
|
24996
|
+
'unstage-all-staged': async () => {
|
|
24997
|
+
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'staged');
|
|
24998
|
+
return unstageAllFiles(git, files);
|
|
24999
|
+
},
|
|
25000
|
+
'stage-all-untracked': async () => {
|
|
25001
|
+
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'untracked');
|
|
25002
|
+
return stageAllFiles(git, files);
|
|
25003
|
+
},
|
|
24598
25004
|
};
|
|
24599
25005
|
const handler = handlers[id];
|
|
24600
25006
|
if (!handler) {
|
|
@@ -24620,7 +25026,7 @@ function LogInkApp(deps) {
|
|
|
24620
25026
|
}
|
|
24621
25027
|
}, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
|
|
24622
25028
|
state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
|
|
24623
|
-
state.tagSort]);
|
|
25029
|
+
state.statusFilterMask, state.tagSort]);
|
|
24624
25030
|
// Resolve the active view's "yank target" (commit hash / branch /
|
|
24625
25031
|
// tag / stash ref / file path) against the live filtered+sorted list,
|
|
24626
25032
|
// copy it to the system clipboard, and surface the result on the
|
|
@@ -24675,7 +25081,7 @@ function LogInkApp(deps) {
|
|
|
24675
25081
|
// Read from the mask-filtered list (#776) so the cursor and the
|
|
24676
25082
|
// yanked path always match what's on screen — yanking a hidden
|
|
24677
25083
|
// row is always a desync bug.
|
|
24678
|
-
const path =
|
|
25084
|
+
const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
|
|
24679
25085
|
if (path) {
|
|
24680
25086
|
value = path;
|
|
24681
25087
|
label = `path ${path}`;
|
|
@@ -24683,7 +25089,7 @@ function LogInkApp(deps) {
|
|
|
24683
25089
|
}
|
|
24684
25090
|
else if (view === 'diff') {
|
|
24685
25091
|
if (state.diffSource === 'worktree') {
|
|
24686
|
-
const path =
|
|
25092
|
+
const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
|
|
24687
25093
|
if (path) {
|
|
24688
25094
|
value = path;
|
|
24689
25095
|
label = `path ${path}`;
|
|
@@ -24693,17 +25099,8 @@ function LogInkApp(deps) {
|
|
|
24693
25099
|
// Walk back to the most recent file header at or before the
|
|
24694
25100
|
// current preview offset — same logic the input-context block
|
|
24695
25101
|
// uses to expose stashDiffSelectedPath.
|
|
24696
|
-
const
|
|
24697
|
-
if (
|
|
24698
|
-
let current = files[0];
|
|
24699
|
-
for (const file of files) {
|
|
24700
|
-
if (file.startLine <= state.diffPreviewOffset) {
|
|
24701
|
-
current = file;
|
|
24702
|
-
}
|
|
24703
|
-
else {
|
|
24704
|
-
break;
|
|
24705
|
-
}
|
|
24706
|
-
}
|
|
25102
|
+
const current = findStashFileForOffset(parseStashDiffFiles(stashDiffLines), state.diffPreviewOffset);
|
|
25103
|
+
if (current) {
|
|
24707
25104
|
value = current.path;
|
|
24708
25105
|
label = `path ${current.path}`;
|
|
24709
25106
|
}
|
|
@@ -24757,7 +25154,7 @@ function LogInkApp(deps) {
|
|
|
24757
25154
|
state.selectedTagIndex,
|
|
24758
25155
|
state.selectedWorktreeFileIndex,
|
|
24759
25156
|
state.tagSort,
|
|
24760
|
-
|
|
25157
|
+
visibleWorktreeFilesGrouped,
|
|
24761
25158
|
]);
|
|
24762
25159
|
React.useEffect(() => {
|
|
24763
25160
|
let active = true;
|
|
@@ -24997,28 +25394,14 @@ function LogInkApp(deps) {
|
|
|
24997
25394
|
? parseStashDiffFiles(stashDiffLines)
|
|
24998
25395
|
: [];
|
|
24999
25396
|
const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
|
|
25000
|
-
const stashDiffSelectedPath =
|
|
25001
|
-
|
|
25002
|
-
|
|
25003
|
-
const offset = state.diffPreviewOffset;
|
|
25004
|
-
// Walk backwards to the most recent file header at or before the
|
|
25005
|
-
// current cursor offset.
|
|
25006
|
-
let current = stashDiffFiles[0];
|
|
25007
|
-
for (const file of stashDiffFiles) {
|
|
25008
|
-
if (file.startLine <= offset) {
|
|
25009
|
-
current = file;
|
|
25010
|
-
}
|
|
25011
|
-
else {
|
|
25012
|
-
break;
|
|
25013
|
-
}
|
|
25014
|
-
}
|
|
25015
|
-
return current.path;
|
|
25016
|
-
})();
|
|
25397
|
+
const stashDiffSelectedPath = state.diffSource === 'stash'
|
|
25398
|
+
? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
|
|
25399
|
+
: undefined;
|
|
25017
25400
|
getLogInkInputEvents(state, inputValue, key, {
|
|
25018
25401
|
detailFileCount: detail?.files.length,
|
|
25019
25402
|
previewLineCount: diffPreviewLineCount,
|
|
25020
25403
|
worktreeDiffLineCount: worktreeDiff?.lines.length,
|
|
25021
|
-
worktreeFileCount:
|
|
25404
|
+
worktreeFileCount: visibleWorktreeFilesGrouped.length,
|
|
25022
25405
|
worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
|
|
25023
25406
|
commitDiffHunkOffsets,
|
|
25024
25407
|
branchCount: branchVisibleCount,
|
|
@@ -25028,7 +25411,13 @@ function LogInkApp(deps) {
|
|
|
25028
25411
|
stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
|
|
25029
25412
|
stashDiffSelectedPath,
|
|
25030
25413
|
worktreeListCount: worktreeVisibleCount,
|
|
25031
|
-
worktreeSelectedPath:
|
|
25414
|
+
worktreeSelectedPath: visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path,
|
|
25415
|
+
statusGroups: visibleWorktreeGroups.map((group) => ({
|
|
25416
|
+
state: group.state,
|
|
25417
|
+
count: group.files.length,
|
|
25418
|
+
startIndex: group.startIndex,
|
|
25419
|
+
})),
|
|
25420
|
+
inspectorActionCount: getInspectorActionsForState(state).length,
|
|
25032
25421
|
commitDiffSelectedPath: state.diffSource === 'commit'
|
|
25033
25422
|
? selectedDetailFile?.path
|
|
25034
25423
|
: undefined,
|
|
@@ -25174,6 +25563,11 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
|
|
|
25174
25563
|
// Accordion layout — every tab's title is visible on its own line, but
|
|
25175
25564
|
// only the active tab expands its content underneath. Switching tabs
|
|
25176
25565
|
// (1-5 / [/]) collapses the previous and expands the next.
|
|
25566
|
+
// When sidebar focus has been promoted to the tab header (#806
|
|
25567
|
+
// follow-up), the active tab's title row gets selection styling
|
|
25568
|
+
// and the items below it render without their cursor highlight
|
|
25569
|
+
// (which now lives on the header).
|
|
25570
|
+
const headerFocused = focused && state.sidebarHeaderFocused;
|
|
25177
25571
|
const tabBlocks = tabs.flatMap((tab, tabIndex) => {
|
|
25178
25572
|
const isActive = tab === state.sidebarTab;
|
|
25179
25573
|
const count = sidebarTabCount(tab, context);
|
|
@@ -25181,6 +25575,7 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
|
|
|
25181
25575
|
? `${sidebarTabLabel(tab)} (${count})`
|
|
25182
25576
|
: sidebarTabLabel(tab);
|
|
25183
25577
|
const headerText = isActive ? `[${labelWithCount}]` : labelWithCount;
|
|
25578
|
+
const headerSelected = isActive && headerFocused;
|
|
25184
25579
|
const blocks = [];
|
|
25185
25580
|
if (tabIndex > 0) {
|
|
25186
25581
|
blocks.push(h(Text, { key: `tab-spacer-${tab}` }, ''));
|
|
@@ -25189,6 +25584,12 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
|
|
|
25189
25584
|
key: `tab-header-${tab}`,
|
|
25190
25585
|
bold: isActive,
|
|
25191
25586
|
dimColor: !isActive,
|
|
25587
|
+
// Selection styling on the header itself when the cursor has
|
|
25588
|
+
// been promoted off the items list. inverse swaps fg/bg so the
|
|
25589
|
+
// highlight reads as "this is the cursor target" identically
|
|
25590
|
+
// to how items render when focused.
|
|
25591
|
+
backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
25592
|
+
inverse: headerSelected,
|
|
25192
25593
|
}, headerText));
|
|
25193
25594
|
if (isActive) {
|
|
25194
25595
|
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
|
|
@@ -25226,7 +25627,11 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
25226
25627
|
// ↑/↓ navigates within the sidebar list and Enter / per-entity keys
|
|
25227
25628
|
// act on the cursored item without needing to drill into the
|
|
25228
25629
|
// dedicated view (#791 follow-up — in-sidebar selection).
|
|
25229
|
-
|
|
25630
|
+
// Items render with the cursor highlight only when the sidebar is
|
|
25631
|
+
// focused on this tab AND the cursor is on items (not promoted to
|
|
25632
|
+
// the tab header). The header-focused branch up in `renderSidebar`
|
|
25633
|
+
// owns the highlight in that case.
|
|
25634
|
+
const focused = state.focus === 'sidebar' && state.sidebarTab === tab && !state.sidebarHeaderFocused;
|
|
25230
25635
|
if (tab === 'branches') {
|
|
25231
25636
|
if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
|
|
25232
25637
|
return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
|
|
@@ -25526,6 +25931,16 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
|
|
|
25526
25931
|
backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
|
|
25527
25932
|
}, truncate$1(label, 140));
|
|
25528
25933
|
}
|
|
25934
|
+
function buildStatusSurfaceRows(groups) {
|
|
25935
|
+
const rows = [];
|
|
25936
|
+
for (const group of groups) {
|
|
25937
|
+
rows.push({ kind: 'header', group });
|
|
25938
|
+
group.files.forEach((file, offset) => {
|
|
25939
|
+
rows.push({ kind: 'file', group, file, flatIndex: group.startIndex + offset });
|
|
25940
|
+
});
|
|
25941
|
+
}
|
|
25942
|
+
return rows;
|
|
25943
|
+
}
|
|
25529
25944
|
function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
25530
25945
|
const { Box, Text } = components;
|
|
25531
25946
|
const focused = state.focus === 'commits';
|
|
@@ -25535,26 +25950,64 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
|
|
|
25535
25950
|
// uses for j/k navigation. `visibleFiles` may be a strict subset of
|
|
25536
25951
|
// worktree.files when the user has narrowed via 1/2/3.
|
|
25537
25952
|
const visibleFiles = applyStatusFilterMask(worktree?.files || [], state.statusFilterMask);
|
|
25953
|
+
// Group + canonical-sort. The runtime + input handler agree on this
|
|
25954
|
+
// order so a `selectedWorktreeFileIndex` of N always points to the
|
|
25955
|
+
// same file across all three (renderer / input / workflow handlers).
|
|
25956
|
+
const visibleGroups = groupWorktreeFiles(visibleFiles);
|
|
25957
|
+
const surfaceRows = buildStatusSurfaceRows(visibleGroups);
|
|
25538
25958
|
const listRows = Math.max(4, bodyRows - 5);
|
|
25539
25959
|
const selectedIndex = state.selectedWorktreeFileIndex;
|
|
25960
|
+
const headerFocused = state.statusGroupHeaderFocused;
|
|
25961
|
+
// Resolve the cursor's row index in the flat (header-and-file) row
|
|
25962
|
+
// list. Used to window the visible slice around the cursor.
|
|
25963
|
+
const cursorRowIndex = (() => {
|
|
25964
|
+
if (!surfaceRows.length)
|
|
25965
|
+
return 0;
|
|
25966
|
+
const currentGroup = visibleGroups.find((group) => selectedIndex >= group.startIndex && selectedIndex < group.startIndex + group.files.length);
|
|
25967
|
+
if (!currentGroup)
|
|
25968
|
+
return 0;
|
|
25969
|
+
if (headerFocused) {
|
|
25970
|
+
const idx = surfaceRows.findIndex((row) => row.kind === 'header' && row.group === currentGroup);
|
|
25971
|
+
return idx >= 0 ? idx : 0;
|
|
25972
|
+
}
|
|
25973
|
+
const idx = surfaceRows.findIndex((row) => row.kind === 'file' && row.flatIndex === selectedIndex);
|
|
25974
|
+
return idx >= 0 ? idx : 0;
|
|
25975
|
+
})();
|
|
25540
25976
|
const cleanHint = formatLogInkStatusEmpty({ hasChanges: Boolean(worktree?.files.length) });
|
|
25541
|
-
const
|
|
25977
|
+
const windowStart = Math.max(0, Math.min(Math.max(0, surfaceRows.length - listRows), cursorRowIndex - Math.floor(listRows / 2)));
|
|
25542
25978
|
const isLoading = isLogInkContextKeyLoading(contextStatus, 'worktree');
|
|
25543
|
-
const
|
|
25979
|
+
const renderedRows = isLoading || !surfaceRows.length
|
|
25544
25980
|
? []
|
|
25545
|
-
:
|
|
25546
|
-
const
|
|
25547
|
-
|
|
25981
|
+
: surfaceRows.slice(windowStart, windowStart + listRows).map((row, offset) => {
|
|
25982
|
+
const rowIndex = windowStart + offset;
|
|
25983
|
+
if (row.kind === 'header') {
|
|
25984
|
+
const groupContainsCursor = selectedIndex >= row.group.startIndex &&
|
|
25985
|
+
selectedIndex < row.group.startIndex + row.group.files.length;
|
|
25986
|
+
const headerSelected = focused && headerFocused && groupContainsCursor;
|
|
25987
|
+
const arrow = theme.ascii ? '>' : '▾';
|
|
25988
|
+
const groupLabel = capitalizeGroupName(row.group.state);
|
|
25989
|
+
const text = ` ${arrow} ${groupLabel} (${row.group.files.length})`;
|
|
25990
|
+
return h(Text, {
|
|
25991
|
+
key: `status-group-${row.group.state}-${rowIndex}`,
|
|
25992
|
+
bold: true,
|
|
25993
|
+
dimColor: !headerSelected && rowIndex > cursorRowIndex,
|
|
25994
|
+
backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
25995
|
+
inverse: headerSelected,
|
|
25996
|
+
}, truncate$1(text, 140));
|
|
25997
|
+
}
|
|
25998
|
+
const isSelected = !headerFocused && row.flatIndex === selectedIndex;
|
|
25548
25999
|
const cursorPart = `${isSelected ? '>' : ' '} `;
|
|
25549
|
-
const dotColor = getStageStatusDotColor(file.state, theme);
|
|
26000
|
+
const dotColor = getStageStatusDotColor(row.file.state, theme);
|
|
25550
26001
|
const useDot = dotColor !== undefined;
|
|
25551
26002
|
const dotCells = useDot ? cellWidth(STAGE_STATUS_DOT) + 1 : 0;
|
|
25552
|
-
const tail = `${file.indexStatus}${file.worktreeStatus} ${
|
|
25553
|
-
const tailTrunc = truncate$1(tail, Math.max(0, 140 - cellWidth(cursorPart) - dotCells));
|
|
26003
|
+
const tail = `${row.file.indexStatus}${row.file.worktreeStatus} ${row.file.path}`;
|
|
26004
|
+
const tailTrunc = truncate$1(tail, Math.max(0, 140 - cellWidth(cursorPart) - dotCells - 2));
|
|
25554
26005
|
return h(Text, {
|
|
25555
|
-
key: `status-row-${
|
|
25556
|
-
dimColor:
|
|
25557
|
-
|
|
26006
|
+
key: `status-file-${row.flatIndex}-${rowIndex}`,
|
|
26007
|
+
dimColor: !isSelected && rowIndex > cursorRowIndex,
|
|
26008
|
+
backgroundColor: isSelected && focused && !theme.noColor ? theme.colors.selection : undefined,
|
|
26009
|
+
inverse: isSelected && focused,
|
|
26010
|
+
}, ` ${cursorPart}`, ...(useDot ? [h(Text, { color: dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
|
|
25558
26011
|
});
|
|
25559
26012
|
// When the mask narrows the list to nothing but the underlying repo
|
|
25560
26013
|
// is non-clean, surface why the panel looks empty so the user can
|
|
@@ -25584,11 +26037,14 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
|
|
|
25584
26037
|
// never touch the filter.
|
|
25585
26038
|
...(isStatusFilterMaskActive(state.statusFilterMask)
|
|
25586
26039
|
? [h(Text, { key: 'status-mask-indicator', dimColor: true }, `filter: ${formatStatusFilterMask(state.statusFilterMask)} (1/2/3 to toggle)`)]
|
|
25587
|
-
: []), ...
|
|
26040
|
+
: []), ...renderedRows, ...fallbackLines.map((line, index) => h(Text, {
|
|
25588
26041
|
key: `status-surface-fallback-${index}`,
|
|
25589
26042
|
dimColor: index > 0,
|
|
25590
26043
|
}, truncate$1(line, 140))));
|
|
25591
26044
|
}
|
|
26045
|
+
function capitalizeGroupName(value) {
|
|
26046
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
26047
|
+
}
|
|
25592
26048
|
function isStatusFilterMaskActive(mask) {
|
|
25593
26049
|
return !mask.staged || !mask.unstaged || !mask.untracked;
|
|
25594
26050
|
}
|
|
@@ -26038,20 +26494,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
26038
26494
|
const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
26039
26495
|
const stashFiles = parseStashDiffFiles(lines);
|
|
26040
26496
|
const fileCount = stashFiles.length;
|
|
26041
|
-
const currentFile = (
|
|
26042
|
-
if (fileCount === 0)
|
|
26043
|
-
return undefined;
|
|
26044
|
-
let current = stashFiles[0];
|
|
26045
|
-
for (const file of stashFiles) {
|
|
26046
|
-
if (file.startLine <= state.diffPreviewOffset) {
|
|
26047
|
-
current = file;
|
|
26048
|
-
}
|
|
26049
|
-
else {
|
|
26050
|
-
break;
|
|
26051
|
-
}
|
|
26052
|
-
}
|
|
26053
|
-
return current;
|
|
26054
|
-
})();
|
|
26497
|
+
const currentFile = findStashFileForOffset(stashFiles, state.diffPreviewOffset);
|
|
26055
26498
|
const currentFileIndex = currentFile
|
|
26056
26499
|
? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
|
|
26057
26500
|
: -1;
|
|
@@ -26078,14 +26521,41 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
26078
26521
|
const headerLines = splitRequestedButTooNarrow
|
|
26079
26522
|
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
26080
26523
|
: baseHeaderLines;
|
|
26524
|
+
// File header anchor map: absolute line index → owning stash file.
|
|
26525
|
+
// Lets the body-render pass restyle each `diff --git` row in O(1)
|
|
26526
|
+
// and decide which one is the *active* file (the one currently
|
|
26527
|
+
// containing `diffPreviewOffset`). The active header gets the
|
|
26528
|
+
// selection background to mark "the file the cursor is inside."
|
|
26529
|
+
const stashFileByStartLine = new Map(stashFiles.map((file) => [file.startLine, file]));
|
|
26530
|
+
const activeStartLine = currentFile?.startLine;
|
|
26081
26531
|
const stashBodyNodes = stashDiffLoading || !lines.length
|
|
26082
26532
|
? []
|
|
26083
26533
|
: splitActive
|
|
26084
26534
|
? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
|
|
26085
|
-
: visibleLines.map((line, index) =>
|
|
26086
|
-
|
|
26087
|
-
|
|
26088
|
-
|
|
26535
|
+
: visibleLines.map((line, index) => {
|
|
26536
|
+
const absoluteIndex = state.diffPreviewOffset + index;
|
|
26537
|
+
const headerFile = stashFileByStartLine.get(absoluteIndex);
|
|
26538
|
+
if (headerFile) {
|
|
26539
|
+
// Replace the verbose `diff --git a/<path> b/<path>` text
|
|
26540
|
+
// with a compact `▾ <path>` marker — the path itself is
|
|
26541
|
+
// the meaningful identifier, not the a/b duplication. The
|
|
26542
|
+
// active file's header gets selection styling so the user
|
|
26543
|
+
// sees at a glance which file the cursor is inside.
|
|
26544
|
+
const isActive = absoluteIndex === activeStartLine;
|
|
26545
|
+
const arrow = theme.ascii ? '> ' : '▾ ';
|
|
26546
|
+
return h(Text, {
|
|
26547
|
+
key: `stash-diff-line-${absoluteIndex}`,
|
|
26548
|
+
bold: true,
|
|
26549
|
+
color: theme.noColor ? undefined : theme.colors.accent,
|
|
26550
|
+
backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
|
|
26551
|
+
inverse: isActive && focused,
|
|
26552
|
+
}, truncate$1(`${arrow}${headerFile.path}`, width - 4));
|
|
26553
|
+
}
|
|
26554
|
+
return h(Text, {
|
|
26555
|
+
key: `stash-diff-line-${absoluteIndex}`,
|
|
26556
|
+
...diffLineProps(line, theme),
|
|
26557
|
+
}, truncate$1(line, width - 4));
|
|
26558
|
+
});
|
|
26089
26559
|
return h(Box, {
|
|
26090
26560
|
borderColor: focusBorderColor(theme, focused),
|
|
26091
26561
|
borderStyle: theme.borderStyle,
|
|
@@ -26268,7 +26738,10 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
26268
26738
|
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
|
|
26269
26739
|
key: `detail-${index}`,
|
|
26270
26740
|
dimColor: index > 1,
|
|
26271
|
-
}, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme
|
|
26741
|
+
}, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26742
|
+
cursorIndex: state.inspectorActionIndex,
|
|
26743
|
+
cursorActive: focused && state.inspectorTab === 'actions',
|
|
26744
|
+
}));
|
|
26272
26745
|
}
|
|
26273
26746
|
const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
|
|
26274
26747
|
// P5.1 — link the commit hash and each ref out to GitHub when we know
|
|
@@ -26329,7 +26802,10 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
26329
26802
|
paddingX: 1,
|
|
26330
26803
|
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...(activeTab === 'inspector'
|
|
26331
26804
|
? [...headerNodes, ...fileListNodes]
|
|
26332
|
-
: renderInspectorActionsSection(h, Text, 'history-commit', width, theme
|
|
26805
|
+
: renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26806
|
+
cursorIndex: state.inspectorActionIndex,
|
|
26807
|
+
cursorActive: focused,
|
|
26808
|
+
})));
|
|
26333
26809
|
}
|
|
26334
26810
|
return h(Box, {
|
|
26335
26811
|
borderColor: focusBorderColor(theme, focused),
|
|
@@ -26337,7 +26813,10 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
26337
26813
|
flexDirection: 'column',
|
|
26338
26814
|
width,
|
|
26339
26815
|
paddingX: 1,
|
|
26340
|
-
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme
|
|
26816
|
+
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26817
|
+
cursorIndex: state.inspectorActionIndex,
|
|
26818
|
+
cursorActive: focused && state.inspectorTab === 'actions',
|
|
26819
|
+
}));
|
|
26341
26820
|
}
|
|
26342
26821
|
/**
|
|
26343
26822
|
* Render the trailing "Actions:" section that surfaces which keystrokes
|
|
@@ -26350,7 +26829,7 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
26350
26829
|
* minimum from `getLogInkLayout`) so an overflowing label never wraps and
|
|
26351
26830
|
* collides with the next row.
|
|
26352
26831
|
*/
|
|
26353
|
-
function renderInspectorActionsSection(h, Text, context, width, theme) {
|
|
26832
|
+
function renderInspectorActionsSection(h, Text, context, width, theme, options = {}) {
|
|
26354
26833
|
const actions = getInspectorActions(context);
|
|
26355
26834
|
if (!actions.length)
|
|
26356
26835
|
return [];
|
|
@@ -26361,10 +26840,13 @@ function renderInspectorActionsSection(h, Text, context, width, theme) {
|
|
|
26361
26840
|
const GAP = ' ';
|
|
26362
26841
|
const DESTRUCTIVE_SUFFIX = ' [!]';
|
|
26363
26842
|
const labelBudget = Math.max(4, width - 4 /* border + padX */ - KEY_COLUMN - GAP.length - DESTRUCTIVE_SUFFIX.length);
|
|
26843
|
+
const cursorIndex = options.cursorIndex ?? 0;
|
|
26844
|
+
const cursorActive = options.cursorActive ?? false;
|
|
26364
26845
|
const nodes = [
|
|
26365
26846
|
h(Text, { key: 'actions-spacer' }, ''),
|
|
26366
|
-
h(Text, { key: 'actions-title' }, 'Actions:'),
|
|
26847
|
+
h(Text, { key: 'actions-title' }, cursorActive ? '[Actions]' : 'Actions:'),
|
|
26367
26848
|
...actions.map((action, index) => {
|
|
26849
|
+
const isSelected = cursorActive && index === cursorIndex;
|
|
26368
26850
|
const keyCell = action.key.padEnd(KEY_COLUMN);
|
|
26369
26851
|
const label = truncate$1(action.label, labelBudget);
|
|
26370
26852
|
const children = [
|
|
@@ -26382,7 +26864,11 @@ function renderInspectorActionsSection(h, Text, context, width, theme) {
|
|
|
26382
26864
|
dimColor: false,
|
|
26383
26865
|
}, DESTRUCTIVE_SUFFIX));
|
|
26384
26866
|
}
|
|
26385
|
-
return h(Text, {
|
|
26867
|
+
return h(Text, {
|
|
26868
|
+
key: `actions-${index}`,
|
|
26869
|
+
backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
26870
|
+
inverse: isSelected,
|
|
26871
|
+
}, ...children);
|
|
26386
26872
|
}),
|
|
26387
26873
|
];
|
|
26388
26874
|
return nodes;
|