git-coco 0.38.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 +1130 -197
- package/dist/index.js +1130 -197
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
|
|
|
78
78
|
/**
|
|
79
79
|
* Current build version from package.json
|
|
80
80
|
*/
|
|
81
|
-
const BUILD_VERSION = "0.
|
|
81
|
+
const BUILD_VERSION = "0.40.0";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -14772,84 +14772,6 @@ function formatInkRefLabels(refs) {
|
|
|
14772
14772
|
return refs.length ? ` ${refs.map((ref) => `[${ref}]`).join(' ')}` : '';
|
|
14773
14773
|
}
|
|
14774
14774
|
|
|
14775
|
-
function countLabel(count, singular, plural = `${singular}s`) {
|
|
14776
|
-
return `${count} ${count === 1 ? singular : plural}`;
|
|
14777
|
-
}
|
|
14778
|
-
function getLogInkWorkflowSections(context) {
|
|
14779
|
-
const currentBranch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
|
|
14780
|
-
const dirty = context.branches?.dirty ? 'dirty worktree' : 'clean worktree';
|
|
14781
|
-
const loading = context.contextLoading;
|
|
14782
|
-
const currentPullRequest = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
|
|
14783
|
-
const repository = context.provider?.repository;
|
|
14784
|
-
const repoName = repository?.owner && repository.name
|
|
14785
|
-
? `${repository.owner}/${repository.name}`
|
|
14786
|
-
: repository?.message || 'local repository';
|
|
14787
|
-
const operation = context.operation;
|
|
14788
|
-
const worktree = context.worktree;
|
|
14789
|
-
return [
|
|
14790
|
-
{
|
|
14791
|
-
title: 'Branch',
|
|
14792
|
-
lines: [
|
|
14793
|
-
`Current: ${currentBranch}`,
|
|
14794
|
-
`State: ${dirty}`,
|
|
14795
|
-
loading && !context.branches
|
|
14796
|
-
? 'Branch data loading'
|
|
14797
|
-
: context.branches
|
|
14798
|
-
? `${countLabel(context.branches.localBranches.length, 'local branch', 'local branches')} | ${countLabel(context.branches.remoteBranches.length, 'remote branch', 'remote branches')}`
|
|
14799
|
-
: 'Branch data unavailable',
|
|
14800
|
-
],
|
|
14801
|
-
},
|
|
14802
|
-
{
|
|
14803
|
-
title: 'Provider / PR',
|
|
14804
|
-
lines: [
|
|
14805
|
-
`Repository: ${repoName}`,
|
|
14806
|
-
loading && !context.provider && !context.pullRequest
|
|
14807
|
-
? 'Provider and pull request data loading'
|
|
14808
|
-
: currentPullRequest
|
|
14809
|
-
? `PR #${currentPullRequest.number} ${currentPullRequest.state}${currentPullRequest.isDraft ? ' draft' : ''}`
|
|
14810
|
-
: 'No pull request detected for current branch',
|
|
14811
|
-
context.provider?.authenticated === false ? 'Provider auth: offline' : 'Provider auth: available',
|
|
14812
|
-
],
|
|
14813
|
-
},
|
|
14814
|
-
{
|
|
14815
|
-
title: 'Status',
|
|
14816
|
-
lines: loading && !worktree
|
|
14817
|
-
? ['Status data loading']
|
|
14818
|
-
: worktree
|
|
14819
|
-
? [
|
|
14820
|
-
`${countLabel(worktree.stagedCount, 'staged file')}`,
|
|
14821
|
-
`${countLabel(worktree.unstagedCount, 'unstaged file')}`,
|
|
14822
|
-
`${countLabel(worktree.untrackedCount, 'untracked file')}`,
|
|
14823
|
-
]
|
|
14824
|
-
: ['Status data unavailable'],
|
|
14825
|
-
},
|
|
14826
|
-
{
|
|
14827
|
-
title: 'Tags / Stashes / Worktrees',
|
|
14828
|
-
lines: [
|
|
14829
|
-
loading && !context.tags ? 'Tags loading' : context.tags ? countLabel(context.tags.tags.length, 'tag') : 'Tags unavailable',
|
|
14830
|
-
loading && !context.stashes ? 'Stashes loading' : context.stashes ? countLabel(context.stashes.stashes.length, 'stash', 'stashes') : 'Stashes unavailable',
|
|
14831
|
-
context.worktreeList
|
|
14832
|
-
? countLabel(context.worktreeList.worktrees.length, 'worktree')
|
|
14833
|
-
: loading
|
|
14834
|
-
? 'Worktrees loading'
|
|
14835
|
-
: 'Worktrees unavailable',
|
|
14836
|
-
],
|
|
14837
|
-
},
|
|
14838
|
-
{
|
|
14839
|
-
title: 'Operation / AI',
|
|
14840
|
-
lines: [
|
|
14841
|
-
loading && !operation
|
|
14842
|
-
? 'Operation data loading'
|
|
14843
|
-
: operation?.operation
|
|
14844
|
-
? `${operation.operation} in progress with ${countLabel(operation.conflictedFiles.length, 'conflict')}`
|
|
14845
|
-
: 'No merge, rebase, cherry-pick, or revert in progress',
|
|
14846
|
-
context.selectedCommit
|
|
14847
|
-
? `AI actions target ${context.selectedCommit.shortHash}; estimates shown before execution`
|
|
14848
|
-
: 'AI actions require a selected commit',
|
|
14849
|
-
],
|
|
14850
|
-
},
|
|
14851
|
-
];
|
|
14852
|
-
}
|
|
14853
14775
|
function getLogInkWorkflowActions() {
|
|
14854
14776
|
return [
|
|
14855
14777
|
{
|
|
@@ -14983,6 +14905,35 @@ function getLogInkWorkflowActions() {
|
|
|
14983
14905
|
kind: 'normal',
|
|
14984
14906
|
requiresConfirmation: false,
|
|
14985
14907
|
},
|
|
14908
|
+
// Status surface group-level batch ops (#791 follow-up). Triggered
|
|
14909
|
+
// by Enter when the cursor is on a status group header
|
|
14910
|
+
// (Staged / Unstaged / Untracked). Empty `key` keeps them
|
|
14911
|
+
// palette-discoverable without registering a global hotkey — the
|
|
14912
|
+
// Enter-on-header path in inkInput is the canonical trigger.
|
|
14913
|
+
{
|
|
14914
|
+
id: 'unstage-all-staged',
|
|
14915
|
+
key: '',
|
|
14916
|
+
label: 'Unstage all staged files',
|
|
14917
|
+
description: 'Unstage every file currently in the staged group.',
|
|
14918
|
+
kind: 'normal',
|
|
14919
|
+
requiresConfirmation: false,
|
|
14920
|
+
},
|
|
14921
|
+
{
|
|
14922
|
+
id: 'stage-all-unstaged',
|
|
14923
|
+
key: '',
|
|
14924
|
+
label: 'Stage all unstaged files',
|
|
14925
|
+
description: 'Stage every modified-but-not-staged file.',
|
|
14926
|
+
kind: 'normal',
|
|
14927
|
+
requiresConfirmation: false,
|
|
14928
|
+
},
|
|
14929
|
+
{
|
|
14930
|
+
id: 'stage-all-untracked',
|
|
14931
|
+
key: '',
|
|
14932
|
+
label: 'Stage all untracked files',
|
|
14933
|
+
description: 'Add every untracked file to the index after confirmation.',
|
|
14934
|
+
kind: 'destructive',
|
|
14935
|
+
requiresConfirmation: true,
|
|
14936
|
+
},
|
|
14986
14937
|
{
|
|
14987
14938
|
id: 'delete-branch',
|
|
14988
14939
|
key: 'D',
|
|
@@ -15093,6 +15044,38 @@ function getLogInkWorkflowActions() {
|
|
|
15093
15044
|
kind: 'destructive',
|
|
15094
15045
|
requiresConfirmation: true,
|
|
15095
15046
|
},
|
|
15047
|
+
{
|
|
15048
|
+
// Per-view-only: scoped to the history view in inkInput (key `B`).
|
|
15049
|
+
// The prompt itself is the affirmative gate — the user has to
|
|
15050
|
+
// type a branch name before anything happens — so this skips the
|
|
15051
|
+
// y-confirm path. Empty key keeps it palette-discoverable; the
|
|
15052
|
+
// palette path can't synthesize a branch name and surfaces a
|
|
15053
|
+
// hint instead.
|
|
15054
|
+
//
|
|
15055
|
+
// Distinct from `create-branch` (palette / `+` on branches view),
|
|
15056
|
+
// which uses `git switch -c` and switches onto the new branch.
|
|
15057
|
+
// This workflow uses `git branch <name> <sha>` and stays put —
|
|
15058
|
+
// GitKraken's "create branch here" semantic.
|
|
15059
|
+
id: 'create-branch-here',
|
|
15060
|
+
key: '',
|
|
15061
|
+
label: 'Create branch from commit',
|
|
15062
|
+
description: 'Create a branch pointed at the cursored commit (does not switch).',
|
|
15063
|
+
kind: 'normal',
|
|
15064
|
+
requiresConfirmation: false,
|
|
15065
|
+
},
|
|
15066
|
+
{
|
|
15067
|
+
// Per-view-only: scoped to the history view in inkInput via the
|
|
15068
|
+
// `gT` chord (bare `T` is taken by delete-tag on the tags view).
|
|
15069
|
+
// Same prompt-as-confirmation pattern as create-branch-here.
|
|
15070
|
+
// Lightweight tag — annotated tags remain available through the
|
|
15071
|
+
// existing `+` flow on the tags view.
|
|
15072
|
+
id: 'create-tag-here',
|
|
15073
|
+
key: '',
|
|
15074
|
+
label: 'Create tag at commit',
|
|
15075
|
+
description: 'Create a lightweight tag at the cursored commit.',
|
|
15076
|
+
kind: 'normal',
|
|
15077
|
+
requiresConfirmation: false,
|
|
15078
|
+
},
|
|
15096
15079
|
{
|
|
15097
15080
|
// Per-view-only: scoped to the history view in inkInput. `i`
|
|
15098
15081
|
// (lowercase) is used instead of `I` so the existing `I`
|
|
@@ -15105,6 +15088,38 @@ function getLogInkWorkflowActions() {
|
|
|
15105
15088
|
kind: 'destructive',
|
|
15106
15089
|
requiresConfirmation: true,
|
|
15107
15090
|
},
|
|
15091
|
+
{
|
|
15092
|
+
// Per-view-only: scoped to the history view in inkInput (key `B`).
|
|
15093
|
+
// The prompt itself is the affirmative gate — the user has to
|
|
15094
|
+
// type a branch name before anything happens — so this skips the
|
|
15095
|
+
// y-confirm path. Empty key keeps it palette-discoverable; the
|
|
15096
|
+
// palette path can't synthesize a branch name and surfaces a
|
|
15097
|
+
// hint instead.
|
|
15098
|
+
//
|
|
15099
|
+
// Distinct from `create-branch` (palette / `+` on branches view),
|
|
15100
|
+
// which uses `git switch -c` and switches onto the new branch.
|
|
15101
|
+
// This workflow uses `git branch <name> <sha>` and stays put —
|
|
15102
|
+
// GitKraken's "create branch here" semantic.
|
|
15103
|
+
id: 'create-branch-here',
|
|
15104
|
+
key: '',
|
|
15105
|
+
label: 'Create branch from commit',
|
|
15106
|
+
description: 'Create a branch pointed at the cursored commit (does not switch).',
|
|
15107
|
+
kind: 'normal',
|
|
15108
|
+
requiresConfirmation: false,
|
|
15109
|
+
},
|
|
15110
|
+
{
|
|
15111
|
+
// Per-view-only: scoped to the history view in inkInput via the
|
|
15112
|
+
// `gT` chord (bare `T` is taken by delete-tag on the tags view).
|
|
15113
|
+
// Same prompt-as-confirmation pattern as create-branch-here.
|
|
15114
|
+
// Lightweight tag — annotated tags remain available through the
|
|
15115
|
+
// existing `+` flow on the tags view.
|
|
15116
|
+
id: 'create-tag-here',
|
|
15117
|
+
key: '',
|
|
15118
|
+
label: 'Create tag at commit',
|
|
15119
|
+
description: 'Create a lightweight tag at the cursored commit.',
|
|
15120
|
+
kind: 'normal',
|
|
15121
|
+
requiresConfirmation: false,
|
|
15122
|
+
},
|
|
15108
15123
|
{
|
|
15109
15124
|
id: 'ai-commit-summary',
|
|
15110
15125
|
key: 'I',
|
|
@@ -15639,9 +15654,12 @@ function getLogInkFooterHints(options) {
|
|
|
15639
15654
|
// History view default hints. Mutating ops (`c` cherry-pick, `R`
|
|
15640
15655
|
// revert, `Z` reset, `i` interactive-rebase) all route through a
|
|
15641
15656
|
// y-confirm or mode prompt — none fire silently from the keystroke.
|
|
15642
|
-
//
|
|
15643
|
-
//
|
|
15644
|
-
|
|
15657
|
+
// `B` create-branch-here and `gT` create-tag-here use a prompt as
|
|
15658
|
+
// the affirmative gate (typing the name is the confirmation).
|
|
15659
|
+
// Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
|
|
15660
|
+
// the footer stays scannable; full descriptions live in `?` help
|
|
15661
|
+
// and the palette.
|
|
15662
|
+
contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'y/Y yank', '/ search', 'gg/G top/bottom'],
|
|
15645
15663
|
global: NORMAL_GLOBAL_HINTS,
|
|
15646
15664
|
};
|
|
15647
15665
|
}
|
|
@@ -15933,6 +15951,88 @@ function extractDiffHunk(input) {
|
|
|
15933
15951
|
return { patchText };
|
|
15934
15952
|
}
|
|
15935
15953
|
|
|
15954
|
+
/**
|
|
15955
|
+
* Hardcoded per-entity action lists surfaced inside the right-hand
|
|
15956
|
+
* inspector panel. The inspector used to repeat the repo / branch /
|
|
15957
|
+
* status content the top header and left sidebar already show; we drop
|
|
15958
|
+
* that trailer in favor of an actionable cheat-sheet so the user knows
|
|
15959
|
+
* exactly which keystrokes apply to whatever they have under the cursor.
|
|
15960
|
+
*
|
|
15961
|
+
* Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
|
|
15962
|
+
* - Most per-entity actions live in `inkInput.ts` as direct keystroke
|
|
15963
|
+
* handlers (e.g. `c` cherry-pick, `R` revert) rather than as
|
|
15964
|
+
* globally-registered bindings, so the registry would be a partial
|
|
15965
|
+
* view at best.
|
|
15966
|
+
* - The bindings registry's `contexts` model (normal / search / focus
|
|
15967
|
+
* name) does not cleanly map to inspector entity types like "branch"
|
|
15968
|
+
* or "tag". Filtering it would mean replicating the same per-view
|
|
15969
|
+
* scoping logic the input dispatcher already encodes.
|
|
15970
|
+
* - New per-entity actions are added infrequently — the maintenance
|
|
15971
|
+
* cost of mirroring them here is low and keeps this file the single
|
|
15972
|
+
* source of truth for "what shows in the inspector".
|
|
15973
|
+
*
|
|
15974
|
+
* If you wire up a new per-entity keystroke in `inkInput.ts` — for
|
|
15975
|
+
* example a "create branch from this commit" or "create tag from this
|
|
15976
|
+
* commit" action — add the matching row to the relevant array below so
|
|
15977
|
+
* it shows up in the inspector automatically.
|
|
15978
|
+
*/
|
|
15979
|
+
const HISTORY_COMMIT_ACTIONS = [
|
|
15980
|
+
{ key: 'enter', label: 'Open diff' },
|
|
15981
|
+
{ key: 'c', label: 'Cherry-pick' },
|
|
15982
|
+
{ key: 'R', label: 'Revert', destructive: true },
|
|
15983
|
+
{ key: 'Z', label: 'Reset to commit', destructive: true },
|
|
15984
|
+
{ key: 'i', label: 'Interactive rebase', destructive: true },
|
|
15985
|
+
{ key: 'y', label: 'Yank hash' },
|
|
15986
|
+
{ key: 'Y', label: 'Yank short hash' },
|
|
15987
|
+
{ key: 'O', label: 'Open in browser' },
|
|
15988
|
+
];
|
|
15989
|
+
const BRANCH_ACTIONS = [
|
|
15990
|
+
{ key: 'enter', label: 'Checkout' },
|
|
15991
|
+
{ key: '+', label: 'New branch' },
|
|
15992
|
+
{ key: 'R', label: 'Rename' },
|
|
15993
|
+
{ key: 'u', label: 'Set upstream' },
|
|
15994
|
+
{ key: 'D', label: 'Delete', destructive: true },
|
|
15995
|
+
{ key: 'P', label: 'Push current' },
|
|
15996
|
+
{ key: 'F', label: 'Fetch all' },
|
|
15997
|
+
{ key: 'y', label: 'Yank name' },
|
|
15998
|
+
];
|
|
15999
|
+
const TAG_ACTIONS = [
|
|
16000
|
+
{ key: '+', label: 'New tag' },
|
|
16001
|
+
{ key: 'P', label: 'Push tag' },
|
|
16002
|
+
{ key: 'T', label: 'Delete', destructive: true },
|
|
16003
|
+
{ key: 'R', label: 'Delete remote', destructive: true },
|
|
16004
|
+
{ key: 'y', label: 'Yank name' },
|
|
16005
|
+
];
|
|
16006
|
+
const STASH_ACTIONS = [
|
|
16007
|
+
{ key: 'enter', label: 'Open diff' },
|
|
16008
|
+
{ key: 'a', label: 'Apply' },
|
|
16009
|
+
{ key: 'p', label: 'Pop' },
|
|
16010
|
+
{ key: 'X', label: 'Drop', destructive: true },
|
|
16011
|
+
{ key: 'y', label: 'Yank ref' },
|
|
16012
|
+
];
|
|
16013
|
+
const WORKTREE_ACTIONS = [
|
|
16014
|
+
{ key: 'W', label: 'Remove', destructive: true },
|
|
16015
|
+
{ key: 'y', label: 'Yank path' },
|
|
16016
|
+
];
|
|
16017
|
+
function getInspectorActions(context) {
|
|
16018
|
+
switch (context) {
|
|
16019
|
+
case 'history-commit':
|
|
16020
|
+
return HISTORY_COMMIT_ACTIONS;
|
|
16021
|
+
case 'branch':
|
|
16022
|
+
return BRANCH_ACTIONS;
|
|
16023
|
+
case 'tag':
|
|
16024
|
+
return TAG_ACTIONS;
|
|
16025
|
+
case 'stash':
|
|
16026
|
+
return STASH_ACTIONS;
|
|
16027
|
+
case 'worktree':
|
|
16028
|
+
return WORKTREE_ACTIONS;
|
|
16029
|
+
default: {
|
|
16030
|
+
const exhaustive = context;
|
|
16031
|
+
throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
|
|
16032
|
+
}
|
|
16033
|
+
}
|
|
16034
|
+
}
|
|
16035
|
+
|
|
15936
16036
|
/**
|
|
15937
16037
|
* Sort modes for the promoted views (P4.2).
|
|
15938
16038
|
*
|
|
@@ -15952,23 +16052,31 @@ function cycleBranchSort(mode) {
|
|
|
15952
16052
|
return BRANCH_SORT_MODES[(index + 1) % BRANCH_SORT_MODES.length];
|
|
15953
16053
|
}
|
|
15954
16054
|
function sortBranches(branches, mode) {
|
|
15955
|
-
|
|
16055
|
+
// Pin the current branch at index 0 regardless of sort mode (#806
|
|
16056
|
+
// follow-up). Lands the user's cursor on the active branch by
|
|
16057
|
+
// default and keeps the most-relevant row glued to the top of the
|
|
16058
|
+
// list as they cycle sorts.
|
|
16059
|
+
const current = branches.find((entry) => entry.current);
|
|
16060
|
+
const rest = branches.filter((entry) => !entry.current);
|
|
16061
|
+
const sortedRest = rest.slice();
|
|
15956
16062
|
switch (mode) {
|
|
15957
16063
|
case 'name':
|
|
15958
|
-
|
|
16064
|
+
sortedRest.sort((a, b) => a.shortName.localeCompare(b.shortName));
|
|
16065
|
+
break;
|
|
15959
16066
|
case 'recent':
|
|
15960
16067
|
// ISO-shaped dates compare byte-for-byte; descending so the freshest
|
|
15961
16068
|
// branch sits at the top.
|
|
15962
|
-
|
|
16069
|
+
sortedRest.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
|
|
15963
16070
|
a.shortName.localeCompare(b.shortName));
|
|
16071
|
+
break;
|
|
15964
16072
|
case 'ahead':
|
|
15965
16073
|
// ahead-first; ties broken by behind, then by name. Keeps "this branch
|
|
15966
16074
|
// has unmerged work" in the user's first scroll.
|
|
15967
|
-
|
|
16075
|
+
sortedRest.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
|
|
15968
16076
|
a.shortName.localeCompare(b.shortName));
|
|
15969
|
-
|
|
15970
|
-
return copy;
|
|
16077
|
+
break;
|
|
15971
16078
|
}
|
|
16079
|
+
return current ? [current, ...sortedRest] : sortedRest;
|
|
15972
16080
|
}
|
|
15973
16081
|
const TAG_SORT_MODES = ['recent', 'name'];
|
|
15974
16082
|
const DEFAULT_TAG_SORT_MODE = 'recent';
|
|
@@ -16131,6 +16239,7 @@ function withPushedView(state, value) {
|
|
|
16131
16239
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
16132
16240
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
16133
16241
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
16242
|
+
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
16134
16243
|
pendingKey: undefined,
|
|
16135
16244
|
};
|
|
16136
16245
|
}
|
|
@@ -16153,6 +16262,7 @@ function withPoppedView(state) {
|
|
|
16153
16262
|
diffSource: next === 'diff' ? state.diffSource : undefined,
|
|
16154
16263
|
stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
|
|
16155
16264
|
pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
|
|
16265
|
+
statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
|
|
16156
16266
|
pendingKey: undefined,
|
|
16157
16267
|
};
|
|
16158
16268
|
}
|
|
@@ -16170,6 +16280,7 @@ function withReplacedView(state, value) {
|
|
|
16170
16280
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
16171
16281
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
16172
16282
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
16283
|
+
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
16173
16284
|
pendingKey: undefined,
|
|
16174
16285
|
};
|
|
16175
16286
|
}
|
|
@@ -16301,8 +16412,12 @@ function createLogInkState(rows, options = {}) {
|
|
|
16301
16412
|
focus: 'commits',
|
|
16302
16413
|
sidebarTab: 'status',
|
|
16303
16414
|
userSidebarTab: 'status',
|
|
16415
|
+
sidebarHeaderFocused: false,
|
|
16416
|
+
statusGroupHeaderFocused: false,
|
|
16304
16417
|
statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
|
|
16305
16418
|
diffViewMode: 'unified',
|
|
16419
|
+
inspectorTab: 'inspector',
|
|
16420
|
+
inspectorActionIndex: 0,
|
|
16306
16421
|
};
|
|
16307
16422
|
}
|
|
16308
16423
|
function getSelectedInkCommit(state) {
|
|
@@ -16343,12 +16458,21 @@ function applyLogInkAction(state, action) {
|
|
|
16343
16458
|
return {
|
|
16344
16459
|
...state,
|
|
16345
16460
|
focus: cycleValue(FOCUS_ORDER, state.focus, 1),
|
|
16461
|
+
// Reset header focus when leaving the sidebar so the next
|
|
16462
|
+
// re-entry starts on items rather than mid-flag.
|
|
16463
|
+
sidebarHeaderFocused: false,
|
|
16464
|
+
// Same idea for the status group header — Tab cycling away
|
|
16465
|
+
// from 'commits' should always land back on a real file when
|
|
16466
|
+
// the user returns.
|
|
16467
|
+
statusGroupHeaderFocused: false,
|
|
16346
16468
|
pendingKey: undefined,
|
|
16347
16469
|
};
|
|
16348
16470
|
case 'focusPrevious':
|
|
16349
16471
|
return {
|
|
16350
16472
|
...state,
|
|
16351
16473
|
focus: cycleValue(FOCUS_ORDER, state.focus, -1),
|
|
16474
|
+
sidebarHeaderFocused: false,
|
|
16475
|
+
statusGroupHeaderFocused: false,
|
|
16352
16476
|
pendingKey: undefined,
|
|
16353
16477
|
};
|
|
16354
16478
|
case 'move':
|
|
@@ -16360,6 +16484,28 @@ function applyLogInkAction(state, action) {
|
|
|
16360
16484
|
pendingCommitFocused: false,
|
|
16361
16485
|
pendingKey: undefined,
|
|
16362
16486
|
};
|
|
16487
|
+
case 'selectCommitByHash': {
|
|
16488
|
+
// Locates a commit by its full or short hash within the active
|
|
16489
|
+
// filtered list and snaps the cursor to it. Used by the
|
|
16490
|
+
// branch/tag auto-jump effect (#806 follow-up): cursoring a
|
|
16491
|
+
// branch in the sidebar tracks the history view to that
|
|
16492
|
+
// branch's tip without the user manually scrolling. No-op when
|
|
16493
|
+
// the hash isn't in the loaded list (the runtime surfaces a
|
|
16494
|
+
// status hint in that case).
|
|
16495
|
+
const target = action.hash;
|
|
16496
|
+
const index = state.filteredCommits.findIndex((commit) => commit.hash === target || commit.shortHash === target);
|
|
16497
|
+
if (index < 0) {
|
|
16498
|
+
return state;
|
|
16499
|
+
}
|
|
16500
|
+
return {
|
|
16501
|
+
...state,
|
|
16502
|
+
selectedIndex: index,
|
|
16503
|
+
selectedFileIndex: 0,
|
|
16504
|
+
diffPreviewOffset: 0,
|
|
16505
|
+
pendingCommitFocused: false,
|
|
16506
|
+
pendingKey: undefined,
|
|
16507
|
+
};
|
|
16508
|
+
}
|
|
16363
16509
|
case 'focusPendingCommit':
|
|
16364
16510
|
return {
|
|
16365
16511
|
...state,
|
|
@@ -16391,6 +16537,9 @@ function applyLogInkAction(state, action) {
|
|
|
16391
16537
|
selectedWorktreeFileIndex: clampIndex$1(state.selectedWorktreeFileIndex + action.delta, action.fileCount),
|
|
16392
16538
|
selectedWorktreeHunkIndex: 0,
|
|
16393
16539
|
worktreeDiffOffset: 0,
|
|
16540
|
+
// Cursor moved to a real file row — drop header focus so the
|
|
16541
|
+
// file Enter handler (open diff) is what fires next.
|
|
16542
|
+
statusGroupHeaderFocused: false,
|
|
16394
16543
|
};
|
|
16395
16544
|
}
|
|
16396
16545
|
case 'moveBranch':
|
|
@@ -16399,6 +16548,77 @@ function applyLogInkAction(state, action) {
|
|
|
16399
16548
|
selectedBranchIndex: clampIndex$1(state.selectedBranchIndex + action.delta, action.count),
|
|
16400
16549
|
pendingKey: undefined,
|
|
16401
16550
|
};
|
|
16551
|
+
case 'resetBranchSelection':
|
|
16552
|
+
// Snap the branches sidebar / view cursor back to position 0.
|
|
16553
|
+
// Used after a successful checkout (#806 follow-up): combined
|
|
16554
|
+
// with the "current branch pinned at top" rule from #809, this
|
|
16555
|
+
// lands the user's cursor on the just-checked-out branch.
|
|
16556
|
+
return {
|
|
16557
|
+
...state,
|
|
16558
|
+
selectedBranchIndex: 0,
|
|
16559
|
+
pendingKey: undefined,
|
|
16560
|
+
};
|
|
16561
|
+
case 'setSidebarHeaderFocused':
|
|
16562
|
+
return {
|
|
16563
|
+
...state,
|
|
16564
|
+
sidebarHeaderFocused: action.value,
|
|
16565
|
+
pendingKey: undefined,
|
|
16566
|
+
};
|
|
16567
|
+
case 'setStatusGroupHeaderFocused':
|
|
16568
|
+
return {
|
|
16569
|
+
...state,
|
|
16570
|
+
statusGroupHeaderFocused: action.value,
|
|
16571
|
+
pendingKey: undefined,
|
|
16572
|
+
};
|
|
16573
|
+
case 'jumpToStatusGroup':
|
|
16574
|
+
// Used by ←/→ on the status surface to land on the first file of
|
|
16575
|
+
// the previous / next non-empty group. Clears header focus so the
|
|
16576
|
+
// user is on a real file after the jump (matches the
|
|
16577
|
+
// sidebar pattern where ←/→ between tabs lands on items, not on
|
|
16578
|
+
// the next tab's header).
|
|
16579
|
+
return {
|
|
16580
|
+
...state,
|
|
16581
|
+
selectedWorktreeFileIndex: Math.max(0, action.targetIndex),
|
|
16582
|
+
selectedWorktreeHunkIndex: 0,
|
|
16583
|
+
worktreeDiffOffset: 0,
|
|
16584
|
+
statusGroupHeaderFocused: false,
|
|
16585
|
+
pendingKey: undefined,
|
|
16586
|
+
};
|
|
16587
|
+
case 'setInspectorTab':
|
|
16588
|
+
return {
|
|
16589
|
+
...state,
|
|
16590
|
+
inspectorTab: action.value,
|
|
16591
|
+
// Reset the action cursor so a fresh tab visit always starts
|
|
16592
|
+
// on the first action, regardless of where the user left off
|
|
16593
|
+
// in a previous entity context.
|
|
16594
|
+
inspectorActionIndex: 0,
|
|
16595
|
+
pendingKey: undefined,
|
|
16596
|
+
};
|
|
16597
|
+
case 'cycleInspectorTab': {
|
|
16598
|
+
// Two-tab toggle — `delta` is symmetrical so direction does not
|
|
16599
|
+
// matter, but we keep the action shape consistent with the
|
|
16600
|
+
// sidebar's `nextSidebarTab` / `previousSidebarTab` so callers
|
|
16601
|
+
// can mirror the sidebar pattern verbatim.
|
|
16602
|
+
const next = state.inspectorTab === 'inspector' ? 'actions' : 'inspector';
|
|
16603
|
+
return {
|
|
16604
|
+
...state,
|
|
16605
|
+
inspectorTab: next,
|
|
16606
|
+
inspectorActionIndex: 0,
|
|
16607
|
+
pendingKey: undefined,
|
|
16608
|
+
};
|
|
16609
|
+
}
|
|
16610
|
+
case 'moveInspectorAction':
|
|
16611
|
+
return {
|
|
16612
|
+
...state,
|
|
16613
|
+
inspectorActionIndex: clampIndex$1(state.inspectorActionIndex + action.delta, action.actionCount),
|
|
16614
|
+
pendingKey: undefined,
|
|
16615
|
+
};
|
|
16616
|
+
case 'resetInspectorActionIndex':
|
|
16617
|
+
return {
|
|
16618
|
+
...state,
|
|
16619
|
+
inspectorActionIndex: 0,
|
|
16620
|
+
pendingKey: undefined,
|
|
16621
|
+
};
|
|
16402
16622
|
case 'moveTag':
|
|
16403
16623
|
return {
|
|
16404
16624
|
...state,
|
|
@@ -16468,6 +16688,10 @@ function applyLogInkAction(state, action) {
|
|
|
16468
16688
|
...state,
|
|
16469
16689
|
statusFilterMask: allOff ? { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK } : next,
|
|
16470
16690
|
selectedWorktreeFileIndex: 0,
|
|
16691
|
+
// Group composition changed — header focus would be ambiguous
|
|
16692
|
+
// (cursor lands on file 0 which may belong to a different
|
|
16693
|
+
// group now). Reset to clear the indicator.
|
|
16694
|
+
statusGroupHeaderFocused: false,
|
|
16471
16695
|
pendingKey: undefined,
|
|
16472
16696
|
};
|
|
16473
16697
|
}
|
|
@@ -16638,6 +16862,13 @@ function applyLogInkAction(state, action) {
|
|
|
16638
16862
|
return {
|
|
16639
16863
|
...state,
|
|
16640
16864
|
focus: action.value,
|
|
16865
|
+
// Reset sidebar header focus when leaving the sidebar so a
|
|
16866
|
+
// re-entry starts on items rather than mid-flag.
|
|
16867
|
+
sidebarHeaderFocused: action.value === 'sidebar' ? state.sidebarHeaderFocused : false,
|
|
16868
|
+
// The status group header lives in the 'commits' focus on
|
|
16869
|
+
// the status view — clear when focus moves away so a
|
|
16870
|
+
// re-entry starts on a real file.
|
|
16871
|
+
statusGroupHeaderFocused: action.value === 'commits' ? state.statusGroupHeaderFocused : false,
|
|
16641
16872
|
pendingKey: undefined,
|
|
16642
16873
|
};
|
|
16643
16874
|
case 'setPendingKey':
|
|
@@ -16839,6 +17070,82 @@ function action(actionValue) {
|
|
|
16839
17070
|
action: actionValue,
|
|
16840
17071
|
};
|
|
16841
17072
|
}
|
|
17073
|
+
/**
|
|
17074
|
+
* Resolve which inspector action context applies for the current
|
|
17075
|
+
* state. Today only history commits expose actions in the inspector
|
|
17076
|
+
* (the renderer hard-coded `'history-commit'`); future PRs can fan
|
|
17077
|
+
* this out to branch / tag / stash / worktree contexts as the
|
|
17078
|
+
* inspector gains entity-aware sections. Returns `undefined` when no
|
|
17079
|
+
* actions section should be shown (so the cursor model stays a
|
|
17080
|
+
* no-op).
|
|
17081
|
+
*/
|
|
17082
|
+
function resolveInspectorActionContext(state) {
|
|
17083
|
+
if (state.activeView === 'history' && !state.pendingCommitFocused) {
|
|
17084
|
+
return 'history-commit';
|
|
17085
|
+
}
|
|
17086
|
+
return undefined;
|
|
17087
|
+
}
|
|
17088
|
+
function getInspectorActionsForState(state) {
|
|
17089
|
+
const ctx = resolveInspectorActionContext(state);
|
|
17090
|
+
return ctx ? getInspectorActions(ctx) : [];
|
|
17091
|
+
}
|
|
17092
|
+
/**
|
|
17093
|
+
* Synthesize the events that fire when the user presses Enter on a
|
|
17094
|
+
* cursored inspector action (#791 follow-up). Mirrors
|
|
17095
|
+
* `getLogInkPaletteExecuteEvents` — each action's `key` field
|
|
17096
|
+
* routes to the same dispatch the corresponding keystroke would
|
|
17097
|
+
* trigger from the history view's commit cursor. Per-key dispatch
|
|
17098
|
+
* (rather than recursively re-running the keystroke through
|
|
17099
|
+
* `getLogInkInputEvents`) avoids the gating problem: most history
|
|
17100
|
+
* keystroke handlers require `state.focus === 'commits'`, but the
|
|
17101
|
+
* inspector executor fires from `state.focus === 'detail'`.
|
|
17102
|
+
*/
|
|
17103
|
+
function getInspectorActionExecuteEvents(inspectorAction, state) {
|
|
17104
|
+
const commit = state.filteredCommits[state.selectedIndex];
|
|
17105
|
+
const requireCommit = (fn) => {
|
|
17106
|
+
if (!commit) {
|
|
17107
|
+
return [action({ type: 'setStatus', value: 'No commit selected' })];
|
|
17108
|
+
}
|
|
17109
|
+
return fn(commit.hash, state.selectedIndex);
|
|
17110
|
+
};
|
|
17111
|
+
switch (inspectorAction.key) {
|
|
17112
|
+
case 'enter':
|
|
17113
|
+
return requireCommit((sha, commitIndex) => [
|
|
17114
|
+
action({ type: 'navigateOpenDiffForCommit', sha, commitIndex }),
|
|
17115
|
+
]);
|
|
17116
|
+
case 'c':
|
|
17117
|
+
return requireCommit(() => [
|
|
17118
|
+
action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' }),
|
|
17119
|
+
]);
|
|
17120
|
+
case 'R':
|
|
17121
|
+
return requireCommit(() => [
|
|
17122
|
+
action({ type: 'setPendingConfirmation', value: 'revert-commit' }),
|
|
17123
|
+
]);
|
|
17124
|
+
case 'Z':
|
|
17125
|
+
return requireCommit(() => [
|
|
17126
|
+
action({
|
|
17127
|
+
type: 'openInputPrompt',
|
|
17128
|
+
kind: 'reset-mode',
|
|
17129
|
+
label: 'Reset mode (soft / mixed / hard)',
|
|
17130
|
+
}),
|
|
17131
|
+
]);
|
|
17132
|
+
case 'i':
|
|
17133
|
+
return requireCommit(() => [
|
|
17134
|
+
action({ type: 'setPendingConfirmation', value: 'interactive-rebase' }),
|
|
17135
|
+
]);
|
|
17136
|
+
case 'y':
|
|
17137
|
+
return requireCommit(() => [{ type: 'yankFromActiveView' }]);
|
|
17138
|
+
case 'Y':
|
|
17139
|
+
return requireCommit(() => [{ type: 'yankFromActiveView', short: true }]);
|
|
17140
|
+
case 'O':
|
|
17141
|
+
return [{ type: 'runWorkflowAction', id: 'open-pr' }];
|
|
17142
|
+
default:
|
|
17143
|
+
return [action({
|
|
17144
|
+
type: 'setStatus',
|
|
17145
|
+
value: `Action ${inspectorAction.key} not yet wired`,
|
|
17146
|
+
})];
|
|
17147
|
+
}
|
|
17148
|
+
}
|
|
16842
17149
|
/**
|
|
16843
17150
|
* Build the events needed to apply the hunk under the diff cursor. The
|
|
16844
17151
|
* runtime workflow handler expects payload format `<target>\n<patch>`
|
|
@@ -17484,6 +17791,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17484
17791
|
action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
|
|
17485
17792
|
];
|
|
17486
17793
|
}
|
|
17794
|
+
// `gT` chord: create a lightweight tag at the cursored commit on the
|
|
17795
|
+
// history view. Bare `T` is taken (delete-tag on the tags view) so we
|
|
17796
|
+
// use the chord. Mirrors `gH` exactly — uppercase letter after the
|
|
17797
|
+
// `g` chord prefix, distinct from the lowercase `gt` chord which
|
|
17798
|
+
// jumps to the tags view. The prompt is the affirmative gate.
|
|
17799
|
+
if (state.pendingKey === 'g' && inputValue === 'T') {
|
|
17800
|
+
if (state.activeView === 'history' &&
|
|
17801
|
+
state.focus === 'commits' &&
|
|
17802
|
+
state.filteredCommits.length > 0 &&
|
|
17803
|
+
!state.pendingCommitFocused) {
|
|
17804
|
+
return [
|
|
17805
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
17806
|
+
action({
|
|
17807
|
+
type: 'openInputPrompt',
|
|
17808
|
+
kind: 'create-tag-here',
|
|
17809
|
+
label: 'New tag name (at cursored commit)',
|
|
17810
|
+
}),
|
|
17811
|
+
];
|
|
17812
|
+
}
|
|
17813
|
+
return [
|
|
17814
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
17815
|
+
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
|
|
17816
|
+
];
|
|
17817
|
+
}
|
|
17487
17818
|
if (inputValue === 'g') {
|
|
17488
17819
|
if (state.pendingKey === 'g') {
|
|
17489
17820
|
return [
|
|
@@ -17565,6 +17896,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17565
17896
|
hunkOffsets: context.commitDiffHunkOffsets,
|
|
17566
17897
|
})];
|
|
17567
17898
|
}
|
|
17899
|
+
// Inspector focused: cycle the inspector tab. The renderer only
|
|
17900
|
+
// honors the tab field on short terminals (where the inspector
|
|
17901
|
+
// collapses into a tabbed layout), but we let the user pre-set
|
|
17902
|
+
// their preference on tall terminals too.
|
|
17903
|
+
if (state.focus === 'detail') {
|
|
17904
|
+
return [action({ type: 'cycleInspectorTab', delta: -1 })];
|
|
17905
|
+
}
|
|
17568
17906
|
return [action({ type: 'previousSidebarTab' })];
|
|
17569
17907
|
}
|
|
17570
17908
|
if (inputValue === ']') {
|
|
@@ -17589,6 +17927,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17589
17927
|
hunkOffsets: context.commitDiffHunkOffsets,
|
|
17590
17928
|
})];
|
|
17591
17929
|
}
|
|
17930
|
+
if (state.focus === 'detail') {
|
|
17931
|
+
return [action({ type: 'cycleInspectorTab', delta: 1 })];
|
|
17932
|
+
}
|
|
17592
17933
|
return [action({ type: 'nextSidebarTab' })];
|
|
17593
17934
|
}
|
|
17594
17935
|
// Status surface intercepts 1/2/3 before the sidebar-tab numeric
|
|
@@ -17616,11 +17957,62 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17616
17957
|
if (key.rightArrow && state.focus === 'sidebar') {
|
|
17617
17958
|
return [action({ type: 'nextSidebarTab' })];
|
|
17618
17959
|
}
|
|
17960
|
+
// ←/→ on the status surface jump between the staged / unstaged /
|
|
17961
|
+
// untracked groups — the horizontal axis is "between groups", the
|
|
17962
|
+
// vertical axis (↑/↓ below) is "within the active group's files".
|
|
17963
|
+
// Lands on the first file of the target group (clears header
|
|
17964
|
+
// focus) so the user is always on a real file after a jump,
|
|
17965
|
+
// mirroring the sidebar's tab-switch landing behavior.
|
|
17966
|
+
if ((key.leftArrow || key.rightArrow) &&
|
|
17967
|
+
state.activeView === 'status' &&
|
|
17968
|
+
state.focus === 'commits' &&
|
|
17969
|
+
context.statusGroups &&
|
|
17970
|
+
context.statusGroups.length > 1) {
|
|
17971
|
+
const groups = context.statusGroups;
|
|
17972
|
+
const currentIndex = groups.findIndex((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
|
|
17973
|
+
state.selectedWorktreeFileIndex < group.startIndex + group.count);
|
|
17974
|
+
const fallback = currentIndex >= 0 ? currentIndex : 0;
|
|
17975
|
+
const delta = key.leftArrow ? -1 : 1;
|
|
17976
|
+
const nextIndex = Math.max(0, Math.min(groups.length - 1, fallback + delta));
|
|
17977
|
+
if (nextIndex !== fallback) {
|
|
17978
|
+
return [action({ type: 'jumpToStatusGroup', targetIndex: groups[nextIndex].startIndex })];
|
|
17979
|
+
}
|
|
17980
|
+
return [];
|
|
17981
|
+
}
|
|
17619
17982
|
if (key.upArrow || inputValue === 'k') {
|
|
17983
|
+
// Inspector Actions tab: ↑/↓ moves the cursor through the
|
|
17984
|
+
// executable action list. Wins over moveDetailFile so a
|
|
17985
|
+
// history-commit explore with both file list AND actions visible
|
|
17986
|
+
// navigates the actions when the user has [/]-toggled to the
|
|
17987
|
+
// actions tab. (#791 follow-up)
|
|
17988
|
+
if (state.focus === 'detail' && state.inspectorTab === 'actions' && context.inspectorActionCount) {
|
|
17989
|
+
return [action({
|
|
17990
|
+
type: 'moveInspectorAction',
|
|
17991
|
+
delta: -1,
|
|
17992
|
+
actionCount: context.inspectorActionCount,
|
|
17993
|
+
})];
|
|
17994
|
+
}
|
|
17620
17995
|
if (state.focus === 'detail' && context.detailFileCount) {
|
|
17621
17996
|
return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
|
|
17622
17997
|
}
|
|
17623
17998
|
if (state.activeView === 'status' && context.worktreeFileCount) {
|
|
17999
|
+
// Already on the group header — ↑ is a no-op (use ←/→ to switch
|
|
18000
|
+
// groups). Mirrors the sidebar's "header is the top of the
|
|
18001
|
+
// hierarchy" behavior.
|
|
18002
|
+
if (state.statusGroupHeaderFocused) {
|
|
18003
|
+
return [];
|
|
18004
|
+
}
|
|
18005
|
+
// Cursor at the first file of its group → promote to the group
|
|
18006
|
+
// header rather than crossing the boundary into the previous
|
|
18007
|
+
// group's last file. Keeps the cursor inside its current
|
|
18008
|
+
// container; ←/→ is the explicit way to move between groups.
|
|
18009
|
+
if (context.statusGroups && context.statusGroups.length > 0) {
|
|
18010
|
+
const currentGroup = context.statusGroups.find((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
|
|
18011
|
+
state.selectedWorktreeFileIndex < group.startIndex + group.count);
|
|
18012
|
+
if (currentGroup && state.selectedWorktreeFileIndex === currentGroup.startIndex) {
|
|
18013
|
+
return [action({ type: 'setStatusGroupHeaderFocused', value: true })];
|
|
18014
|
+
}
|
|
18015
|
+
}
|
|
17624
18016
|
return [action({
|
|
17625
18017
|
type: 'moveWorktreeFile',
|
|
17626
18018
|
delta: -1,
|
|
@@ -17645,6 +18037,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17645
18037
|
previewLineCount: context.previewLineCount,
|
|
17646
18038
|
})];
|
|
17647
18039
|
}
|
|
18040
|
+
// Sidebar header focus: ↑ at item index 0 promotes the cursor
|
|
18041
|
+
// onto the active tab's header. Pressing ↑ again is a no-op
|
|
18042
|
+
// (use ←/→ to switch between tab headers, Enter to drill in).
|
|
18043
|
+
// Only triggers when the sidebar is focused on a content tab —
|
|
18044
|
+
// dedicated promoted views (`g b` etc.) keep the legacy clamp
|
|
18045
|
+
// behavior because they have no header to escape to.
|
|
18046
|
+
if (state.focus === 'sidebar' && !state.sidebarHeaderFocused) {
|
|
18047
|
+
if (state.sidebarTab === 'branches' && state.selectedBranchIndex === 0 && (context.branchCount ?? 0) > 0) {
|
|
18048
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18049
|
+
}
|
|
18050
|
+
if (state.sidebarTab === 'tags' && state.selectedTagIndex === 0 && (context.tagCount ?? 0) > 0) {
|
|
18051
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18052
|
+
}
|
|
18053
|
+
if (state.sidebarTab === 'stashes' && state.selectedStashIndex === 0 && (context.stashCount ?? 0) > 0) {
|
|
18054
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18055
|
+
}
|
|
18056
|
+
if (state.sidebarTab === 'worktrees' && state.selectedWorktreeListIndex === 0 && (context.worktreeListCount ?? 0) > 0) {
|
|
18057
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18058
|
+
}
|
|
18059
|
+
}
|
|
18060
|
+
// Already on the header — ↑ is a no-op (←/→ switches tabs).
|
|
18061
|
+
if (state.focus === 'sidebar' && state.sidebarHeaderFocused) {
|
|
18062
|
+
return [];
|
|
18063
|
+
}
|
|
17648
18064
|
if (isBranchActionTarget(state) && context.branchCount) {
|
|
17649
18065
|
return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
|
|
17650
18066
|
}
|
|
@@ -17679,10 +18095,23 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17679
18095
|
if (state.activeView === 'history' && state.pendingCommitFocused) {
|
|
17680
18096
|
return [action({ type: 'unfocusPendingCommit' })];
|
|
17681
18097
|
}
|
|
18098
|
+
if (state.focus === 'detail' && state.inspectorTab === 'actions' && context.inspectorActionCount) {
|
|
18099
|
+
return [action({
|
|
18100
|
+
type: 'moveInspectorAction',
|
|
18101
|
+
delta: 1,
|
|
18102
|
+
actionCount: context.inspectorActionCount,
|
|
18103
|
+
})];
|
|
18104
|
+
}
|
|
17682
18105
|
if (state.focus === 'detail' && context.detailFileCount) {
|
|
17683
18106
|
return [action({ type: 'moveDetailFile', delta: 1, fileCount: context.detailFileCount })];
|
|
17684
18107
|
}
|
|
17685
18108
|
if (state.activeView === 'status' && context.worktreeFileCount) {
|
|
18109
|
+
// Header focused → ↓ re-enters the group at the cursored file
|
|
18110
|
+
// (which is already the group's first file by construction).
|
|
18111
|
+
// Just clear the flag.
|
|
18112
|
+
if (state.statusGroupHeaderFocused) {
|
|
18113
|
+
return [action({ type: 'setStatusGroupHeaderFocused', value: false })];
|
|
18114
|
+
}
|
|
17686
18115
|
return [action({
|
|
17687
18116
|
type: 'moveWorktreeFile',
|
|
17688
18117
|
delta: 1,
|
|
@@ -17703,6 +18132,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17703
18132
|
previewLineCount: context.previewLineCount,
|
|
17704
18133
|
})];
|
|
17705
18134
|
}
|
|
18135
|
+
// Sidebar header focused: ↓ re-enters the list at index 0.
|
|
18136
|
+
// Clears the header flag and snaps the per-entity selection to 0
|
|
18137
|
+
// (mirrors the existing default selection behavior on first
|
|
18138
|
+
// sidebar focus).
|
|
18139
|
+
if (state.focus === 'sidebar' && state.sidebarHeaderFocused) {
|
|
18140
|
+
return [action({ type: 'setSidebarHeaderFocused', value: false })];
|
|
18141
|
+
}
|
|
17706
18142
|
if (isBranchActionTarget(state) && context.branchCount) {
|
|
17707
18143
|
return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
|
|
17708
18144
|
}
|
|
@@ -17796,6 +18232,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17796
18232
|
];
|
|
17797
18233
|
}
|
|
17798
18234
|
}
|
|
18235
|
+
// Inspector Actions tab: Enter on the cursored action fires its
|
|
18236
|
+
// associated event (cherry-pick / revert / yank / etc.). Wins over
|
|
18237
|
+
// the file-list Enter below when the user has [/]-toggled to the
|
|
18238
|
+
// actions tab. Routes through `getInspectorActionExecuteEvents` so
|
|
18239
|
+
// the per-action dispatch table stays the single source of truth
|
|
18240
|
+
// for what each action does. (#791 follow-up)
|
|
18241
|
+
if (key.return &&
|
|
18242
|
+
state.focus === 'detail' &&
|
|
18243
|
+
state.inspectorTab === 'actions') {
|
|
18244
|
+
const actions = getInspectorActionsForState(state);
|
|
18245
|
+
const cursored = actions[state.inspectorActionIndex];
|
|
18246
|
+
if (cursored) {
|
|
18247
|
+
return getInspectorActionExecuteEvents(cursored, state);
|
|
18248
|
+
}
|
|
18249
|
+
}
|
|
17799
18250
|
// From the inspector / commit-diff detail panel, Enter opens (or refocuses)
|
|
17800
18251
|
// the diff view scoped to the currently-selected commit and file. Lets the
|
|
17801
18252
|
// user drive the explore flow entirely from the right panel: j/k picks a
|
|
@@ -17834,7 +18285,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17834
18285
|
const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
|
|
17835
18286
|
const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
|
|
17836
18287
|
sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
|
|
17837
|
-
|
|
18288
|
+
// Three cases drill into the dedicated view:
|
|
18289
|
+
// 1. The cursor is on the tab header (user pressed ↑ at the
|
|
18290
|
+
// top of the list to escape the items — Enter explicitly
|
|
18291
|
+
// jumps to the dedicated view).
|
|
18292
|
+
// 2. The tab has no in-sidebar primary action defined (status,
|
|
18293
|
+
// tags, worktrees — drilling in is the canonical path).
|
|
18294
|
+
// 3. The tab has zero items (the dedicated view's empty state
|
|
18295
|
+
// tells the user what to do next).
|
|
18296
|
+
if (state.sidebarHeaderFocused || !hasInSidebarPrimaryAction) {
|
|
17838
18297
|
const tabToView = {
|
|
17839
18298
|
status: 'status',
|
|
17840
18299
|
branches: 'branches',
|
|
@@ -17854,6 +18313,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17854
18313
|
// Fall through — per-entity Enter handler below claims the keystroke.
|
|
17855
18314
|
}
|
|
17856
18315
|
if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
|
|
18316
|
+
// Group header focused → fire the group's batch workflow action.
|
|
18317
|
+
// Routed through the workflow runner so the runtime owns the
|
|
18318
|
+
// git invocation + status messaging consistently with the
|
|
18319
|
+
// single-file `space` toggle. The `payload` carries the group's
|
|
18320
|
+
// state ('staged' / 'unstaged' / 'untracked') so the runtime can
|
|
18321
|
+
// resolve which files to act on without re-deriving group state.
|
|
18322
|
+
if (state.statusGroupHeaderFocused && context.statusGroups) {
|
|
18323
|
+
const currentGroup = context.statusGroups.find((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
|
|
18324
|
+
state.selectedWorktreeFileIndex < group.startIndex + group.count);
|
|
18325
|
+
if (currentGroup) {
|
|
18326
|
+
const workflowId = currentGroup.state === 'staged'
|
|
18327
|
+
? 'unstage-all-staged'
|
|
18328
|
+
: currentGroup.state === 'unstaged'
|
|
18329
|
+
? 'stage-all-unstaged'
|
|
18330
|
+
: 'stage-all-untracked';
|
|
18331
|
+
return [{ type: 'runWorkflowAction', id: workflowId, payload: currentGroup.state }];
|
|
18332
|
+
}
|
|
18333
|
+
}
|
|
17857
18334
|
return [action({
|
|
17858
18335
|
type: 'navigateOpenDiffForWorktreeFile',
|
|
17859
18336
|
fileIndex: state.selectedWorktreeFileIndex,
|
|
@@ -18088,6 +18565,22 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18088
18565
|
!state.pendingCommitFocused) {
|
|
18089
18566
|
return [action({ type: 'setPendingConfirmation', value: 'interactive-rebase' })];
|
|
18090
18567
|
}
|
|
18568
|
+
// `B` opens a create-branch prompt rooted at the cursored commit
|
|
18569
|
+
// (`git branch <name> <sha>` — does NOT switch to the new branch).
|
|
18570
|
+
// The prompt itself is the affirmative gate, so no separate y-confirm.
|
|
18571
|
+
// Bare uppercase `B` since the lowercase `b` is used by the `gb`
|
|
18572
|
+
// chord prefix and we want a single keystroke for this common op.
|
|
18573
|
+
if (inputValue === 'B' &&
|
|
18574
|
+
state.activeView === 'history' &&
|
|
18575
|
+
state.focus === 'commits' &&
|
|
18576
|
+
state.filteredCommits.length > 0 &&
|
|
18577
|
+
!state.pendingCommitFocused) {
|
|
18578
|
+
return [action({
|
|
18579
|
+
type: 'openInputPrompt',
|
|
18580
|
+
kind: 'create-branch-here',
|
|
18581
|
+
label: 'New branch name (at cursored commit)',
|
|
18582
|
+
})];
|
|
18583
|
+
}
|
|
18091
18584
|
// `y` / `Y` yank the contextually relevant identifier from the active
|
|
18092
18585
|
// view to the system clipboard:
|
|
18093
18586
|
// history → cursored commit hash (Y for short hash)
|
|
@@ -18714,10 +19207,25 @@ const LOG_INK_MIN_COLUMNS = 80;
|
|
|
18714
19207
|
const LOG_INK_MIN_ROWS = 24;
|
|
18715
19208
|
const LOG_INK_DEFAULT_COLUMNS = 120;
|
|
18716
19209
|
const LOG_INK_DEFAULT_ROWS = 40;
|
|
19210
|
+
/**
|
|
19211
|
+
* Terminal-row threshold below which the inspector switches to a
|
|
19212
|
+
* tabbed layout (commit-detail vs actions). Picked empirically: at
|
|
19213
|
+
* 28 rows the inspector's full stack (~30 rows when fully populated)
|
|
19214
|
+
* starts clipping the actions section; below that, the tabbed mode
|
|
19215
|
+
* gives both views their own air.
|
|
19216
|
+
*/
|
|
19217
|
+
const INSPECTOR_TABBED_BELOW_ROWS = 28;
|
|
18717
19218
|
function getLogInkLayout(input) {
|
|
18718
19219
|
const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
|
|
18719
19220
|
const rows = input.rows || LOG_INK_DEFAULT_ROWS;
|
|
18720
|
-
|
|
19221
|
+
// Inspector width — at rest 20-32 cells (~22% of width), focused
|
|
19222
|
+
// 36-60 cells (~40% of width). Narrow rest state keeps the commit
|
|
19223
|
+
// graph dominant; focus expansion gives the inspector room for long
|
|
19224
|
+
// commit bodies / file lists / action labels. Mirrors the sidebar
|
|
19225
|
+
// pattern (sidebarFocused above): instant transition per render.
|
|
19226
|
+
const detailWidth = input.inspectorFocused
|
|
19227
|
+
? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
|
|
19228
|
+
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
18721
19229
|
// Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
|
|
18722
19230
|
// (~36% of width). The transition is instant per render — focus tab to
|
|
18723
19231
|
// expand, focus away to collapse.
|
|
@@ -18732,6 +19240,7 @@ function getLogInkLayout(input) {
|
|
|
18732
19240
|
rows,
|
|
18733
19241
|
sidebarWidth,
|
|
18734
19242
|
tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
|
|
19243
|
+
inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
|
|
18735
19244
|
};
|
|
18736
19245
|
}
|
|
18737
19246
|
|
|
@@ -20227,6 +20736,63 @@ function resetToCommit(git, commit, mode) {
|
|
|
20227
20736
|
: result.details,
|
|
20228
20737
|
}));
|
|
20229
20738
|
}
|
|
20739
|
+
/**
|
|
20740
|
+
* Create a new local branch pointed at <commit>, without switching to it.
|
|
20741
|
+
*
|
|
20742
|
+
* This is the "create branch from cursored commit" history action — the
|
|
20743
|
+
* user types the new branch name into an input prompt and we run
|
|
20744
|
+
* `git branch <name> <sha>` (NOT `git switch -c`, which is what
|
|
20745
|
+
* `branchActions.createBranch` does for the create-branch-at-HEAD flow).
|
|
20746
|
+
* The split exists because GitKraken-style "create branch here" is
|
|
20747
|
+
* specifically about marking a historical commit, not about switching
|
|
20748
|
+
* onto a new working branch.
|
|
20749
|
+
*
|
|
20750
|
+
* Note for the inspector follow-up: workflow surfacing is driven by the
|
|
20751
|
+
* registry in `inkWorkflows.ts`, not a hardcoded action list — adding
|
|
20752
|
+
* `create-branch-here` there is enough for the inspector / palette to
|
|
20753
|
+
* pick this up.
|
|
20754
|
+
*/
|
|
20755
|
+
function createBranchFromCommit(git, name, commit) {
|
|
20756
|
+
const trimmedName = name.trim();
|
|
20757
|
+
if (!commit) {
|
|
20758
|
+
return Promise.resolve({
|
|
20759
|
+
ok: false,
|
|
20760
|
+
message: 'No commit selected.',
|
|
20761
|
+
});
|
|
20762
|
+
}
|
|
20763
|
+
if (!trimmedName) {
|
|
20764
|
+
return Promise.resolve({
|
|
20765
|
+
ok: false,
|
|
20766
|
+
message: 'Branch name required.',
|
|
20767
|
+
});
|
|
20768
|
+
}
|
|
20769
|
+
return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['branch', trimmedName, commit.hash]), `Created branch ${trimmedName} at ${commit.shortHash}`)));
|
|
20770
|
+
}
|
|
20771
|
+
/**
|
|
20772
|
+
* Create a lightweight tag pointed at <commit>.
|
|
20773
|
+
*
|
|
20774
|
+
* Mirrors `createBranchFromCommit` for the tag side: the user types a
|
|
20775
|
+
* tag name into an input prompt and we run `git tag <name> <sha>`
|
|
20776
|
+
* (lightweight, no `-a`/`-m`). Annotated tags remain available through
|
|
20777
|
+
* the existing `+` flow on the tags view; this is the per-commit
|
|
20778
|
+
* shortcut.
|
|
20779
|
+
*/
|
|
20780
|
+
function createTagAtCommit(git, name, commit) {
|
|
20781
|
+
const trimmedName = name.trim();
|
|
20782
|
+
if (!commit) {
|
|
20783
|
+
return Promise.resolve({
|
|
20784
|
+
ok: false,
|
|
20785
|
+
message: 'No commit selected.',
|
|
20786
|
+
});
|
|
20787
|
+
}
|
|
20788
|
+
if (!trimmedName) {
|
|
20789
|
+
return Promise.resolve({
|
|
20790
|
+
ok: false,
|
|
20791
|
+
message: 'Tag name required.',
|
|
20792
|
+
});
|
|
20793
|
+
}
|
|
20794
|
+
return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['tag', trimmedName, commit.hash]), `Created tag ${trimmedName} at ${commit.shortHash}`)));
|
|
20795
|
+
}
|
|
20230
20796
|
function startInteractiveRebase(git, commit) {
|
|
20231
20797
|
if (!commit) {
|
|
20232
20798
|
return Promise.resolve({
|
|
@@ -20507,6 +21073,35 @@ function parseStashDiffFiles(lines) {
|
|
|
20507
21073
|
}
|
|
20508
21074
|
return files;
|
|
20509
21075
|
}
|
|
21076
|
+
/**
|
|
21077
|
+
* Resolve which stash file *contains* a given line offset — the user's
|
|
21078
|
+
* cursor scrolls through a concatenated multi-file patch, and this is
|
|
21079
|
+
* what powers the "File N/M: <path>" panel header, the inline header
|
|
21080
|
+
* highlighting (#791 follow-up), and the cherry-pick / open-in-editor
|
|
21081
|
+
* dispatchers' "what file is the cursor on" lookup.
|
|
21082
|
+
*
|
|
21083
|
+
* Returns `undefined` when the file list is empty *or* the offset
|
|
21084
|
+
* lands before the very first file's `diff --git` header (e.g. when
|
|
21085
|
+
* `--stat` summary lines lead the patch). Callers fall through to a
|
|
21086
|
+
* "no file selected" state in that case.
|
|
21087
|
+
*/
|
|
21088
|
+
function findStashFileForOffset(files, offset) {
|
|
21089
|
+
if (files.length === 0)
|
|
21090
|
+
return undefined;
|
|
21091
|
+
let current;
|
|
21092
|
+
for (const file of files) {
|
|
21093
|
+
if (file.startLine <= offset) {
|
|
21094
|
+
current = file;
|
|
21095
|
+
}
|
|
21096
|
+
else {
|
|
21097
|
+
break;
|
|
21098
|
+
}
|
|
21099
|
+
}
|
|
21100
|
+
// First file is the canonical fallback — even if the offset lands
|
|
21101
|
+
// before its header (rare), we want the cursor to be "in" something
|
|
21102
|
+
// so the user's actions have a target.
|
|
21103
|
+
return current ?? files[0];
|
|
21104
|
+
}
|
|
20510
21105
|
const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
|
|
20511
21106
|
function parseDiffGitHeader(line) {
|
|
20512
21107
|
const match = line.match(DIFF_GIT_HEADER);
|
|
@@ -20561,6 +21156,25 @@ function revertFile(git, file) {
|
|
|
20561
21156
|
}
|
|
20562
21157
|
return runAction$3(() => git.raw(['restore', '--', file.path]), `Reverted ${file.path}`);
|
|
20563
21158
|
}
|
|
21159
|
+
/**
|
|
21160
|
+
* Group-level batch ops triggered by Enter on a status group header
|
|
21161
|
+
* (staged / unstaged / untracked). Pass the files belonging to that
|
|
21162
|
+
* group; the helpers run a single `git add` / `git restore --staged`
|
|
21163
|
+
* with all paths in one invocation rather than looping per-file —
|
|
21164
|
+
* faster + atomic from the user's point of view.
|
|
21165
|
+
*/
|
|
21166
|
+
function stageAllFiles(git, files) {
|
|
21167
|
+
if (files.length === 0) {
|
|
21168
|
+
return Promise.resolve({ ok: false, message: 'No files to stage' });
|
|
21169
|
+
}
|
|
21170
|
+
return runAction$3(() => git.raw(['add', '--', ...files.map((file) => file.path)]), `Staged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
|
|
21171
|
+
}
|
|
21172
|
+
function unstageAllFiles(git, files) {
|
|
21173
|
+
if (files.length === 0) {
|
|
21174
|
+
return Promise.resolve({ ok: false, message: 'No files to unstage' });
|
|
21175
|
+
}
|
|
21176
|
+
return runAction$3(() => git.raw(['restore', '--staged', '--', ...files.map((file) => file.path)]), `Unstaged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
|
|
21177
|
+
}
|
|
20564
21178
|
|
|
20565
21179
|
function fileState(indexStatus, worktreeStatus) {
|
|
20566
21180
|
if (indexStatus === '?' && worktreeStatus === '?') {
|
|
@@ -20604,6 +21218,22 @@ function applyStatusFilterMask(files, mask) {
|
|
|
20604
21218
|
}
|
|
20605
21219
|
return files.filter((file) => mask[file.state]);
|
|
20606
21220
|
}
|
|
21221
|
+
const WORKTREE_GROUP_ORDER = ['staged', 'unstaged', 'untracked'];
|
|
21222
|
+
function groupWorktreeFiles(files) {
|
|
21223
|
+
const groups = [];
|
|
21224
|
+
let cursor = 0;
|
|
21225
|
+
for (const groupState of WORKTREE_GROUP_ORDER) {
|
|
21226
|
+
const groupFiles = files.filter((file) => file.state === groupState);
|
|
21227
|
+
if (groupFiles.length > 0) {
|
|
21228
|
+
groups.push({ state: groupState, files: groupFiles, startIndex: cursor });
|
|
21229
|
+
cursor += groupFiles.length;
|
|
21230
|
+
}
|
|
21231
|
+
}
|
|
21232
|
+
return groups;
|
|
21233
|
+
}
|
|
21234
|
+
function flattenWorktreeGroups(groups) {
|
|
21235
|
+
return groups.flatMap((group) => group.files);
|
|
21236
|
+
}
|
|
20607
21237
|
|
|
20608
21238
|
function hunkHeader(hunk) {
|
|
20609
21239
|
return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
|
|
@@ -23508,7 +24138,16 @@ function LogInkApp(deps) {
|
|
|
23508
24138
|
// count, selected-file resolution, and the rendered list all key off
|
|
23509
24139
|
// it so toggles never desync the cursor from the rendered rows.
|
|
23510
24140
|
const visibleWorktreeFiles = React.useMemo(() => applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask), [context.worktree?.files, state.statusFilterMask]);
|
|
23511
|
-
|
|
24141
|
+
// Sectioned view of the visible files (#791 follow-up). Drives the
|
|
24142
|
+
// status surface's three-tier cursor model: ←/→ jumps between
|
|
24143
|
+
// groups, ↑ at index 0 promotes to the group header, Enter on the
|
|
24144
|
+
// header fires the group's batch action. The renderer also consumes
|
|
24145
|
+
// this so the visible file list stays in canonical group order
|
|
24146
|
+
// regardless of whatever order `git status --porcelain` happens to
|
|
24147
|
+
// emit.
|
|
24148
|
+
const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
|
|
24149
|
+
const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
|
|
24150
|
+
const selectedWorktreeFile = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex];
|
|
23512
24151
|
const dispatch = React.useCallback((action) => {
|
|
23513
24152
|
setState((current) => applyLogInkAction(current, action));
|
|
23514
24153
|
}, []);
|
|
@@ -23752,6 +24391,80 @@ function LogInkApp(deps) {
|
|
|
23752
24391
|
active = false;
|
|
23753
24392
|
};
|
|
23754
24393
|
}, [git, selected?.hash]);
|
|
24394
|
+
// #806 follow-up — auto-jump the history view to whichever branch /
|
|
24395
|
+
// tag the user is currently cursoring in the sidebar (or the
|
|
24396
|
+
// dedicated branches / tags view). Debounced so cursor-scrolling
|
|
24397
|
+
// through a long branch list doesn't dispatch on every keystroke.
|
|
24398
|
+
// No-op when the cursored ref's tip isn't in the loaded commit
|
|
24399
|
+
// window (under compact mode the cursored branch's tip may not be
|
|
24400
|
+
// fetched yet); a status hint surfaces in that case so the user
|
|
24401
|
+
// knows to toggle full graph or load older commits.
|
|
24402
|
+
React.useEffect(() => {
|
|
24403
|
+
const onBranchTab = state.activeView === 'branches' ||
|
|
24404
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
24405
|
+
const onTagTab = state.activeView === 'tags' ||
|
|
24406
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
24407
|
+
if (!onBranchTab && !onTagTab)
|
|
24408
|
+
return;
|
|
24409
|
+
let cancelled = false;
|
|
24410
|
+
const timer = setTimeout(() => {
|
|
24411
|
+
if (cancelled)
|
|
24412
|
+
return;
|
|
24413
|
+
let targetHash;
|
|
24414
|
+
let targetLabel;
|
|
24415
|
+
if (onBranchTab) {
|
|
24416
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
24417
|
+
const visible = state.filter
|
|
24418
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
|
|
24419
|
+
: all;
|
|
24420
|
+
const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
|
|
24421
|
+
if (branch) {
|
|
24422
|
+
targetHash = branch.hash;
|
|
24423
|
+
targetLabel = `branch ${branch.shortName}`;
|
|
24424
|
+
}
|
|
24425
|
+
}
|
|
24426
|
+
else if (onTagTab) {
|
|
24427
|
+
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24428
|
+
const visible = state.filter
|
|
24429
|
+
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
|
|
24430
|
+
: all;
|
|
24431
|
+
const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
|
|
24432
|
+
if (tag) {
|
|
24433
|
+
targetHash = tag.hash;
|
|
24434
|
+
targetLabel = `tag ${tag.name}`;
|
|
24435
|
+
}
|
|
24436
|
+
}
|
|
24437
|
+
if (!targetHash)
|
|
24438
|
+
return;
|
|
24439
|
+
const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
|
|
24440
|
+
if (loaded) {
|
|
24441
|
+
dispatch({ type: 'selectCommitByHash', hash: targetHash });
|
|
24442
|
+
// Confirmation status message so the user gets feedback even
|
|
24443
|
+
// when the dedicated branches / tags view is occupying the
|
|
24444
|
+
// main panel and the history cursor moves invisibly behind it.
|
|
24445
|
+
dispatch({
|
|
24446
|
+
type: 'setStatus',
|
|
24447
|
+
value: `Synced history to ${targetLabel} tip`,
|
|
24448
|
+
});
|
|
24449
|
+
}
|
|
24450
|
+
else {
|
|
24451
|
+
dispatch({
|
|
24452
|
+
type: 'setStatus',
|
|
24453
|
+
value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
|
|
24454
|
+
});
|
|
24455
|
+
}
|
|
24456
|
+
}, 150);
|
|
24457
|
+
return () => {
|
|
24458
|
+
cancelled = true;
|
|
24459
|
+
clearTimeout(timer);
|
|
24460
|
+
};
|
|
24461
|
+
}, [
|
|
24462
|
+
dispatch, context.branches, context.tags,
|
|
24463
|
+
state.activeView, state.focus, state.sidebarTab,
|
|
24464
|
+
state.selectedBranchIndex, state.selectedTagIndex,
|
|
24465
|
+
state.branchSort, state.tagSort, state.filter,
|
|
24466
|
+
state.filteredCommits,
|
|
24467
|
+
]);
|
|
23755
24468
|
React.useEffect(() => {
|
|
23756
24469
|
let active = true;
|
|
23757
24470
|
async function loadWorktreeDiff() {
|
|
@@ -24127,6 +24840,30 @@ function LogInkApp(deps) {
|
|
|
24127
24840
|
message: commit.message,
|
|
24128
24841
|
});
|
|
24129
24842
|
},
|
|
24843
|
+
'create-branch-here': async () => {
|
|
24844
|
+
const commit = getSelectedInkCommit(state);
|
|
24845
|
+
const name = payload?.trim();
|
|
24846
|
+
if (!commit)
|
|
24847
|
+
return { ok: false, message: 'No commit selected' };
|
|
24848
|
+
if (!name)
|
|
24849
|
+
return { ok: false, message: 'Branch name required' };
|
|
24850
|
+
return createBranchFromCommit(git, name, {
|
|
24851
|
+
hash: commit.hash,
|
|
24852
|
+
shortHash: commit.shortHash,
|
|
24853
|
+
});
|
|
24854
|
+
},
|
|
24855
|
+
'create-tag-here': async () => {
|
|
24856
|
+
const commit = getSelectedInkCommit(state);
|
|
24857
|
+
const name = payload?.trim();
|
|
24858
|
+
if (!commit)
|
|
24859
|
+
return { ok: false, message: 'No commit selected' };
|
|
24860
|
+
if (!name)
|
|
24861
|
+
return { ok: false, message: 'Tag name required' };
|
|
24862
|
+
return createTagAtCommit(git, name, {
|
|
24863
|
+
hash: commit.hash,
|
|
24864
|
+
shortHash: commit.shortHash,
|
|
24865
|
+
});
|
|
24866
|
+
},
|
|
24130
24867
|
'checkout-file-from-commit': async () => {
|
|
24131
24868
|
// payload is "<sha> <path>" so we pass both through a single
|
|
24132
24869
|
// string field on the action.
|
|
@@ -24179,6 +24916,17 @@ function LogInkApp(deps) {
|
|
|
24179
24916
|
if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
|
|
24180
24917
|
return { ok: false, message: 'No GitHub remote detected for this repo' };
|
|
24181
24918
|
}
|
|
24919
|
+
// History view: prefer the cursored commit's URL so `O` from
|
|
24920
|
+
// a commit context lands the user on the commit page rather
|
|
24921
|
+
// than the repo root or the current PR. The user-visible
|
|
24922
|
+
// intent of `O` is "open whatever I'm cursoring on the web";
|
|
24923
|
+
// a commit is what the cursor is on in the history view.
|
|
24924
|
+
if (state.activeView === 'history') {
|
|
24925
|
+
const commit = getSelectedInkCommit(state);
|
|
24926
|
+
if (commit) {
|
|
24927
|
+
return openProviderUrl(repo, { type: 'commit', commit: commit.hash });
|
|
24928
|
+
}
|
|
24929
|
+
}
|
|
24182
24930
|
const pr = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
|
|
24183
24931
|
if (pr) {
|
|
24184
24932
|
return openProviderUrl(repo, { type: 'pull-request', number: pr.number });
|
|
@@ -24258,6 +25006,26 @@ function LogInkApp(deps) {
|
|
|
24258
25006
|
return { ok: false, message: 'Comment body required' };
|
|
24259
25007
|
return commentPullRequest(body);
|
|
24260
25008
|
},
|
|
25009
|
+
// Status surface group-level batch ops (#791 follow-up). The
|
|
25010
|
+
// input handler dispatches these when the user presses Enter on a
|
|
25011
|
+
// group header. We re-derive the file list from the live
|
|
25012
|
+
// `context.worktree?.files` rather than trusting a snapshot —
|
|
25013
|
+
// the worktree may have changed since the keystroke fired (rare,
|
|
25014
|
+
// but the cost of re-filtering is negligible compared to the cost
|
|
25015
|
+
// of a stale add). The mask is honored too so a user who's
|
|
25016
|
+
// hidden a category never has it touched by accident.
|
|
25017
|
+
'stage-all-unstaged': async () => {
|
|
25018
|
+
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'unstaged');
|
|
25019
|
+
return stageAllFiles(git, files);
|
|
25020
|
+
},
|
|
25021
|
+
'unstage-all-staged': async () => {
|
|
25022
|
+
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'staged');
|
|
25023
|
+
return unstageAllFiles(git, files);
|
|
25024
|
+
},
|
|
25025
|
+
'stage-all-untracked': async () => {
|
|
25026
|
+
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'untracked');
|
|
25027
|
+
return stageAllFiles(git, files);
|
|
25028
|
+
},
|
|
24261
25029
|
};
|
|
24262
25030
|
const handler = handlers[id];
|
|
24263
25031
|
if (!handler) {
|
|
@@ -24266,12 +25034,24 @@ function LogInkApp(deps) {
|
|
|
24266
25034
|
}
|
|
24267
25035
|
const result = await handler();
|
|
24268
25036
|
dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
|
|
24269
|
-
//
|
|
24270
|
-
//
|
|
24271
|
-
|
|
25037
|
+
// Checkout-branch is the one workflow where we want a *visible*
|
|
25038
|
+
// refresh so the user sees the branches sidebar repaint with the
|
|
25039
|
+
// new current branch (per #806 follow-up). Snap the cursor to
|
|
25040
|
+
// position 0 first so when the refresh completes and the new
|
|
25041
|
+
// current branch lands at the top (per #809's pin-current rule),
|
|
25042
|
+
// the cursor is already there waiting.
|
|
25043
|
+
if (id === 'checkout-branch' && result?.ok) {
|
|
25044
|
+
dispatch({ type: 'resetBranchSelection' });
|
|
25045
|
+
await refreshContext();
|
|
25046
|
+
}
|
|
25047
|
+
else {
|
|
25048
|
+
// Silent refresh so the deleted item disappears from the list
|
|
25049
|
+
// without flickering the surfaces through a 'loading' phase.
|
|
25050
|
+
await refreshContext({ silent: true });
|
|
25051
|
+
}
|
|
24272
25052
|
}, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
|
|
24273
25053
|
state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
|
|
24274
|
-
state.tagSort]);
|
|
25054
|
+
state.statusFilterMask, state.tagSort]);
|
|
24275
25055
|
// Resolve the active view's "yank target" (commit hash / branch /
|
|
24276
25056
|
// tag / stash ref / file path) against the live filtered+sorted list,
|
|
24277
25057
|
// copy it to the system clipboard, and surface the result on the
|
|
@@ -24326,7 +25106,7 @@ function LogInkApp(deps) {
|
|
|
24326
25106
|
// Read from the mask-filtered list (#776) so the cursor and the
|
|
24327
25107
|
// yanked path always match what's on screen — yanking a hidden
|
|
24328
25108
|
// row is always a desync bug.
|
|
24329
|
-
const path =
|
|
25109
|
+
const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
|
|
24330
25110
|
if (path) {
|
|
24331
25111
|
value = path;
|
|
24332
25112
|
label = `path ${path}`;
|
|
@@ -24334,7 +25114,7 @@ function LogInkApp(deps) {
|
|
|
24334
25114
|
}
|
|
24335
25115
|
else if (view === 'diff') {
|
|
24336
25116
|
if (state.diffSource === 'worktree') {
|
|
24337
|
-
const path =
|
|
25117
|
+
const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
|
|
24338
25118
|
if (path) {
|
|
24339
25119
|
value = path;
|
|
24340
25120
|
label = `path ${path}`;
|
|
@@ -24344,17 +25124,8 @@ function LogInkApp(deps) {
|
|
|
24344
25124
|
// Walk back to the most recent file header at or before the
|
|
24345
25125
|
// current preview offset — same logic the input-context block
|
|
24346
25126
|
// uses to expose stashDiffSelectedPath.
|
|
24347
|
-
const
|
|
24348
|
-
if (
|
|
24349
|
-
let current = files[0];
|
|
24350
|
-
for (const file of files) {
|
|
24351
|
-
if (file.startLine <= state.diffPreviewOffset) {
|
|
24352
|
-
current = file;
|
|
24353
|
-
}
|
|
24354
|
-
else {
|
|
24355
|
-
break;
|
|
24356
|
-
}
|
|
24357
|
-
}
|
|
25127
|
+
const current = findStashFileForOffset(parseStashDiffFiles(stashDiffLines), state.diffPreviewOffset);
|
|
25128
|
+
if (current) {
|
|
24358
25129
|
value = current.path;
|
|
24359
25130
|
label = `path ${current.path}`;
|
|
24360
25131
|
}
|
|
@@ -24408,7 +25179,7 @@ function LogInkApp(deps) {
|
|
|
24408
25179
|
state.selectedTagIndex,
|
|
24409
25180
|
state.selectedWorktreeFileIndex,
|
|
24410
25181
|
state.tagSort,
|
|
24411
|
-
|
|
25182
|
+
visibleWorktreeFilesGrouped,
|
|
24412
25183
|
]);
|
|
24413
25184
|
React.useEffect(() => {
|
|
24414
25185
|
let active = true;
|
|
@@ -24648,28 +25419,14 @@ function LogInkApp(deps) {
|
|
|
24648
25419
|
? parseStashDiffFiles(stashDiffLines)
|
|
24649
25420
|
: [];
|
|
24650
25421
|
const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
|
|
24651
|
-
const stashDiffSelectedPath =
|
|
24652
|
-
|
|
24653
|
-
|
|
24654
|
-
const offset = state.diffPreviewOffset;
|
|
24655
|
-
// Walk backwards to the most recent file header at or before the
|
|
24656
|
-
// current cursor offset.
|
|
24657
|
-
let current = stashDiffFiles[0];
|
|
24658
|
-
for (const file of stashDiffFiles) {
|
|
24659
|
-
if (file.startLine <= offset) {
|
|
24660
|
-
current = file;
|
|
24661
|
-
}
|
|
24662
|
-
else {
|
|
24663
|
-
break;
|
|
24664
|
-
}
|
|
24665
|
-
}
|
|
24666
|
-
return current.path;
|
|
24667
|
-
})();
|
|
25422
|
+
const stashDiffSelectedPath = state.diffSource === 'stash'
|
|
25423
|
+
? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
|
|
25424
|
+
: undefined;
|
|
24668
25425
|
getLogInkInputEvents(state, inputValue, key, {
|
|
24669
25426
|
detailFileCount: detail?.files.length,
|
|
24670
25427
|
previewLineCount: diffPreviewLineCount,
|
|
24671
25428
|
worktreeDiffLineCount: worktreeDiff?.lines.length,
|
|
24672
|
-
worktreeFileCount:
|
|
25429
|
+
worktreeFileCount: visibleWorktreeFilesGrouped.length,
|
|
24673
25430
|
worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
|
|
24674
25431
|
commitDiffHunkOffsets,
|
|
24675
25432
|
branchCount: branchVisibleCount,
|
|
@@ -24679,7 +25436,13 @@ function LogInkApp(deps) {
|
|
|
24679
25436
|
stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
|
|
24680
25437
|
stashDiffSelectedPath,
|
|
24681
25438
|
worktreeListCount: worktreeVisibleCount,
|
|
24682
|
-
worktreeSelectedPath:
|
|
25439
|
+
worktreeSelectedPath: visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path,
|
|
25440
|
+
statusGroups: visibleWorktreeGroups.map((group) => ({
|
|
25441
|
+
state: group.state,
|
|
25442
|
+
count: group.files.length,
|
|
25443
|
+
startIndex: group.startIndex,
|
|
25444
|
+
})),
|
|
25445
|
+
inspectorActionCount: getInspectorActionsForState(state).length,
|
|
24683
25446
|
commitDiffSelectedPath: state.diffSource === 'commit'
|
|
24684
25447
|
? selectedDetailFile?.path
|
|
24685
25448
|
: undefined,
|
|
@@ -24750,6 +25513,7 @@ function LogInkApp(deps) {
|
|
|
24750
25513
|
columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
|
|
24751
25514
|
rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
|
|
24752
25515
|
sidebarFocused: state.focus === 'sidebar',
|
|
25516
|
+
inspectorFocused: state.focus === 'detail',
|
|
24753
25517
|
});
|
|
24754
25518
|
if (layout.tooSmall) {
|
|
24755
25519
|
return h(Box, {
|
|
@@ -24764,7 +25528,7 @@ function LogInkApp(deps) {
|
|
|
24764
25528
|
if (showOnboarding) {
|
|
24765
25529
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
24766
25530
|
}
|
|
24767
|
-
return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
|
|
25531
|
+
return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
|
|
24768
25532
|
}
|
|
24769
25533
|
function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
|
|
24770
25534
|
const { Box, Text } = components;
|
|
@@ -24817,13 +25581,18 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
|
|
|
24817
25581
|
? h(Text, { bold: true, color: theme.colors.accent }, `${prLabel}${titleSuffix}`)
|
|
24818
25582
|
: undefined, h(Text, { bold: true, color: modeColor }, ` ${mode}`), search ? h(Text, { dimColor: true }, ` ${truncate$1(search, 36)}`) : undefined);
|
|
24819
25583
|
}
|
|
24820
|
-
function renderSidebar(h, components, state, context, contextStatus, width, theme) {
|
|
25584
|
+
function renderSidebar(h, components, state, context, contextStatus, width, bodyRows, theme) {
|
|
24821
25585
|
const { Box, Text } = components;
|
|
24822
25586
|
const focused = state.focus === 'sidebar';
|
|
24823
25587
|
const tabs = getLogInkSidebarTabs();
|
|
24824
25588
|
// Accordion layout — every tab's title is visible on its own line, but
|
|
24825
25589
|
// only the active tab expands its content underneath. Switching tabs
|
|
24826
25590
|
// (1-5 / [/]) collapses the previous and expands the next.
|
|
25591
|
+
// When sidebar focus has been promoted to the tab header (#806
|
|
25592
|
+
// follow-up), the active tab's title row gets selection styling
|
|
25593
|
+
// and the items below it render without their cursor highlight
|
|
25594
|
+
// (which now lives on the header).
|
|
25595
|
+
const headerFocused = focused && state.sidebarHeaderFocused;
|
|
24827
25596
|
const tabBlocks = tabs.flatMap((tab, tabIndex) => {
|
|
24828
25597
|
const isActive = tab === state.sidebarTab;
|
|
24829
25598
|
const count = sidebarTabCount(tab, context);
|
|
@@ -24831,6 +25600,7 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
|
|
|
24831
25600
|
? `${sidebarTabLabel(tab)} (${count})`
|
|
24832
25601
|
: sidebarTabLabel(tab);
|
|
24833
25602
|
const headerText = isActive ? `[${labelWithCount}]` : labelWithCount;
|
|
25603
|
+
const headerSelected = isActive && headerFocused;
|
|
24834
25604
|
const blocks = [];
|
|
24835
25605
|
if (tabIndex > 0) {
|
|
24836
25606
|
blocks.push(h(Text, { key: `tab-spacer-${tab}` }, ''));
|
|
@@ -24839,9 +25609,15 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
|
|
|
24839
25609
|
key: `tab-header-${tab}`,
|
|
24840
25610
|
bold: isActive,
|
|
24841
25611
|
dimColor: !isActive,
|
|
25612
|
+
// Selection styling on the header itself when the cursor has
|
|
25613
|
+
// been promoted off the items list. inverse swaps fg/bg so the
|
|
25614
|
+
// highlight reads as "this is the cursor target" identically
|
|
25615
|
+
// to how items render when focused.
|
|
25616
|
+
backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
25617
|
+
inverse: headerSelected,
|
|
24842
25618
|
}, headerText));
|
|
24843
25619
|
if (isActive) {
|
|
24844
|
-
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme));
|
|
25620
|
+
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
|
|
24845
25621
|
}
|
|
24846
25622
|
return blocks;
|
|
24847
25623
|
});
|
|
@@ -24860,7 +25636,15 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
|
|
|
24860
25636
|
* surface; every other tab falls through to `sidebarLines` for its
|
|
24861
25637
|
* string-based summary.
|
|
24862
25638
|
*/
|
|
24863
|
-
function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme) {
|
|
25639
|
+
function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme) {
|
|
25640
|
+
// Available rows for the active tab's list. The sidebar chrome
|
|
25641
|
+
// takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
|
|
25642
|
+
// spacers); the branches tab eats 3 more for its summary header
|
|
25643
|
+
// (Current / Worktree / spacer). Floor of 8 keeps short terminals
|
|
25644
|
+
// usable; tall terminals (40+ rows) get noticeably more items.
|
|
25645
|
+
const sidebarChrome = 10;
|
|
25646
|
+
const branchHeaderRows = tab === 'branches' ? 3 : 0;
|
|
25647
|
+
const visibleListCount = Math.max(8, bodyRows - sidebarChrome - branchHeaderRows);
|
|
24864
25648
|
if (tab === 'status') {
|
|
24865
25649
|
return renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme);
|
|
24866
25650
|
}
|
|
@@ -24868,7 +25652,11 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24868
25652
|
// ↑/↓ navigates within the sidebar list and Enter / per-entity keys
|
|
24869
25653
|
// act on the cursored item without needing to drill into the
|
|
24870
25654
|
// dedicated view (#791 follow-up — in-sidebar selection).
|
|
24871
|
-
|
|
25655
|
+
// Items render with the cursor highlight only when the sidebar is
|
|
25656
|
+
// focused on this tab AND the cursor is on items (not promoted to
|
|
25657
|
+
// the tab header). The header-focused branch up in `renderSidebar`
|
|
25658
|
+
// owns the highlight in that case.
|
|
25659
|
+
const focused = state.focus === 'sidebar' && state.sidebarTab === tab && !state.sidebarHeaderFocused;
|
|
24872
25660
|
if (tab === 'branches') {
|
|
24873
25661
|
if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
|
|
24874
25662
|
return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
|
|
@@ -24885,7 +25673,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24885
25673
|
];
|
|
24886
25674
|
return [
|
|
24887
25675
|
...headerRows,
|
|
24888
|
-
...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches'),
|
|
25676
|
+
...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches', visibleListCount),
|
|
24889
25677
|
];
|
|
24890
25678
|
}
|
|
24891
25679
|
if (tab === 'tags') {
|
|
@@ -24896,7 +25684,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24896
25684
|
if (tags.length === 0) {
|
|
24897
25685
|
return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
|
|
24898
25686
|
}
|
|
24899
|
-
return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags');
|
|
25687
|
+
return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags', visibleListCount);
|
|
24900
25688
|
}
|
|
24901
25689
|
if (tab === 'stashes') {
|
|
24902
25690
|
if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
|
|
@@ -24906,7 +25694,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24906
25694
|
if (stashes.length === 0) {
|
|
24907
25695
|
return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
|
|
24908
25696
|
}
|
|
24909
|
-
return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes');
|
|
25697
|
+
return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes', visibleListCount);
|
|
24910
25698
|
}
|
|
24911
25699
|
// worktrees
|
|
24912
25700
|
if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
|
|
@@ -24920,7 +25708,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24920
25708
|
const marker = worktree.current ? '*' : ' ';
|
|
24921
25709
|
const wstate = worktree.dirty ? 'dirty' : 'clean';
|
|
24922
25710
|
return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
|
|
24923
|
-
}, 'tab-worktrees');
|
|
25711
|
+
}, 'tab-worktrees', visibleListCount);
|
|
24924
25712
|
}
|
|
24925
25713
|
/**
|
|
24926
25714
|
* Render a sliding-window list of selectable sidebar rows. The cursor
|
|
@@ -24929,10 +25717,10 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24929
25717
|
* Sliding window keeps the cursor in view as the user navigates a long
|
|
24930
25718
|
* list; truncation hints surface the count of hidden rows.
|
|
24931
25719
|
*/
|
|
24932
|
-
function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix) {
|
|
25720
|
+
function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount) {
|
|
24933
25721
|
if (items.length === 0)
|
|
24934
25722
|
return [];
|
|
24935
|
-
const window = getSidebarVisibleWindow(items.length, selectedIndex);
|
|
25723
|
+
const window = getSidebarVisibleWindow(items.length, selectedIndex, visibleCount);
|
|
24936
25724
|
const elements = [];
|
|
24937
25725
|
if (window.truncatedAbove > 0) {
|
|
24938
25726
|
elements.push(h(Text, {
|
|
@@ -25168,6 +25956,16 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
|
|
|
25168
25956
|
backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
|
|
25169
25957
|
}, truncate$1(label, 140));
|
|
25170
25958
|
}
|
|
25959
|
+
function buildStatusSurfaceRows(groups) {
|
|
25960
|
+
const rows = [];
|
|
25961
|
+
for (const group of groups) {
|
|
25962
|
+
rows.push({ kind: 'header', group });
|
|
25963
|
+
group.files.forEach((file, offset) => {
|
|
25964
|
+
rows.push({ kind: 'file', group, file, flatIndex: group.startIndex + offset });
|
|
25965
|
+
});
|
|
25966
|
+
}
|
|
25967
|
+
return rows;
|
|
25968
|
+
}
|
|
25171
25969
|
function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
25172
25970
|
const { Box, Text } = components;
|
|
25173
25971
|
const focused = state.focus === 'commits';
|
|
@@ -25177,26 +25975,64 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
|
|
|
25177
25975
|
// uses for j/k navigation. `visibleFiles` may be a strict subset of
|
|
25178
25976
|
// worktree.files when the user has narrowed via 1/2/3.
|
|
25179
25977
|
const visibleFiles = applyStatusFilterMask(worktree?.files || [], state.statusFilterMask);
|
|
25978
|
+
// Group + canonical-sort. The runtime + input handler agree on this
|
|
25979
|
+
// order so a `selectedWorktreeFileIndex` of N always points to the
|
|
25980
|
+
// same file across all three (renderer / input / workflow handlers).
|
|
25981
|
+
const visibleGroups = groupWorktreeFiles(visibleFiles);
|
|
25982
|
+
const surfaceRows = buildStatusSurfaceRows(visibleGroups);
|
|
25180
25983
|
const listRows = Math.max(4, bodyRows - 5);
|
|
25181
25984
|
const selectedIndex = state.selectedWorktreeFileIndex;
|
|
25985
|
+
const headerFocused = state.statusGroupHeaderFocused;
|
|
25986
|
+
// Resolve the cursor's row index in the flat (header-and-file) row
|
|
25987
|
+
// list. Used to window the visible slice around the cursor.
|
|
25988
|
+
const cursorRowIndex = (() => {
|
|
25989
|
+
if (!surfaceRows.length)
|
|
25990
|
+
return 0;
|
|
25991
|
+
const currentGroup = visibleGroups.find((group) => selectedIndex >= group.startIndex && selectedIndex < group.startIndex + group.files.length);
|
|
25992
|
+
if (!currentGroup)
|
|
25993
|
+
return 0;
|
|
25994
|
+
if (headerFocused) {
|
|
25995
|
+
const idx = surfaceRows.findIndex((row) => row.kind === 'header' && row.group === currentGroup);
|
|
25996
|
+
return idx >= 0 ? idx : 0;
|
|
25997
|
+
}
|
|
25998
|
+
const idx = surfaceRows.findIndex((row) => row.kind === 'file' && row.flatIndex === selectedIndex);
|
|
25999
|
+
return idx >= 0 ? idx : 0;
|
|
26000
|
+
})();
|
|
25182
26001
|
const cleanHint = formatLogInkStatusEmpty({ hasChanges: Boolean(worktree?.files.length) });
|
|
25183
|
-
const
|
|
26002
|
+
const windowStart = Math.max(0, Math.min(Math.max(0, surfaceRows.length - listRows), cursorRowIndex - Math.floor(listRows / 2)));
|
|
25184
26003
|
const isLoading = isLogInkContextKeyLoading(contextStatus, 'worktree');
|
|
25185
|
-
const
|
|
26004
|
+
const renderedRows = isLoading || !surfaceRows.length
|
|
25186
26005
|
? []
|
|
25187
|
-
:
|
|
25188
|
-
const
|
|
25189
|
-
|
|
26006
|
+
: surfaceRows.slice(windowStart, windowStart + listRows).map((row, offset) => {
|
|
26007
|
+
const rowIndex = windowStart + offset;
|
|
26008
|
+
if (row.kind === 'header') {
|
|
26009
|
+
const groupContainsCursor = selectedIndex >= row.group.startIndex &&
|
|
26010
|
+
selectedIndex < row.group.startIndex + row.group.files.length;
|
|
26011
|
+
const headerSelected = focused && headerFocused && groupContainsCursor;
|
|
26012
|
+
const arrow = theme.ascii ? '>' : '▾';
|
|
26013
|
+
const groupLabel = capitalizeGroupName(row.group.state);
|
|
26014
|
+
const text = ` ${arrow} ${groupLabel} (${row.group.files.length})`;
|
|
26015
|
+
return h(Text, {
|
|
26016
|
+
key: `status-group-${row.group.state}-${rowIndex}`,
|
|
26017
|
+
bold: true,
|
|
26018
|
+
dimColor: !headerSelected && rowIndex > cursorRowIndex,
|
|
26019
|
+
backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
26020
|
+
inverse: headerSelected,
|
|
26021
|
+
}, truncate$1(text, 140));
|
|
26022
|
+
}
|
|
26023
|
+
const isSelected = !headerFocused && row.flatIndex === selectedIndex;
|
|
25190
26024
|
const cursorPart = `${isSelected ? '>' : ' '} `;
|
|
25191
|
-
const dotColor = getStageStatusDotColor(file.state, theme);
|
|
26025
|
+
const dotColor = getStageStatusDotColor(row.file.state, theme);
|
|
25192
26026
|
const useDot = dotColor !== undefined;
|
|
25193
26027
|
const dotCells = useDot ? cellWidth(STAGE_STATUS_DOT) + 1 : 0;
|
|
25194
|
-
const tail = `${file.indexStatus}${file.worktreeStatus} ${
|
|
25195
|
-
const tailTrunc = truncate$1(tail, Math.max(0, 140 - cellWidth(cursorPart) - dotCells));
|
|
26028
|
+
const tail = `${row.file.indexStatus}${row.file.worktreeStatus} ${row.file.path}`;
|
|
26029
|
+
const tailTrunc = truncate$1(tail, Math.max(0, 140 - cellWidth(cursorPart) - dotCells - 2));
|
|
25196
26030
|
return h(Text, {
|
|
25197
|
-
key: `status-row-${
|
|
25198
|
-
dimColor:
|
|
25199
|
-
|
|
26031
|
+
key: `status-file-${row.flatIndex}-${rowIndex}`,
|
|
26032
|
+
dimColor: !isSelected && rowIndex > cursorRowIndex,
|
|
26033
|
+
backgroundColor: isSelected && focused && !theme.noColor ? theme.colors.selection : undefined,
|
|
26034
|
+
inverse: isSelected && focused,
|
|
26035
|
+
}, ` ${cursorPart}`, ...(useDot ? [h(Text, { color: dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
|
|
25200
26036
|
});
|
|
25201
26037
|
// When the mask narrows the list to nothing but the underlying repo
|
|
25202
26038
|
// is non-clean, surface why the panel looks empty so the user can
|
|
@@ -25226,11 +26062,14 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
|
|
|
25226
26062
|
// never touch the filter.
|
|
25227
26063
|
...(isStatusFilterMaskActive(state.statusFilterMask)
|
|
25228
26064
|
? [h(Text, { key: 'status-mask-indicator', dimColor: true }, `filter: ${formatStatusFilterMask(state.statusFilterMask)} (1/2/3 to toggle)`)]
|
|
25229
|
-
: []), ...
|
|
26065
|
+
: []), ...renderedRows, ...fallbackLines.map((line, index) => h(Text, {
|
|
25230
26066
|
key: `status-surface-fallback-${index}`,
|
|
25231
26067
|
dimColor: index > 0,
|
|
25232
26068
|
}, truncate$1(line, 140))));
|
|
25233
26069
|
}
|
|
26070
|
+
function capitalizeGroupName(value) {
|
|
26071
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
26072
|
+
}
|
|
25234
26073
|
function isStatusFilterMaskActive(mask) {
|
|
25235
26074
|
return !mask.staged || !mask.unstaged || !mask.untracked;
|
|
25236
26075
|
}
|
|
@@ -25680,20 +26519,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
25680
26519
|
const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
25681
26520
|
const stashFiles = parseStashDiffFiles(lines);
|
|
25682
26521
|
const fileCount = stashFiles.length;
|
|
25683
|
-
const currentFile = (
|
|
25684
|
-
if (fileCount === 0)
|
|
25685
|
-
return undefined;
|
|
25686
|
-
let current = stashFiles[0];
|
|
25687
|
-
for (const file of stashFiles) {
|
|
25688
|
-
if (file.startLine <= state.diffPreviewOffset) {
|
|
25689
|
-
current = file;
|
|
25690
|
-
}
|
|
25691
|
-
else {
|
|
25692
|
-
break;
|
|
25693
|
-
}
|
|
25694
|
-
}
|
|
25695
|
-
return current;
|
|
25696
|
-
})();
|
|
26522
|
+
const currentFile = findStashFileForOffset(stashFiles, state.diffPreviewOffset);
|
|
25697
26523
|
const currentFileIndex = currentFile
|
|
25698
26524
|
? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
|
|
25699
26525
|
: -1;
|
|
@@ -25720,14 +26546,41 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
25720
26546
|
const headerLines = splitRequestedButTooNarrow
|
|
25721
26547
|
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
25722
26548
|
: baseHeaderLines;
|
|
26549
|
+
// File header anchor map: absolute line index → owning stash file.
|
|
26550
|
+
// Lets the body-render pass restyle each `diff --git` row in O(1)
|
|
26551
|
+
// and decide which one is the *active* file (the one currently
|
|
26552
|
+
// containing `diffPreviewOffset`). The active header gets the
|
|
26553
|
+
// selection background to mark "the file the cursor is inside."
|
|
26554
|
+
const stashFileByStartLine = new Map(stashFiles.map((file) => [file.startLine, file]));
|
|
26555
|
+
const activeStartLine = currentFile?.startLine;
|
|
25723
26556
|
const stashBodyNodes = stashDiffLoading || !lines.length
|
|
25724
26557
|
? []
|
|
25725
26558
|
: splitActive
|
|
25726
26559
|
? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
|
|
25727
|
-
: visibleLines.map((line, index) =>
|
|
25728
|
-
|
|
25729
|
-
|
|
25730
|
-
|
|
26560
|
+
: visibleLines.map((line, index) => {
|
|
26561
|
+
const absoluteIndex = state.diffPreviewOffset + index;
|
|
26562
|
+
const headerFile = stashFileByStartLine.get(absoluteIndex);
|
|
26563
|
+
if (headerFile) {
|
|
26564
|
+
// Replace the verbose `diff --git a/<path> b/<path>` text
|
|
26565
|
+
// with a compact `▾ <path>` marker — the path itself is
|
|
26566
|
+
// the meaningful identifier, not the a/b duplication. The
|
|
26567
|
+
// active file's header gets selection styling so the user
|
|
26568
|
+
// sees at a glance which file the cursor is inside.
|
|
26569
|
+
const isActive = absoluteIndex === activeStartLine;
|
|
26570
|
+
const arrow = theme.ascii ? '> ' : '▾ ';
|
|
26571
|
+
return h(Text, {
|
|
26572
|
+
key: `stash-diff-line-${absoluteIndex}`,
|
|
26573
|
+
bold: true,
|
|
26574
|
+
color: theme.noColor ? undefined : theme.colors.accent,
|
|
26575
|
+
backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
|
|
26576
|
+
inverse: isActive && focused,
|
|
26577
|
+
}, truncate$1(`${arrow}${headerFile.path}`, width - 4));
|
|
26578
|
+
}
|
|
26579
|
+
return h(Text, {
|
|
26580
|
+
key: `stash-diff-line-${absoluteIndex}`,
|
|
26581
|
+
...diffLineProps(line, theme),
|
|
26582
|
+
}, truncate$1(line, width - 4));
|
|
26583
|
+
});
|
|
25731
26584
|
return h(Box, {
|
|
25732
26585
|
borderColor: focusBorderColor(theme, focused),
|
|
25733
26586
|
borderStyle: theme.borderStyle,
|
|
@@ -25833,7 +26686,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
25833
26686
|
}, truncate$1(line, 140)))
|
|
25834
26687
|
: []));
|
|
25835
26688
|
}
|
|
25836
|
-
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme) {
|
|
26689
|
+
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme) {
|
|
25837
26690
|
const focused = state.focus === 'detail';
|
|
25838
26691
|
if (state.showHelp) {
|
|
25839
26692
|
return renderHelpPanel(h, components, state, width, theme, focused);
|
|
@@ -25890,16 +26743,11 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
25890
26743
|
if (state.activeView === 'stash') {
|
|
25891
26744
|
return renderStashPreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
|
|
25892
26745
|
}
|
|
25893
|
-
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme, focused);
|
|
26746
|
+
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
|
|
25894
26747
|
}
|
|
25895
|
-
function renderHistoryInspector(h, components, state, context,
|
|
26748
|
+
function renderHistoryInspector(h, components, state, context, _contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, tabbed, theme, focused) {
|
|
25896
26749
|
const { Box, Text } = components;
|
|
25897
26750
|
const selected = getSelectedInkCommit(state);
|
|
25898
|
-
const workflowSections = getLogInkWorkflowSections({
|
|
25899
|
-
...context,
|
|
25900
|
-
contextLoading: isLogInkContextLoading(contextStatus),
|
|
25901
|
-
selectedCommit: selected,
|
|
25902
|
-
});
|
|
25903
26751
|
if (!detail) {
|
|
25904
26752
|
const fallbackLines = [
|
|
25905
26753
|
selected?.message || 'No commit selected.',
|
|
@@ -25915,7 +26763,10 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
|
|
|
25915
26763
|
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
|
|
25916
26764
|
key: `detail-${index}`,
|
|
25917
26765
|
dimColor: index > 1,
|
|
25918
|
-
}, truncate$1(line, width - 4)))
|
|
26766
|
+
}, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26767
|
+
cursorIndex: state.inspectorActionIndex,
|
|
26768
|
+
cursorActive: focused && state.inspectorTab === 'actions',
|
|
26769
|
+
}));
|
|
25919
26770
|
}
|
|
25920
26771
|
const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
|
|
25921
26772
|
// P5.1 — link the commit hash and each ref out to GitHub when we know
|
|
@@ -25926,18 +26777,26 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
|
|
|
25926
26777
|
const refNodes = detail.refs.length
|
|
25927
26778
|
? renderInspectorRefs(h, Text, detail.refs, repository)
|
|
25928
26779
|
: null;
|
|
26780
|
+
// Inspector reorder (PR — drop duplicative Workflows trailer):
|
|
26781
|
+
// 1. Commit message (the headline of what you're looking at)
|
|
26782
|
+
// 2. Metadata (hash / author / date / refs / stats)
|
|
26783
|
+
// 3. Body preview (up to 8 lines now that the trailer is gone)
|
|
26784
|
+
// 4. Changed files list (cursored entry highlights)
|
|
26785
|
+
// 5. Actions cheat-sheet (per-entity keystrokes; destructive marked)
|
|
26786
|
+
// The Workflows: trailer that used to repeat the repo / branch /
|
|
26787
|
+
// status from the top header and left sidebar is intentionally gone.
|
|
25929
26788
|
const headerNodes = [
|
|
25930
26789
|
h(Text, { key: 'detail-msg' }, truncate$1(detail.message, width - 4)),
|
|
25931
26790
|
h(Text, { key: 'detail-spacer-1' }, ''),
|
|
25932
26791
|
h(Text, { key: 'detail-commit', dimColor: true }, 'Commit: ', commitLink),
|
|
25933
26792
|
h(Text, { key: 'detail-author', dimColor: true }, truncate$1(`Author: ${detail.author}`, width - 4)),
|
|
25934
|
-
h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date:
|
|
26793
|
+
h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
|
|
25935
26794
|
refNodes
|
|
25936
|
-
? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs:
|
|
25937
|
-
: h(Text, { key: 'detail-refs', dimColor: true }, 'Refs:
|
|
25938
|
-
h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(statLine
|
|
26795
|
+
? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
|
|
26796
|
+
: h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
|
|
26797
|
+
h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(`Stats: ${statLine}`, width - 4)),
|
|
25939
26798
|
h(Text, { key: 'detail-spacer-2' }, ''),
|
|
25940
|
-
...(detail.body ? detail.body.split('\n').slice(0,
|
|
26799
|
+
...(detail.body ? detail.body.split('\n').slice(0, 8) : ['No commit body.']).map((line, index) => h(Text, {
|
|
25941
26800
|
key: `detail-body-${index}`,
|
|
25942
26801
|
dimColor: true,
|
|
25943
26802
|
}, truncate$1(line, width - 4))),
|
|
@@ -25946,24 +26805,98 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
|
|
|
25946
26805
|
];
|
|
25947
26806
|
const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
|
|
25948
26807
|
const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
|
|
25949
|
-
|
|
25950
|
-
|
|
25951
|
-
|
|
25952
|
-
|
|
25953
|
-
|
|
25954
|
-
|
|
25955
|
-
|
|
25956
|
-
|
|
26808
|
+
// Tabbed mode (#806 follow-up — short terminals): render only the
|
|
26809
|
+
// active inspector tab with a `[Inspector] Actions` header so the
|
|
26810
|
+
// user knows what they're seeing and how to switch (`[/]` while
|
|
26811
|
+
// focus is on the inspector). Tall terminals stack both sections
|
|
26812
|
+
// as before.
|
|
26813
|
+
if (tabbed) {
|
|
26814
|
+
const activeTab = state.inspectorTab;
|
|
26815
|
+
const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
|
|
26816
|
+
bold: activeTab === 'inspector',
|
|
26817
|
+
dimColor: activeTab !== 'inspector',
|
|
26818
|
+
}, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), ' ', h(Text, {
|
|
26819
|
+
bold: activeTab === 'actions',
|
|
26820
|
+
dimColor: activeTab !== 'actions',
|
|
26821
|
+
}, activeTab === 'actions' ? '[Actions]' : ' Actions '));
|
|
26822
|
+
return h(Box, {
|
|
26823
|
+
borderColor: focusBorderColor(theme, focused),
|
|
26824
|
+
borderStyle: theme.borderStyle,
|
|
26825
|
+
flexDirection: 'column',
|
|
26826
|
+
width,
|
|
26827
|
+
paddingX: 1,
|
|
26828
|
+
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...(activeTab === 'inspector'
|
|
26829
|
+
? [...headerNodes, ...fileListNodes]
|
|
26830
|
+
: renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26831
|
+
cursorIndex: state.inspectorActionIndex,
|
|
26832
|
+
cursorActive: focused,
|
|
26833
|
+
})));
|
|
26834
|
+
}
|
|
25957
26835
|
return h(Box, {
|
|
25958
26836
|
borderColor: focusBorderColor(theme, focused),
|
|
25959
26837
|
borderStyle: theme.borderStyle,
|
|
25960
26838
|
flexDirection: 'column',
|
|
25961
26839
|
width,
|
|
25962
26840
|
paddingX: 1,
|
|
25963
|
-
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...
|
|
25964
|
-
|
|
25965
|
-
|
|
25966
|
-
}
|
|
26841
|
+
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26842
|
+
cursorIndex: state.inspectorActionIndex,
|
|
26843
|
+
cursorActive: focused && state.inspectorTab === 'actions',
|
|
26844
|
+
}));
|
|
26845
|
+
}
|
|
26846
|
+
/**
|
|
26847
|
+
* Render the trailing "Actions:" section that surfaces which keystrokes
|
|
26848
|
+
* apply to whatever the inspector is focused on. Keys are colored with
|
|
26849
|
+
* `theme.colors.accent` so they pop as the actionable element. Destructive
|
|
26850
|
+
* actions get the danger color plus a `[!]` marker so they don't blend
|
|
26851
|
+
* into the cherry-pick / yank rows.
|
|
26852
|
+
*
|
|
26853
|
+
* Truncates labels when the inspector is narrow (down to the 26-cell
|
|
26854
|
+
* minimum from `getLogInkLayout`) so an overflowing label never wraps and
|
|
26855
|
+
* collides with the next row.
|
|
26856
|
+
*/
|
|
26857
|
+
function renderInspectorActionsSection(h, Text, context, width, theme, options = {}) {
|
|
26858
|
+
const actions = getInspectorActions(context);
|
|
26859
|
+
if (!actions.length)
|
|
26860
|
+
return [];
|
|
26861
|
+
// Width budget for each row: subtract padding + " " gutter, the key
|
|
26862
|
+
// column (left-padded to 5 cells so labels align), the " " gap
|
|
26863
|
+
// between key and label, and the optional " [!]" suffix (5 cells).
|
|
26864
|
+
const KEY_COLUMN = 5;
|
|
26865
|
+
const GAP = ' ';
|
|
26866
|
+
const DESTRUCTIVE_SUFFIX = ' [!]';
|
|
26867
|
+
const labelBudget = Math.max(4, width - 4 /* border + padX */ - KEY_COLUMN - GAP.length - DESTRUCTIVE_SUFFIX.length);
|
|
26868
|
+
const cursorIndex = options.cursorIndex ?? 0;
|
|
26869
|
+
const cursorActive = options.cursorActive ?? false;
|
|
26870
|
+
const nodes = [
|
|
26871
|
+
h(Text, { key: 'actions-spacer' }, ''),
|
|
26872
|
+
h(Text, { key: 'actions-title' }, cursorActive ? '[Actions]' : 'Actions:'),
|
|
26873
|
+
...actions.map((action, index) => {
|
|
26874
|
+
const isSelected = cursorActive && index === cursorIndex;
|
|
26875
|
+
const keyCell = action.key.padEnd(KEY_COLUMN);
|
|
26876
|
+
const label = truncate$1(action.label, labelBudget);
|
|
26877
|
+
const children = [
|
|
26878
|
+
h(Text, {
|
|
26879
|
+
key: `actions-${index}-key`,
|
|
26880
|
+
color: action.destructive ? theme.colors.danger : theme.colors.accent,
|
|
26881
|
+
}, keyCell),
|
|
26882
|
+
GAP,
|
|
26883
|
+
label,
|
|
26884
|
+
];
|
|
26885
|
+
if (action.destructive) {
|
|
26886
|
+
children.push(h(Text, {
|
|
26887
|
+
key: `actions-${index}-mark`,
|
|
26888
|
+
color: theme.colors.danger,
|
|
26889
|
+
dimColor: false,
|
|
26890
|
+
}, DESTRUCTIVE_SUFFIX));
|
|
26891
|
+
}
|
|
26892
|
+
return h(Text, {
|
|
26893
|
+
key: `actions-${index}`,
|
|
26894
|
+
backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
26895
|
+
inverse: isSelected,
|
|
26896
|
+
}, ...children);
|
|
26897
|
+
}),
|
|
26898
|
+
];
|
|
26899
|
+
return nodes;
|
|
25967
26900
|
}
|
|
25968
26901
|
/**
|
|
25969
26902
|
* Build a commit URL for the repo when GitHub provider info is available.
|
|
@@ -26537,7 +27470,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
|
26537
27470
|
const git = options.git || getRepo();
|
|
26538
27471
|
const rows = options.rows || (await getLogRows(git, logArgv));
|
|
26539
27472
|
await startInkInteractiveLog(git, rows, {}, {
|
|
26540
|
-
appLabel: 'coco
|
|
27473
|
+
appLabel: 'coco',
|
|
26541
27474
|
idleTips: config.logTui?.idleTips,
|
|
26542
27475
|
initialView: 'history',
|
|
26543
27476
|
logArgv,
|
|
@@ -26550,7 +27483,7 @@ async function startCocoUi(argv) {
|
|
|
26550
27483
|
const logArgv = createLogArgvFromUiArgv(argv);
|
|
26551
27484
|
const rows = await getLogRows(git, logArgv);
|
|
26552
27485
|
await startInkInteractiveLog(git, rows, {}, {
|
|
26553
|
-
appLabel: 'coco
|
|
27486
|
+
appLabel: 'coco',
|
|
26554
27487
|
idleTips: config.logTui?.idleTips,
|
|
26555
27488
|
initialView: argv.view || 'history',
|
|
26556
27489
|
logArgv,
|