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.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.1";
|
|
57
57
|
|
|
58
58
|
const isInteractive = (config) => {
|
|
59
59
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -2127,11 +2127,21 @@ function formatAuthenticationError(error, logger) {
|
|
|
2127
2127
|
logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
|
|
2128
2128
|
}
|
|
2129
2129
|
/**
|
|
2130
|
-
* Formats a generic error
|
|
2130
|
+
* Formats a generic error.
|
|
2131
|
+
*
|
|
2132
|
+
* The error message prints unconditionally (was previously gated behind
|
|
2133
|
+
* `--verbose`, which left users staring at a "Failed to execute command"
|
|
2134
|
+
* line with no actionable detail when something crashed). The full stack
|
|
2135
|
+
* trace stays under `logger.verbose` so plain output stays focused on the
|
|
2136
|
+
* one-line cause; users running into something they can't diagnose can opt
|
|
2137
|
+
* in with `--verbose` for the trace.
|
|
2131
2138
|
*/
|
|
2132
2139
|
function formatGenericError(error, logger) {
|
|
2133
2140
|
logger.log('\nFailed to execute command', { color: 'yellow' });
|
|
2134
|
-
logger.
|
|
2141
|
+
logger.log(`\nError: ${error.message}`, { color: 'red' });
|
|
2142
|
+
if (error.stack) {
|
|
2143
|
+
logger.verbose(`\n${error.stack}`, { color: 'gray' });
|
|
2144
|
+
}
|
|
2135
2145
|
}
|
|
2136
2146
|
function commandExecutor(handler) {
|
|
2137
2147
|
return async (argv) => {
|
|
@@ -14880,6 +14890,35 @@ function getLogInkWorkflowActions() {
|
|
|
14880
14890
|
kind: 'normal',
|
|
14881
14891
|
requiresConfirmation: false,
|
|
14882
14892
|
},
|
|
14893
|
+
// Status surface group-level batch ops (#791 follow-up). Triggered
|
|
14894
|
+
// by Enter when the cursor is on a status group header
|
|
14895
|
+
// (Staged / Unstaged / Untracked). Empty `key` keeps them
|
|
14896
|
+
// palette-discoverable without registering a global hotkey — the
|
|
14897
|
+
// Enter-on-header path in inkInput is the canonical trigger.
|
|
14898
|
+
{
|
|
14899
|
+
id: 'unstage-all-staged',
|
|
14900
|
+
key: '',
|
|
14901
|
+
label: 'Unstage all staged files',
|
|
14902
|
+
description: 'Unstage every file currently in the staged group.',
|
|
14903
|
+
kind: 'normal',
|
|
14904
|
+
requiresConfirmation: false,
|
|
14905
|
+
},
|
|
14906
|
+
{
|
|
14907
|
+
id: 'stage-all-unstaged',
|
|
14908
|
+
key: '',
|
|
14909
|
+
label: 'Stage all unstaged files',
|
|
14910
|
+
description: 'Stage every modified-but-not-staged file.',
|
|
14911
|
+
kind: 'normal',
|
|
14912
|
+
requiresConfirmation: false,
|
|
14913
|
+
},
|
|
14914
|
+
{
|
|
14915
|
+
id: 'stage-all-untracked',
|
|
14916
|
+
key: '',
|
|
14917
|
+
label: 'Stage all untracked files',
|
|
14918
|
+
description: 'Add every untracked file to the index after confirmation.',
|
|
14919
|
+
kind: 'destructive',
|
|
14920
|
+
requiresConfirmation: true,
|
|
14921
|
+
},
|
|
14883
14922
|
{
|
|
14884
14923
|
id: 'delete-branch',
|
|
14885
14924
|
key: 'D',
|
|
@@ -15897,6 +15936,88 @@ function extractDiffHunk(input) {
|
|
|
15897
15936
|
return { patchText };
|
|
15898
15937
|
}
|
|
15899
15938
|
|
|
15939
|
+
/**
|
|
15940
|
+
* Hardcoded per-entity action lists surfaced inside the right-hand
|
|
15941
|
+
* inspector panel. The inspector used to repeat the repo / branch /
|
|
15942
|
+
* status content the top header and left sidebar already show; we drop
|
|
15943
|
+
* that trailer in favor of an actionable cheat-sheet so the user knows
|
|
15944
|
+
* exactly which keystrokes apply to whatever they have under the cursor.
|
|
15945
|
+
*
|
|
15946
|
+
* Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
|
|
15947
|
+
* - Most per-entity actions live in `inkInput.ts` as direct keystroke
|
|
15948
|
+
* handlers (e.g. `c` cherry-pick, `R` revert) rather than as
|
|
15949
|
+
* globally-registered bindings, so the registry would be a partial
|
|
15950
|
+
* view at best.
|
|
15951
|
+
* - The bindings registry's `contexts` model (normal / search / focus
|
|
15952
|
+
* name) does not cleanly map to inspector entity types like "branch"
|
|
15953
|
+
* or "tag". Filtering it would mean replicating the same per-view
|
|
15954
|
+
* scoping logic the input dispatcher already encodes.
|
|
15955
|
+
* - New per-entity actions are added infrequently — the maintenance
|
|
15956
|
+
* cost of mirroring them here is low and keeps this file the single
|
|
15957
|
+
* source of truth for "what shows in the inspector".
|
|
15958
|
+
*
|
|
15959
|
+
* If you wire up a new per-entity keystroke in `inkInput.ts` — for
|
|
15960
|
+
* example a "create branch from this commit" or "create tag from this
|
|
15961
|
+
* commit" action — add the matching row to the relevant array below so
|
|
15962
|
+
* it shows up in the inspector automatically.
|
|
15963
|
+
*/
|
|
15964
|
+
const HISTORY_COMMIT_ACTIONS = [
|
|
15965
|
+
{ key: 'enter', label: 'Open diff' },
|
|
15966
|
+
{ key: 'c', label: 'Cherry-pick' },
|
|
15967
|
+
{ key: 'R', label: 'Revert', destructive: true },
|
|
15968
|
+
{ key: 'Z', label: 'Reset to commit', destructive: true },
|
|
15969
|
+
{ key: 'i', label: 'Interactive rebase', destructive: true },
|
|
15970
|
+
{ key: 'y', label: 'Yank hash' },
|
|
15971
|
+
{ key: 'Y', label: 'Yank short hash' },
|
|
15972
|
+
{ key: 'O', label: 'Open in browser' },
|
|
15973
|
+
];
|
|
15974
|
+
const BRANCH_ACTIONS = [
|
|
15975
|
+
{ key: 'enter', label: 'Checkout' },
|
|
15976
|
+
{ key: '+', label: 'New branch' },
|
|
15977
|
+
{ key: 'R', label: 'Rename' },
|
|
15978
|
+
{ key: 'u', label: 'Set upstream' },
|
|
15979
|
+
{ key: 'D', label: 'Delete', destructive: true },
|
|
15980
|
+
{ key: 'P', label: 'Push current' },
|
|
15981
|
+
{ key: 'F', label: 'Fetch all' },
|
|
15982
|
+
{ key: 'y', label: 'Yank name' },
|
|
15983
|
+
];
|
|
15984
|
+
const TAG_ACTIONS = [
|
|
15985
|
+
{ key: '+', label: 'New tag' },
|
|
15986
|
+
{ key: 'P', label: 'Push tag' },
|
|
15987
|
+
{ key: 'T', label: 'Delete', destructive: true },
|
|
15988
|
+
{ key: 'R', label: 'Delete remote', destructive: true },
|
|
15989
|
+
{ key: 'y', label: 'Yank name' },
|
|
15990
|
+
];
|
|
15991
|
+
const STASH_ACTIONS = [
|
|
15992
|
+
{ key: 'enter', label: 'Open diff' },
|
|
15993
|
+
{ key: 'a', label: 'Apply' },
|
|
15994
|
+
{ key: 'p', label: 'Pop' },
|
|
15995
|
+
{ key: 'X', label: 'Drop', destructive: true },
|
|
15996
|
+
{ key: 'y', label: 'Yank ref' },
|
|
15997
|
+
];
|
|
15998
|
+
const WORKTREE_ACTIONS = [
|
|
15999
|
+
{ key: 'W', label: 'Remove', destructive: true },
|
|
16000
|
+
{ key: 'y', label: 'Yank path' },
|
|
16001
|
+
];
|
|
16002
|
+
function getInspectorActions(context) {
|
|
16003
|
+
switch (context) {
|
|
16004
|
+
case 'history-commit':
|
|
16005
|
+
return HISTORY_COMMIT_ACTIONS;
|
|
16006
|
+
case 'branch':
|
|
16007
|
+
return BRANCH_ACTIONS;
|
|
16008
|
+
case 'tag':
|
|
16009
|
+
return TAG_ACTIONS;
|
|
16010
|
+
case 'stash':
|
|
16011
|
+
return STASH_ACTIONS;
|
|
16012
|
+
case 'worktree':
|
|
16013
|
+
return WORKTREE_ACTIONS;
|
|
16014
|
+
default: {
|
|
16015
|
+
const exhaustive = context;
|
|
16016
|
+
throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
|
|
16017
|
+
}
|
|
16018
|
+
}
|
|
16019
|
+
}
|
|
16020
|
+
|
|
15900
16021
|
/**
|
|
15901
16022
|
* Sort modes for the promoted views (P4.2).
|
|
15902
16023
|
*
|
|
@@ -16103,6 +16224,7 @@ function withPushedView(state, value) {
|
|
|
16103
16224
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
16104
16225
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
16105
16226
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
16227
|
+
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
16106
16228
|
pendingKey: undefined,
|
|
16107
16229
|
};
|
|
16108
16230
|
}
|
|
@@ -16125,6 +16247,7 @@ function withPoppedView(state) {
|
|
|
16125
16247
|
diffSource: next === 'diff' ? state.diffSource : undefined,
|
|
16126
16248
|
stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
|
|
16127
16249
|
pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
|
|
16250
|
+
statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
|
|
16128
16251
|
pendingKey: undefined,
|
|
16129
16252
|
};
|
|
16130
16253
|
}
|
|
@@ -16142,6 +16265,7 @@ function withReplacedView(state, value) {
|
|
|
16142
16265
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
16143
16266
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
16144
16267
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
16268
|
+
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
16145
16269
|
pendingKey: undefined,
|
|
16146
16270
|
};
|
|
16147
16271
|
}
|
|
@@ -16273,9 +16397,12 @@ function createLogInkState(rows, options = {}) {
|
|
|
16273
16397
|
focus: 'commits',
|
|
16274
16398
|
sidebarTab: 'status',
|
|
16275
16399
|
userSidebarTab: 'status',
|
|
16400
|
+
sidebarHeaderFocused: false,
|
|
16401
|
+
statusGroupHeaderFocused: false,
|
|
16276
16402
|
statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
|
|
16277
16403
|
diffViewMode: 'unified',
|
|
16278
16404
|
inspectorTab: 'inspector',
|
|
16405
|
+
inspectorActionIndex: 0,
|
|
16279
16406
|
};
|
|
16280
16407
|
}
|
|
16281
16408
|
function getSelectedInkCommit(state) {
|
|
@@ -16316,12 +16443,21 @@ function applyLogInkAction(state, action) {
|
|
|
16316
16443
|
return {
|
|
16317
16444
|
...state,
|
|
16318
16445
|
focus: cycleValue(FOCUS_ORDER, state.focus, 1),
|
|
16446
|
+
// Reset header focus when leaving the sidebar so the next
|
|
16447
|
+
// re-entry starts on items rather than mid-flag.
|
|
16448
|
+
sidebarHeaderFocused: false,
|
|
16449
|
+
// Same idea for the status group header — Tab cycling away
|
|
16450
|
+
// from 'commits' should always land back on a real file when
|
|
16451
|
+
// the user returns.
|
|
16452
|
+
statusGroupHeaderFocused: false,
|
|
16319
16453
|
pendingKey: undefined,
|
|
16320
16454
|
};
|
|
16321
16455
|
case 'focusPrevious':
|
|
16322
16456
|
return {
|
|
16323
16457
|
...state,
|
|
16324
16458
|
focus: cycleValue(FOCUS_ORDER, state.focus, -1),
|
|
16459
|
+
sidebarHeaderFocused: false,
|
|
16460
|
+
statusGroupHeaderFocused: false,
|
|
16325
16461
|
pendingKey: undefined,
|
|
16326
16462
|
};
|
|
16327
16463
|
case 'move':
|
|
@@ -16386,6 +16522,9 @@ function applyLogInkAction(state, action) {
|
|
|
16386
16522
|
selectedWorktreeFileIndex: clampIndex$1(state.selectedWorktreeFileIndex + action.delta, action.fileCount),
|
|
16387
16523
|
selectedWorktreeHunkIndex: 0,
|
|
16388
16524
|
worktreeDiffOffset: 0,
|
|
16525
|
+
// Cursor moved to a real file row — drop header focus so the
|
|
16526
|
+
// file Enter handler (open diff) is what fires next.
|
|
16527
|
+
statusGroupHeaderFocused: false,
|
|
16389
16528
|
};
|
|
16390
16529
|
}
|
|
16391
16530
|
case 'moveBranch':
|
|
@@ -16404,10 +16543,40 @@ function applyLogInkAction(state, action) {
|
|
|
16404
16543
|
selectedBranchIndex: 0,
|
|
16405
16544
|
pendingKey: undefined,
|
|
16406
16545
|
};
|
|
16546
|
+
case 'setSidebarHeaderFocused':
|
|
16547
|
+
return {
|
|
16548
|
+
...state,
|
|
16549
|
+
sidebarHeaderFocused: action.value,
|
|
16550
|
+
pendingKey: undefined,
|
|
16551
|
+
};
|
|
16552
|
+
case 'setStatusGroupHeaderFocused':
|
|
16553
|
+
return {
|
|
16554
|
+
...state,
|
|
16555
|
+
statusGroupHeaderFocused: action.value,
|
|
16556
|
+
pendingKey: undefined,
|
|
16557
|
+
};
|
|
16558
|
+
case 'jumpToStatusGroup':
|
|
16559
|
+
// Used by ←/→ on the status surface to land on the first file of
|
|
16560
|
+
// the previous / next non-empty group. Clears header focus so the
|
|
16561
|
+
// user is on a real file after the jump (matches the
|
|
16562
|
+
// sidebar pattern where ←/→ between tabs lands on items, not on
|
|
16563
|
+
// the next tab's header).
|
|
16564
|
+
return {
|
|
16565
|
+
...state,
|
|
16566
|
+
selectedWorktreeFileIndex: Math.max(0, action.targetIndex),
|
|
16567
|
+
selectedWorktreeHunkIndex: 0,
|
|
16568
|
+
worktreeDiffOffset: 0,
|
|
16569
|
+
statusGroupHeaderFocused: false,
|
|
16570
|
+
pendingKey: undefined,
|
|
16571
|
+
};
|
|
16407
16572
|
case 'setInspectorTab':
|
|
16408
16573
|
return {
|
|
16409
16574
|
...state,
|
|
16410
16575
|
inspectorTab: action.value,
|
|
16576
|
+
// Reset the action cursor so a fresh tab visit always starts
|
|
16577
|
+
// on the first action, regardless of where the user left off
|
|
16578
|
+
// in a previous entity context.
|
|
16579
|
+
inspectorActionIndex: 0,
|
|
16411
16580
|
pendingKey: undefined,
|
|
16412
16581
|
};
|
|
16413
16582
|
case 'cycleInspectorTab': {
|
|
@@ -16419,9 +16588,22 @@ function applyLogInkAction(state, action) {
|
|
|
16419
16588
|
return {
|
|
16420
16589
|
...state,
|
|
16421
16590
|
inspectorTab: next,
|
|
16591
|
+
inspectorActionIndex: 0,
|
|
16422
16592
|
pendingKey: undefined,
|
|
16423
16593
|
};
|
|
16424
16594
|
}
|
|
16595
|
+
case 'moveInspectorAction':
|
|
16596
|
+
return {
|
|
16597
|
+
...state,
|
|
16598
|
+
inspectorActionIndex: clampIndex$1(state.inspectorActionIndex + action.delta, action.actionCount),
|
|
16599
|
+
pendingKey: undefined,
|
|
16600
|
+
};
|
|
16601
|
+
case 'resetInspectorActionIndex':
|
|
16602
|
+
return {
|
|
16603
|
+
...state,
|
|
16604
|
+
inspectorActionIndex: 0,
|
|
16605
|
+
pendingKey: undefined,
|
|
16606
|
+
};
|
|
16425
16607
|
case 'moveTag':
|
|
16426
16608
|
return {
|
|
16427
16609
|
...state,
|
|
@@ -16491,6 +16673,10 @@ function applyLogInkAction(state, action) {
|
|
|
16491
16673
|
...state,
|
|
16492
16674
|
statusFilterMask: allOff ? { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK } : next,
|
|
16493
16675
|
selectedWorktreeFileIndex: 0,
|
|
16676
|
+
// Group composition changed — header focus would be ambiguous
|
|
16677
|
+
// (cursor lands on file 0 which may belong to a different
|
|
16678
|
+
// group now). Reset to clear the indicator.
|
|
16679
|
+
statusGroupHeaderFocused: false,
|
|
16494
16680
|
pendingKey: undefined,
|
|
16495
16681
|
};
|
|
16496
16682
|
}
|
|
@@ -16661,6 +16847,13 @@ function applyLogInkAction(state, action) {
|
|
|
16661
16847
|
return {
|
|
16662
16848
|
...state,
|
|
16663
16849
|
focus: action.value,
|
|
16850
|
+
// Reset sidebar header focus when leaving the sidebar so a
|
|
16851
|
+
// re-entry starts on items rather than mid-flag.
|
|
16852
|
+
sidebarHeaderFocused: action.value === 'sidebar' ? state.sidebarHeaderFocused : false,
|
|
16853
|
+
// The status group header lives in the 'commits' focus on
|
|
16854
|
+
// the status view — clear when focus moves away so a
|
|
16855
|
+
// re-entry starts on a real file.
|
|
16856
|
+
statusGroupHeaderFocused: action.value === 'commits' ? state.statusGroupHeaderFocused : false,
|
|
16664
16857
|
pendingKey: undefined,
|
|
16665
16858
|
};
|
|
16666
16859
|
case 'setPendingKey':
|
|
@@ -16862,6 +17055,82 @@ function action(actionValue) {
|
|
|
16862
17055
|
action: actionValue,
|
|
16863
17056
|
};
|
|
16864
17057
|
}
|
|
17058
|
+
/**
|
|
17059
|
+
* Resolve which inspector action context applies for the current
|
|
17060
|
+
* state. Today only history commits expose actions in the inspector
|
|
17061
|
+
* (the renderer hard-coded `'history-commit'`); future PRs can fan
|
|
17062
|
+
* this out to branch / tag / stash / worktree contexts as the
|
|
17063
|
+
* inspector gains entity-aware sections. Returns `undefined` when no
|
|
17064
|
+
* actions section should be shown (so the cursor model stays a
|
|
17065
|
+
* no-op).
|
|
17066
|
+
*/
|
|
17067
|
+
function resolveInspectorActionContext(state) {
|
|
17068
|
+
if (state.activeView === 'history' && !state.pendingCommitFocused) {
|
|
17069
|
+
return 'history-commit';
|
|
17070
|
+
}
|
|
17071
|
+
return undefined;
|
|
17072
|
+
}
|
|
17073
|
+
function getInspectorActionsForState(state) {
|
|
17074
|
+
const ctx = resolveInspectorActionContext(state);
|
|
17075
|
+
return ctx ? getInspectorActions(ctx) : [];
|
|
17076
|
+
}
|
|
17077
|
+
/**
|
|
17078
|
+
* Synthesize the events that fire when the user presses Enter on a
|
|
17079
|
+
* cursored inspector action (#791 follow-up). Mirrors
|
|
17080
|
+
* `getLogInkPaletteExecuteEvents` — each action's `key` field
|
|
17081
|
+
* routes to the same dispatch the corresponding keystroke would
|
|
17082
|
+
* trigger from the history view's commit cursor. Per-key dispatch
|
|
17083
|
+
* (rather than recursively re-running the keystroke through
|
|
17084
|
+
* `getLogInkInputEvents`) avoids the gating problem: most history
|
|
17085
|
+
* keystroke handlers require `state.focus === 'commits'`, but the
|
|
17086
|
+
* inspector executor fires from `state.focus === 'detail'`.
|
|
17087
|
+
*/
|
|
17088
|
+
function getInspectorActionExecuteEvents(inspectorAction, state) {
|
|
17089
|
+
const commit = state.filteredCommits[state.selectedIndex];
|
|
17090
|
+
const requireCommit = (fn) => {
|
|
17091
|
+
if (!commit) {
|
|
17092
|
+
return [action({ type: 'setStatus', value: 'No commit selected' })];
|
|
17093
|
+
}
|
|
17094
|
+
return fn(commit.hash, state.selectedIndex);
|
|
17095
|
+
};
|
|
17096
|
+
switch (inspectorAction.key) {
|
|
17097
|
+
case 'enter':
|
|
17098
|
+
return requireCommit((sha, commitIndex) => [
|
|
17099
|
+
action({ type: 'navigateOpenDiffForCommit', sha, commitIndex }),
|
|
17100
|
+
]);
|
|
17101
|
+
case 'c':
|
|
17102
|
+
return requireCommit(() => [
|
|
17103
|
+
action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' }),
|
|
17104
|
+
]);
|
|
17105
|
+
case 'R':
|
|
17106
|
+
return requireCommit(() => [
|
|
17107
|
+
action({ type: 'setPendingConfirmation', value: 'revert-commit' }),
|
|
17108
|
+
]);
|
|
17109
|
+
case 'Z':
|
|
17110
|
+
return requireCommit(() => [
|
|
17111
|
+
action({
|
|
17112
|
+
type: 'openInputPrompt',
|
|
17113
|
+
kind: 'reset-mode',
|
|
17114
|
+
label: 'Reset mode (soft / mixed / hard)',
|
|
17115
|
+
}),
|
|
17116
|
+
]);
|
|
17117
|
+
case 'i':
|
|
17118
|
+
return requireCommit(() => [
|
|
17119
|
+
action({ type: 'setPendingConfirmation', value: 'interactive-rebase' }),
|
|
17120
|
+
]);
|
|
17121
|
+
case 'y':
|
|
17122
|
+
return requireCommit(() => [{ type: 'yankFromActiveView' }]);
|
|
17123
|
+
case 'Y':
|
|
17124
|
+
return requireCommit(() => [{ type: 'yankFromActiveView', short: true }]);
|
|
17125
|
+
case 'O':
|
|
17126
|
+
return [{ type: 'runWorkflowAction', id: 'open-pr' }];
|
|
17127
|
+
default:
|
|
17128
|
+
return [action({
|
|
17129
|
+
type: 'setStatus',
|
|
17130
|
+
value: `Action ${inspectorAction.key} not yet wired`,
|
|
17131
|
+
})];
|
|
17132
|
+
}
|
|
17133
|
+
}
|
|
16865
17134
|
/**
|
|
16866
17135
|
* Build the events needed to apply the hunk under the diff cursor. The
|
|
16867
17136
|
* runtime workflow handler expects payload format `<target>\n<patch>`
|
|
@@ -17673,11 +17942,74 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17673
17942
|
if (key.rightArrow && state.focus === 'sidebar') {
|
|
17674
17943
|
return [action({ type: 'nextSidebarTab' })];
|
|
17675
17944
|
}
|
|
17945
|
+
// ←/→ on the inspector switch between the [Inspector] / [Actions]
|
|
17946
|
+
// tabs, mirroring the sidebar's left/right tab semantics. `[` and
|
|
17947
|
+
// `]` still work as keyboard alternatives, but the visible hint in
|
|
17948
|
+
// the inspector chrome shows ←/→ because the bracketed `[/]`
|
|
17949
|
+
// notation reads as "press the / key" — which is the global filter
|
|
17950
|
+
// trigger and was making users think the binding was busted.
|
|
17951
|
+
if (key.leftArrow && state.focus === 'detail') {
|
|
17952
|
+
return [action({ type: 'setInspectorTab', value: 'inspector' })];
|
|
17953
|
+
}
|
|
17954
|
+
if (key.rightArrow && state.focus === 'detail') {
|
|
17955
|
+
return [action({ type: 'setInspectorTab', value: 'actions' })];
|
|
17956
|
+
}
|
|
17957
|
+
// ←/→ on the status surface jump between the staged / unstaged /
|
|
17958
|
+
// untracked groups — the horizontal axis is "between groups", the
|
|
17959
|
+
// vertical axis (↑/↓ below) is "within the active group's files".
|
|
17960
|
+
// Lands on the first file of the target group (clears header
|
|
17961
|
+
// focus) so the user is always on a real file after a jump,
|
|
17962
|
+
// mirroring the sidebar's tab-switch landing behavior.
|
|
17963
|
+
if ((key.leftArrow || key.rightArrow) &&
|
|
17964
|
+
state.activeView === 'status' &&
|
|
17965
|
+
state.focus === 'commits' &&
|
|
17966
|
+
context.statusGroups &&
|
|
17967
|
+
context.statusGroups.length > 1) {
|
|
17968
|
+
const groups = context.statusGroups;
|
|
17969
|
+
const currentIndex = groups.findIndex((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
|
|
17970
|
+
state.selectedWorktreeFileIndex < group.startIndex + group.count);
|
|
17971
|
+
const fallback = currentIndex >= 0 ? currentIndex : 0;
|
|
17972
|
+
const delta = key.leftArrow ? -1 : 1;
|
|
17973
|
+
const nextIndex = Math.max(0, Math.min(groups.length - 1, fallback + delta));
|
|
17974
|
+
if (nextIndex !== fallback) {
|
|
17975
|
+
return [action({ type: 'jumpToStatusGroup', targetIndex: groups[nextIndex].startIndex })];
|
|
17976
|
+
}
|
|
17977
|
+
return [];
|
|
17978
|
+
}
|
|
17676
17979
|
if (key.upArrow || inputValue === 'k') {
|
|
17980
|
+
// Inspector Actions tab: ↑/↓ moves the cursor through the
|
|
17981
|
+
// executable action list. Wins over moveDetailFile so a
|
|
17982
|
+
// history-commit explore with both file list AND actions visible
|
|
17983
|
+
// navigates the actions when the user has [/]-toggled to the
|
|
17984
|
+
// actions tab. (#791 follow-up)
|
|
17985
|
+
if (state.focus === 'detail' && state.inspectorTab === 'actions' && context.inspectorActionCount) {
|
|
17986
|
+
return [action({
|
|
17987
|
+
type: 'moveInspectorAction',
|
|
17988
|
+
delta: -1,
|
|
17989
|
+
actionCount: context.inspectorActionCount,
|
|
17990
|
+
})];
|
|
17991
|
+
}
|
|
17677
17992
|
if (state.focus === 'detail' && context.detailFileCount) {
|
|
17678
17993
|
return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
|
|
17679
17994
|
}
|
|
17680
17995
|
if (state.activeView === 'status' && context.worktreeFileCount) {
|
|
17996
|
+
// Already on the group header — ↑ is a no-op (use ←/→ to switch
|
|
17997
|
+
// groups). Mirrors the sidebar's "header is the top of the
|
|
17998
|
+
// hierarchy" behavior.
|
|
17999
|
+
if (state.statusGroupHeaderFocused) {
|
|
18000
|
+
return [];
|
|
18001
|
+
}
|
|
18002
|
+
// Cursor at the first file of its group → promote to the group
|
|
18003
|
+
// header rather than crossing the boundary into the previous
|
|
18004
|
+
// group's last file. Keeps the cursor inside its current
|
|
18005
|
+
// container; ←/→ is the explicit way to move between groups.
|
|
18006
|
+
if (context.statusGroups && context.statusGroups.length > 0) {
|
|
18007
|
+
const currentGroup = context.statusGroups.find((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
|
|
18008
|
+
state.selectedWorktreeFileIndex < group.startIndex + group.count);
|
|
18009
|
+
if (currentGroup && state.selectedWorktreeFileIndex === currentGroup.startIndex) {
|
|
18010
|
+
return [action({ type: 'setStatusGroupHeaderFocused', value: true })];
|
|
18011
|
+
}
|
|
18012
|
+
}
|
|
17681
18013
|
return [action({
|
|
17682
18014
|
type: 'moveWorktreeFile',
|
|
17683
18015
|
delta: -1,
|
|
@@ -17702,6 +18034,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17702
18034
|
previewLineCount: context.previewLineCount,
|
|
17703
18035
|
})];
|
|
17704
18036
|
}
|
|
18037
|
+
// Sidebar header focus: ↑ at item index 0 promotes the cursor
|
|
18038
|
+
// onto the active tab's header. Pressing ↑ again is a no-op
|
|
18039
|
+
// (use ←/→ to switch between tab headers, Enter to drill in).
|
|
18040
|
+
// Only triggers when the sidebar is focused on a content tab —
|
|
18041
|
+
// dedicated promoted views (`g b` etc.) keep the legacy clamp
|
|
18042
|
+
// behavior because they have no header to escape to.
|
|
18043
|
+
if (state.focus === 'sidebar' && !state.sidebarHeaderFocused) {
|
|
18044
|
+
if (state.sidebarTab === 'branches' && state.selectedBranchIndex === 0 && (context.branchCount ?? 0) > 0) {
|
|
18045
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18046
|
+
}
|
|
18047
|
+
if (state.sidebarTab === 'tags' && state.selectedTagIndex === 0 && (context.tagCount ?? 0) > 0) {
|
|
18048
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18049
|
+
}
|
|
18050
|
+
if (state.sidebarTab === 'stashes' && state.selectedStashIndex === 0 && (context.stashCount ?? 0) > 0) {
|
|
18051
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18052
|
+
}
|
|
18053
|
+
if (state.sidebarTab === 'worktrees' && state.selectedWorktreeListIndex === 0 && (context.worktreeListCount ?? 0) > 0) {
|
|
18054
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18055
|
+
}
|
|
18056
|
+
}
|
|
18057
|
+
// Already on the header — ↑ is a no-op (←/→ switches tabs).
|
|
18058
|
+
if (state.focus === 'sidebar' && state.sidebarHeaderFocused) {
|
|
18059
|
+
return [];
|
|
18060
|
+
}
|
|
17705
18061
|
if (isBranchActionTarget(state) && context.branchCount) {
|
|
17706
18062
|
return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
|
|
17707
18063
|
}
|
|
@@ -17736,10 +18092,23 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17736
18092
|
if (state.activeView === 'history' && state.pendingCommitFocused) {
|
|
17737
18093
|
return [action({ type: 'unfocusPendingCommit' })];
|
|
17738
18094
|
}
|
|
18095
|
+
if (state.focus === 'detail' && state.inspectorTab === 'actions' && context.inspectorActionCount) {
|
|
18096
|
+
return [action({
|
|
18097
|
+
type: 'moveInspectorAction',
|
|
18098
|
+
delta: 1,
|
|
18099
|
+
actionCount: context.inspectorActionCount,
|
|
18100
|
+
})];
|
|
18101
|
+
}
|
|
17739
18102
|
if (state.focus === 'detail' && context.detailFileCount) {
|
|
17740
18103
|
return [action({ type: 'moveDetailFile', delta: 1, fileCount: context.detailFileCount })];
|
|
17741
18104
|
}
|
|
17742
18105
|
if (state.activeView === 'status' && context.worktreeFileCount) {
|
|
18106
|
+
// Header focused → ↓ re-enters the group at the cursored file
|
|
18107
|
+
// (which is already the group's first file by construction).
|
|
18108
|
+
// Just clear the flag.
|
|
18109
|
+
if (state.statusGroupHeaderFocused) {
|
|
18110
|
+
return [action({ type: 'setStatusGroupHeaderFocused', value: false })];
|
|
18111
|
+
}
|
|
17743
18112
|
return [action({
|
|
17744
18113
|
type: 'moveWorktreeFile',
|
|
17745
18114
|
delta: 1,
|
|
@@ -17760,6 +18129,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17760
18129
|
previewLineCount: context.previewLineCount,
|
|
17761
18130
|
})];
|
|
17762
18131
|
}
|
|
18132
|
+
// Sidebar header focused: ↓ re-enters the list at index 0.
|
|
18133
|
+
// Clears the header flag and snaps the per-entity selection to 0
|
|
18134
|
+
// (mirrors the existing default selection behavior on first
|
|
18135
|
+
// sidebar focus).
|
|
18136
|
+
if (state.focus === 'sidebar' && state.sidebarHeaderFocused) {
|
|
18137
|
+
return [action({ type: 'setSidebarHeaderFocused', value: false })];
|
|
18138
|
+
}
|
|
17763
18139
|
if (isBranchActionTarget(state) && context.branchCount) {
|
|
17764
18140
|
return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
|
|
17765
18141
|
}
|
|
@@ -17853,6 +18229,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17853
18229
|
];
|
|
17854
18230
|
}
|
|
17855
18231
|
}
|
|
18232
|
+
// Inspector Actions tab: Enter on the cursored action fires its
|
|
18233
|
+
// associated event (cherry-pick / revert / yank / etc.). Wins over
|
|
18234
|
+
// the file-list Enter below when the user has [/]-toggled to the
|
|
18235
|
+
// actions tab. Routes through `getInspectorActionExecuteEvents` so
|
|
18236
|
+
// the per-action dispatch table stays the single source of truth
|
|
18237
|
+
// for what each action does. (#791 follow-up)
|
|
18238
|
+
if (key.return &&
|
|
18239
|
+
state.focus === 'detail' &&
|
|
18240
|
+
state.inspectorTab === 'actions') {
|
|
18241
|
+
const actions = getInspectorActionsForState(state);
|
|
18242
|
+
const cursored = actions[state.inspectorActionIndex];
|
|
18243
|
+
if (cursored) {
|
|
18244
|
+
return getInspectorActionExecuteEvents(cursored, state);
|
|
18245
|
+
}
|
|
18246
|
+
}
|
|
17856
18247
|
// From the inspector / commit-diff detail panel, Enter opens (or refocuses)
|
|
17857
18248
|
// the diff view scoped to the currently-selected commit and file. Lets the
|
|
17858
18249
|
// user drive the explore flow entirely from the right panel: j/k picks a
|
|
@@ -17891,7 +18282,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17891
18282
|
const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
|
|
17892
18283
|
const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
|
|
17893
18284
|
sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
|
|
17894
|
-
|
|
18285
|
+
// Three cases drill into the dedicated view:
|
|
18286
|
+
// 1. The cursor is on the tab header (user pressed ↑ at the
|
|
18287
|
+
// top of the list to escape the items — Enter explicitly
|
|
18288
|
+
// jumps to the dedicated view).
|
|
18289
|
+
// 2. The tab has no in-sidebar primary action defined (status,
|
|
18290
|
+
// tags, worktrees — drilling in is the canonical path).
|
|
18291
|
+
// 3. The tab has zero items (the dedicated view's empty state
|
|
18292
|
+
// tells the user what to do next).
|
|
18293
|
+
if (state.sidebarHeaderFocused || !hasInSidebarPrimaryAction) {
|
|
17895
18294
|
const tabToView = {
|
|
17896
18295
|
status: 'status',
|
|
17897
18296
|
branches: 'branches',
|
|
@@ -17911,6 +18310,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17911
18310
|
// Fall through — per-entity Enter handler below claims the keystroke.
|
|
17912
18311
|
}
|
|
17913
18312
|
if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
|
|
18313
|
+
// Group header focused → fire the group's batch workflow action.
|
|
18314
|
+
// Routed through the workflow runner so the runtime owns the
|
|
18315
|
+
// git invocation + status messaging consistently with the
|
|
18316
|
+
// single-file `space` toggle. The `payload` carries the group's
|
|
18317
|
+
// state ('staged' / 'unstaged' / 'untracked') so the runtime can
|
|
18318
|
+
// resolve which files to act on without re-deriving group state.
|
|
18319
|
+
if (state.statusGroupHeaderFocused && context.statusGroups) {
|
|
18320
|
+
const currentGroup = context.statusGroups.find((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
|
|
18321
|
+
state.selectedWorktreeFileIndex < group.startIndex + group.count);
|
|
18322
|
+
if (currentGroup) {
|
|
18323
|
+
const workflowId = currentGroup.state === 'staged'
|
|
18324
|
+
? 'unstage-all-staged'
|
|
18325
|
+
: currentGroup.state === 'unstaged'
|
|
18326
|
+
? 'stage-all-unstaged'
|
|
18327
|
+
: 'stage-all-untracked';
|
|
18328
|
+
return [{ type: 'runWorkflowAction', id: workflowId, payload: currentGroup.state }];
|
|
18329
|
+
}
|
|
18330
|
+
}
|
|
17914
18331
|
return [action({
|
|
17915
18332
|
type: 'navigateOpenDiffForWorktreeFile',
|
|
17916
18333
|
fileIndex: state.selectedWorktreeFileIndex,
|
|
@@ -20653,6 +21070,35 @@ function parseStashDiffFiles(lines) {
|
|
|
20653
21070
|
}
|
|
20654
21071
|
return files;
|
|
20655
21072
|
}
|
|
21073
|
+
/**
|
|
21074
|
+
* Resolve which stash file *contains* a given line offset — the user's
|
|
21075
|
+
* cursor scrolls through a concatenated multi-file patch, and this is
|
|
21076
|
+
* what powers the "File N/M: <path>" panel header, the inline header
|
|
21077
|
+
* highlighting (#791 follow-up), and the cherry-pick / open-in-editor
|
|
21078
|
+
* dispatchers' "what file is the cursor on" lookup.
|
|
21079
|
+
*
|
|
21080
|
+
* Returns `undefined` when the file list is empty *or* the offset
|
|
21081
|
+
* lands before the very first file's `diff --git` header (e.g. when
|
|
21082
|
+
* `--stat` summary lines lead the patch). Callers fall through to a
|
|
21083
|
+
* "no file selected" state in that case.
|
|
21084
|
+
*/
|
|
21085
|
+
function findStashFileForOffset(files, offset) {
|
|
21086
|
+
if (files.length === 0)
|
|
21087
|
+
return undefined;
|
|
21088
|
+
let current;
|
|
21089
|
+
for (const file of files) {
|
|
21090
|
+
if (file.startLine <= offset) {
|
|
21091
|
+
current = file;
|
|
21092
|
+
}
|
|
21093
|
+
else {
|
|
21094
|
+
break;
|
|
21095
|
+
}
|
|
21096
|
+
}
|
|
21097
|
+
// First file is the canonical fallback — even if the offset lands
|
|
21098
|
+
// before its header (rare), we want the cursor to be "in" something
|
|
21099
|
+
// so the user's actions have a target.
|
|
21100
|
+
return current ?? files[0];
|
|
21101
|
+
}
|
|
20656
21102
|
const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
|
|
20657
21103
|
function parseDiffGitHeader(line) {
|
|
20658
21104
|
const match = line.match(DIFF_GIT_HEADER);
|
|
@@ -20707,6 +21153,25 @@ function revertFile(git, file) {
|
|
|
20707
21153
|
}
|
|
20708
21154
|
return runAction$3(() => git.raw(['restore', '--', file.path]), `Reverted ${file.path}`);
|
|
20709
21155
|
}
|
|
21156
|
+
/**
|
|
21157
|
+
* Group-level batch ops triggered by Enter on a status group header
|
|
21158
|
+
* (staged / unstaged / untracked). Pass the files belonging to that
|
|
21159
|
+
* group; the helpers run a single `git add` / `git restore --staged`
|
|
21160
|
+
* with all paths in one invocation rather than looping per-file —
|
|
21161
|
+
* faster + atomic from the user's point of view.
|
|
21162
|
+
*/
|
|
21163
|
+
function stageAllFiles(git, files) {
|
|
21164
|
+
if (files.length === 0) {
|
|
21165
|
+
return Promise.resolve({ ok: false, message: 'No files to stage' });
|
|
21166
|
+
}
|
|
21167
|
+
return runAction$3(() => git.raw(['add', '--', ...files.map((file) => file.path)]), `Staged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
|
|
21168
|
+
}
|
|
21169
|
+
function unstageAllFiles(git, files) {
|
|
21170
|
+
if (files.length === 0) {
|
|
21171
|
+
return Promise.resolve({ ok: false, message: 'No files to unstage' });
|
|
21172
|
+
}
|
|
21173
|
+
return runAction$3(() => git.raw(['restore', '--staged', '--', ...files.map((file) => file.path)]), `Unstaged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
|
|
21174
|
+
}
|
|
20710
21175
|
|
|
20711
21176
|
function fileState(indexStatus, worktreeStatus) {
|
|
20712
21177
|
if (indexStatus === '?' && worktreeStatus === '?') {
|
|
@@ -20750,6 +21215,22 @@ function applyStatusFilterMask(files, mask) {
|
|
|
20750
21215
|
}
|
|
20751
21216
|
return files.filter((file) => mask[file.state]);
|
|
20752
21217
|
}
|
|
21218
|
+
const WORKTREE_GROUP_ORDER = ['staged', 'unstaged', 'untracked'];
|
|
21219
|
+
function groupWorktreeFiles(files) {
|
|
21220
|
+
const groups = [];
|
|
21221
|
+
let cursor = 0;
|
|
21222
|
+
for (const groupState of WORKTREE_GROUP_ORDER) {
|
|
21223
|
+
const groupFiles = files.filter((file) => file.state === groupState);
|
|
21224
|
+
if (groupFiles.length > 0) {
|
|
21225
|
+
groups.push({ state: groupState, files: groupFiles, startIndex: cursor });
|
|
21226
|
+
cursor += groupFiles.length;
|
|
21227
|
+
}
|
|
21228
|
+
}
|
|
21229
|
+
return groups;
|
|
21230
|
+
}
|
|
21231
|
+
function flattenWorktreeGroups(groups) {
|
|
21232
|
+
return groups.flatMap((group) => group.files);
|
|
21233
|
+
}
|
|
20753
21234
|
|
|
20754
21235
|
function hunkHeader(hunk) {
|
|
20755
21236
|
return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
|
|
@@ -23099,88 +23580,6 @@ function formatPullRequestStateLine(pr) {
|
|
|
23099
23580
|
return parts.join(' · ');
|
|
23100
23581
|
}
|
|
23101
23582
|
|
|
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
23583
|
function sectionLines(title, diff) {
|
|
23185
23584
|
const lines = diff.split('\n').map((line) => line.trimEnd());
|
|
23186
23585
|
return [
|
|
@@ -23736,7 +24135,16 @@ function LogInkApp(deps) {
|
|
|
23736
24135
|
// count, selected-file resolution, and the rendered list all key off
|
|
23737
24136
|
// it so toggles never desync the cursor from the rendered rows.
|
|
23738
24137
|
const visibleWorktreeFiles = React.useMemo(() => applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask), [context.worktree?.files, state.statusFilterMask]);
|
|
23739
|
-
|
|
24138
|
+
// Sectioned view of the visible files (#791 follow-up). Drives the
|
|
24139
|
+
// status surface's three-tier cursor model: ←/→ jumps between
|
|
24140
|
+
// groups, ↑ at index 0 promotes to the group header, Enter on the
|
|
24141
|
+
// header fires the group's batch action. The renderer also consumes
|
|
24142
|
+
// this so the visible file list stays in canonical group order
|
|
24143
|
+
// regardless of whatever order `git status --porcelain` happens to
|
|
24144
|
+
// emit.
|
|
24145
|
+
const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
|
|
24146
|
+
const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
|
|
24147
|
+
const selectedWorktreeFile = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex];
|
|
23740
24148
|
const dispatch = React.useCallback((action) => {
|
|
23741
24149
|
setState((current) => applyLogInkAction(current, action));
|
|
23742
24150
|
}, []);
|
|
@@ -24595,6 +25003,26 @@ function LogInkApp(deps) {
|
|
|
24595
25003
|
return { ok: false, message: 'Comment body required' };
|
|
24596
25004
|
return commentPullRequest(body);
|
|
24597
25005
|
},
|
|
25006
|
+
// Status surface group-level batch ops (#791 follow-up). The
|
|
25007
|
+
// input handler dispatches these when the user presses Enter on a
|
|
25008
|
+
// group header. We re-derive the file list from the live
|
|
25009
|
+
// `context.worktree?.files` rather than trusting a snapshot —
|
|
25010
|
+
// the worktree may have changed since the keystroke fired (rare,
|
|
25011
|
+
// but the cost of re-filtering is negligible compared to the cost
|
|
25012
|
+
// of a stale add). The mask is honored too so a user who's
|
|
25013
|
+
// hidden a category never has it touched by accident.
|
|
25014
|
+
'stage-all-unstaged': async () => {
|
|
25015
|
+
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'unstaged');
|
|
25016
|
+
return stageAllFiles(git, files);
|
|
25017
|
+
},
|
|
25018
|
+
'unstage-all-staged': async () => {
|
|
25019
|
+
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'staged');
|
|
25020
|
+
return unstageAllFiles(git, files);
|
|
25021
|
+
},
|
|
25022
|
+
'stage-all-untracked': async () => {
|
|
25023
|
+
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'untracked');
|
|
25024
|
+
return stageAllFiles(git, files);
|
|
25025
|
+
},
|
|
24598
25026
|
};
|
|
24599
25027
|
const handler = handlers[id];
|
|
24600
25028
|
if (!handler) {
|
|
@@ -24620,7 +25048,7 @@ function LogInkApp(deps) {
|
|
|
24620
25048
|
}
|
|
24621
25049
|
}, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
|
|
24622
25050
|
state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
|
|
24623
|
-
state.tagSort]);
|
|
25051
|
+
state.statusFilterMask, state.tagSort]);
|
|
24624
25052
|
// Resolve the active view's "yank target" (commit hash / branch /
|
|
24625
25053
|
// tag / stash ref / file path) against the live filtered+sorted list,
|
|
24626
25054
|
// copy it to the system clipboard, and surface the result on the
|
|
@@ -24675,7 +25103,7 @@ function LogInkApp(deps) {
|
|
|
24675
25103
|
// Read from the mask-filtered list (#776) so the cursor and the
|
|
24676
25104
|
// yanked path always match what's on screen — yanking a hidden
|
|
24677
25105
|
// row is always a desync bug.
|
|
24678
|
-
const path =
|
|
25106
|
+
const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
|
|
24679
25107
|
if (path) {
|
|
24680
25108
|
value = path;
|
|
24681
25109
|
label = `path ${path}`;
|
|
@@ -24683,7 +25111,7 @@ function LogInkApp(deps) {
|
|
|
24683
25111
|
}
|
|
24684
25112
|
else if (view === 'diff') {
|
|
24685
25113
|
if (state.diffSource === 'worktree') {
|
|
24686
|
-
const path =
|
|
25114
|
+
const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
|
|
24687
25115
|
if (path) {
|
|
24688
25116
|
value = path;
|
|
24689
25117
|
label = `path ${path}`;
|
|
@@ -24693,17 +25121,8 @@ function LogInkApp(deps) {
|
|
|
24693
25121
|
// Walk back to the most recent file header at or before the
|
|
24694
25122
|
// current preview offset — same logic the input-context block
|
|
24695
25123
|
// 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
|
-
}
|
|
25124
|
+
const current = findStashFileForOffset(parseStashDiffFiles(stashDiffLines), state.diffPreviewOffset);
|
|
25125
|
+
if (current) {
|
|
24707
25126
|
value = current.path;
|
|
24708
25127
|
label = `path ${current.path}`;
|
|
24709
25128
|
}
|
|
@@ -24757,7 +25176,7 @@ function LogInkApp(deps) {
|
|
|
24757
25176
|
state.selectedTagIndex,
|
|
24758
25177
|
state.selectedWorktreeFileIndex,
|
|
24759
25178
|
state.tagSort,
|
|
24760
|
-
|
|
25179
|
+
visibleWorktreeFilesGrouped,
|
|
24761
25180
|
]);
|
|
24762
25181
|
React.useEffect(() => {
|
|
24763
25182
|
let active = true;
|
|
@@ -24997,28 +25416,14 @@ function LogInkApp(deps) {
|
|
|
24997
25416
|
? parseStashDiffFiles(stashDiffLines)
|
|
24998
25417
|
: [];
|
|
24999
25418
|
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
|
-
})();
|
|
25419
|
+
const stashDiffSelectedPath = state.diffSource === 'stash'
|
|
25420
|
+
? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
|
|
25421
|
+
: undefined;
|
|
25017
25422
|
getLogInkInputEvents(state, inputValue, key, {
|
|
25018
25423
|
detailFileCount: detail?.files.length,
|
|
25019
25424
|
previewLineCount: diffPreviewLineCount,
|
|
25020
25425
|
worktreeDiffLineCount: worktreeDiff?.lines.length,
|
|
25021
|
-
worktreeFileCount:
|
|
25426
|
+
worktreeFileCount: visibleWorktreeFilesGrouped.length,
|
|
25022
25427
|
worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
|
|
25023
25428
|
commitDiffHunkOffsets,
|
|
25024
25429
|
branchCount: branchVisibleCount,
|
|
@@ -25028,7 +25433,13 @@ function LogInkApp(deps) {
|
|
|
25028
25433
|
stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
|
|
25029
25434
|
stashDiffSelectedPath,
|
|
25030
25435
|
worktreeListCount: worktreeVisibleCount,
|
|
25031
|
-
worktreeSelectedPath:
|
|
25436
|
+
worktreeSelectedPath: visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path,
|
|
25437
|
+
statusGroups: visibleWorktreeGroups.map((group) => ({
|
|
25438
|
+
state: group.state,
|
|
25439
|
+
count: group.files.length,
|
|
25440
|
+
startIndex: group.startIndex,
|
|
25441
|
+
})),
|
|
25442
|
+
inspectorActionCount: getInspectorActionsForState(state).length,
|
|
25032
25443
|
commitDiffSelectedPath: state.diffSource === 'commit'
|
|
25033
25444
|
? selectedDetailFile?.path
|
|
25034
25445
|
: undefined,
|
|
@@ -25174,6 +25585,11 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
|
|
|
25174
25585
|
// Accordion layout — every tab's title is visible on its own line, but
|
|
25175
25586
|
// only the active tab expands its content underneath. Switching tabs
|
|
25176
25587
|
// (1-5 / [/]) collapses the previous and expands the next.
|
|
25588
|
+
// When sidebar focus has been promoted to the tab header (#806
|
|
25589
|
+
// follow-up), the active tab's title row gets selection styling
|
|
25590
|
+
// and the items below it render without their cursor highlight
|
|
25591
|
+
// (which now lives on the header).
|
|
25592
|
+
const headerFocused = focused && state.sidebarHeaderFocused;
|
|
25177
25593
|
const tabBlocks = tabs.flatMap((tab, tabIndex) => {
|
|
25178
25594
|
const isActive = tab === state.sidebarTab;
|
|
25179
25595
|
const count = sidebarTabCount(tab, context);
|
|
@@ -25181,6 +25597,7 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
|
|
|
25181
25597
|
? `${sidebarTabLabel(tab)} (${count})`
|
|
25182
25598
|
: sidebarTabLabel(tab);
|
|
25183
25599
|
const headerText = isActive ? `[${labelWithCount}]` : labelWithCount;
|
|
25600
|
+
const headerSelected = isActive && headerFocused;
|
|
25184
25601
|
const blocks = [];
|
|
25185
25602
|
if (tabIndex > 0) {
|
|
25186
25603
|
blocks.push(h(Text, { key: `tab-spacer-${tab}` }, ''));
|
|
@@ -25189,6 +25606,12 @@ function renderSidebar(h, components, state, context, contextStatus, width, body
|
|
|
25189
25606
|
key: `tab-header-${tab}`,
|
|
25190
25607
|
bold: isActive,
|
|
25191
25608
|
dimColor: !isActive,
|
|
25609
|
+
// Selection styling on the header itself when the cursor has
|
|
25610
|
+
// been promoted off the items list. inverse swaps fg/bg so the
|
|
25611
|
+
// highlight reads as "this is the cursor target" identically
|
|
25612
|
+
// to how items render when focused.
|
|
25613
|
+
backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
25614
|
+
inverse: headerSelected,
|
|
25192
25615
|
}, headerText));
|
|
25193
25616
|
if (isActive) {
|
|
25194
25617
|
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
|
|
@@ -25226,7 +25649,11 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
25226
25649
|
// ↑/↓ navigates within the sidebar list and Enter / per-entity keys
|
|
25227
25650
|
// act on the cursored item without needing to drill into the
|
|
25228
25651
|
// dedicated view (#791 follow-up — in-sidebar selection).
|
|
25229
|
-
|
|
25652
|
+
// Items render with the cursor highlight only when the sidebar is
|
|
25653
|
+
// focused on this tab AND the cursor is on items (not promoted to
|
|
25654
|
+
// the tab header). The header-focused branch up in `renderSidebar`
|
|
25655
|
+
// owns the highlight in that case.
|
|
25656
|
+
const focused = state.focus === 'sidebar' && state.sidebarTab === tab && !state.sidebarHeaderFocused;
|
|
25230
25657
|
if (tab === 'branches') {
|
|
25231
25658
|
if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
|
|
25232
25659
|
return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
|
|
@@ -25526,6 +25953,16 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
|
|
|
25526
25953
|
backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
|
|
25527
25954
|
}, truncate$1(label, 140));
|
|
25528
25955
|
}
|
|
25956
|
+
function buildStatusSurfaceRows(groups) {
|
|
25957
|
+
const rows = [];
|
|
25958
|
+
for (const group of groups) {
|
|
25959
|
+
rows.push({ kind: 'header', group });
|
|
25960
|
+
group.files.forEach((file, offset) => {
|
|
25961
|
+
rows.push({ kind: 'file', group, file, flatIndex: group.startIndex + offset });
|
|
25962
|
+
});
|
|
25963
|
+
}
|
|
25964
|
+
return rows;
|
|
25965
|
+
}
|
|
25529
25966
|
function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
25530
25967
|
const { Box, Text } = components;
|
|
25531
25968
|
const focused = state.focus === 'commits';
|
|
@@ -25535,26 +25972,64 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
|
|
|
25535
25972
|
// uses for j/k navigation. `visibleFiles` may be a strict subset of
|
|
25536
25973
|
// worktree.files when the user has narrowed via 1/2/3.
|
|
25537
25974
|
const visibleFiles = applyStatusFilterMask(worktree?.files || [], state.statusFilterMask);
|
|
25975
|
+
// Group + canonical-sort. The runtime + input handler agree on this
|
|
25976
|
+
// order so a `selectedWorktreeFileIndex` of N always points to the
|
|
25977
|
+
// same file across all three (renderer / input / workflow handlers).
|
|
25978
|
+
const visibleGroups = groupWorktreeFiles(visibleFiles);
|
|
25979
|
+
const surfaceRows = buildStatusSurfaceRows(visibleGroups);
|
|
25538
25980
|
const listRows = Math.max(4, bodyRows - 5);
|
|
25539
25981
|
const selectedIndex = state.selectedWorktreeFileIndex;
|
|
25982
|
+
const headerFocused = state.statusGroupHeaderFocused;
|
|
25983
|
+
// Resolve the cursor's row index in the flat (header-and-file) row
|
|
25984
|
+
// list. Used to window the visible slice around the cursor.
|
|
25985
|
+
const cursorRowIndex = (() => {
|
|
25986
|
+
if (!surfaceRows.length)
|
|
25987
|
+
return 0;
|
|
25988
|
+
const currentGroup = visibleGroups.find((group) => selectedIndex >= group.startIndex && selectedIndex < group.startIndex + group.files.length);
|
|
25989
|
+
if (!currentGroup)
|
|
25990
|
+
return 0;
|
|
25991
|
+
if (headerFocused) {
|
|
25992
|
+
const idx = surfaceRows.findIndex((row) => row.kind === 'header' && row.group === currentGroup);
|
|
25993
|
+
return idx >= 0 ? idx : 0;
|
|
25994
|
+
}
|
|
25995
|
+
const idx = surfaceRows.findIndex((row) => row.kind === 'file' && row.flatIndex === selectedIndex);
|
|
25996
|
+
return idx >= 0 ? idx : 0;
|
|
25997
|
+
})();
|
|
25540
25998
|
const cleanHint = formatLogInkStatusEmpty({ hasChanges: Boolean(worktree?.files.length) });
|
|
25541
|
-
const
|
|
25999
|
+
const windowStart = Math.max(0, Math.min(Math.max(0, surfaceRows.length - listRows), cursorRowIndex - Math.floor(listRows / 2)));
|
|
25542
26000
|
const isLoading = isLogInkContextKeyLoading(contextStatus, 'worktree');
|
|
25543
|
-
const
|
|
26001
|
+
const renderedRows = isLoading || !surfaceRows.length
|
|
25544
26002
|
? []
|
|
25545
|
-
:
|
|
25546
|
-
const
|
|
25547
|
-
|
|
26003
|
+
: surfaceRows.slice(windowStart, windowStart + listRows).map((row, offset) => {
|
|
26004
|
+
const rowIndex = windowStart + offset;
|
|
26005
|
+
if (row.kind === 'header') {
|
|
26006
|
+
const groupContainsCursor = selectedIndex >= row.group.startIndex &&
|
|
26007
|
+
selectedIndex < row.group.startIndex + row.group.files.length;
|
|
26008
|
+
const headerSelected = focused && headerFocused && groupContainsCursor;
|
|
26009
|
+
const arrow = theme.ascii ? '>' : '▾';
|
|
26010
|
+
const groupLabel = capitalizeGroupName(row.group.state);
|
|
26011
|
+
const text = ` ${arrow} ${groupLabel} (${row.group.files.length})`;
|
|
26012
|
+
return h(Text, {
|
|
26013
|
+
key: `status-group-${row.group.state}-${rowIndex}`,
|
|
26014
|
+
bold: true,
|
|
26015
|
+
dimColor: !headerSelected && rowIndex > cursorRowIndex,
|
|
26016
|
+
backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
26017
|
+
inverse: headerSelected,
|
|
26018
|
+
}, truncate$1(text, 140));
|
|
26019
|
+
}
|
|
26020
|
+
const isSelected = !headerFocused && row.flatIndex === selectedIndex;
|
|
25548
26021
|
const cursorPart = `${isSelected ? '>' : ' '} `;
|
|
25549
|
-
const dotColor = getStageStatusDotColor(file.state, theme);
|
|
26022
|
+
const dotColor = getStageStatusDotColor(row.file.state, theme);
|
|
25550
26023
|
const useDot = dotColor !== undefined;
|
|
25551
26024
|
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));
|
|
26025
|
+
const tail = `${row.file.indexStatus}${row.file.worktreeStatus} ${row.file.path}`;
|
|
26026
|
+
const tailTrunc = truncate$1(tail, Math.max(0, 140 - cellWidth(cursorPart) - dotCells - 2));
|
|
25554
26027
|
return h(Text, {
|
|
25555
|
-
key: `status-row-${
|
|
25556
|
-
dimColor:
|
|
25557
|
-
|
|
26028
|
+
key: `status-file-${row.flatIndex}-${rowIndex}`,
|
|
26029
|
+
dimColor: !isSelected && rowIndex > cursorRowIndex,
|
|
26030
|
+
backgroundColor: isSelected && focused && !theme.noColor ? theme.colors.selection : undefined,
|
|
26031
|
+
inverse: isSelected && focused,
|
|
26032
|
+
}, ` ${cursorPart}`, ...(useDot ? [h(Text, { color: dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
|
|
25558
26033
|
});
|
|
25559
26034
|
// When the mask narrows the list to nothing but the underlying repo
|
|
25560
26035
|
// is non-clean, surface why the panel looks empty so the user can
|
|
@@ -25584,11 +26059,14 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
|
|
|
25584
26059
|
// never touch the filter.
|
|
25585
26060
|
...(isStatusFilterMaskActive(state.statusFilterMask)
|
|
25586
26061
|
? [h(Text, { key: 'status-mask-indicator', dimColor: true }, `filter: ${formatStatusFilterMask(state.statusFilterMask)} (1/2/3 to toggle)`)]
|
|
25587
|
-
: []), ...
|
|
26062
|
+
: []), ...renderedRows, ...fallbackLines.map((line, index) => h(Text, {
|
|
25588
26063
|
key: `status-surface-fallback-${index}`,
|
|
25589
26064
|
dimColor: index > 0,
|
|
25590
26065
|
}, truncate$1(line, 140))));
|
|
25591
26066
|
}
|
|
26067
|
+
function capitalizeGroupName(value) {
|
|
26068
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
26069
|
+
}
|
|
25592
26070
|
function isStatusFilterMaskActive(mask) {
|
|
25593
26071
|
return !mask.staged || !mask.unstaged || !mask.untracked;
|
|
25594
26072
|
}
|
|
@@ -26038,20 +26516,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
26038
26516
|
const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
26039
26517
|
const stashFiles = parseStashDiffFiles(lines);
|
|
26040
26518
|
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
|
-
})();
|
|
26519
|
+
const currentFile = findStashFileForOffset(stashFiles, state.diffPreviewOffset);
|
|
26055
26520
|
const currentFileIndex = currentFile
|
|
26056
26521
|
? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
|
|
26057
26522
|
: -1;
|
|
@@ -26078,14 +26543,41 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
26078
26543
|
const headerLines = splitRequestedButTooNarrow
|
|
26079
26544
|
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
26080
26545
|
: baseHeaderLines;
|
|
26546
|
+
// File header anchor map: absolute line index → owning stash file.
|
|
26547
|
+
// Lets the body-render pass restyle each `diff --git` row in O(1)
|
|
26548
|
+
// and decide which one is the *active* file (the one currently
|
|
26549
|
+
// containing `diffPreviewOffset`). The active header gets the
|
|
26550
|
+
// selection background to mark "the file the cursor is inside."
|
|
26551
|
+
const stashFileByStartLine = new Map(stashFiles.map((file) => [file.startLine, file]));
|
|
26552
|
+
const activeStartLine = currentFile?.startLine;
|
|
26081
26553
|
const stashBodyNodes = stashDiffLoading || !lines.length
|
|
26082
26554
|
? []
|
|
26083
26555
|
: splitActive
|
|
26084
26556
|
? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
|
|
26085
|
-
: visibleLines.map((line, index) =>
|
|
26086
|
-
|
|
26087
|
-
|
|
26088
|
-
|
|
26557
|
+
: visibleLines.map((line, index) => {
|
|
26558
|
+
const absoluteIndex = state.diffPreviewOffset + index;
|
|
26559
|
+
const headerFile = stashFileByStartLine.get(absoluteIndex);
|
|
26560
|
+
if (headerFile) {
|
|
26561
|
+
// Replace the verbose `diff --git a/<path> b/<path>` text
|
|
26562
|
+
// with a compact `▾ <path>` marker — the path itself is
|
|
26563
|
+
// the meaningful identifier, not the a/b duplication. The
|
|
26564
|
+
// active file's header gets selection styling so the user
|
|
26565
|
+
// sees at a glance which file the cursor is inside.
|
|
26566
|
+
const isActive = absoluteIndex === activeStartLine;
|
|
26567
|
+
const arrow = theme.ascii ? '> ' : '▾ ';
|
|
26568
|
+
return h(Text, {
|
|
26569
|
+
key: `stash-diff-line-${absoluteIndex}`,
|
|
26570
|
+
bold: true,
|
|
26571
|
+
color: theme.noColor ? undefined : theme.colors.accent,
|
|
26572
|
+
backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
|
|
26573
|
+
inverse: isActive && focused,
|
|
26574
|
+
}, truncate$1(`${arrow}${headerFile.path}`, width - 4));
|
|
26575
|
+
}
|
|
26576
|
+
return h(Text, {
|
|
26577
|
+
key: `stash-diff-line-${absoluteIndex}`,
|
|
26578
|
+
...diffLineProps(line, theme),
|
|
26579
|
+
}, truncate$1(line, width - 4));
|
|
26580
|
+
});
|
|
26089
26581
|
return h(Box, {
|
|
26090
26582
|
borderColor: focusBorderColor(theme, focused),
|
|
26091
26583
|
borderStyle: theme.borderStyle,
|
|
@@ -26268,7 +26760,10 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
26268
26760
|
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
|
|
26269
26761
|
key: `detail-${index}`,
|
|
26270
26762
|
dimColor: index > 1,
|
|
26271
|
-
}, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme
|
|
26763
|
+
}, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26764
|
+
cursorIndex: state.inspectorActionIndex,
|
|
26765
|
+
cursorActive: focused && state.inspectorTab === 'actions',
|
|
26766
|
+
}));
|
|
26272
26767
|
}
|
|
26273
26768
|
const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
|
|
26274
26769
|
// P5.1 — link the commit hash and each ref out to GitHub when we know
|
|
@@ -26305,22 +26800,39 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
26305
26800
|
h(Text, { key: 'detail-spacer-3' }, ''),
|
|
26306
26801
|
h(Text, { key: 'detail-files-title' }, 'Changed files:'),
|
|
26307
26802
|
];
|
|
26803
|
+
// Single-cursor invariant: the file list owns the cursor when the
|
|
26804
|
+
// inspector tab is active; the actions list owns it when the actions
|
|
26805
|
+
// tab is active. Pass `focused` only for the matching tab so users
|
|
26806
|
+
// never see two simultaneous selection highlights inside the panel.
|
|
26807
|
+
const fileListFocused = focused && state.inspectorTab === 'inspector';
|
|
26308
26808
|
const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
|
|
26309
|
-
const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex,
|
|
26310
|
-
//
|
|
26311
|
-
//
|
|
26312
|
-
//
|
|
26313
|
-
//
|
|
26314
|
-
//
|
|
26809
|
+
const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, fileListFocused, fileListMaxRows, width, theme);
|
|
26810
|
+
// Tab indicator. Renders in BOTH tabbed (short-terminal) mode and
|
|
26811
|
+
// tall-stacked mode so the user can always see which tab the cursor
|
|
26812
|
+
// owns and learn the `[/]` toggle. Without this on tall terminals,
|
|
26813
|
+
// the actions list looked like a static cheat-sheet — there was no
|
|
26814
|
+
// visible signal that the cursor could move into it.
|
|
26815
|
+
//
|
|
26816
|
+
// Spacing between tab labels comes from the labels' own padding
|
|
26817
|
+
// (the active label is bracketed `[Inspector]` while the inactive
|
|
26818
|
+
// one is space-padded ` Inspector `, so adjacency reads cleanly).
|
|
26819
|
+
// Earlier revisions stuck a raw `' '` between the Text children to
|
|
26820
|
+
// pad them visually — that crashes Ink at first paint with
|
|
26821
|
+
// "Text string ' ' must be rendered inside <Text> component"
|
|
26822
|
+
// because Box only accepts component children, never bare strings.
|
|
26823
|
+
const activeTab = state.inspectorTab;
|
|
26824
|
+
const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
|
|
26825
|
+
bold: activeTab === 'inspector',
|
|
26826
|
+
dimColor: activeTab !== 'inspector',
|
|
26827
|
+
}, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), h(Text, {
|
|
26828
|
+
bold: activeTab === 'actions',
|
|
26829
|
+
dimColor: activeTab !== 'actions',
|
|
26830
|
+
}, activeTab === 'actions' ? '[Actions]' : ' Actions '), ...(focused
|
|
26831
|
+
? [h(Text, { key: 'inspector-tabs-hint', dimColor: true }, ' · ←/→ switch')]
|
|
26832
|
+
: []));
|
|
26833
|
+
// Tabbed mode (short terminals): render only the active tab's
|
|
26834
|
+
// content under the tab header.
|
|
26315
26835
|
if (tabbed) {
|
|
26316
|
-
const activeTab = state.inspectorTab;
|
|
26317
|
-
const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
|
|
26318
|
-
bold: activeTab === 'inspector',
|
|
26319
|
-
dimColor: activeTab !== 'inspector',
|
|
26320
|
-
}, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), ' ', h(Text, {
|
|
26321
|
-
bold: activeTab === 'actions',
|
|
26322
|
-
dimColor: activeTab !== 'actions',
|
|
26323
|
-
}, activeTab === 'actions' ? '[Actions]' : ' Actions '));
|
|
26324
26836
|
return h(Box, {
|
|
26325
26837
|
borderColor: focusBorderColor(theme, focused),
|
|
26326
26838
|
borderStyle: theme.borderStyle,
|
|
@@ -26329,15 +26841,24 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
26329
26841
|
paddingX: 1,
|
|
26330
26842
|
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...(activeTab === 'inspector'
|
|
26331
26843
|
? [...headerNodes, ...fileListNodes]
|
|
26332
|
-
: renderInspectorActionsSection(h, Text, 'history-commit', width, theme
|
|
26333
|
-
|
|
26844
|
+
: renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26845
|
+
cursorIndex: state.inspectorActionIndex,
|
|
26846
|
+
cursorActive: focused,
|
|
26847
|
+
})));
|
|
26848
|
+
}
|
|
26849
|
+
// Tall mode: stack both sections so the user can read everything at
|
|
26850
|
+
// once, but show the tab header so the active section (and the
|
|
26851
|
+
// `[/]` switch affordance) is visible.
|
|
26334
26852
|
return h(Box, {
|
|
26335
26853
|
borderColor: focusBorderColor(theme, focused),
|
|
26336
26854
|
borderStyle: theme.borderStyle,
|
|
26337
26855
|
flexDirection: 'column',
|
|
26338
26856
|
width,
|
|
26339
26857
|
paddingX: 1,
|
|
26340
|
-
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme
|
|
26858
|
+
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26859
|
+
cursorIndex: state.inspectorActionIndex,
|
|
26860
|
+
cursorActive: focused && state.inspectorTab === 'actions',
|
|
26861
|
+
}));
|
|
26341
26862
|
}
|
|
26342
26863
|
/**
|
|
26343
26864
|
* Render the trailing "Actions:" section that surfaces which keystrokes
|
|
@@ -26350,7 +26871,7 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
26350
26871
|
* minimum from `getLogInkLayout`) so an overflowing label never wraps and
|
|
26351
26872
|
* collides with the next row.
|
|
26352
26873
|
*/
|
|
26353
|
-
function renderInspectorActionsSection(h, Text, context, width, theme) {
|
|
26874
|
+
function renderInspectorActionsSection(h, Text, context, width, theme, options = {}) {
|
|
26354
26875
|
const actions = getInspectorActions(context);
|
|
26355
26876
|
if (!actions.length)
|
|
26356
26877
|
return [];
|
|
@@ -26361,10 +26882,13 @@ function renderInspectorActionsSection(h, Text, context, width, theme) {
|
|
|
26361
26882
|
const GAP = ' ';
|
|
26362
26883
|
const DESTRUCTIVE_SUFFIX = ' [!]';
|
|
26363
26884
|
const labelBudget = Math.max(4, width - 4 /* border + padX */ - KEY_COLUMN - GAP.length - DESTRUCTIVE_SUFFIX.length);
|
|
26885
|
+
const cursorIndex = options.cursorIndex ?? 0;
|
|
26886
|
+
const cursorActive = options.cursorActive ?? false;
|
|
26364
26887
|
const nodes = [
|
|
26365
26888
|
h(Text, { key: 'actions-spacer' }, ''),
|
|
26366
|
-
h(Text, { key: 'actions-title' }, 'Actions:'),
|
|
26889
|
+
h(Text, { key: 'actions-title' }, cursorActive ? '[Actions]' : 'Actions:'),
|
|
26367
26890
|
...actions.map((action, index) => {
|
|
26891
|
+
const isSelected = cursorActive && index === cursorIndex;
|
|
26368
26892
|
const keyCell = action.key.padEnd(KEY_COLUMN);
|
|
26369
26893
|
const label = truncate$1(action.label, labelBudget);
|
|
26370
26894
|
const children = [
|
|
@@ -26382,7 +26906,11 @@ function renderInspectorActionsSection(h, Text, context, width, theme) {
|
|
|
26382
26906
|
dimColor: false,
|
|
26383
26907
|
}, DESTRUCTIVE_SUFFIX));
|
|
26384
26908
|
}
|
|
26385
|
-
return h(Text, {
|
|
26909
|
+
return h(Text, {
|
|
26910
|
+
key: `actions-${index}`,
|
|
26911
|
+
backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
26912
|
+
inverse: isSelected,
|
|
26913
|
+
}, ...children);
|
|
26386
26914
|
}),
|
|
26387
26915
|
];
|
|
26388
26916
|
return nodes;
|