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.esm.mjs
CHANGED
|
@@ -53,7 +53,7 @@ import { pathToFileURL } from 'url';
|
|
|
53
53
|
/**
|
|
54
54
|
* Current build version from package.json
|
|
55
55
|
*/
|
|
56
|
-
const BUILD_VERSION = "0.
|
|
56
|
+
const BUILD_VERSION = "0.40.0";
|
|
57
57
|
|
|
58
58
|
const isInteractive = (config) => {
|
|
59
59
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -14747,84 +14747,6 @@ function formatInkRefLabels(refs) {
|
|
|
14747
14747
|
return refs.length ? ` ${refs.map((ref) => `[${ref}]`).join(' ')}` : '';
|
|
14748
14748
|
}
|
|
14749
14749
|
|
|
14750
|
-
function countLabel(count, singular, plural = `${singular}s`) {
|
|
14751
|
-
return `${count} ${count === 1 ? singular : plural}`;
|
|
14752
|
-
}
|
|
14753
|
-
function getLogInkWorkflowSections(context) {
|
|
14754
|
-
const currentBranch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
|
|
14755
|
-
const dirty = context.branches?.dirty ? 'dirty worktree' : 'clean worktree';
|
|
14756
|
-
const loading = context.contextLoading;
|
|
14757
|
-
const currentPullRequest = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
|
|
14758
|
-
const repository = context.provider?.repository;
|
|
14759
|
-
const repoName = repository?.owner && repository.name
|
|
14760
|
-
? `${repository.owner}/${repository.name}`
|
|
14761
|
-
: repository?.message || 'local repository';
|
|
14762
|
-
const operation = context.operation;
|
|
14763
|
-
const worktree = context.worktree;
|
|
14764
|
-
return [
|
|
14765
|
-
{
|
|
14766
|
-
title: 'Branch',
|
|
14767
|
-
lines: [
|
|
14768
|
-
`Current: ${currentBranch}`,
|
|
14769
|
-
`State: ${dirty}`,
|
|
14770
|
-
loading && !context.branches
|
|
14771
|
-
? 'Branch data loading'
|
|
14772
|
-
: context.branches
|
|
14773
|
-
? `${countLabel(context.branches.localBranches.length, 'local branch', 'local branches')} | ${countLabel(context.branches.remoteBranches.length, 'remote branch', 'remote branches')}`
|
|
14774
|
-
: 'Branch data unavailable',
|
|
14775
|
-
],
|
|
14776
|
-
},
|
|
14777
|
-
{
|
|
14778
|
-
title: 'Provider / PR',
|
|
14779
|
-
lines: [
|
|
14780
|
-
`Repository: ${repoName}`,
|
|
14781
|
-
loading && !context.provider && !context.pullRequest
|
|
14782
|
-
? 'Provider and pull request data loading'
|
|
14783
|
-
: currentPullRequest
|
|
14784
|
-
? `PR #${currentPullRequest.number} ${currentPullRequest.state}${currentPullRequest.isDraft ? ' draft' : ''}`
|
|
14785
|
-
: 'No pull request detected for current branch',
|
|
14786
|
-
context.provider?.authenticated === false ? 'Provider auth: offline' : 'Provider auth: available',
|
|
14787
|
-
],
|
|
14788
|
-
},
|
|
14789
|
-
{
|
|
14790
|
-
title: 'Status',
|
|
14791
|
-
lines: loading && !worktree
|
|
14792
|
-
? ['Status data loading']
|
|
14793
|
-
: worktree
|
|
14794
|
-
? [
|
|
14795
|
-
`${countLabel(worktree.stagedCount, 'staged file')}`,
|
|
14796
|
-
`${countLabel(worktree.unstagedCount, 'unstaged file')}`,
|
|
14797
|
-
`${countLabel(worktree.untrackedCount, 'untracked file')}`,
|
|
14798
|
-
]
|
|
14799
|
-
: ['Status data unavailable'],
|
|
14800
|
-
},
|
|
14801
|
-
{
|
|
14802
|
-
title: 'Tags / Stashes / Worktrees',
|
|
14803
|
-
lines: [
|
|
14804
|
-
loading && !context.tags ? 'Tags loading' : context.tags ? countLabel(context.tags.tags.length, 'tag') : 'Tags unavailable',
|
|
14805
|
-
loading && !context.stashes ? 'Stashes loading' : context.stashes ? countLabel(context.stashes.stashes.length, 'stash', 'stashes') : 'Stashes unavailable',
|
|
14806
|
-
context.worktreeList
|
|
14807
|
-
? countLabel(context.worktreeList.worktrees.length, 'worktree')
|
|
14808
|
-
: loading
|
|
14809
|
-
? 'Worktrees loading'
|
|
14810
|
-
: 'Worktrees unavailable',
|
|
14811
|
-
],
|
|
14812
|
-
},
|
|
14813
|
-
{
|
|
14814
|
-
title: 'Operation / AI',
|
|
14815
|
-
lines: [
|
|
14816
|
-
loading && !operation
|
|
14817
|
-
? 'Operation data loading'
|
|
14818
|
-
: operation?.operation
|
|
14819
|
-
? `${operation.operation} in progress with ${countLabel(operation.conflictedFiles.length, 'conflict')}`
|
|
14820
|
-
: 'No merge, rebase, cherry-pick, or revert in progress',
|
|
14821
|
-
context.selectedCommit
|
|
14822
|
-
? `AI actions target ${context.selectedCommit.shortHash}; estimates shown before execution`
|
|
14823
|
-
: 'AI actions require a selected commit',
|
|
14824
|
-
],
|
|
14825
|
-
},
|
|
14826
|
-
];
|
|
14827
|
-
}
|
|
14828
14750
|
function getLogInkWorkflowActions() {
|
|
14829
14751
|
return [
|
|
14830
14752
|
{
|
|
@@ -14958,6 +14880,35 @@ function getLogInkWorkflowActions() {
|
|
|
14958
14880
|
kind: 'normal',
|
|
14959
14881
|
requiresConfirmation: false,
|
|
14960
14882
|
},
|
|
14883
|
+
// Status surface group-level batch ops (#791 follow-up). Triggered
|
|
14884
|
+
// by Enter when the cursor is on a status group header
|
|
14885
|
+
// (Staged / Unstaged / Untracked). Empty `key` keeps them
|
|
14886
|
+
// palette-discoverable without registering a global hotkey — the
|
|
14887
|
+
// Enter-on-header path in inkInput is the canonical trigger.
|
|
14888
|
+
{
|
|
14889
|
+
id: 'unstage-all-staged',
|
|
14890
|
+
key: '',
|
|
14891
|
+
label: 'Unstage all staged files',
|
|
14892
|
+
description: 'Unstage every file currently in the staged group.',
|
|
14893
|
+
kind: 'normal',
|
|
14894
|
+
requiresConfirmation: false,
|
|
14895
|
+
},
|
|
14896
|
+
{
|
|
14897
|
+
id: 'stage-all-unstaged',
|
|
14898
|
+
key: '',
|
|
14899
|
+
label: 'Stage all unstaged files',
|
|
14900
|
+
description: 'Stage every modified-but-not-staged file.',
|
|
14901
|
+
kind: 'normal',
|
|
14902
|
+
requiresConfirmation: false,
|
|
14903
|
+
},
|
|
14904
|
+
{
|
|
14905
|
+
id: 'stage-all-untracked',
|
|
14906
|
+
key: '',
|
|
14907
|
+
label: 'Stage all untracked files',
|
|
14908
|
+
description: 'Add every untracked file to the index after confirmation.',
|
|
14909
|
+
kind: 'destructive',
|
|
14910
|
+
requiresConfirmation: true,
|
|
14911
|
+
},
|
|
14961
14912
|
{
|
|
14962
14913
|
id: 'delete-branch',
|
|
14963
14914
|
key: 'D',
|
|
@@ -15068,6 +15019,38 @@ function getLogInkWorkflowActions() {
|
|
|
15068
15019
|
kind: 'destructive',
|
|
15069
15020
|
requiresConfirmation: true,
|
|
15070
15021
|
},
|
|
15022
|
+
{
|
|
15023
|
+
// Per-view-only: scoped to the history view in inkInput (key `B`).
|
|
15024
|
+
// The prompt itself is the affirmative gate — the user has to
|
|
15025
|
+
// type a branch name before anything happens — so this skips the
|
|
15026
|
+
// y-confirm path. Empty key keeps it palette-discoverable; the
|
|
15027
|
+
// palette path can't synthesize a branch name and surfaces a
|
|
15028
|
+
// hint instead.
|
|
15029
|
+
//
|
|
15030
|
+
// Distinct from `create-branch` (palette / `+` on branches view),
|
|
15031
|
+
// which uses `git switch -c` and switches onto the new branch.
|
|
15032
|
+
// This workflow uses `git branch <name> <sha>` and stays put —
|
|
15033
|
+
// GitKraken's "create branch here" semantic.
|
|
15034
|
+
id: 'create-branch-here',
|
|
15035
|
+
key: '',
|
|
15036
|
+
label: 'Create branch from commit',
|
|
15037
|
+
description: 'Create a branch pointed at the cursored commit (does not switch).',
|
|
15038
|
+
kind: 'normal',
|
|
15039
|
+
requiresConfirmation: false,
|
|
15040
|
+
},
|
|
15041
|
+
{
|
|
15042
|
+
// Per-view-only: scoped to the history view in inkInput via the
|
|
15043
|
+
// `gT` chord (bare `T` is taken by delete-tag on the tags view).
|
|
15044
|
+
// Same prompt-as-confirmation pattern as create-branch-here.
|
|
15045
|
+
// Lightweight tag — annotated tags remain available through the
|
|
15046
|
+
// existing `+` flow on the tags view.
|
|
15047
|
+
id: 'create-tag-here',
|
|
15048
|
+
key: '',
|
|
15049
|
+
label: 'Create tag at commit',
|
|
15050
|
+
description: 'Create a lightweight tag at the cursored commit.',
|
|
15051
|
+
kind: 'normal',
|
|
15052
|
+
requiresConfirmation: false,
|
|
15053
|
+
},
|
|
15071
15054
|
{
|
|
15072
15055
|
// Per-view-only: scoped to the history view in inkInput. `i`
|
|
15073
15056
|
// (lowercase) is used instead of `I` so the existing `I`
|
|
@@ -15080,6 +15063,38 @@ function getLogInkWorkflowActions() {
|
|
|
15080
15063
|
kind: 'destructive',
|
|
15081
15064
|
requiresConfirmation: true,
|
|
15082
15065
|
},
|
|
15066
|
+
{
|
|
15067
|
+
// Per-view-only: scoped to the history view in inkInput (key `B`).
|
|
15068
|
+
// The prompt itself is the affirmative gate — the user has to
|
|
15069
|
+
// type a branch name before anything happens — so this skips the
|
|
15070
|
+
// y-confirm path. Empty key keeps it palette-discoverable; the
|
|
15071
|
+
// palette path can't synthesize a branch name and surfaces a
|
|
15072
|
+
// hint instead.
|
|
15073
|
+
//
|
|
15074
|
+
// Distinct from `create-branch` (palette / `+` on branches view),
|
|
15075
|
+
// which uses `git switch -c` and switches onto the new branch.
|
|
15076
|
+
// This workflow uses `git branch <name> <sha>` and stays put —
|
|
15077
|
+
// GitKraken's "create branch here" semantic.
|
|
15078
|
+
id: 'create-branch-here',
|
|
15079
|
+
key: '',
|
|
15080
|
+
label: 'Create branch from commit',
|
|
15081
|
+
description: 'Create a branch pointed at the cursored commit (does not switch).',
|
|
15082
|
+
kind: 'normal',
|
|
15083
|
+
requiresConfirmation: false,
|
|
15084
|
+
},
|
|
15085
|
+
{
|
|
15086
|
+
// Per-view-only: scoped to the history view in inkInput via the
|
|
15087
|
+
// `gT` chord (bare `T` is taken by delete-tag on the tags view).
|
|
15088
|
+
// Same prompt-as-confirmation pattern as create-branch-here.
|
|
15089
|
+
// Lightweight tag — annotated tags remain available through the
|
|
15090
|
+
// existing `+` flow on the tags view.
|
|
15091
|
+
id: 'create-tag-here',
|
|
15092
|
+
key: '',
|
|
15093
|
+
label: 'Create tag at commit',
|
|
15094
|
+
description: 'Create a lightweight tag at the cursored commit.',
|
|
15095
|
+
kind: 'normal',
|
|
15096
|
+
requiresConfirmation: false,
|
|
15097
|
+
},
|
|
15083
15098
|
{
|
|
15084
15099
|
id: 'ai-commit-summary',
|
|
15085
15100
|
key: 'I',
|
|
@@ -15614,9 +15629,12 @@ function getLogInkFooterHints(options) {
|
|
|
15614
15629
|
// History view default hints. Mutating ops (`c` cherry-pick, `R`
|
|
15615
15630
|
// revert, `Z` reset, `i` interactive-rebase) all route through a
|
|
15616
15631
|
// y-confirm or mode prompt — none fire silently from the keystroke.
|
|
15617
|
-
//
|
|
15618
|
-
//
|
|
15619
|
-
|
|
15632
|
+
// `B` create-branch-here and `gT` create-tag-here use a prompt as
|
|
15633
|
+
// the affirmative gate (typing the name is the confirmation).
|
|
15634
|
+
// Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
|
|
15635
|
+
// the footer stays scannable; full descriptions live in `?` help
|
|
15636
|
+
// and the palette.
|
|
15637
|
+
contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'y/Y yank', '/ search', 'gg/G top/bottom'],
|
|
15620
15638
|
global: NORMAL_GLOBAL_HINTS,
|
|
15621
15639
|
};
|
|
15622
15640
|
}
|
|
@@ -15908,6 +15926,88 @@ function extractDiffHunk(input) {
|
|
|
15908
15926
|
return { patchText };
|
|
15909
15927
|
}
|
|
15910
15928
|
|
|
15929
|
+
/**
|
|
15930
|
+
* Hardcoded per-entity action lists surfaced inside the right-hand
|
|
15931
|
+
* inspector panel. The inspector used to repeat the repo / branch /
|
|
15932
|
+
* status content the top header and left sidebar already show; we drop
|
|
15933
|
+
* that trailer in favor of an actionable cheat-sheet so the user knows
|
|
15934
|
+
* exactly which keystrokes apply to whatever they have under the cursor.
|
|
15935
|
+
*
|
|
15936
|
+
* Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
|
|
15937
|
+
* - Most per-entity actions live in `inkInput.ts` as direct keystroke
|
|
15938
|
+
* handlers (e.g. `c` cherry-pick, `R` revert) rather than as
|
|
15939
|
+
* globally-registered bindings, so the registry would be a partial
|
|
15940
|
+
* view at best.
|
|
15941
|
+
* - The bindings registry's `contexts` model (normal / search / focus
|
|
15942
|
+
* name) does not cleanly map to inspector entity types like "branch"
|
|
15943
|
+
* or "tag". Filtering it would mean replicating the same per-view
|
|
15944
|
+
* scoping logic the input dispatcher already encodes.
|
|
15945
|
+
* - New per-entity actions are added infrequently — the maintenance
|
|
15946
|
+
* cost of mirroring them here is low and keeps this file the single
|
|
15947
|
+
* source of truth for "what shows in the inspector".
|
|
15948
|
+
*
|
|
15949
|
+
* If you wire up a new per-entity keystroke in `inkInput.ts` — for
|
|
15950
|
+
* example a "create branch from this commit" or "create tag from this
|
|
15951
|
+
* commit" action — add the matching row to the relevant array below so
|
|
15952
|
+
* it shows up in the inspector automatically.
|
|
15953
|
+
*/
|
|
15954
|
+
const HISTORY_COMMIT_ACTIONS = [
|
|
15955
|
+
{ key: 'enter', label: 'Open diff' },
|
|
15956
|
+
{ key: 'c', label: 'Cherry-pick' },
|
|
15957
|
+
{ key: 'R', label: 'Revert', destructive: true },
|
|
15958
|
+
{ key: 'Z', label: 'Reset to commit', destructive: true },
|
|
15959
|
+
{ key: 'i', label: 'Interactive rebase', destructive: true },
|
|
15960
|
+
{ key: 'y', label: 'Yank hash' },
|
|
15961
|
+
{ key: 'Y', label: 'Yank short hash' },
|
|
15962
|
+
{ key: 'O', label: 'Open in browser' },
|
|
15963
|
+
];
|
|
15964
|
+
const BRANCH_ACTIONS = [
|
|
15965
|
+
{ key: 'enter', label: 'Checkout' },
|
|
15966
|
+
{ key: '+', label: 'New branch' },
|
|
15967
|
+
{ key: 'R', label: 'Rename' },
|
|
15968
|
+
{ key: 'u', label: 'Set upstream' },
|
|
15969
|
+
{ key: 'D', label: 'Delete', destructive: true },
|
|
15970
|
+
{ key: 'P', label: 'Push current' },
|
|
15971
|
+
{ key: 'F', label: 'Fetch all' },
|
|
15972
|
+
{ key: 'y', label: 'Yank name' },
|
|
15973
|
+
];
|
|
15974
|
+
const TAG_ACTIONS = [
|
|
15975
|
+
{ key: '+', label: 'New tag' },
|
|
15976
|
+
{ key: 'P', label: 'Push tag' },
|
|
15977
|
+
{ key: 'T', label: 'Delete', destructive: true },
|
|
15978
|
+
{ key: 'R', label: 'Delete remote', destructive: true },
|
|
15979
|
+
{ key: 'y', label: 'Yank name' },
|
|
15980
|
+
];
|
|
15981
|
+
const STASH_ACTIONS = [
|
|
15982
|
+
{ key: 'enter', label: 'Open diff' },
|
|
15983
|
+
{ key: 'a', label: 'Apply' },
|
|
15984
|
+
{ key: 'p', label: 'Pop' },
|
|
15985
|
+
{ key: 'X', label: 'Drop', destructive: true },
|
|
15986
|
+
{ key: 'y', label: 'Yank ref' },
|
|
15987
|
+
];
|
|
15988
|
+
const WORKTREE_ACTIONS = [
|
|
15989
|
+
{ key: 'W', label: 'Remove', destructive: true },
|
|
15990
|
+
{ key: 'y', label: 'Yank path' },
|
|
15991
|
+
];
|
|
15992
|
+
function getInspectorActions(context) {
|
|
15993
|
+
switch (context) {
|
|
15994
|
+
case 'history-commit':
|
|
15995
|
+
return HISTORY_COMMIT_ACTIONS;
|
|
15996
|
+
case 'branch':
|
|
15997
|
+
return BRANCH_ACTIONS;
|
|
15998
|
+
case 'tag':
|
|
15999
|
+
return TAG_ACTIONS;
|
|
16000
|
+
case 'stash':
|
|
16001
|
+
return STASH_ACTIONS;
|
|
16002
|
+
case 'worktree':
|
|
16003
|
+
return WORKTREE_ACTIONS;
|
|
16004
|
+
default: {
|
|
16005
|
+
const exhaustive = context;
|
|
16006
|
+
throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
|
|
16007
|
+
}
|
|
16008
|
+
}
|
|
16009
|
+
}
|
|
16010
|
+
|
|
15911
16011
|
/**
|
|
15912
16012
|
* Sort modes for the promoted views (P4.2).
|
|
15913
16013
|
*
|
|
@@ -15927,23 +16027,31 @@ function cycleBranchSort(mode) {
|
|
|
15927
16027
|
return BRANCH_SORT_MODES[(index + 1) % BRANCH_SORT_MODES.length];
|
|
15928
16028
|
}
|
|
15929
16029
|
function sortBranches(branches, mode) {
|
|
15930
|
-
|
|
16030
|
+
// Pin the current branch at index 0 regardless of sort mode (#806
|
|
16031
|
+
// follow-up). Lands the user's cursor on the active branch by
|
|
16032
|
+
// default and keeps the most-relevant row glued to the top of the
|
|
16033
|
+
// list as they cycle sorts.
|
|
16034
|
+
const current = branches.find((entry) => entry.current);
|
|
16035
|
+
const rest = branches.filter((entry) => !entry.current);
|
|
16036
|
+
const sortedRest = rest.slice();
|
|
15931
16037
|
switch (mode) {
|
|
15932
16038
|
case 'name':
|
|
15933
|
-
|
|
16039
|
+
sortedRest.sort((a, b) => a.shortName.localeCompare(b.shortName));
|
|
16040
|
+
break;
|
|
15934
16041
|
case 'recent':
|
|
15935
16042
|
// ISO-shaped dates compare byte-for-byte; descending so the freshest
|
|
15936
16043
|
// branch sits at the top.
|
|
15937
|
-
|
|
16044
|
+
sortedRest.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
|
|
15938
16045
|
a.shortName.localeCompare(b.shortName));
|
|
16046
|
+
break;
|
|
15939
16047
|
case 'ahead':
|
|
15940
16048
|
// ahead-first; ties broken by behind, then by name. Keeps "this branch
|
|
15941
16049
|
// has unmerged work" in the user's first scroll.
|
|
15942
|
-
|
|
16050
|
+
sortedRest.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
|
|
15943
16051
|
a.shortName.localeCompare(b.shortName));
|
|
15944
|
-
|
|
15945
|
-
return copy;
|
|
16052
|
+
break;
|
|
15946
16053
|
}
|
|
16054
|
+
return current ? [current, ...sortedRest] : sortedRest;
|
|
15947
16055
|
}
|
|
15948
16056
|
const TAG_SORT_MODES = ['recent', 'name'];
|
|
15949
16057
|
const DEFAULT_TAG_SORT_MODE = 'recent';
|
|
@@ -16106,6 +16214,7 @@ function withPushedView(state, value) {
|
|
|
16106
16214
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
16107
16215
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
16108
16216
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
16217
|
+
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
16109
16218
|
pendingKey: undefined,
|
|
16110
16219
|
};
|
|
16111
16220
|
}
|
|
@@ -16128,6 +16237,7 @@ function withPoppedView(state) {
|
|
|
16128
16237
|
diffSource: next === 'diff' ? state.diffSource : undefined,
|
|
16129
16238
|
stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
|
|
16130
16239
|
pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
|
|
16240
|
+
statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
|
|
16131
16241
|
pendingKey: undefined,
|
|
16132
16242
|
};
|
|
16133
16243
|
}
|
|
@@ -16145,6 +16255,7 @@ function withReplacedView(state, value) {
|
|
|
16145
16255
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
16146
16256
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
16147
16257
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
16258
|
+
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
16148
16259
|
pendingKey: undefined,
|
|
16149
16260
|
};
|
|
16150
16261
|
}
|
|
@@ -16276,8 +16387,12 @@ function createLogInkState(rows, options = {}) {
|
|
|
16276
16387
|
focus: 'commits',
|
|
16277
16388
|
sidebarTab: 'status',
|
|
16278
16389
|
userSidebarTab: 'status',
|
|
16390
|
+
sidebarHeaderFocused: false,
|
|
16391
|
+
statusGroupHeaderFocused: false,
|
|
16279
16392
|
statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
|
|
16280
16393
|
diffViewMode: 'unified',
|
|
16394
|
+
inspectorTab: 'inspector',
|
|
16395
|
+
inspectorActionIndex: 0,
|
|
16281
16396
|
};
|
|
16282
16397
|
}
|
|
16283
16398
|
function getSelectedInkCommit(state) {
|
|
@@ -16318,12 +16433,21 @@ function applyLogInkAction(state, action) {
|
|
|
16318
16433
|
return {
|
|
16319
16434
|
...state,
|
|
16320
16435
|
focus: cycleValue(FOCUS_ORDER, state.focus, 1),
|
|
16436
|
+
// Reset header focus when leaving the sidebar so the next
|
|
16437
|
+
// re-entry starts on items rather than mid-flag.
|
|
16438
|
+
sidebarHeaderFocused: false,
|
|
16439
|
+
// Same idea for the status group header — Tab cycling away
|
|
16440
|
+
// from 'commits' should always land back on a real file when
|
|
16441
|
+
// the user returns.
|
|
16442
|
+
statusGroupHeaderFocused: false,
|
|
16321
16443
|
pendingKey: undefined,
|
|
16322
16444
|
};
|
|
16323
16445
|
case 'focusPrevious':
|
|
16324
16446
|
return {
|
|
16325
16447
|
...state,
|
|
16326
16448
|
focus: cycleValue(FOCUS_ORDER, state.focus, -1),
|
|
16449
|
+
sidebarHeaderFocused: false,
|
|
16450
|
+
statusGroupHeaderFocused: false,
|
|
16327
16451
|
pendingKey: undefined,
|
|
16328
16452
|
};
|
|
16329
16453
|
case 'move':
|
|
@@ -16335,6 +16459,28 @@ function applyLogInkAction(state, action) {
|
|
|
16335
16459
|
pendingCommitFocused: false,
|
|
16336
16460
|
pendingKey: undefined,
|
|
16337
16461
|
};
|
|
16462
|
+
case 'selectCommitByHash': {
|
|
16463
|
+
// Locates a commit by its full or short hash within the active
|
|
16464
|
+
// filtered list and snaps the cursor to it. Used by the
|
|
16465
|
+
// branch/tag auto-jump effect (#806 follow-up): cursoring a
|
|
16466
|
+
// branch in the sidebar tracks the history view to that
|
|
16467
|
+
// branch's tip without the user manually scrolling. No-op when
|
|
16468
|
+
// the hash isn't in the loaded list (the runtime surfaces a
|
|
16469
|
+
// status hint in that case).
|
|
16470
|
+
const target = action.hash;
|
|
16471
|
+
const index = state.filteredCommits.findIndex((commit) => commit.hash === target || commit.shortHash === target);
|
|
16472
|
+
if (index < 0) {
|
|
16473
|
+
return state;
|
|
16474
|
+
}
|
|
16475
|
+
return {
|
|
16476
|
+
...state,
|
|
16477
|
+
selectedIndex: index,
|
|
16478
|
+
selectedFileIndex: 0,
|
|
16479
|
+
diffPreviewOffset: 0,
|
|
16480
|
+
pendingCommitFocused: false,
|
|
16481
|
+
pendingKey: undefined,
|
|
16482
|
+
};
|
|
16483
|
+
}
|
|
16338
16484
|
case 'focusPendingCommit':
|
|
16339
16485
|
return {
|
|
16340
16486
|
...state,
|
|
@@ -16366,6 +16512,9 @@ function applyLogInkAction(state, action) {
|
|
|
16366
16512
|
selectedWorktreeFileIndex: clampIndex$1(state.selectedWorktreeFileIndex + action.delta, action.fileCount),
|
|
16367
16513
|
selectedWorktreeHunkIndex: 0,
|
|
16368
16514
|
worktreeDiffOffset: 0,
|
|
16515
|
+
// Cursor moved to a real file row — drop header focus so the
|
|
16516
|
+
// file Enter handler (open diff) is what fires next.
|
|
16517
|
+
statusGroupHeaderFocused: false,
|
|
16369
16518
|
};
|
|
16370
16519
|
}
|
|
16371
16520
|
case 'moveBranch':
|
|
@@ -16374,6 +16523,77 @@ function applyLogInkAction(state, action) {
|
|
|
16374
16523
|
selectedBranchIndex: clampIndex$1(state.selectedBranchIndex + action.delta, action.count),
|
|
16375
16524
|
pendingKey: undefined,
|
|
16376
16525
|
};
|
|
16526
|
+
case 'resetBranchSelection':
|
|
16527
|
+
// Snap the branches sidebar / view cursor back to position 0.
|
|
16528
|
+
// Used after a successful checkout (#806 follow-up): combined
|
|
16529
|
+
// with the "current branch pinned at top" rule from #809, this
|
|
16530
|
+
// lands the user's cursor on the just-checked-out branch.
|
|
16531
|
+
return {
|
|
16532
|
+
...state,
|
|
16533
|
+
selectedBranchIndex: 0,
|
|
16534
|
+
pendingKey: undefined,
|
|
16535
|
+
};
|
|
16536
|
+
case 'setSidebarHeaderFocused':
|
|
16537
|
+
return {
|
|
16538
|
+
...state,
|
|
16539
|
+
sidebarHeaderFocused: action.value,
|
|
16540
|
+
pendingKey: undefined,
|
|
16541
|
+
};
|
|
16542
|
+
case 'setStatusGroupHeaderFocused':
|
|
16543
|
+
return {
|
|
16544
|
+
...state,
|
|
16545
|
+
statusGroupHeaderFocused: action.value,
|
|
16546
|
+
pendingKey: undefined,
|
|
16547
|
+
};
|
|
16548
|
+
case 'jumpToStatusGroup':
|
|
16549
|
+
// Used by ←/→ on the status surface to land on the first file of
|
|
16550
|
+
// the previous / next non-empty group. Clears header focus so the
|
|
16551
|
+
// user is on a real file after the jump (matches the
|
|
16552
|
+
// sidebar pattern where ←/→ between tabs lands on items, not on
|
|
16553
|
+
// the next tab's header).
|
|
16554
|
+
return {
|
|
16555
|
+
...state,
|
|
16556
|
+
selectedWorktreeFileIndex: Math.max(0, action.targetIndex),
|
|
16557
|
+
selectedWorktreeHunkIndex: 0,
|
|
16558
|
+
worktreeDiffOffset: 0,
|
|
16559
|
+
statusGroupHeaderFocused: false,
|
|
16560
|
+
pendingKey: undefined,
|
|
16561
|
+
};
|
|
16562
|
+
case 'setInspectorTab':
|
|
16563
|
+
return {
|
|
16564
|
+
...state,
|
|
16565
|
+
inspectorTab: action.value,
|
|
16566
|
+
// Reset the action cursor so a fresh tab visit always starts
|
|
16567
|
+
// on the first action, regardless of where the user left off
|
|
16568
|
+
// in a previous entity context.
|
|
16569
|
+
inspectorActionIndex: 0,
|
|
16570
|
+
pendingKey: undefined,
|
|
16571
|
+
};
|
|
16572
|
+
case 'cycleInspectorTab': {
|
|
16573
|
+
// Two-tab toggle — `delta` is symmetrical so direction does not
|
|
16574
|
+
// matter, but we keep the action shape consistent with the
|
|
16575
|
+
// sidebar's `nextSidebarTab` / `previousSidebarTab` so callers
|
|
16576
|
+
// can mirror the sidebar pattern verbatim.
|
|
16577
|
+
const next = state.inspectorTab === 'inspector' ? 'actions' : 'inspector';
|
|
16578
|
+
return {
|
|
16579
|
+
...state,
|
|
16580
|
+
inspectorTab: next,
|
|
16581
|
+
inspectorActionIndex: 0,
|
|
16582
|
+
pendingKey: undefined,
|
|
16583
|
+
};
|
|
16584
|
+
}
|
|
16585
|
+
case 'moveInspectorAction':
|
|
16586
|
+
return {
|
|
16587
|
+
...state,
|
|
16588
|
+
inspectorActionIndex: clampIndex$1(state.inspectorActionIndex + action.delta, action.actionCount),
|
|
16589
|
+
pendingKey: undefined,
|
|
16590
|
+
};
|
|
16591
|
+
case 'resetInspectorActionIndex':
|
|
16592
|
+
return {
|
|
16593
|
+
...state,
|
|
16594
|
+
inspectorActionIndex: 0,
|
|
16595
|
+
pendingKey: undefined,
|
|
16596
|
+
};
|
|
16377
16597
|
case 'moveTag':
|
|
16378
16598
|
return {
|
|
16379
16599
|
...state,
|
|
@@ -16443,6 +16663,10 @@ function applyLogInkAction(state, action) {
|
|
|
16443
16663
|
...state,
|
|
16444
16664
|
statusFilterMask: allOff ? { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK } : next,
|
|
16445
16665
|
selectedWorktreeFileIndex: 0,
|
|
16666
|
+
// Group composition changed — header focus would be ambiguous
|
|
16667
|
+
// (cursor lands on file 0 which may belong to a different
|
|
16668
|
+
// group now). Reset to clear the indicator.
|
|
16669
|
+
statusGroupHeaderFocused: false,
|
|
16446
16670
|
pendingKey: undefined,
|
|
16447
16671
|
};
|
|
16448
16672
|
}
|
|
@@ -16613,6 +16837,13 @@ function applyLogInkAction(state, action) {
|
|
|
16613
16837
|
return {
|
|
16614
16838
|
...state,
|
|
16615
16839
|
focus: action.value,
|
|
16840
|
+
// Reset sidebar header focus when leaving the sidebar so a
|
|
16841
|
+
// re-entry starts on items rather than mid-flag.
|
|
16842
|
+
sidebarHeaderFocused: action.value === 'sidebar' ? state.sidebarHeaderFocused : false,
|
|
16843
|
+
// The status group header lives in the 'commits' focus on
|
|
16844
|
+
// the status view — clear when focus moves away so a
|
|
16845
|
+
// re-entry starts on a real file.
|
|
16846
|
+
statusGroupHeaderFocused: action.value === 'commits' ? state.statusGroupHeaderFocused : false,
|
|
16616
16847
|
pendingKey: undefined,
|
|
16617
16848
|
};
|
|
16618
16849
|
case 'setPendingKey':
|
|
@@ -16814,6 +17045,82 @@ function action(actionValue) {
|
|
|
16814
17045
|
action: actionValue,
|
|
16815
17046
|
};
|
|
16816
17047
|
}
|
|
17048
|
+
/**
|
|
17049
|
+
* Resolve which inspector action context applies for the current
|
|
17050
|
+
* state. Today only history commits expose actions in the inspector
|
|
17051
|
+
* (the renderer hard-coded `'history-commit'`); future PRs can fan
|
|
17052
|
+
* this out to branch / tag / stash / worktree contexts as the
|
|
17053
|
+
* inspector gains entity-aware sections. Returns `undefined` when no
|
|
17054
|
+
* actions section should be shown (so the cursor model stays a
|
|
17055
|
+
* no-op).
|
|
17056
|
+
*/
|
|
17057
|
+
function resolveInspectorActionContext(state) {
|
|
17058
|
+
if (state.activeView === 'history' && !state.pendingCommitFocused) {
|
|
17059
|
+
return 'history-commit';
|
|
17060
|
+
}
|
|
17061
|
+
return undefined;
|
|
17062
|
+
}
|
|
17063
|
+
function getInspectorActionsForState(state) {
|
|
17064
|
+
const ctx = resolveInspectorActionContext(state);
|
|
17065
|
+
return ctx ? getInspectorActions(ctx) : [];
|
|
17066
|
+
}
|
|
17067
|
+
/**
|
|
17068
|
+
* Synthesize the events that fire when the user presses Enter on a
|
|
17069
|
+
* cursored inspector action (#791 follow-up). Mirrors
|
|
17070
|
+
* `getLogInkPaletteExecuteEvents` — each action's `key` field
|
|
17071
|
+
* routes to the same dispatch the corresponding keystroke would
|
|
17072
|
+
* trigger from the history view's commit cursor. Per-key dispatch
|
|
17073
|
+
* (rather than recursively re-running the keystroke through
|
|
17074
|
+
* `getLogInkInputEvents`) avoids the gating problem: most history
|
|
17075
|
+
* keystroke handlers require `state.focus === 'commits'`, but the
|
|
17076
|
+
* inspector executor fires from `state.focus === 'detail'`.
|
|
17077
|
+
*/
|
|
17078
|
+
function getInspectorActionExecuteEvents(inspectorAction, state) {
|
|
17079
|
+
const commit = state.filteredCommits[state.selectedIndex];
|
|
17080
|
+
const requireCommit = (fn) => {
|
|
17081
|
+
if (!commit) {
|
|
17082
|
+
return [action({ type: 'setStatus', value: 'No commit selected' })];
|
|
17083
|
+
}
|
|
17084
|
+
return fn(commit.hash, state.selectedIndex);
|
|
17085
|
+
};
|
|
17086
|
+
switch (inspectorAction.key) {
|
|
17087
|
+
case 'enter':
|
|
17088
|
+
return requireCommit((sha, commitIndex) => [
|
|
17089
|
+
action({ type: 'navigateOpenDiffForCommit', sha, commitIndex }),
|
|
17090
|
+
]);
|
|
17091
|
+
case 'c':
|
|
17092
|
+
return requireCommit(() => [
|
|
17093
|
+
action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' }),
|
|
17094
|
+
]);
|
|
17095
|
+
case 'R':
|
|
17096
|
+
return requireCommit(() => [
|
|
17097
|
+
action({ type: 'setPendingConfirmation', value: 'revert-commit' }),
|
|
17098
|
+
]);
|
|
17099
|
+
case 'Z':
|
|
17100
|
+
return requireCommit(() => [
|
|
17101
|
+
action({
|
|
17102
|
+
type: 'openInputPrompt',
|
|
17103
|
+
kind: 'reset-mode',
|
|
17104
|
+
label: 'Reset mode (soft / mixed / hard)',
|
|
17105
|
+
}),
|
|
17106
|
+
]);
|
|
17107
|
+
case 'i':
|
|
17108
|
+
return requireCommit(() => [
|
|
17109
|
+
action({ type: 'setPendingConfirmation', value: 'interactive-rebase' }),
|
|
17110
|
+
]);
|
|
17111
|
+
case 'y':
|
|
17112
|
+
return requireCommit(() => [{ type: 'yankFromActiveView' }]);
|
|
17113
|
+
case 'Y':
|
|
17114
|
+
return requireCommit(() => [{ type: 'yankFromActiveView', short: true }]);
|
|
17115
|
+
case 'O':
|
|
17116
|
+
return [{ type: 'runWorkflowAction', id: 'open-pr' }];
|
|
17117
|
+
default:
|
|
17118
|
+
return [action({
|
|
17119
|
+
type: 'setStatus',
|
|
17120
|
+
value: `Action ${inspectorAction.key} not yet wired`,
|
|
17121
|
+
})];
|
|
17122
|
+
}
|
|
17123
|
+
}
|
|
16817
17124
|
/**
|
|
16818
17125
|
* Build the events needed to apply the hunk under the diff cursor. The
|
|
16819
17126
|
* runtime workflow handler expects payload format `<target>\n<patch>`
|
|
@@ -17459,6 +17766,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17459
17766
|
action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
|
|
17460
17767
|
];
|
|
17461
17768
|
}
|
|
17769
|
+
// `gT` chord: create a lightweight tag at the cursored commit on the
|
|
17770
|
+
// history view. Bare `T` is taken (delete-tag on the tags view) so we
|
|
17771
|
+
// use the chord. Mirrors `gH` exactly — uppercase letter after the
|
|
17772
|
+
// `g` chord prefix, distinct from the lowercase `gt` chord which
|
|
17773
|
+
// jumps to the tags view. The prompt is the affirmative gate.
|
|
17774
|
+
if (state.pendingKey === 'g' && inputValue === 'T') {
|
|
17775
|
+
if (state.activeView === 'history' &&
|
|
17776
|
+
state.focus === 'commits' &&
|
|
17777
|
+
state.filteredCommits.length > 0 &&
|
|
17778
|
+
!state.pendingCommitFocused) {
|
|
17779
|
+
return [
|
|
17780
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
17781
|
+
action({
|
|
17782
|
+
type: 'openInputPrompt',
|
|
17783
|
+
kind: 'create-tag-here',
|
|
17784
|
+
label: 'New tag name (at cursored commit)',
|
|
17785
|
+
}),
|
|
17786
|
+
];
|
|
17787
|
+
}
|
|
17788
|
+
return [
|
|
17789
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
17790
|
+
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
|
|
17791
|
+
];
|
|
17792
|
+
}
|
|
17462
17793
|
if (inputValue === 'g') {
|
|
17463
17794
|
if (state.pendingKey === 'g') {
|
|
17464
17795
|
return [
|
|
@@ -17540,6 +17871,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17540
17871
|
hunkOffsets: context.commitDiffHunkOffsets,
|
|
17541
17872
|
})];
|
|
17542
17873
|
}
|
|
17874
|
+
// Inspector focused: cycle the inspector tab. The renderer only
|
|
17875
|
+
// honors the tab field on short terminals (where the inspector
|
|
17876
|
+
// collapses into a tabbed layout), but we let the user pre-set
|
|
17877
|
+
// their preference on tall terminals too.
|
|
17878
|
+
if (state.focus === 'detail') {
|
|
17879
|
+
return [action({ type: 'cycleInspectorTab', delta: -1 })];
|
|
17880
|
+
}
|
|
17543
17881
|
return [action({ type: 'previousSidebarTab' })];
|
|
17544
17882
|
}
|
|
17545
17883
|
if (inputValue === ']') {
|
|
@@ -17564,6 +17902,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17564
17902
|
hunkOffsets: context.commitDiffHunkOffsets,
|
|
17565
17903
|
})];
|
|
17566
17904
|
}
|
|
17905
|
+
if (state.focus === 'detail') {
|
|
17906
|
+
return [action({ type: 'cycleInspectorTab', delta: 1 })];
|
|
17907
|
+
}
|
|
17567
17908
|
return [action({ type: 'nextSidebarTab' })];
|
|
17568
17909
|
}
|
|
17569
17910
|
// Status surface intercepts 1/2/3 before the sidebar-tab numeric
|
|
@@ -17591,11 +17932,62 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17591
17932
|
if (key.rightArrow && state.focus === 'sidebar') {
|
|
17592
17933
|
return [action({ type: 'nextSidebarTab' })];
|
|
17593
17934
|
}
|
|
17935
|
+
// ←/→ on the status surface jump between the staged / unstaged /
|
|
17936
|
+
// untracked groups — the horizontal axis is "between groups", the
|
|
17937
|
+
// vertical axis (↑/↓ below) is "within the active group's files".
|
|
17938
|
+
// Lands on the first file of the target group (clears header
|
|
17939
|
+
// focus) so the user is always on a real file after a jump,
|
|
17940
|
+
// mirroring the sidebar's tab-switch landing behavior.
|
|
17941
|
+
if ((key.leftArrow || key.rightArrow) &&
|
|
17942
|
+
state.activeView === 'status' &&
|
|
17943
|
+
state.focus === 'commits' &&
|
|
17944
|
+
context.statusGroups &&
|
|
17945
|
+
context.statusGroups.length > 1) {
|
|
17946
|
+
const groups = context.statusGroups;
|
|
17947
|
+
const currentIndex = groups.findIndex((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
|
|
17948
|
+
state.selectedWorktreeFileIndex < group.startIndex + group.count);
|
|
17949
|
+
const fallback = currentIndex >= 0 ? currentIndex : 0;
|
|
17950
|
+
const delta = key.leftArrow ? -1 : 1;
|
|
17951
|
+
const nextIndex = Math.max(0, Math.min(groups.length - 1, fallback + delta));
|
|
17952
|
+
if (nextIndex !== fallback) {
|
|
17953
|
+
return [action({ type: 'jumpToStatusGroup', targetIndex: groups[nextIndex].startIndex })];
|
|
17954
|
+
}
|
|
17955
|
+
return [];
|
|
17956
|
+
}
|
|
17594
17957
|
if (key.upArrow || inputValue === 'k') {
|
|
17958
|
+
// Inspector Actions tab: ↑/↓ moves the cursor through the
|
|
17959
|
+
// executable action list. Wins over moveDetailFile so a
|
|
17960
|
+
// history-commit explore with both file list AND actions visible
|
|
17961
|
+
// navigates the actions when the user has [/]-toggled to the
|
|
17962
|
+
// actions tab. (#791 follow-up)
|
|
17963
|
+
if (state.focus === 'detail' && state.inspectorTab === 'actions' && context.inspectorActionCount) {
|
|
17964
|
+
return [action({
|
|
17965
|
+
type: 'moveInspectorAction',
|
|
17966
|
+
delta: -1,
|
|
17967
|
+
actionCount: context.inspectorActionCount,
|
|
17968
|
+
})];
|
|
17969
|
+
}
|
|
17595
17970
|
if (state.focus === 'detail' && context.detailFileCount) {
|
|
17596
17971
|
return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
|
|
17597
17972
|
}
|
|
17598
17973
|
if (state.activeView === 'status' && context.worktreeFileCount) {
|
|
17974
|
+
// Already on the group header — ↑ is a no-op (use ←/→ to switch
|
|
17975
|
+
// groups). Mirrors the sidebar's "header is the top of the
|
|
17976
|
+
// hierarchy" behavior.
|
|
17977
|
+
if (state.statusGroupHeaderFocused) {
|
|
17978
|
+
return [];
|
|
17979
|
+
}
|
|
17980
|
+
// Cursor at the first file of its group → promote to the group
|
|
17981
|
+
// header rather than crossing the boundary into the previous
|
|
17982
|
+
// group's last file. Keeps the cursor inside its current
|
|
17983
|
+
// container; ←/→ is the explicit way to move between groups.
|
|
17984
|
+
if (context.statusGroups && context.statusGroups.length > 0) {
|
|
17985
|
+
const currentGroup = context.statusGroups.find((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
|
|
17986
|
+
state.selectedWorktreeFileIndex < group.startIndex + group.count);
|
|
17987
|
+
if (currentGroup && state.selectedWorktreeFileIndex === currentGroup.startIndex) {
|
|
17988
|
+
return [action({ type: 'setStatusGroupHeaderFocused', value: true })];
|
|
17989
|
+
}
|
|
17990
|
+
}
|
|
17599
17991
|
return [action({
|
|
17600
17992
|
type: 'moveWorktreeFile',
|
|
17601
17993
|
delta: -1,
|
|
@@ -17620,6 +18012,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17620
18012
|
previewLineCount: context.previewLineCount,
|
|
17621
18013
|
})];
|
|
17622
18014
|
}
|
|
18015
|
+
// Sidebar header focus: ↑ at item index 0 promotes the cursor
|
|
18016
|
+
// onto the active tab's header. Pressing ↑ again is a no-op
|
|
18017
|
+
// (use ←/→ to switch between tab headers, Enter to drill in).
|
|
18018
|
+
// Only triggers when the sidebar is focused on a content tab —
|
|
18019
|
+
// dedicated promoted views (`g b` etc.) keep the legacy clamp
|
|
18020
|
+
// behavior because they have no header to escape to.
|
|
18021
|
+
if (state.focus === 'sidebar' && !state.sidebarHeaderFocused) {
|
|
18022
|
+
if (state.sidebarTab === 'branches' && state.selectedBranchIndex === 0 && (context.branchCount ?? 0) > 0) {
|
|
18023
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18024
|
+
}
|
|
18025
|
+
if (state.sidebarTab === 'tags' && state.selectedTagIndex === 0 && (context.tagCount ?? 0) > 0) {
|
|
18026
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18027
|
+
}
|
|
18028
|
+
if (state.sidebarTab === 'stashes' && state.selectedStashIndex === 0 && (context.stashCount ?? 0) > 0) {
|
|
18029
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18030
|
+
}
|
|
18031
|
+
if (state.sidebarTab === 'worktrees' && state.selectedWorktreeListIndex === 0 && (context.worktreeListCount ?? 0) > 0) {
|
|
18032
|
+
return [action({ type: 'setSidebarHeaderFocused', value: true })];
|
|
18033
|
+
}
|
|
18034
|
+
}
|
|
18035
|
+
// Already on the header — ↑ is a no-op (←/→ switches tabs).
|
|
18036
|
+
if (state.focus === 'sidebar' && state.sidebarHeaderFocused) {
|
|
18037
|
+
return [];
|
|
18038
|
+
}
|
|
17623
18039
|
if (isBranchActionTarget(state) && context.branchCount) {
|
|
17624
18040
|
return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
|
|
17625
18041
|
}
|
|
@@ -17654,10 +18070,23 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17654
18070
|
if (state.activeView === 'history' && state.pendingCommitFocused) {
|
|
17655
18071
|
return [action({ type: 'unfocusPendingCommit' })];
|
|
17656
18072
|
}
|
|
18073
|
+
if (state.focus === 'detail' && state.inspectorTab === 'actions' && context.inspectorActionCount) {
|
|
18074
|
+
return [action({
|
|
18075
|
+
type: 'moveInspectorAction',
|
|
18076
|
+
delta: 1,
|
|
18077
|
+
actionCount: context.inspectorActionCount,
|
|
18078
|
+
})];
|
|
18079
|
+
}
|
|
17657
18080
|
if (state.focus === 'detail' && context.detailFileCount) {
|
|
17658
18081
|
return [action({ type: 'moveDetailFile', delta: 1, fileCount: context.detailFileCount })];
|
|
17659
18082
|
}
|
|
17660
18083
|
if (state.activeView === 'status' && context.worktreeFileCount) {
|
|
18084
|
+
// Header focused → ↓ re-enters the group at the cursored file
|
|
18085
|
+
// (which is already the group's first file by construction).
|
|
18086
|
+
// Just clear the flag.
|
|
18087
|
+
if (state.statusGroupHeaderFocused) {
|
|
18088
|
+
return [action({ type: 'setStatusGroupHeaderFocused', value: false })];
|
|
18089
|
+
}
|
|
17661
18090
|
return [action({
|
|
17662
18091
|
type: 'moveWorktreeFile',
|
|
17663
18092
|
delta: 1,
|
|
@@ -17678,6 +18107,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17678
18107
|
previewLineCount: context.previewLineCount,
|
|
17679
18108
|
})];
|
|
17680
18109
|
}
|
|
18110
|
+
// Sidebar header focused: ↓ re-enters the list at index 0.
|
|
18111
|
+
// Clears the header flag and snaps the per-entity selection to 0
|
|
18112
|
+
// (mirrors the existing default selection behavior on first
|
|
18113
|
+
// sidebar focus).
|
|
18114
|
+
if (state.focus === 'sidebar' && state.sidebarHeaderFocused) {
|
|
18115
|
+
return [action({ type: 'setSidebarHeaderFocused', value: false })];
|
|
18116
|
+
}
|
|
17681
18117
|
if (isBranchActionTarget(state) && context.branchCount) {
|
|
17682
18118
|
return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
|
|
17683
18119
|
}
|
|
@@ -17771,6 +18207,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17771
18207
|
];
|
|
17772
18208
|
}
|
|
17773
18209
|
}
|
|
18210
|
+
// Inspector Actions tab: Enter on the cursored action fires its
|
|
18211
|
+
// associated event (cherry-pick / revert / yank / etc.). Wins over
|
|
18212
|
+
// the file-list Enter below when the user has [/]-toggled to the
|
|
18213
|
+
// actions tab. Routes through `getInspectorActionExecuteEvents` so
|
|
18214
|
+
// the per-action dispatch table stays the single source of truth
|
|
18215
|
+
// for what each action does. (#791 follow-up)
|
|
18216
|
+
if (key.return &&
|
|
18217
|
+
state.focus === 'detail' &&
|
|
18218
|
+
state.inspectorTab === 'actions') {
|
|
18219
|
+
const actions = getInspectorActionsForState(state);
|
|
18220
|
+
const cursored = actions[state.inspectorActionIndex];
|
|
18221
|
+
if (cursored) {
|
|
18222
|
+
return getInspectorActionExecuteEvents(cursored, state);
|
|
18223
|
+
}
|
|
18224
|
+
}
|
|
17774
18225
|
// From the inspector / commit-diff detail panel, Enter opens (or refocuses)
|
|
17775
18226
|
// the diff view scoped to the currently-selected commit and file. Lets the
|
|
17776
18227
|
// user drive the explore flow entirely from the right panel: j/k picks a
|
|
@@ -17809,7 +18260,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17809
18260
|
const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
|
|
17810
18261
|
const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
|
|
17811
18262
|
sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
|
|
17812
|
-
|
|
18263
|
+
// Three cases drill into the dedicated view:
|
|
18264
|
+
// 1. The cursor is on the tab header (user pressed ↑ at the
|
|
18265
|
+
// top of the list to escape the items — Enter explicitly
|
|
18266
|
+
// jumps to the dedicated view).
|
|
18267
|
+
// 2. The tab has no in-sidebar primary action defined (status,
|
|
18268
|
+
// tags, worktrees — drilling in is the canonical path).
|
|
18269
|
+
// 3. The tab has zero items (the dedicated view's empty state
|
|
18270
|
+
// tells the user what to do next).
|
|
18271
|
+
if (state.sidebarHeaderFocused || !hasInSidebarPrimaryAction) {
|
|
17813
18272
|
const tabToView = {
|
|
17814
18273
|
status: 'status',
|
|
17815
18274
|
branches: 'branches',
|
|
@@ -17829,6 +18288,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17829
18288
|
// Fall through — per-entity Enter handler below claims the keystroke.
|
|
17830
18289
|
}
|
|
17831
18290
|
if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
|
|
18291
|
+
// Group header focused → fire the group's batch workflow action.
|
|
18292
|
+
// Routed through the workflow runner so the runtime owns the
|
|
18293
|
+
// git invocation + status messaging consistently with the
|
|
18294
|
+
// single-file `space` toggle. The `payload` carries the group's
|
|
18295
|
+
// state ('staged' / 'unstaged' / 'untracked') so the runtime can
|
|
18296
|
+
// resolve which files to act on without re-deriving group state.
|
|
18297
|
+
if (state.statusGroupHeaderFocused && context.statusGroups) {
|
|
18298
|
+
const currentGroup = context.statusGroups.find((group) => state.selectedWorktreeFileIndex >= group.startIndex &&
|
|
18299
|
+
state.selectedWorktreeFileIndex < group.startIndex + group.count);
|
|
18300
|
+
if (currentGroup) {
|
|
18301
|
+
const workflowId = currentGroup.state === 'staged'
|
|
18302
|
+
? 'unstage-all-staged'
|
|
18303
|
+
: currentGroup.state === 'unstaged'
|
|
18304
|
+
? 'stage-all-unstaged'
|
|
18305
|
+
: 'stage-all-untracked';
|
|
18306
|
+
return [{ type: 'runWorkflowAction', id: workflowId, payload: currentGroup.state }];
|
|
18307
|
+
}
|
|
18308
|
+
}
|
|
17832
18309
|
return [action({
|
|
17833
18310
|
type: 'navigateOpenDiffForWorktreeFile',
|
|
17834
18311
|
fileIndex: state.selectedWorktreeFileIndex,
|
|
@@ -18063,6 +18540,22 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18063
18540
|
!state.pendingCommitFocused) {
|
|
18064
18541
|
return [action({ type: 'setPendingConfirmation', value: 'interactive-rebase' })];
|
|
18065
18542
|
}
|
|
18543
|
+
// `B` opens a create-branch prompt rooted at the cursored commit
|
|
18544
|
+
// (`git branch <name> <sha>` — does NOT switch to the new branch).
|
|
18545
|
+
// The prompt itself is the affirmative gate, so no separate y-confirm.
|
|
18546
|
+
// Bare uppercase `B` since the lowercase `b` is used by the `gb`
|
|
18547
|
+
// chord prefix and we want a single keystroke for this common op.
|
|
18548
|
+
if (inputValue === 'B' &&
|
|
18549
|
+
state.activeView === 'history' &&
|
|
18550
|
+
state.focus === 'commits' &&
|
|
18551
|
+
state.filteredCommits.length > 0 &&
|
|
18552
|
+
!state.pendingCommitFocused) {
|
|
18553
|
+
return [action({
|
|
18554
|
+
type: 'openInputPrompt',
|
|
18555
|
+
kind: 'create-branch-here',
|
|
18556
|
+
label: 'New branch name (at cursored commit)',
|
|
18557
|
+
})];
|
|
18558
|
+
}
|
|
18066
18559
|
// `y` / `Y` yank the contextually relevant identifier from the active
|
|
18067
18560
|
// view to the system clipboard:
|
|
18068
18561
|
// history → cursored commit hash (Y for short hash)
|
|
@@ -18689,10 +19182,25 @@ const LOG_INK_MIN_COLUMNS = 80;
|
|
|
18689
19182
|
const LOG_INK_MIN_ROWS = 24;
|
|
18690
19183
|
const LOG_INK_DEFAULT_COLUMNS = 120;
|
|
18691
19184
|
const LOG_INK_DEFAULT_ROWS = 40;
|
|
19185
|
+
/**
|
|
19186
|
+
* Terminal-row threshold below which the inspector switches to a
|
|
19187
|
+
* tabbed layout (commit-detail vs actions). Picked empirically: at
|
|
19188
|
+
* 28 rows the inspector's full stack (~30 rows when fully populated)
|
|
19189
|
+
* starts clipping the actions section; below that, the tabbed mode
|
|
19190
|
+
* gives both views their own air.
|
|
19191
|
+
*/
|
|
19192
|
+
const INSPECTOR_TABBED_BELOW_ROWS = 28;
|
|
18692
19193
|
function getLogInkLayout(input) {
|
|
18693
19194
|
const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
|
|
18694
19195
|
const rows = input.rows || LOG_INK_DEFAULT_ROWS;
|
|
18695
|
-
|
|
19196
|
+
// Inspector width — at rest 20-32 cells (~22% of width), focused
|
|
19197
|
+
// 36-60 cells (~40% of width). Narrow rest state keeps the commit
|
|
19198
|
+
// graph dominant; focus expansion gives the inspector room for long
|
|
19199
|
+
// commit bodies / file lists / action labels. Mirrors the sidebar
|
|
19200
|
+
// pattern (sidebarFocused above): instant transition per render.
|
|
19201
|
+
const detailWidth = input.inspectorFocused
|
|
19202
|
+
? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
|
|
19203
|
+
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
18696
19204
|
// Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
|
|
18697
19205
|
// (~36% of width). The transition is instant per render — focus tab to
|
|
18698
19206
|
// expand, focus away to collapse.
|
|
@@ -18707,6 +19215,7 @@ function getLogInkLayout(input) {
|
|
|
18707
19215
|
rows,
|
|
18708
19216
|
sidebarWidth,
|
|
18709
19217
|
tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
|
|
19218
|
+
inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
|
|
18710
19219
|
};
|
|
18711
19220
|
}
|
|
18712
19221
|
|
|
@@ -20202,6 +20711,63 @@ function resetToCommit(git, commit, mode) {
|
|
|
20202
20711
|
: result.details,
|
|
20203
20712
|
}));
|
|
20204
20713
|
}
|
|
20714
|
+
/**
|
|
20715
|
+
* Create a new local branch pointed at <commit>, without switching to it.
|
|
20716
|
+
*
|
|
20717
|
+
* This is the "create branch from cursored commit" history action — the
|
|
20718
|
+
* user types the new branch name into an input prompt and we run
|
|
20719
|
+
* `git branch <name> <sha>` (NOT `git switch -c`, which is what
|
|
20720
|
+
* `branchActions.createBranch` does for the create-branch-at-HEAD flow).
|
|
20721
|
+
* The split exists because GitKraken-style "create branch here" is
|
|
20722
|
+
* specifically about marking a historical commit, not about switching
|
|
20723
|
+
* onto a new working branch.
|
|
20724
|
+
*
|
|
20725
|
+
* Note for the inspector follow-up: workflow surfacing is driven by the
|
|
20726
|
+
* registry in `inkWorkflows.ts`, not a hardcoded action list — adding
|
|
20727
|
+
* `create-branch-here` there is enough for the inspector / palette to
|
|
20728
|
+
* pick this up.
|
|
20729
|
+
*/
|
|
20730
|
+
function createBranchFromCommit(git, name, commit) {
|
|
20731
|
+
const trimmedName = name.trim();
|
|
20732
|
+
if (!commit) {
|
|
20733
|
+
return Promise.resolve({
|
|
20734
|
+
ok: false,
|
|
20735
|
+
message: 'No commit selected.',
|
|
20736
|
+
});
|
|
20737
|
+
}
|
|
20738
|
+
if (!trimmedName) {
|
|
20739
|
+
return Promise.resolve({
|
|
20740
|
+
ok: false,
|
|
20741
|
+
message: 'Branch name required.',
|
|
20742
|
+
});
|
|
20743
|
+
}
|
|
20744
|
+
return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['branch', trimmedName, commit.hash]), `Created branch ${trimmedName} at ${commit.shortHash}`)));
|
|
20745
|
+
}
|
|
20746
|
+
/**
|
|
20747
|
+
* Create a lightweight tag pointed at <commit>.
|
|
20748
|
+
*
|
|
20749
|
+
* Mirrors `createBranchFromCommit` for the tag side: the user types a
|
|
20750
|
+
* tag name into an input prompt and we run `git tag <name> <sha>`
|
|
20751
|
+
* (lightweight, no `-a`/`-m`). Annotated tags remain available through
|
|
20752
|
+
* the existing `+` flow on the tags view; this is the per-commit
|
|
20753
|
+
* shortcut.
|
|
20754
|
+
*/
|
|
20755
|
+
function createTagAtCommit(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: 'Tag name required.',
|
|
20767
|
+
});
|
|
20768
|
+
}
|
|
20769
|
+
return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['tag', trimmedName, commit.hash]), `Created tag ${trimmedName} at ${commit.shortHash}`)));
|
|
20770
|
+
}
|
|
20205
20771
|
function startInteractiveRebase(git, commit) {
|
|
20206
20772
|
if (!commit) {
|
|
20207
20773
|
return Promise.resolve({
|
|
@@ -20482,6 +21048,35 @@ function parseStashDiffFiles(lines) {
|
|
|
20482
21048
|
}
|
|
20483
21049
|
return files;
|
|
20484
21050
|
}
|
|
21051
|
+
/**
|
|
21052
|
+
* Resolve which stash file *contains* a given line offset — the user's
|
|
21053
|
+
* cursor scrolls through a concatenated multi-file patch, and this is
|
|
21054
|
+
* what powers the "File N/M: <path>" panel header, the inline header
|
|
21055
|
+
* highlighting (#791 follow-up), and the cherry-pick / open-in-editor
|
|
21056
|
+
* dispatchers' "what file is the cursor on" lookup.
|
|
21057
|
+
*
|
|
21058
|
+
* Returns `undefined` when the file list is empty *or* the offset
|
|
21059
|
+
* lands before the very first file's `diff --git` header (e.g. when
|
|
21060
|
+
* `--stat` summary lines lead the patch). Callers fall through to a
|
|
21061
|
+
* "no file selected" state in that case.
|
|
21062
|
+
*/
|
|
21063
|
+
function findStashFileForOffset(files, offset) {
|
|
21064
|
+
if (files.length === 0)
|
|
21065
|
+
return undefined;
|
|
21066
|
+
let current;
|
|
21067
|
+
for (const file of files) {
|
|
21068
|
+
if (file.startLine <= offset) {
|
|
21069
|
+
current = file;
|
|
21070
|
+
}
|
|
21071
|
+
else {
|
|
21072
|
+
break;
|
|
21073
|
+
}
|
|
21074
|
+
}
|
|
21075
|
+
// First file is the canonical fallback — even if the offset lands
|
|
21076
|
+
// before its header (rare), we want the cursor to be "in" something
|
|
21077
|
+
// so the user's actions have a target.
|
|
21078
|
+
return current ?? files[0];
|
|
21079
|
+
}
|
|
20485
21080
|
const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
|
|
20486
21081
|
function parseDiffGitHeader(line) {
|
|
20487
21082
|
const match = line.match(DIFF_GIT_HEADER);
|
|
@@ -20536,6 +21131,25 @@ function revertFile(git, file) {
|
|
|
20536
21131
|
}
|
|
20537
21132
|
return runAction$3(() => git.raw(['restore', '--', file.path]), `Reverted ${file.path}`);
|
|
20538
21133
|
}
|
|
21134
|
+
/**
|
|
21135
|
+
* Group-level batch ops triggered by Enter on a status group header
|
|
21136
|
+
* (staged / unstaged / untracked). Pass the files belonging to that
|
|
21137
|
+
* group; the helpers run a single `git add` / `git restore --staged`
|
|
21138
|
+
* with all paths in one invocation rather than looping per-file —
|
|
21139
|
+
* faster + atomic from the user's point of view.
|
|
21140
|
+
*/
|
|
21141
|
+
function stageAllFiles(git, files) {
|
|
21142
|
+
if (files.length === 0) {
|
|
21143
|
+
return Promise.resolve({ ok: false, message: 'No files to stage' });
|
|
21144
|
+
}
|
|
21145
|
+
return runAction$3(() => git.raw(['add', '--', ...files.map((file) => file.path)]), `Staged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
|
|
21146
|
+
}
|
|
21147
|
+
function unstageAllFiles(git, files) {
|
|
21148
|
+
if (files.length === 0) {
|
|
21149
|
+
return Promise.resolve({ ok: false, message: 'No files to unstage' });
|
|
21150
|
+
}
|
|
21151
|
+
return runAction$3(() => git.raw(['restore', '--staged', '--', ...files.map((file) => file.path)]), `Unstaged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
|
|
21152
|
+
}
|
|
20539
21153
|
|
|
20540
21154
|
function fileState(indexStatus, worktreeStatus) {
|
|
20541
21155
|
if (indexStatus === '?' && worktreeStatus === '?') {
|
|
@@ -20579,6 +21193,22 @@ function applyStatusFilterMask(files, mask) {
|
|
|
20579
21193
|
}
|
|
20580
21194
|
return files.filter((file) => mask[file.state]);
|
|
20581
21195
|
}
|
|
21196
|
+
const WORKTREE_GROUP_ORDER = ['staged', 'unstaged', 'untracked'];
|
|
21197
|
+
function groupWorktreeFiles(files) {
|
|
21198
|
+
const groups = [];
|
|
21199
|
+
let cursor = 0;
|
|
21200
|
+
for (const groupState of WORKTREE_GROUP_ORDER) {
|
|
21201
|
+
const groupFiles = files.filter((file) => file.state === groupState);
|
|
21202
|
+
if (groupFiles.length > 0) {
|
|
21203
|
+
groups.push({ state: groupState, files: groupFiles, startIndex: cursor });
|
|
21204
|
+
cursor += groupFiles.length;
|
|
21205
|
+
}
|
|
21206
|
+
}
|
|
21207
|
+
return groups;
|
|
21208
|
+
}
|
|
21209
|
+
function flattenWorktreeGroups(groups) {
|
|
21210
|
+
return groups.flatMap((group) => group.files);
|
|
21211
|
+
}
|
|
20582
21212
|
|
|
20583
21213
|
function hunkHeader(hunk) {
|
|
20584
21214
|
return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
|
|
@@ -23483,7 +24113,16 @@ function LogInkApp(deps) {
|
|
|
23483
24113
|
// count, selected-file resolution, and the rendered list all key off
|
|
23484
24114
|
// it so toggles never desync the cursor from the rendered rows.
|
|
23485
24115
|
const visibleWorktreeFiles = React.useMemo(() => applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask), [context.worktree?.files, state.statusFilterMask]);
|
|
23486
|
-
|
|
24116
|
+
// Sectioned view of the visible files (#791 follow-up). Drives the
|
|
24117
|
+
// status surface's three-tier cursor model: ←/→ jumps between
|
|
24118
|
+
// groups, ↑ at index 0 promotes to the group header, Enter on the
|
|
24119
|
+
// header fires the group's batch action. The renderer also consumes
|
|
24120
|
+
// this so the visible file list stays in canonical group order
|
|
24121
|
+
// regardless of whatever order `git status --porcelain` happens to
|
|
24122
|
+
// emit.
|
|
24123
|
+
const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
|
|
24124
|
+
const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
|
|
24125
|
+
const selectedWorktreeFile = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex];
|
|
23487
24126
|
const dispatch = React.useCallback((action) => {
|
|
23488
24127
|
setState((current) => applyLogInkAction(current, action));
|
|
23489
24128
|
}, []);
|
|
@@ -23727,6 +24366,80 @@ function LogInkApp(deps) {
|
|
|
23727
24366
|
active = false;
|
|
23728
24367
|
};
|
|
23729
24368
|
}, [git, selected?.hash]);
|
|
24369
|
+
// #806 follow-up — auto-jump the history view to whichever branch /
|
|
24370
|
+
// tag the user is currently cursoring in the sidebar (or the
|
|
24371
|
+
// dedicated branches / tags view). Debounced so cursor-scrolling
|
|
24372
|
+
// through a long branch list doesn't dispatch on every keystroke.
|
|
24373
|
+
// No-op when the cursored ref's tip isn't in the loaded commit
|
|
24374
|
+
// window (under compact mode the cursored branch's tip may not be
|
|
24375
|
+
// fetched yet); a status hint surfaces in that case so the user
|
|
24376
|
+
// knows to toggle full graph or load older commits.
|
|
24377
|
+
React.useEffect(() => {
|
|
24378
|
+
const onBranchTab = state.activeView === 'branches' ||
|
|
24379
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
24380
|
+
const onTagTab = state.activeView === 'tags' ||
|
|
24381
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
24382
|
+
if (!onBranchTab && !onTagTab)
|
|
24383
|
+
return;
|
|
24384
|
+
let cancelled = false;
|
|
24385
|
+
const timer = setTimeout(() => {
|
|
24386
|
+
if (cancelled)
|
|
24387
|
+
return;
|
|
24388
|
+
let targetHash;
|
|
24389
|
+
let targetLabel;
|
|
24390
|
+
if (onBranchTab) {
|
|
24391
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
24392
|
+
const visible = state.filter
|
|
24393
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
|
|
24394
|
+
: all;
|
|
24395
|
+
const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
|
|
24396
|
+
if (branch) {
|
|
24397
|
+
targetHash = branch.hash;
|
|
24398
|
+
targetLabel = `branch ${branch.shortName}`;
|
|
24399
|
+
}
|
|
24400
|
+
}
|
|
24401
|
+
else if (onTagTab) {
|
|
24402
|
+
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24403
|
+
const visible = state.filter
|
|
24404
|
+
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
|
|
24405
|
+
: all;
|
|
24406
|
+
const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
|
|
24407
|
+
if (tag) {
|
|
24408
|
+
targetHash = tag.hash;
|
|
24409
|
+
targetLabel = `tag ${tag.name}`;
|
|
24410
|
+
}
|
|
24411
|
+
}
|
|
24412
|
+
if (!targetHash)
|
|
24413
|
+
return;
|
|
24414
|
+
const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
|
|
24415
|
+
if (loaded) {
|
|
24416
|
+
dispatch({ type: 'selectCommitByHash', hash: targetHash });
|
|
24417
|
+
// Confirmation status message so the user gets feedback even
|
|
24418
|
+
// when the dedicated branches / tags view is occupying the
|
|
24419
|
+
// main panel and the history cursor moves invisibly behind it.
|
|
24420
|
+
dispatch({
|
|
24421
|
+
type: 'setStatus',
|
|
24422
|
+
value: `Synced history to ${targetLabel} tip`,
|
|
24423
|
+
});
|
|
24424
|
+
}
|
|
24425
|
+
else {
|
|
24426
|
+
dispatch({
|
|
24427
|
+
type: 'setStatus',
|
|
24428
|
+
value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
|
|
24429
|
+
});
|
|
24430
|
+
}
|
|
24431
|
+
}, 150);
|
|
24432
|
+
return () => {
|
|
24433
|
+
cancelled = true;
|
|
24434
|
+
clearTimeout(timer);
|
|
24435
|
+
};
|
|
24436
|
+
}, [
|
|
24437
|
+
dispatch, context.branches, context.tags,
|
|
24438
|
+
state.activeView, state.focus, state.sidebarTab,
|
|
24439
|
+
state.selectedBranchIndex, state.selectedTagIndex,
|
|
24440
|
+
state.branchSort, state.tagSort, state.filter,
|
|
24441
|
+
state.filteredCommits,
|
|
24442
|
+
]);
|
|
23730
24443
|
React.useEffect(() => {
|
|
23731
24444
|
let active = true;
|
|
23732
24445
|
async function loadWorktreeDiff() {
|
|
@@ -24102,6 +24815,30 @@ function LogInkApp(deps) {
|
|
|
24102
24815
|
message: commit.message,
|
|
24103
24816
|
});
|
|
24104
24817
|
},
|
|
24818
|
+
'create-branch-here': async () => {
|
|
24819
|
+
const commit = getSelectedInkCommit(state);
|
|
24820
|
+
const name = payload?.trim();
|
|
24821
|
+
if (!commit)
|
|
24822
|
+
return { ok: false, message: 'No commit selected' };
|
|
24823
|
+
if (!name)
|
|
24824
|
+
return { ok: false, message: 'Branch name required' };
|
|
24825
|
+
return createBranchFromCommit(git, name, {
|
|
24826
|
+
hash: commit.hash,
|
|
24827
|
+
shortHash: commit.shortHash,
|
|
24828
|
+
});
|
|
24829
|
+
},
|
|
24830
|
+
'create-tag-here': async () => {
|
|
24831
|
+
const commit = getSelectedInkCommit(state);
|
|
24832
|
+
const name = payload?.trim();
|
|
24833
|
+
if (!commit)
|
|
24834
|
+
return { ok: false, message: 'No commit selected' };
|
|
24835
|
+
if (!name)
|
|
24836
|
+
return { ok: false, message: 'Tag name required' };
|
|
24837
|
+
return createTagAtCommit(git, name, {
|
|
24838
|
+
hash: commit.hash,
|
|
24839
|
+
shortHash: commit.shortHash,
|
|
24840
|
+
});
|
|
24841
|
+
},
|
|
24105
24842
|
'checkout-file-from-commit': async () => {
|
|
24106
24843
|
// payload is "<sha> <path>" so we pass both through a single
|
|
24107
24844
|
// string field on the action.
|
|
@@ -24154,6 +24891,17 @@ function LogInkApp(deps) {
|
|
|
24154
24891
|
if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
|
|
24155
24892
|
return { ok: false, message: 'No GitHub remote detected for this repo' };
|
|
24156
24893
|
}
|
|
24894
|
+
// History view: prefer the cursored commit's URL so `O` from
|
|
24895
|
+
// a commit context lands the user on the commit page rather
|
|
24896
|
+
// than the repo root or the current PR. The user-visible
|
|
24897
|
+
// intent of `O` is "open whatever I'm cursoring on the web";
|
|
24898
|
+
// a commit is what the cursor is on in the history view.
|
|
24899
|
+
if (state.activeView === 'history') {
|
|
24900
|
+
const commit = getSelectedInkCommit(state);
|
|
24901
|
+
if (commit) {
|
|
24902
|
+
return openProviderUrl(repo, { type: 'commit', commit: commit.hash });
|
|
24903
|
+
}
|
|
24904
|
+
}
|
|
24157
24905
|
const pr = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
|
|
24158
24906
|
if (pr) {
|
|
24159
24907
|
return openProviderUrl(repo, { type: 'pull-request', number: pr.number });
|
|
@@ -24233,6 +24981,26 @@ function LogInkApp(deps) {
|
|
|
24233
24981
|
return { ok: false, message: 'Comment body required' };
|
|
24234
24982
|
return commentPullRequest(body);
|
|
24235
24983
|
},
|
|
24984
|
+
// Status surface group-level batch ops (#791 follow-up). The
|
|
24985
|
+
// input handler dispatches these when the user presses Enter on a
|
|
24986
|
+
// group header. We re-derive the file list from the live
|
|
24987
|
+
// `context.worktree?.files` rather than trusting a snapshot —
|
|
24988
|
+
// the worktree may have changed since the keystroke fired (rare,
|
|
24989
|
+
// but the cost of re-filtering is negligible compared to the cost
|
|
24990
|
+
// of a stale add). The mask is honored too so a user who's
|
|
24991
|
+
// hidden a category never has it touched by accident.
|
|
24992
|
+
'stage-all-unstaged': async () => {
|
|
24993
|
+
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'unstaged');
|
|
24994
|
+
return stageAllFiles(git, files);
|
|
24995
|
+
},
|
|
24996
|
+
'unstage-all-staged': async () => {
|
|
24997
|
+
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'staged');
|
|
24998
|
+
return unstageAllFiles(git, files);
|
|
24999
|
+
},
|
|
25000
|
+
'stage-all-untracked': async () => {
|
|
25001
|
+
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'untracked');
|
|
25002
|
+
return stageAllFiles(git, files);
|
|
25003
|
+
},
|
|
24236
25004
|
};
|
|
24237
25005
|
const handler = handlers[id];
|
|
24238
25006
|
if (!handler) {
|
|
@@ -24241,12 +25009,24 @@ function LogInkApp(deps) {
|
|
|
24241
25009
|
}
|
|
24242
25010
|
const result = await handler();
|
|
24243
25011
|
dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
|
|
24244
|
-
//
|
|
24245
|
-
//
|
|
24246
|
-
|
|
25012
|
+
// Checkout-branch is the one workflow where we want a *visible*
|
|
25013
|
+
// refresh so the user sees the branches sidebar repaint with the
|
|
25014
|
+
// new current branch (per #806 follow-up). Snap the cursor to
|
|
25015
|
+
// position 0 first so when the refresh completes and the new
|
|
25016
|
+
// current branch lands at the top (per #809's pin-current rule),
|
|
25017
|
+
// the cursor is already there waiting.
|
|
25018
|
+
if (id === 'checkout-branch' && result?.ok) {
|
|
25019
|
+
dispatch({ type: 'resetBranchSelection' });
|
|
25020
|
+
await refreshContext();
|
|
25021
|
+
}
|
|
25022
|
+
else {
|
|
25023
|
+
// Silent refresh so the deleted item disappears from the list
|
|
25024
|
+
// without flickering the surfaces through a 'loading' phase.
|
|
25025
|
+
await refreshContext({ silent: true });
|
|
25026
|
+
}
|
|
24247
25027
|
}, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
|
|
24248
25028
|
state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
|
|
24249
|
-
state.tagSort]);
|
|
25029
|
+
state.statusFilterMask, state.tagSort]);
|
|
24250
25030
|
// Resolve the active view's "yank target" (commit hash / branch /
|
|
24251
25031
|
// tag / stash ref / file path) against the live filtered+sorted list,
|
|
24252
25032
|
// copy it to the system clipboard, and surface the result on the
|
|
@@ -24301,7 +25081,7 @@ function LogInkApp(deps) {
|
|
|
24301
25081
|
// Read from the mask-filtered list (#776) so the cursor and the
|
|
24302
25082
|
// yanked path always match what's on screen — yanking a hidden
|
|
24303
25083
|
// row is always a desync bug.
|
|
24304
|
-
const path =
|
|
25084
|
+
const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
|
|
24305
25085
|
if (path) {
|
|
24306
25086
|
value = path;
|
|
24307
25087
|
label = `path ${path}`;
|
|
@@ -24309,7 +25089,7 @@ function LogInkApp(deps) {
|
|
|
24309
25089
|
}
|
|
24310
25090
|
else if (view === 'diff') {
|
|
24311
25091
|
if (state.diffSource === 'worktree') {
|
|
24312
|
-
const path =
|
|
25092
|
+
const path = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path;
|
|
24313
25093
|
if (path) {
|
|
24314
25094
|
value = path;
|
|
24315
25095
|
label = `path ${path}`;
|
|
@@ -24319,17 +25099,8 @@ function LogInkApp(deps) {
|
|
|
24319
25099
|
// Walk back to the most recent file header at or before the
|
|
24320
25100
|
// current preview offset — same logic the input-context block
|
|
24321
25101
|
// uses to expose stashDiffSelectedPath.
|
|
24322
|
-
const
|
|
24323
|
-
if (
|
|
24324
|
-
let current = files[0];
|
|
24325
|
-
for (const file of files) {
|
|
24326
|
-
if (file.startLine <= state.diffPreviewOffset) {
|
|
24327
|
-
current = file;
|
|
24328
|
-
}
|
|
24329
|
-
else {
|
|
24330
|
-
break;
|
|
24331
|
-
}
|
|
24332
|
-
}
|
|
25102
|
+
const current = findStashFileForOffset(parseStashDiffFiles(stashDiffLines), state.diffPreviewOffset);
|
|
25103
|
+
if (current) {
|
|
24333
25104
|
value = current.path;
|
|
24334
25105
|
label = `path ${current.path}`;
|
|
24335
25106
|
}
|
|
@@ -24383,7 +25154,7 @@ function LogInkApp(deps) {
|
|
|
24383
25154
|
state.selectedTagIndex,
|
|
24384
25155
|
state.selectedWorktreeFileIndex,
|
|
24385
25156
|
state.tagSort,
|
|
24386
|
-
|
|
25157
|
+
visibleWorktreeFilesGrouped,
|
|
24387
25158
|
]);
|
|
24388
25159
|
React.useEffect(() => {
|
|
24389
25160
|
let active = true;
|
|
@@ -24623,28 +25394,14 @@ function LogInkApp(deps) {
|
|
|
24623
25394
|
? parseStashDiffFiles(stashDiffLines)
|
|
24624
25395
|
: [];
|
|
24625
25396
|
const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
|
|
24626
|
-
const stashDiffSelectedPath =
|
|
24627
|
-
|
|
24628
|
-
|
|
24629
|
-
const offset = state.diffPreviewOffset;
|
|
24630
|
-
// Walk backwards to the most recent file header at or before the
|
|
24631
|
-
// current cursor offset.
|
|
24632
|
-
let current = stashDiffFiles[0];
|
|
24633
|
-
for (const file of stashDiffFiles) {
|
|
24634
|
-
if (file.startLine <= offset) {
|
|
24635
|
-
current = file;
|
|
24636
|
-
}
|
|
24637
|
-
else {
|
|
24638
|
-
break;
|
|
24639
|
-
}
|
|
24640
|
-
}
|
|
24641
|
-
return current.path;
|
|
24642
|
-
})();
|
|
25397
|
+
const stashDiffSelectedPath = state.diffSource === 'stash'
|
|
25398
|
+
? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
|
|
25399
|
+
: undefined;
|
|
24643
25400
|
getLogInkInputEvents(state, inputValue, key, {
|
|
24644
25401
|
detailFileCount: detail?.files.length,
|
|
24645
25402
|
previewLineCount: diffPreviewLineCount,
|
|
24646
25403
|
worktreeDiffLineCount: worktreeDiff?.lines.length,
|
|
24647
|
-
worktreeFileCount:
|
|
25404
|
+
worktreeFileCount: visibleWorktreeFilesGrouped.length,
|
|
24648
25405
|
worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
|
|
24649
25406
|
commitDiffHunkOffsets,
|
|
24650
25407
|
branchCount: branchVisibleCount,
|
|
@@ -24654,7 +25411,13 @@ function LogInkApp(deps) {
|
|
|
24654
25411
|
stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
|
|
24655
25412
|
stashDiffSelectedPath,
|
|
24656
25413
|
worktreeListCount: worktreeVisibleCount,
|
|
24657
|
-
worktreeSelectedPath:
|
|
25414
|
+
worktreeSelectedPath: visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex]?.path,
|
|
25415
|
+
statusGroups: visibleWorktreeGroups.map((group) => ({
|
|
25416
|
+
state: group.state,
|
|
25417
|
+
count: group.files.length,
|
|
25418
|
+
startIndex: group.startIndex,
|
|
25419
|
+
})),
|
|
25420
|
+
inspectorActionCount: getInspectorActionsForState(state).length,
|
|
24658
25421
|
commitDiffSelectedPath: state.diffSource === 'commit'
|
|
24659
25422
|
? selectedDetailFile?.path
|
|
24660
25423
|
: undefined,
|
|
@@ -24725,6 +25488,7 @@ function LogInkApp(deps) {
|
|
|
24725
25488
|
columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
|
|
24726
25489
|
rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
|
|
24727
25490
|
sidebarFocused: state.focus === 'sidebar',
|
|
25491
|
+
inspectorFocused: state.focus === 'detail',
|
|
24728
25492
|
});
|
|
24729
25493
|
if (layout.tooSmall) {
|
|
24730
25494
|
return h(Box, {
|
|
@@ -24739,7 +25503,7 @@ function LogInkApp(deps) {
|
|
|
24739
25503
|
if (showOnboarding) {
|
|
24740
25504
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
24741
25505
|
}
|
|
24742
|
-
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));
|
|
25506
|
+
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));
|
|
24743
25507
|
}
|
|
24744
25508
|
function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
|
|
24745
25509
|
const { Box, Text } = components;
|
|
@@ -24792,13 +25556,18 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
|
|
|
24792
25556
|
? h(Text, { bold: true, color: theme.colors.accent }, `${prLabel}${titleSuffix}`)
|
|
24793
25557
|
: undefined, h(Text, { bold: true, color: modeColor }, ` ${mode}`), search ? h(Text, { dimColor: true }, ` ${truncate$1(search, 36)}`) : undefined);
|
|
24794
25558
|
}
|
|
24795
|
-
function renderSidebar(h, components, state, context, contextStatus, width, theme) {
|
|
25559
|
+
function renderSidebar(h, components, state, context, contextStatus, width, bodyRows, theme) {
|
|
24796
25560
|
const { Box, Text } = components;
|
|
24797
25561
|
const focused = state.focus === 'sidebar';
|
|
24798
25562
|
const tabs = getLogInkSidebarTabs();
|
|
24799
25563
|
// Accordion layout — every tab's title is visible on its own line, but
|
|
24800
25564
|
// only the active tab expands its content underneath. Switching tabs
|
|
24801
25565
|
// (1-5 / [/]) collapses the previous and expands the next.
|
|
25566
|
+
// When sidebar focus has been promoted to the tab header (#806
|
|
25567
|
+
// follow-up), the active tab's title row gets selection styling
|
|
25568
|
+
// and the items below it render without their cursor highlight
|
|
25569
|
+
// (which now lives on the header).
|
|
25570
|
+
const headerFocused = focused && state.sidebarHeaderFocused;
|
|
24802
25571
|
const tabBlocks = tabs.flatMap((tab, tabIndex) => {
|
|
24803
25572
|
const isActive = tab === state.sidebarTab;
|
|
24804
25573
|
const count = sidebarTabCount(tab, context);
|
|
@@ -24806,6 +25575,7 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
|
|
|
24806
25575
|
? `${sidebarTabLabel(tab)} (${count})`
|
|
24807
25576
|
: sidebarTabLabel(tab);
|
|
24808
25577
|
const headerText = isActive ? `[${labelWithCount}]` : labelWithCount;
|
|
25578
|
+
const headerSelected = isActive && headerFocused;
|
|
24809
25579
|
const blocks = [];
|
|
24810
25580
|
if (tabIndex > 0) {
|
|
24811
25581
|
blocks.push(h(Text, { key: `tab-spacer-${tab}` }, ''));
|
|
@@ -24814,9 +25584,15 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
|
|
|
24814
25584
|
key: `tab-header-${tab}`,
|
|
24815
25585
|
bold: isActive,
|
|
24816
25586
|
dimColor: !isActive,
|
|
25587
|
+
// Selection styling on the header itself when the cursor has
|
|
25588
|
+
// been promoted off the items list. inverse swaps fg/bg so the
|
|
25589
|
+
// highlight reads as "this is the cursor target" identically
|
|
25590
|
+
// to how items render when focused.
|
|
25591
|
+
backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
25592
|
+
inverse: headerSelected,
|
|
24817
25593
|
}, headerText));
|
|
24818
25594
|
if (isActive) {
|
|
24819
|
-
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme));
|
|
25595
|
+
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
|
|
24820
25596
|
}
|
|
24821
25597
|
return blocks;
|
|
24822
25598
|
});
|
|
@@ -24835,7 +25611,15 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
|
|
|
24835
25611
|
* surface; every other tab falls through to `sidebarLines` for its
|
|
24836
25612
|
* string-based summary.
|
|
24837
25613
|
*/
|
|
24838
|
-
function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme) {
|
|
25614
|
+
function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme) {
|
|
25615
|
+
// Available rows for the active tab's list. The sidebar chrome
|
|
25616
|
+
// takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
|
|
25617
|
+
// spacers); the branches tab eats 3 more for its summary header
|
|
25618
|
+
// (Current / Worktree / spacer). Floor of 8 keeps short terminals
|
|
25619
|
+
// usable; tall terminals (40+ rows) get noticeably more items.
|
|
25620
|
+
const sidebarChrome = 10;
|
|
25621
|
+
const branchHeaderRows = tab === 'branches' ? 3 : 0;
|
|
25622
|
+
const visibleListCount = Math.max(8, bodyRows - sidebarChrome - branchHeaderRows);
|
|
24839
25623
|
if (tab === 'status') {
|
|
24840
25624
|
return renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme);
|
|
24841
25625
|
}
|
|
@@ -24843,7 +25627,11 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24843
25627
|
// ↑/↓ navigates within the sidebar list and Enter / per-entity keys
|
|
24844
25628
|
// act on the cursored item without needing to drill into the
|
|
24845
25629
|
// dedicated view (#791 follow-up — in-sidebar selection).
|
|
24846
|
-
|
|
25630
|
+
// Items render with the cursor highlight only when the sidebar is
|
|
25631
|
+
// focused on this tab AND the cursor is on items (not promoted to
|
|
25632
|
+
// the tab header). The header-focused branch up in `renderSidebar`
|
|
25633
|
+
// owns the highlight in that case.
|
|
25634
|
+
const focused = state.focus === 'sidebar' && state.sidebarTab === tab && !state.sidebarHeaderFocused;
|
|
24847
25635
|
if (tab === 'branches') {
|
|
24848
25636
|
if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
|
|
24849
25637
|
return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
|
|
@@ -24860,7 +25648,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24860
25648
|
];
|
|
24861
25649
|
return [
|
|
24862
25650
|
...headerRows,
|
|
24863
|
-
...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches'),
|
|
25651
|
+
...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches', visibleListCount),
|
|
24864
25652
|
];
|
|
24865
25653
|
}
|
|
24866
25654
|
if (tab === 'tags') {
|
|
@@ -24871,7 +25659,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24871
25659
|
if (tags.length === 0) {
|
|
24872
25660
|
return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
|
|
24873
25661
|
}
|
|
24874
|
-
return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags');
|
|
25662
|
+
return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags', visibleListCount);
|
|
24875
25663
|
}
|
|
24876
25664
|
if (tab === 'stashes') {
|
|
24877
25665
|
if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
|
|
@@ -24881,7 +25669,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24881
25669
|
if (stashes.length === 0) {
|
|
24882
25670
|
return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
|
|
24883
25671
|
}
|
|
24884
|
-
return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes');
|
|
25672
|
+
return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes', visibleListCount);
|
|
24885
25673
|
}
|
|
24886
25674
|
// worktrees
|
|
24887
25675
|
if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
|
|
@@ -24895,7 +25683,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24895
25683
|
const marker = worktree.current ? '*' : ' ';
|
|
24896
25684
|
const wstate = worktree.dirty ? 'dirty' : 'clean';
|
|
24897
25685
|
return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
|
|
24898
|
-
}, 'tab-worktrees');
|
|
25686
|
+
}, 'tab-worktrees', visibleListCount);
|
|
24899
25687
|
}
|
|
24900
25688
|
/**
|
|
24901
25689
|
* Render a sliding-window list of selectable sidebar rows. The cursor
|
|
@@ -24904,10 +25692,10 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24904
25692
|
* Sliding window keeps the cursor in view as the user navigates a long
|
|
24905
25693
|
* list; truncation hints surface the count of hidden rows.
|
|
24906
25694
|
*/
|
|
24907
|
-
function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix) {
|
|
25695
|
+
function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount) {
|
|
24908
25696
|
if (items.length === 0)
|
|
24909
25697
|
return [];
|
|
24910
|
-
const window = getSidebarVisibleWindow(items.length, selectedIndex);
|
|
25698
|
+
const window = getSidebarVisibleWindow(items.length, selectedIndex, visibleCount);
|
|
24911
25699
|
const elements = [];
|
|
24912
25700
|
if (window.truncatedAbove > 0) {
|
|
24913
25701
|
elements.push(h(Text, {
|
|
@@ -25143,6 +25931,16 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
|
|
|
25143
25931
|
backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
|
|
25144
25932
|
}, truncate$1(label, 140));
|
|
25145
25933
|
}
|
|
25934
|
+
function buildStatusSurfaceRows(groups) {
|
|
25935
|
+
const rows = [];
|
|
25936
|
+
for (const group of groups) {
|
|
25937
|
+
rows.push({ kind: 'header', group });
|
|
25938
|
+
group.files.forEach((file, offset) => {
|
|
25939
|
+
rows.push({ kind: 'file', group, file, flatIndex: group.startIndex + offset });
|
|
25940
|
+
});
|
|
25941
|
+
}
|
|
25942
|
+
return rows;
|
|
25943
|
+
}
|
|
25146
25944
|
function renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
25147
25945
|
const { Box, Text } = components;
|
|
25148
25946
|
const focused = state.focus === 'commits';
|
|
@@ -25152,26 +25950,64 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
|
|
|
25152
25950
|
// uses for j/k navigation. `visibleFiles` may be a strict subset of
|
|
25153
25951
|
// worktree.files when the user has narrowed via 1/2/3.
|
|
25154
25952
|
const visibleFiles = applyStatusFilterMask(worktree?.files || [], state.statusFilterMask);
|
|
25953
|
+
// Group + canonical-sort. The runtime + input handler agree on this
|
|
25954
|
+
// order so a `selectedWorktreeFileIndex` of N always points to the
|
|
25955
|
+
// same file across all three (renderer / input / workflow handlers).
|
|
25956
|
+
const visibleGroups = groupWorktreeFiles(visibleFiles);
|
|
25957
|
+
const surfaceRows = buildStatusSurfaceRows(visibleGroups);
|
|
25155
25958
|
const listRows = Math.max(4, bodyRows - 5);
|
|
25156
25959
|
const selectedIndex = state.selectedWorktreeFileIndex;
|
|
25960
|
+
const headerFocused = state.statusGroupHeaderFocused;
|
|
25961
|
+
// Resolve the cursor's row index in the flat (header-and-file) row
|
|
25962
|
+
// list. Used to window the visible slice around the cursor.
|
|
25963
|
+
const cursorRowIndex = (() => {
|
|
25964
|
+
if (!surfaceRows.length)
|
|
25965
|
+
return 0;
|
|
25966
|
+
const currentGroup = visibleGroups.find((group) => selectedIndex >= group.startIndex && selectedIndex < group.startIndex + group.files.length);
|
|
25967
|
+
if (!currentGroup)
|
|
25968
|
+
return 0;
|
|
25969
|
+
if (headerFocused) {
|
|
25970
|
+
const idx = surfaceRows.findIndex((row) => row.kind === 'header' && row.group === currentGroup);
|
|
25971
|
+
return idx >= 0 ? idx : 0;
|
|
25972
|
+
}
|
|
25973
|
+
const idx = surfaceRows.findIndex((row) => row.kind === 'file' && row.flatIndex === selectedIndex);
|
|
25974
|
+
return idx >= 0 ? idx : 0;
|
|
25975
|
+
})();
|
|
25157
25976
|
const cleanHint = formatLogInkStatusEmpty({ hasChanges: Boolean(worktree?.files.length) });
|
|
25158
|
-
const
|
|
25977
|
+
const windowStart = Math.max(0, Math.min(Math.max(0, surfaceRows.length - listRows), cursorRowIndex - Math.floor(listRows / 2)));
|
|
25159
25978
|
const isLoading = isLogInkContextKeyLoading(contextStatus, 'worktree');
|
|
25160
|
-
const
|
|
25979
|
+
const renderedRows = isLoading || !surfaceRows.length
|
|
25161
25980
|
? []
|
|
25162
|
-
:
|
|
25163
|
-
const
|
|
25164
|
-
|
|
25981
|
+
: surfaceRows.slice(windowStart, windowStart + listRows).map((row, offset) => {
|
|
25982
|
+
const rowIndex = windowStart + offset;
|
|
25983
|
+
if (row.kind === 'header') {
|
|
25984
|
+
const groupContainsCursor = selectedIndex >= row.group.startIndex &&
|
|
25985
|
+
selectedIndex < row.group.startIndex + row.group.files.length;
|
|
25986
|
+
const headerSelected = focused && headerFocused && groupContainsCursor;
|
|
25987
|
+
const arrow = theme.ascii ? '>' : '▾';
|
|
25988
|
+
const groupLabel = capitalizeGroupName(row.group.state);
|
|
25989
|
+
const text = ` ${arrow} ${groupLabel} (${row.group.files.length})`;
|
|
25990
|
+
return h(Text, {
|
|
25991
|
+
key: `status-group-${row.group.state}-${rowIndex}`,
|
|
25992
|
+
bold: true,
|
|
25993
|
+
dimColor: !headerSelected && rowIndex > cursorRowIndex,
|
|
25994
|
+
backgroundColor: headerSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
25995
|
+
inverse: headerSelected,
|
|
25996
|
+
}, truncate$1(text, 140));
|
|
25997
|
+
}
|
|
25998
|
+
const isSelected = !headerFocused && row.flatIndex === selectedIndex;
|
|
25165
25999
|
const cursorPart = `${isSelected ? '>' : ' '} `;
|
|
25166
|
-
const dotColor = getStageStatusDotColor(file.state, theme);
|
|
26000
|
+
const dotColor = getStageStatusDotColor(row.file.state, theme);
|
|
25167
26001
|
const useDot = dotColor !== undefined;
|
|
25168
26002
|
const dotCells = useDot ? cellWidth(STAGE_STATUS_DOT) + 1 : 0;
|
|
25169
|
-
const tail = `${file.indexStatus}${file.worktreeStatus} ${
|
|
25170
|
-
const tailTrunc = truncate$1(tail, Math.max(0, 140 - cellWidth(cursorPart) - dotCells));
|
|
26003
|
+
const tail = `${row.file.indexStatus}${row.file.worktreeStatus} ${row.file.path}`;
|
|
26004
|
+
const tailTrunc = truncate$1(tail, Math.max(0, 140 - cellWidth(cursorPart) - dotCells - 2));
|
|
25171
26005
|
return h(Text, {
|
|
25172
|
-
key: `status-row-${
|
|
25173
|
-
dimColor:
|
|
25174
|
-
|
|
26006
|
+
key: `status-file-${row.flatIndex}-${rowIndex}`,
|
|
26007
|
+
dimColor: !isSelected && rowIndex > cursorRowIndex,
|
|
26008
|
+
backgroundColor: isSelected && focused && !theme.noColor ? theme.colors.selection : undefined,
|
|
26009
|
+
inverse: isSelected && focused,
|
|
26010
|
+
}, ` ${cursorPart}`, ...(useDot ? [h(Text, { color: dotColor }, STAGE_STATUS_DOT), ' '] : []), tailTrunc);
|
|
25175
26011
|
});
|
|
25176
26012
|
// When the mask narrows the list to nothing but the underlying repo
|
|
25177
26013
|
// is non-clean, surface why the panel looks empty so the user can
|
|
@@ -25201,11 +26037,14 @@ function renderStatusSurface(h, components, state, context, contextStatus, bodyR
|
|
|
25201
26037
|
// never touch the filter.
|
|
25202
26038
|
...(isStatusFilterMaskActive(state.statusFilterMask)
|
|
25203
26039
|
? [h(Text, { key: 'status-mask-indicator', dimColor: true }, `filter: ${formatStatusFilterMask(state.statusFilterMask)} (1/2/3 to toggle)`)]
|
|
25204
|
-
: []), ...
|
|
26040
|
+
: []), ...renderedRows, ...fallbackLines.map((line, index) => h(Text, {
|
|
25205
26041
|
key: `status-surface-fallback-${index}`,
|
|
25206
26042
|
dimColor: index > 0,
|
|
25207
26043
|
}, truncate$1(line, 140))));
|
|
25208
26044
|
}
|
|
26045
|
+
function capitalizeGroupName(value) {
|
|
26046
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
26047
|
+
}
|
|
25209
26048
|
function isStatusFilterMaskActive(mask) {
|
|
25210
26049
|
return !mask.staged || !mask.unstaged || !mask.untracked;
|
|
25211
26050
|
}
|
|
@@ -25655,20 +26494,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
25655
26494
|
const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
25656
26495
|
const stashFiles = parseStashDiffFiles(lines);
|
|
25657
26496
|
const fileCount = stashFiles.length;
|
|
25658
|
-
const currentFile = (
|
|
25659
|
-
if (fileCount === 0)
|
|
25660
|
-
return undefined;
|
|
25661
|
-
let current = stashFiles[0];
|
|
25662
|
-
for (const file of stashFiles) {
|
|
25663
|
-
if (file.startLine <= state.diffPreviewOffset) {
|
|
25664
|
-
current = file;
|
|
25665
|
-
}
|
|
25666
|
-
else {
|
|
25667
|
-
break;
|
|
25668
|
-
}
|
|
25669
|
-
}
|
|
25670
|
-
return current;
|
|
25671
|
-
})();
|
|
26497
|
+
const currentFile = findStashFileForOffset(stashFiles, state.diffPreviewOffset);
|
|
25672
26498
|
const currentFileIndex = currentFile
|
|
25673
26499
|
? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
|
|
25674
26500
|
: -1;
|
|
@@ -25695,14 +26521,41 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
25695
26521
|
const headerLines = splitRequestedButTooNarrow
|
|
25696
26522
|
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
25697
26523
|
: baseHeaderLines;
|
|
26524
|
+
// File header anchor map: absolute line index → owning stash file.
|
|
26525
|
+
// Lets the body-render pass restyle each `diff --git` row in O(1)
|
|
26526
|
+
// and decide which one is the *active* file (the one currently
|
|
26527
|
+
// containing `diffPreviewOffset`). The active header gets the
|
|
26528
|
+
// selection background to mark "the file the cursor is inside."
|
|
26529
|
+
const stashFileByStartLine = new Map(stashFiles.map((file) => [file.startLine, file]));
|
|
26530
|
+
const activeStartLine = currentFile?.startLine;
|
|
25698
26531
|
const stashBodyNodes = stashDiffLoading || !lines.length
|
|
25699
26532
|
? []
|
|
25700
26533
|
: splitActive
|
|
25701
26534
|
? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
|
|
25702
|
-
: visibleLines.map((line, index) =>
|
|
25703
|
-
|
|
25704
|
-
|
|
25705
|
-
|
|
26535
|
+
: visibleLines.map((line, index) => {
|
|
26536
|
+
const absoluteIndex = state.diffPreviewOffset + index;
|
|
26537
|
+
const headerFile = stashFileByStartLine.get(absoluteIndex);
|
|
26538
|
+
if (headerFile) {
|
|
26539
|
+
// Replace the verbose `diff --git a/<path> b/<path>` text
|
|
26540
|
+
// with a compact `▾ <path>` marker — the path itself is
|
|
26541
|
+
// the meaningful identifier, not the a/b duplication. The
|
|
26542
|
+
// active file's header gets selection styling so the user
|
|
26543
|
+
// sees at a glance which file the cursor is inside.
|
|
26544
|
+
const isActive = absoluteIndex === activeStartLine;
|
|
26545
|
+
const arrow = theme.ascii ? '> ' : '▾ ';
|
|
26546
|
+
return h(Text, {
|
|
26547
|
+
key: `stash-diff-line-${absoluteIndex}`,
|
|
26548
|
+
bold: true,
|
|
26549
|
+
color: theme.noColor ? undefined : theme.colors.accent,
|
|
26550
|
+
backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
|
|
26551
|
+
inverse: isActive && focused,
|
|
26552
|
+
}, truncate$1(`${arrow}${headerFile.path}`, width - 4));
|
|
26553
|
+
}
|
|
26554
|
+
return h(Text, {
|
|
26555
|
+
key: `stash-diff-line-${absoluteIndex}`,
|
|
26556
|
+
...diffLineProps(line, theme),
|
|
26557
|
+
}, truncate$1(line, width - 4));
|
|
26558
|
+
});
|
|
25706
26559
|
return h(Box, {
|
|
25707
26560
|
borderColor: focusBorderColor(theme, focused),
|
|
25708
26561
|
borderStyle: theme.borderStyle,
|
|
@@ -25808,7 +26661,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
25808
26661
|
}, truncate$1(line, 140)))
|
|
25809
26662
|
: []));
|
|
25810
26663
|
}
|
|
25811
|
-
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme) {
|
|
26664
|
+
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme) {
|
|
25812
26665
|
const focused = state.focus === 'detail';
|
|
25813
26666
|
if (state.showHelp) {
|
|
25814
26667
|
return renderHelpPanel(h, components, state, width, theme, focused);
|
|
@@ -25865,16 +26718,11 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
25865
26718
|
if (state.activeView === 'stash') {
|
|
25866
26719
|
return renderStashPreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
|
|
25867
26720
|
}
|
|
25868
|
-
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme, focused);
|
|
26721
|
+
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
|
|
25869
26722
|
}
|
|
25870
|
-
function renderHistoryInspector(h, components, state, context,
|
|
26723
|
+
function renderHistoryInspector(h, components, state, context, _contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, tabbed, theme, focused) {
|
|
25871
26724
|
const { Box, Text } = components;
|
|
25872
26725
|
const selected = getSelectedInkCommit(state);
|
|
25873
|
-
const workflowSections = getLogInkWorkflowSections({
|
|
25874
|
-
...context,
|
|
25875
|
-
contextLoading: isLogInkContextLoading(contextStatus),
|
|
25876
|
-
selectedCommit: selected,
|
|
25877
|
-
});
|
|
25878
26726
|
if (!detail) {
|
|
25879
26727
|
const fallbackLines = [
|
|
25880
26728
|
selected?.message || 'No commit selected.',
|
|
@@ -25890,7 +26738,10 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
|
|
|
25890
26738
|
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
|
|
25891
26739
|
key: `detail-${index}`,
|
|
25892
26740
|
dimColor: index > 1,
|
|
25893
|
-
}, truncate$1(line, width - 4)))
|
|
26741
|
+
}, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26742
|
+
cursorIndex: state.inspectorActionIndex,
|
|
26743
|
+
cursorActive: focused && state.inspectorTab === 'actions',
|
|
26744
|
+
}));
|
|
25894
26745
|
}
|
|
25895
26746
|
const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
|
|
25896
26747
|
// P5.1 — link the commit hash and each ref out to GitHub when we know
|
|
@@ -25901,18 +26752,26 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
|
|
|
25901
26752
|
const refNodes = detail.refs.length
|
|
25902
26753
|
? renderInspectorRefs(h, Text, detail.refs, repository)
|
|
25903
26754
|
: null;
|
|
26755
|
+
// Inspector reorder (PR — drop duplicative Workflows trailer):
|
|
26756
|
+
// 1. Commit message (the headline of what you're looking at)
|
|
26757
|
+
// 2. Metadata (hash / author / date / refs / stats)
|
|
26758
|
+
// 3. Body preview (up to 8 lines now that the trailer is gone)
|
|
26759
|
+
// 4. Changed files list (cursored entry highlights)
|
|
26760
|
+
// 5. Actions cheat-sheet (per-entity keystrokes; destructive marked)
|
|
26761
|
+
// The Workflows: trailer that used to repeat the repo / branch /
|
|
26762
|
+
// status from the top header and left sidebar is intentionally gone.
|
|
25904
26763
|
const headerNodes = [
|
|
25905
26764
|
h(Text, { key: 'detail-msg' }, truncate$1(detail.message, width - 4)),
|
|
25906
26765
|
h(Text, { key: 'detail-spacer-1' }, ''),
|
|
25907
26766
|
h(Text, { key: 'detail-commit', dimColor: true }, 'Commit: ', commitLink),
|
|
25908
26767
|
h(Text, { key: 'detail-author', dimColor: true }, truncate$1(`Author: ${detail.author}`, width - 4)),
|
|
25909
|
-
h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date:
|
|
26768
|
+
h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
|
|
25910
26769
|
refNodes
|
|
25911
|
-
? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs:
|
|
25912
|
-
: h(Text, { key: 'detail-refs', dimColor: true }, 'Refs:
|
|
25913
|
-
h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(statLine
|
|
26770
|
+
? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
|
|
26771
|
+
: h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
|
|
26772
|
+
h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(`Stats: ${statLine}`, width - 4)),
|
|
25914
26773
|
h(Text, { key: 'detail-spacer-2' }, ''),
|
|
25915
|
-
...(detail.body ? detail.body.split('\n').slice(0,
|
|
26774
|
+
...(detail.body ? detail.body.split('\n').slice(0, 8) : ['No commit body.']).map((line, index) => h(Text, {
|
|
25916
26775
|
key: `detail-body-${index}`,
|
|
25917
26776
|
dimColor: true,
|
|
25918
26777
|
}, truncate$1(line, width - 4))),
|
|
@@ -25921,24 +26780,98 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
|
|
|
25921
26780
|
];
|
|
25922
26781
|
const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
|
|
25923
26782
|
const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
|
|
25924
|
-
|
|
25925
|
-
|
|
25926
|
-
|
|
25927
|
-
|
|
25928
|
-
|
|
25929
|
-
|
|
25930
|
-
|
|
25931
|
-
|
|
26783
|
+
// Tabbed mode (#806 follow-up — short terminals): render only the
|
|
26784
|
+
// active inspector tab with a `[Inspector] Actions` header so the
|
|
26785
|
+
// user knows what they're seeing and how to switch (`[/]` while
|
|
26786
|
+
// focus is on the inspector). Tall terminals stack both sections
|
|
26787
|
+
// as before.
|
|
26788
|
+
if (tabbed) {
|
|
26789
|
+
const activeTab = state.inspectorTab;
|
|
26790
|
+
const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
|
|
26791
|
+
bold: activeTab === 'inspector',
|
|
26792
|
+
dimColor: activeTab !== 'inspector',
|
|
26793
|
+
}, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), ' ', h(Text, {
|
|
26794
|
+
bold: activeTab === 'actions',
|
|
26795
|
+
dimColor: activeTab !== 'actions',
|
|
26796
|
+
}, activeTab === 'actions' ? '[Actions]' : ' Actions '));
|
|
26797
|
+
return h(Box, {
|
|
26798
|
+
borderColor: focusBorderColor(theme, focused),
|
|
26799
|
+
borderStyle: theme.borderStyle,
|
|
26800
|
+
flexDirection: 'column',
|
|
26801
|
+
width,
|
|
26802
|
+
paddingX: 1,
|
|
26803
|
+
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...(activeTab === 'inspector'
|
|
26804
|
+
? [...headerNodes, ...fileListNodes]
|
|
26805
|
+
: renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26806
|
+
cursorIndex: state.inspectorActionIndex,
|
|
26807
|
+
cursorActive: focused,
|
|
26808
|
+
})));
|
|
26809
|
+
}
|
|
25932
26810
|
return h(Box, {
|
|
25933
26811
|
borderColor: focusBorderColor(theme, focused),
|
|
25934
26812
|
borderStyle: theme.borderStyle,
|
|
25935
26813
|
flexDirection: 'column',
|
|
25936
26814
|
width,
|
|
25937
26815
|
paddingX: 1,
|
|
25938
|
-
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...
|
|
25939
|
-
|
|
25940
|
-
|
|
25941
|
-
}
|
|
26816
|
+
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26817
|
+
cursorIndex: state.inspectorActionIndex,
|
|
26818
|
+
cursorActive: focused && state.inspectorTab === 'actions',
|
|
26819
|
+
}));
|
|
26820
|
+
}
|
|
26821
|
+
/**
|
|
26822
|
+
* Render the trailing "Actions:" section that surfaces which keystrokes
|
|
26823
|
+
* apply to whatever the inspector is focused on. Keys are colored with
|
|
26824
|
+
* `theme.colors.accent` so they pop as the actionable element. Destructive
|
|
26825
|
+
* actions get the danger color plus a `[!]` marker so they don't blend
|
|
26826
|
+
* into the cherry-pick / yank rows.
|
|
26827
|
+
*
|
|
26828
|
+
* Truncates labels when the inspector is narrow (down to the 26-cell
|
|
26829
|
+
* minimum from `getLogInkLayout`) so an overflowing label never wraps and
|
|
26830
|
+
* collides with the next row.
|
|
26831
|
+
*/
|
|
26832
|
+
function renderInspectorActionsSection(h, Text, context, width, theme, options = {}) {
|
|
26833
|
+
const actions = getInspectorActions(context);
|
|
26834
|
+
if (!actions.length)
|
|
26835
|
+
return [];
|
|
26836
|
+
// Width budget for each row: subtract padding + " " gutter, the key
|
|
26837
|
+
// column (left-padded to 5 cells so labels align), the " " gap
|
|
26838
|
+
// between key and label, and the optional " [!]" suffix (5 cells).
|
|
26839
|
+
const KEY_COLUMN = 5;
|
|
26840
|
+
const GAP = ' ';
|
|
26841
|
+
const DESTRUCTIVE_SUFFIX = ' [!]';
|
|
26842
|
+
const labelBudget = Math.max(4, width - 4 /* border + padX */ - KEY_COLUMN - GAP.length - DESTRUCTIVE_SUFFIX.length);
|
|
26843
|
+
const cursorIndex = options.cursorIndex ?? 0;
|
|
26844
|
+
const cursorActive = options.cursorActive ?? false;
|
|
26845
|
+
const nodes = [
|
|
26846
|
+
h(Text, { key: 'actions-spacer' }, ''),
|
|
26847
|
+
h(Text, { key: 'actions-title' }, cursorActive ? '[Actions]' : 'Actions:'),
|
|
26848
|
+
...actions.map((action, index) => {
|
|
26849
|
+
const isSelected = cursorActive && index === cursorIndex;
|
|
26850
|
+
const keyCell = action.key.padEnd(KEY_COLUMN);
|
|
26851
|
+
const label = truncate$1(action.label, labelBudget);
|
|
26852
|
+
const children = [
|
|
26853
|
+
h(Text, {
|
|
26854
|
+
key: `actions-${index}-key`,
|
|
26855
|
+
color: action.destructive ? theme.colors.danger : theme.colors.accent,
|
|
26856
|
+
}, keyCell),
|
|
26857
|
+
GAP,
|
|
26858
|
+
label,
|
|
26859
|
+
];
|
|
26860
|
+
if (action.destructive) {
|
|
26861
|
+
children.push(h(Text, {
|
|
26862
|
+
key: `actions-${index}-mark`,
|
|
26863
|
+
color: theme.colors.danger,
|
|
26864
|
+
dimColor: false,
|
|
26865
|
+
}, DESTRUCTIVE_SUFFIX));
|
|
26866
|
+
}
|
|
26867
|
+
return h(Text, {
|
|
26868
|
+
key: `actions-${index}`,
|
|
26869
|
+
backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
26870
|
+
inverse: isSelected,
|
|
26871
|
+
}, ...children);
|
|
26872
|
+
}),
|
|
26873
|
+
];
|
|
26874
|
+
return nodes;
|
|
25942
26875
|
}
|
|
25943
26876
|
/**
|
|
25944
26877
|
* Build a commit URL for the repo when GitHub provider info is available.
|
|
@@ -26512,7 +27445,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
|
26512
27445
|
const git = options.git || getRepo();
|
|
26513
27446
|
const rows = options.rows || (await getLogRows(git, logArgv));
|
|
26514
27447
|
await startInkInteractiveLog(git, rows, {}, {
|
|
26515
|
-
appLabel: 'coco
|
|
27448
|
+
appLabel: 'coco',
|
|
26516
27449
|
idleTips: config.logTui?.idleTips,
|
|
26517
27450
|
initialView: 'history',
|
|
26518
27451
|
logArgv,
|
|
@@ -26525,7 +27458,7 @@ async function startCocoUi(argv) {
|
|
|
26525
27458
|
const logArgv = createLogArgvFromUiArgv(argv);
|
|
26526
27459
|
const rows = await getLogRows(git, logArgv);
|
|
26527
27460
|
await startInkInteractiveLog(git, rows, {}, {
|
|
26528
|
-
appLabel: 'coco
|
|
27461
|
+
appLabel: 'coco',
|
|
26529
27462
|
idleTips: config.logTui?.idleTips,
|
|
26530
27463
|
initialView: argv.view || 'history',
|
|
26531
27464
|
logArgv,
|