git-coco 0.38.0 → 0.39.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 +577 -130
- package/dist/index.js +577 -130
- 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.39.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
|
{
|
|
@@ -15068,6 +14990,38 @@ function getLogInkWorkflowActions() {
|
|
|
15068
14990
|
kind: 'destructive',
|
|
15069
14991
|
requiresConfirmation: true,
|
|
15070
14992
|
},
|
|
14993
|
+
{
|
|
14994
|
+
// Per-view-only: scoped to the history view in inkInput (key `B`).
|
|
14995
|
+
// The prompt itself is the affirmative gate — the user has to
|
|
14996
|
+
// type a branch name before anything happens — so this skips the
|
|
14997
|
+
// y-confirm path. Empty key keeps it palette-discoverable; the
|
|
14998
|
+
// palette path can't synthesize a branch name and surfaces a
|
|
14999
|
+
// hint instead.
|
|
15000
|
+
//
|
|
15001
|
+
// Distinct from `create-branch` (palette / `+` on branches view),
|
|
15002
|
+
// which uses `git switch -c` and switches onto the new branch.
|
|
15003
|
+
// This workflow uses `git branch <name> <sha>` and stays put —
|
|
15004
|
+
// GitKraken's "create branch here" semantic.
|
|
15005
|
+
id: 'create-branch-here',
|
|
15006
|
+
key: '',
|
|
15007
|
+
label: 'Create branch from commit',
|
|
15008
|
+
description: 'Create a branch pointed at the cursored commit (does not switch).',
|
|
15009
|
+
kind: 'normal',
|
|
15010
|
+
requiresConfirmation: false,
|
|
15011
|
+
},
|
|
15012
|
+
{
|
|
15013
|
+
// Per-view-only: scoped to the history view in inkInput via the
|
|
15014
|
+
// `gT` chord (bare `T` is taken by delete-tag on the tags view).
|
|
15015
|
+
// Same prompt-as-confirmation pattern as create-branch-here.
|
|
15016
|
+
// Lightweight tag — annotated tags remain available through the
|
|
15017
|
+
// existing `+` flow on the tags view.
|
|
15018
|
+
id: 'create-tag-here',
|
|
15019
|
+
key: '',
|
|
15020
|
+
label: 'Create tag at commit',
|
|
15021
|
+
description: 'Create a lightweight tag at the cursored commit.',
|
|
15022
|
+
kind: 'normal',
|
|
15023
|
+
requiresConfirmation: false,
|
|
15024
|
+
},
|
|
15071
15025
|
{
|
|
15072
15026
|
// Per-view-only: scoped to the history view in inkInput. `i`
|
|
15073
15027
|
// (lowercase) is used instead of `I` so the existing `I`
|
|
@@ -15080,6 +15034,38 @@ function getLogInkWorkflowActions() {
|
|
|
15080
15034
|
kind: 'destructive',
|
|
15081
15035
|
requiresConfirmation: true,
|
|
15082
15036
|
},
|
|
15037
|
+
{
|
|
15038
|
+
// Per-view-only: scoped to the history view in inkInput (key `B`).
|
|
15039
|
+
// The prompt itself is the affirmative gate — the user has to
|
|
15040
|
+
// type a branch name before anything happens — so this skips the
|
|
15041
|
+
// y-confirm path. Empty key keeps it palette-discoverable; the
|
|
15042
|
+
// palette path can't synthesize a branch name and surfaces a
|
|
15043
|
+
// hint instead.
|
|
15044
|
+
//
|
|
15045
|
+
// Distinct from `create-branch` (palette / `+` on branches view),
|
|
15046
|
+
// which uses `git switch -c` and switches onto the new branch.
|
|
15047
|
+
// This workflow uses `git branch <name> <sha>` and stays put —
|
|
15048
|
+
// GitKraken's "create branch here" semantic.
|
|
15049
|
+
id: 'create-branch-here',
|
|
15050
|
+
key: '',
|
|
15051
|
+
label: 'Create branch from commit',
|
|
15052
|
+
description: 'Create a branch pointed at the cursored commit (does not switch).',
|
|
15053
|
+
kind: 'normal',
|
|
15054
|
+
requiresConfirmation: false,
|
|
15055
|
+
},
|
|
15056
|
+
{
|
|
15057
|
+
// Per-view-only: scoped to the history view in inkInput via the
|
|
15058
|
+
// `gT` chord (bare `T` is taken by delete-tag on the tags view).
|
|
15059
|
+
// Same prompt-as-confirmation pattern as create-branch-here.
|
|
15060
|
+
// Lightweight tag — annotated tags remain available through the
|
|
15061
|
+
// existing `+` flow on the tags view.
|
|
15062
|
+
id: 'create-tag-here',
|
|
15063
|
+
key: '',
|
|
15064
|
+
label: 'Create tag at commit',
|
|
15065
|
+
description: 'Create a lightweight tag at the cursored commit.',
|
|
15066
|
+
kind: 'normal',
|
|
15067
|
+
requiresConfirmation: false,
|
|
15068
|
+
},
|
|
15083
15069
|
{
|
|
15084
15070
|
id: 'ai-commit-summary',
|
|
15085
15071
|
key: 'I',
|
|
@@ -15614,9 +15600,12 @@ function getLogInkFooterHints(options) {
|
|
|
15614
15600
|
// History view default hints. Mutating ops (`c` cherry-pick, `R`
|
|
15615
15601
|
// revert, `Z` reset, `i` interactive-rebase) all route through a
|
|
15616
15602
|
// y-confirm or mode prompt — none fire silently from the keystroke.
|
|
15617
|
-
//
|
|
15618
|
-
//
|
|
15619
|
-
|
|
15603
|
+
// `B` create-branch-here and `gT` create-tag-here use a prompt as
|
|
15604
|
+
// the affirmative gate (typing the name is the confirmation).
|
|
15605
|
+
// Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
|
|
15606
|
+
// the footer stays scannable; full descriptions live in `?` help
|
|
15607
|
+
// and the palette.
|
|
15608
|
+
contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'y/Y yank', '/ search', 'gg/G top/bottom'],
|
|
15620
15609
|
global: NORMAL_GLOBAL_HINTS,
|
|
15621
15610
|
};
|
|
15622
15611
|
}
|
|
@@ -15927,23 +15916,31 @@ function cycleBranchSort(mode) {
|
|
|
15927
15916
|
return BRANCH_SORT_MODES[(index + 1) % BRANCH_SORT_MODES.length];
|
|
15928
15917
|
}
|
|
15929
15918
|
function sortBranches(branches, mode) {
|
|
15930
|
-
|
|
15919
|
+
// Pin the current branch at index 0 regardless of sort mode (#806
|
|
15920
|
+
// follow-up). Lands the user's cursor on the active branch by
|
|
15921
|
+
// default and keeps the most-relevant row glued to the top of the
|
|
15922
|
+
// list as they cycle sorts.
|
|
15923
|
+
const current = branches.find((entry) => entry.current);
|
|
15924
|
+
const rest = branches.filter((entry) => !entry.current);
|
|
15925
|
+
const sortedRest = rest.slice();
|
|
15931
15926
|
switch (mode) {
|
|
15932
15927
|
case 'name':
|
|
15933
|
-
|
|
15928
|
+
sortedRest.sort((a, b) => a.shortName.localeCompare(b.shortName));
|
|
15929
|
+
break;
|
|
15934
15930
|
case 'recent':
|
|
15935
15931
|
// ISO-shaped dates compare byte-for-byte; descending so the freshest
|
|
15936
15932
|
// branch sits at the top.
|
|
15937
|
-
|
|
15933
|
+
sortedRest.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
|
|
15938
15934
|
a.shortName.localeCompare(b.shortName));
|
|
15935
|
+
break;
|
|
15939
15936
|
case 'ahead':
|
|
15940
15937
|
// ahead-first; ties broken by behind, then by name. Keeps "this branch
|
|
15941
15938
|
// has unmerged work" in the user's first scroll.
|
|
15942
|
-
|
|
15939
|
+
sortedRest.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
|
|
15943
15940
|
a.shortName.localeCompare(b.shortName));
|
|
15944
|
-
|
|
15945
|
-
return copy;
|
|
15941
|
+
break;
|
|
15946
15942
|
}
|
|
15943
|
+
return current ? [current, ...sortedRest] : sortedRest;
|
|
15947
15944
|
}
|
|
15948
15945
|
const TAG_SORT_MODES = ['recent', 'name'];
|
|
15949
15946
|
const DEFAULT_TAG_SORT_MODE = 'recent';
|
|
@@ -16278,6 +16275,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
16278
16275
|
userSidebarTab: 'status',
|
|
16279
16276
|
statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
|
|
16280
16277
|
diffViewMode: 'unified',
|
|
16278
|
+
inspectorTab: 'inspector',
|
|
16281
16279
|
};
|
|
16282
16280
|
}
|
|
16283
16281
|
function getSelectedInkCommit(state) {
|
|
@@ -16335,6 +16333,28 @@ function applyLogInkAction(state, action) {
|
|
|
16335
16333
|
pendingCommitFocused: false,
|
|
16336
16334
|
pendingKey: undefined,
|
|
16337
16335
|
};
|
|
16336
|
+
case 'selectCommitByHash': {
|
|
16337
|
+
// Locates a commit by its full or short hash within the active
|
|
16338
|
+
// filtered list and snaps the cursor to it. Used by the
|
|
16339
|
+
// branch/tag auto-jump effect (#806 follow-up): cursoring a
|
|
16340
|
+
// branch in the sidebar tracks the history view to that
|
|
16341
|
+
// branch's tip without the user manually scrolling. No-op when
|
|
16342
|
+
// the hash isn't in the loaded list (the runtime surfaces a
|
|
16343
|
+
// status hint in that case).
|
|
16344
|
+
const target = action.hash;
|
|
16345
|
+
const index = state.filteredCommits.findIndex((commit) => commit.hash === target || commit.shortHash === target);
|
|
16346
|
+
if (index < 0) {
|
|
16347
|
+
return state;
|
|
16348
|
+
}
|
|
16349
|
+
return {
|
|
16350
|
+
...state,
|
|
16351
|
+
selectedIndex: index,
|
|
16352
|
+
selectedFileIndex: 0,
|
|
16353
|
+
diffPreviewOffset: 0,
|
|
16354
|
+
pendingCommitFocused: false,
|
|
16355
|
+
pendingKey: undefined,
|
|
16356
|
+
};
|
|
16357
|
+
}
|
|
16338
16358
|
case 'focusPendingCommit':
|
|
16339
16359
|
return {
|
|
16340
16360
|
...state,
|
|
@@ -16374,6 +16394,34 @@ function applyLogInkAction(state, action) {
|
|
|
16374
16394
|
selectedBranchIndex: clampIndex$1(state.selectedBranchIndex + action.delta, action.count),
|
|
16375
16395
|
pendingKey: undefined,
|
|
16376
16396
|
};
|
|
16397
|
+
case 'resetBranchSelection':
|
|
16398
|
+
// Snap the branches sidebar / view cursor back to position 0.
|
|
16399
|
+
// Used after a successful checkout (#806 follow-up): combined
|
|
16400
|
+
// with the "current branch pinned at top" rule from #809, this
|
|
16401
|
+
// lands the user's cursor on the just-checked-out branch.
|
|
16402
|
+
return {
|
|
16403
|
+
...state,
|
|
16404
|
+
selectedBranchIndex: 0,
|
|
16405
|
+
pendingKey: undefined,
|
|
16406
|
+
};
|
|
16407
|
+
case 'setInspectorTab':
|
|
16408
|
+
return {
|
|
16409
|
+
...state,
|
|
16410
|
+
inspectorTab: action.value,
|
|
16411
|
+
pendingKey: undefined,
|
|
16412
|
+
};
|
|
16413
|
+
case 'cycleInspectorTab': {
|
|
16414
|
+
// Two-tab toggle — `delta` is symmetrical so direction does not
|
|
16415
|
+
// matter, but we keep the action shape consistent with the
|
|
16416
|
+
// sidebar's `nextSidebarTab` / `previousSidebarTab` so callers
|
|
16417
|
+
// can mirror the sidebar pattern verbatim.
|
|
16418
|
+
const next = state.inspectorTab === 'inspector' ? 'actions' : 'inspector';
|
|
16419
|
+
return {
|
|
16420
|
+
...state,
|
|
16421
|
+
inspectorTab: next,
|
|
16422
|
+
pendingKey: undefined,
|
|
16423
|
+
};
|
|
16424
|
+
}
|
|
16377
16425
|
case 'moveTag':
|
|
16378
16426
|
return {
|
|
16379
16427
|
...state,
|
|
@@ -17459,6 +17507,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17459
17507
|
action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
|
|
17460
17508
|
];
|
|
17461
17509
|
}
|
|
17510
|
+
// `gT` chord: create a lightweight tag at the cursored commit on the
|
|
17511
|
+
// history view. Bare `T` is taken (delete-tag on the tags view) so we
|
|
17512
|
+
// use the chord. Mirrors `gH` exactly — uppercase letter after the
|
|
17513
|
+
// `g` chord prefix, distinct from the lowercase `gt` chord which
|
|
17514
|
+
// jumps to the tags view. The prompt is the affirmative gate.
|
|
17515
|
+
if (state.pendingKey === 'g' && inputValue === 'T') {
|
|
17516
|
+
if (state.activeView === 'history' &&
|
|
17517
|
+
state.focus === 'commits' &&
|
|
17518
|
+
state.filteredCommits.length > 0 &&
|
|
17519
|
+
!state.pendingCommitFocused) {
|
|
17520
|
+
return [
|
|
17521
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
17522
|
+
action({
|
|
17523
|
+
type: 'openInputPrompt',
|
|
17524
|
+
kind: 'create-tag-here',
|
|
17525
|
+
label: 'New tag name (at cursored commit)',
|
|
17526
|
+
}),
|
|
17527
|
+
];
|
|
17528
|
+
}
|
|
17529
|
+
return [
|
|
17530
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
17531
|
+
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
|
|
17532
|
+
];
|
|
17533
|
+
}
|
|
17462
17534
|
if (inputValue === 'g') {
|
|
17463
17535
|
if (state.pendingKey === 'g') {
|
|
17464
17536
|
return [
|
|
@@ -17540,6 +17612,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17540
17612
|
hunkOffsets: context.commitDiffHunkOffsets,
|
|
17541
17613
|
})];
|
|
17542
17614
|
}
|
|
17615
|
+
// Inspector focused: cycle the inspector tab. The renderer only
|
|
17616
|
+
// honors the tab field on short terminals (where the inspector
|
|
17617
|
+
// collapses into a tabbed layout), but we let the user pre-set
|
|
17618
|
+
// their preference on tall terminals too.
|
|
17619
|
+
if (state.focus === 'detail') {
|
|
17620
|
+
return [action({ type: 'cycleInspectorTab', delta: -1 })];
|
|
17621
|
+
}
|
|
17543
17622
|
return [action({ type: 'previousSidebarTab' })];
|
|
17544
17623
|
}
|
|
17545
17624
|
if (inputValue === ']') {
|
|
@@ -17564,6 +17643,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17564
17643
|
hunkOffsets: context.commitDiffHunkOffsets,
|
|
17565
17644
|
})];
|
|
17566
17645
|
}
|
|
17646
|
+
if (state.focus === 'detail') {
|
|
17647
|
+
return [action({ type: 'cycleInspectorTab', delta: 1 })];
|
|
17648
|
+
}
|
|
17567
17649
|
return [action({ type: 'nextSidebarTab' })];
|
|
17568
17650
|
}
|
|
17569
17651
|
// Status surface intercepts 1/2/3 before the sidebar-tab numeric
|
|
@@ -18063,6 +18145,22 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18063
18145
|
!state.pendingCommitFocused) {
|
|
18064
18146
|
return [action({ type: 'setPendingConfirmation', value: 'interactive-rebase' })];
|
|
18065
18147
|
}
|
|
18148
|
+
// `B` opens a create-branch prompt rooted at the cursored commit
|
|
18149
|
+
// (`git branch <name> <sha>` — does NOT switch to the new branch).
|
|
18150
|
+
// The prompt itself is the affirmative gate, so no separate y-confirm.
|
|
18151
|
+
// Bare uppercase `B` since the lowercase `b` is used by the `gb`
|
|
18152
|
+
// chord prefix and we want a single keystroke for this common op.
|
|
18153
|
+
if (inputValue === 'B' &&
|
|
18154
|
+
state.activeView === 'history' &&
|
|
18155
|
+
state.focus === 'commits' &&
|
|
18156
|
+
state.filteredCommits.length > 0 &&
|
|
18157
|
+
!state.pendingCommitFocused) {
|
|
18158
|
+
return [action({
|
|
18159
|
+
type: 'openInputPrompt',
|
|
18160
|
+
kind: 'create-branch-here',
|
|
18161
|
+
label: 'New branch name (at cursored commit)',
|
|
18162
|
+
})];
|
|
18163
|
+
}
|
|
18066
18164
|
// `y` / `Y` yank the contextually relevant identifier from the active
|
|
18067
18165
|
// view to the system clipboard:
|
|
18068
18166
|
// history → cursored commit hash (Y for short hash)
|
|
@@ -18689,10 +18787,25 @@ const LOG_INK_MIN_COLUMNS = 80;
|
|
|
18689
18787
|
const LOG_INK_MIN_ROWS = 24;
|
|
18690
18788
|
const LOG_INK_DEFAULT_COLUMNS = 120;
|
|
18691
18789
|
const LOG_INK_DEFAULT_ROWS = 40;
|
|
18790
|
+
/**
|
|
18791
|
+
* Terminal-row threshold below which the inspector switches to a
|
|
18792
|
+
* tabbed layout (commit-detail vs actions). Picked empirically: at
|
|
18793
|
+
* 28 rows the inspector's full stack (~30 rows when fully populated)
|
|
18794
|
+
* starts clipping the actions section; below that, the tabbed mode
|
|
18795
|
+
* gives both views their own air.
|
|
18796
|
+
*/
|
|
18797
|
+
const INSPECTOR_TABBED_BELOW_ROWS = 28;
|
|
18692
18798
|
function getLogInkLayout(input) {
|
|
18693
18799
|
const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
|
|
18694
18800
|
const rows = input.rows || LOG_INK_DEFAULT_ROWS;
|
|
18695
|
-
|
|
18801
|
+
// Inspector width — at rest 20-32 cells (~22% of width), focused
|
|
18802
|
+
// 36-60 cells (~40% of width). Narrow rest state keeps the commit
|
|
18803
|
+
// graph dominant; focus expansion gives the inspector room for long
|
|
18804
|
+
// commit bodies / file lists / action labels. Mirrors the sidebar
|
|
18805
|
+
// pattern (sidebarFocused above): instant transition per render.
|
|
18806
|
+
const detailWidth = input.inspectorFocused
|
|
18807
|
+
? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
|
|
18808
|
+
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
18696
18809
|
// Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
|
|
18697
18810
|
// (~36% of width). The transition is instant per render — focus tab to
|
|
18698
18811
|
// expand, focus away to collapse.
|
|
@@ -18707,6 +18820,7 @@ function getLogInkLayout(input) {
|
|
|
18707
18820
|
rows,
|
|
18708
18821
|
sidebarWidth,
|
|
18709
18822
|
tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
|
|
18823
|
+
inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
|
|
18710
18824
|
};
|
|
18711
18825
|
}
|
|
18712
18826
|
|
|
@@ -20202,6 +20316,63 @@ function resetToCommit(git, commit, mode) {
|
|
|
20202
20316
|
: result.details,
|
|
20203
20317
|
}));
|
|
20204
20318
|
}
|
|
20319
|
+
/**
|
|
20320
|
+
* Create a new local branch pointed at <commit>, without switching to it.
|
|
20321
|
+
*
|
|
20322
|
+
* This is the "create branch from cursored commit" history action — the
|
|
20323
|
+
* user types the new branch name into an input prompt and we run
|
|
20324
|
+
* `git branch <name> <sha>` (NOT `git switch -c`, which is what
|
|
20325
|
+
* `branchActions.createBranch` does for the create-branch-at-HEAD flow).
|
|
20326
|
+
* The split exists because GitKraken-style "create branch here" is
|
|
20327
|
+
* specifically about marking a historical commit, not about switching
|
|
20328
|
+
* onto a new working branch.
|
|
20329
|
+
*
|
|
20330
|
+
* Note for the inspector follow-up: workflow surfacing is driven by the
|
|
20331
|
+
* registry in `inkWorkflows.ts`, not a hardcoded action list — adding
|
|
20332
|
+
* `create-branch-here` there is enough for the inspector / palette to
|
|
20333
|
+
* pick this up.
|
|
20334
|
+
*/
|
|
20335
|
+
function createBranchFromCommit(git, name, commit) {
|
|
20336
|
+
const trimmedName = name.trim();
|
|
20337
|
+
if (!commit) {
|
|
20338
|
+
return Promise.resolve({
|
|
20339
|
+
ok: false,
|
|
20340
|
+
message: 'No commit selected.',
|
|
20341
|
+
});
|
|
20342
|
+
}
|
|
20343
|
+
if (!trimmedName) {
|
|
20344
|
+
return Promise.resolve({
|
|
20345
|
+
ok: false,
|
|
20346
|
+
message: 'Branch name required.',
|
|
20347
|
+
});
|
|
20348
|
+
}
|
|
20349
|
+
return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['branch', trimmedName, commit.hash]), `Created branch ${trimmedName} at ${commit.shortHash}`)));
|
|
20350
|
+
}
|
|
20351
|
+
/**
|
|
20352
|
+
* Create a lightweight tag pointed at <commit>.
|
|
20353
|
+
*
|
|
20354
|
+
* Mirrors `createBranchFromCommit` for the tag side: the user types a
|
|
20355
|
+
* tag name into an input prompt and we run `git tag <name> <sha>`
|
|
20356
|
+
* (lightweight, no `-a`/`-m`). Annotated tags remain available through
|
|
20357
|
+
* the existing `+` flow on the tags view; this is the per-commit
|
|
20358
|
+
* shortcut.
|
|
20359
|
+
*/
|
|
20360
|
+
function createTagAtCommit(git, name, commit) {
|
|
20361
|
+
const trimmedName = name.trim();
|
|
20362
|
+
if (!commit) {
|
|
20363
|
+
return Promise.resolve({
|
|
20364
|
+
ok: false,
|
|
20365
|
+
message: 'No commit selected.',
|
|
20366
|
+
});
|
|
20367
|
+
}
|
|
20368
|
+
if (!trimmedName) {
|
|
20369
|
+
return Promise.resolve({
|
|
20370
|
+
ok: false,
|
|
20371
|
+
message: 'Tag name required.',
|
|
20372
|
+
});
|
|
20373
|
+
}
|
|
20374
|
+
return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['tag', trimmedName, commit.hash]), `Created tag ${trimmedName} at ${commit.shortHash}`)));
|
|
20375
|
+
}
|
|
20205
20376
|
function startInteractiveRebase(git, commit) {
|
|
20206
20377
|
if (!commit) {
|
|
20207
20378
|
return Promise.resolve({
|
|
@@ -22928,6 +23099,88 @@ function formatPullRequestStateLine(pr) {
|
|
|
22928
23099
|
return parts.join(' · ');
|
|
22929
23100
|
}
|
|
22930
23101
|
|
|
23102
|
+
/**
|
|
23103
|
+
* Hardcoded per-entity action lists surfaced inside the right-hand
|
|
23104
|
+
* inspector panel. The inspector used to repeat the repo / branch /
|
|
23105
|
+
* status content the top header and left sidebar already show; we drop
|
|
23106
|
+
* that trailer in favor of an actionable cheat-sheet so the user knows
|
|
23107
|
+
* exactly which keystrokes apply to whatever they have under the cursor.
|
|
23108
|
+
*
|
|
23109
|
+
* Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
|
|
23110
|
+
* - Most per-entity actions live in `inkInput.ts` as direct keystroke
|
|
23111
|
+
* handlers (e.g. `c` cherry-pick, `R` revert) rather than as
|
|
23112
|
+
* globally-registered bindings, so the registry would be a partial
|
|
23113
|
+
* view at best.
|
|
23114
|
+
* - The bindings registry's `contexts` model (normal / search / focus
|
|
23115
|
+
* name) does not cleanly map to inspector entity types like "branch"
|
|
23116
|
+
* or "tag". Filtering it would mean replicating the same per-view
|
|
23117
|
+
* scoping logic the input dispatcher already encodes.
|
|
23118
|
+
* - New per-entity actions are added infrequently — the maintenance
|
|
23119
|
+
* cost of mirroring them here is low and keeps this file the single
|
|
23120
|
+
* source of truth for "what shows in the inspector".
|
|
23121
|
+
*
|
|
23122
|
+
* If you wire up a new per-entity keystroke in `inkInput.ts` — for
|
|
23123
|
+
* example a "create branch from this commit" or "create tag from this
|
|
23124
|
+
* commit" action — add the matching row to the relevant array below so
|
|
23125
|
+
* it shows up in the inspector automatically.
|
|
23126
|
+
*/
|
|
23127
|
+
const HISTORY_COMMIT_ACTIONS = [
|
|
23128
|
+
{ key: 'enter', label: 'Open diff' },
|
|
23129
|
+
{ key: 'c', label: 'Cherry-pick' },
|
|
23130
|
+
{ key: 'R', label: 'Revert', destructive: true },
|
|
23131
|
+
{ key: 'Z', label: 'Reset to commit', destructive: true },
|
|
23132
|
+
{ key: 'i', label: 'Interactive rebase', destructive: true },
|
|
23133
|
+
{ key: 'y', label: 'Yank hash' },
|
|
23134
|
+
{ key: 'Y', label: 'Yank short hash' },
|
|
23135
|
+
{ key: 'O', label: 'Open in browser' },
|
|
23136
|
+
];
|
|
23137
|
+
const BRANCH_ACTIONS = [
|
|
23138
|
+
{ key: 'enter', label: 'Checkout' },
|
|
23139
|
+
{ key: '+', label: 'New branch' },
|
|
23140
|
+
{ key: 'R', label: 'Rename' },
|
|
23141
|
+
{ key: 'u', label: 'Set upstream' },
|
|
23142
|
+
{ key: 'D', label: 'Delete', destructive: true },
|
|
23143
|
+
{ key: 'P', label: 'Push current' },
|
|
23144
|
+
{ key: 'F', label: 'Fetch all' },
|
|
23145
|
+
{ key: 'y', label: 'Yank name' },
|
|
23146
|
+
];
|
|
23147
|
+
const TAG_ACTIONS = [
|
|
23148
|
+
{ key: '+', label: 'New tag' },
|
|
23149
|
+
{ key: 'P', label: 'Push tag' },
|
|
23150
|
+
{ key: 'T', label: 'Delete', destructive: true },
|
|
23151
|
+
{ key: 'R', label: 'Delete remote', destructive: true },
|
|
23152
|
+
{ key: 'y', label: 'Yank name' },
|
|
23153
|
+
];
|
|
23154
|
+
const STASH_ACTIONS = [
|
|
23155
|
+
{ key: 'enter', label: 'Open diff' },
|
|
23156
|
+
{ key: 'a', label: 'Apply' },
|
|
23157
|
+
{ key: 'p', label: 'Pop' },
|
|
23158
|
+
{ key: 'X', label: 'Drop', destructive: true },
|
|
23159
|
+
{ key: 'y', label: 'Yank ref' },
|
|
23160
|
+
];
|
|
23161
|
+
const WORKTREE_ACTIONS = [
|
|
23162
|
+
{ key: 'W', label: 'Remove', destructive: true },
|
|
23163
|
+
{ key: 'y', label: 'Yank path' },
|
|
23164
|
+
];
|
|
23165
|
+
function getInspectorActions(context) {
|
|
23166
|
+
switch (context) {
|
|
23167
|
+
case 'history-commit':
|
|
23168
|
+
return HISTORY_COMMIT_ACTIONS;
|
|
23169
|
+
case 'branch':
|
|
23170
|
+
return BRANCH_ACTIONS;
|
|
23171
|
+
case 'tag':
|
|
23172
|
+
return TAG_ACTIONS;
|
|
23173
|
+
case 'stash':
|
|
23174
|
+
return STASH_ACTIONS;
|
|
23175
|
+
case 'worktree':
|
|
23176
|
+
return WORKTREE_ACTIONS;
|
|
23177
|
+
default: {
|
|
23178
|
+
const exhaustive = context;
|
|
23179
|
+
throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
|
|
23180
|
+
}
|
|
23181
|
+
}
|
|
23182
|
+
}
|
|
23183
|
+
|
|
22931
23184
|
function sectionLines(title, diff) {
|
|
22932
23185
|
const lines = diff.split('\n').map((line) => line.trimEnd());
|
|
22933
23186
|
return [
|
|
@@ -23727,6 +23980,80 @@ function LogInkApp(deps) {
|
|
|
23727
23980
|
active = false;
|
|
23728
23981
|
};
|
|
23729
23982
|
}, [git, selected?.hash]);
|
|
23983
|
+
// #806 follow-up — auto-jump the history view to whichever branch /
|
|
23984
|
+
// tag the user is currently cursoring in the sidebar (or the
|
|
23985
|
+
// dedicated branches / tags view). Debounced so cursor-scrolling
|
|
23986
|
+
// through a long branch list doesn't dispatch on every keystroke.
|
|
23987
|
+
// No-op when the cursored ref's tip isn't in the loaded commit
|
|
23988
|
+
// window (under compact mode the cursored branch's tip may not be
|
|
23989
|
+
// fetched yet); a status hint surfaces in that case so the user
|
|
23990
|
+
// knows to toggle full graph or load older commits.
|
|
23991
|
+
React.useEffect(() => {
|
|
23992
|
+
const onBranchTab = state.activeView === 'branches' ||
|
|
23993
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
23994
|
+
const onTagTab = state.activeView === 'tags' ||
|
|
23995
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
23996
|
+
if (!onBranchTab && !onTagTab)
|
|
23997
|
+
return;
|
|
23998
|
+
let cancelled = false;
|
|
23999
|
+
const timer = setTimeout(() => {
|
|
24000
|
+
if (cancelled)
|
|
24001
|
+
return;
|
|
24002
|
+
let targetHash;
|
|
24003
|
+
let targetLabel;
|
|
24004
|
+
if (onBranchTab) {
|
|
24005
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
24006
|
+
const visible = state.filter
|
|
24007
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
|
|
24008
|
+
: all;
|
|
24009
|
+
const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
|
|
24010
|
+
if (branch) {
|
|
24011
|
+
targetHash = branch.hash;
|
|
24012
|
+
targetLabel = `branch ${branch.shortName}`;
|
|
24013
|
+
}
|
|
24014
|
+
}
|
|
24015
|
+
else if (onTagTab) {
|
|
24016
|
+
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24017
|
+
const visible = state.filter
|
|
24018
|
+
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
|
|
24019
|
+
: all;
|
|
24020
|
+
const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
|
|
24021
|
+
if (tag) {
|
|
24022
|
+
targetHash = tag.hash;
|
|
24023
|
+
targetLabel = `tag ${tag.name}`;
|
|
24024
|
+
}
|
|
24025
|
+
}
|
|
24026
|
+
if (!targetHash)
|
|
24027
|
+
return;
|
|
24028
|
+
const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
|
|
24029
|
+
if (loaded) {
|
|
24030
|
+
dispatch({ type: 'selectCommitByHash', hash: targetHash });
|
|
24031
|
+
// Confirmation status message so the user gets feedback even
|
|
24032
|
+
// when the dedicated branches / tags view is occupying the
|
|
24033
|
+
// main panel and the history cursor moves invisibly behind it.
|
|
24034
|
+
dispatch({
|
|
24035
|
+
type: 'setStatus',
|
|
24036
|
+
value: `Synced history to ${targetLabel} tip`,
|
|
24037
|
+
});
|
|
24038
|
+
}
|
|
24039
|
+
else {
|
|
24040
|
+
dispatch({
|
|
24041
|
+
type: 'setStatus',
|
|
24042
|
+
value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
|
|
24043
|
+
});
|
|
24044
|
+
}
|
|
24045
|
+
}, 150);
|
|
24046
|
+
return () => {
|
|
24047
|
+
cancelled = true;
|
|
24048
|
+
clearTimeout(timer);
|
|
24049
|
+
};
|
|
24050
|
+
}, [
|
|
24051
|
+
dispatch, context.branches, context.tags,
|
|
24052
|
+
state.activeView, state.focus, state.sidebarTab,
|
|
24053
|
+
state.selectedBranchIndex, state.selectedTagIndex,
|
|
24054
|
+
state.branchSort, state.tagSort, state.filter,
|
|
24055
|
+
state.filteredCommits,
|
|
24056
|
+
]);
|
|
23730
24057
|
React.useEffect(() => {
|
|
23731
24058
|
let active = true;
|
|
23732
24059
|
async function loadWorktreeDiff() {
|
|
@@ -24102,6 +24429,30 @@ function LogInkApp(deps) {
|
|
|
24102
24429
|
message: commit.message,
|
|
24103
24430
|
});
|
|
24104
24431
|
},
|
|
24432
|
+
'create-branch-here': async () => {
|
|
24433
|
+
const commit = getSelectedInkCommit(state);
|
|
24434
|
+
const name = payload?.trim();
|
|
24435
|
+
if (!commit)
|
|
24436
|
+
return { ok: false, message: 'No commit selected' };
|
|
24437
|
+
if (!name)
|
|
24438
|
+
return { ok: false, message: 'Branch name required' };
|
|
24439
|
+
return createBranchFromCommit(git, name, {
|
|
24440
|
+
hash: commit.hash,
|
|
24441
|
+
shortHash: commit.shortHash,
|
|
24442
|
+
});
|
|
24443
|
+
},
|
|
24444
|
+
'create-tag-here': async () => {
|
|
24445
|
+
const commit = getSelectedInkCommit(state);
|
|
24446
|
+
const name = payload?.trim();
|
|
24447
|
+
if (!commit)
|
|
24448
|
+
return { ok: false, message: 'No commit selected' };
|
|
24449
|
+
if (!name)
|
|
24450
|
+
return { ok: false, message: 'Tag name required' };
|
|
24451
|
+
return createTagAtCommit(git, name, {
|
|
24452
|
+
hash: commit.hash,
|
|
24453
|
+
shortHash: commit.shortHash,
|
|
24454
|
+
});
|
|
24455
|
+
},
|
|
24105
24456
|
'checkout-file-from-commit': async () => {
|
|
24106
24457
|
// payload is "<sha> <path>" so we pass both through a single
|
|
24107
24458
|
// string field on the action.
|
|
@@ -24154,6 +24505,17 @@ function LogInkApp(deps) {
|
|
|
24154
24505
|
if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
|
|
24155
24506
|
return { ok: false, message: 'No GitHub remote detected for this repo' };
|
|
24156
24507
|
}
|
|
24508
|
+
// History view: prefer the cursored commit's URL so `O` from
|
|
24509
|
+
// a commit context lands the user on the commit page rather
|
|
24510
|
+
// than the repo root or the current PR. The user-visible
|
|
24511
|
+
// intent of `O` is "open whatever I'm cursoring on the web";
|
|
24512
|
+
// a commit is what the cursor is on in the history view.
|
|
24513
|
+
if (state.activeView === 'history') {
|
|
24514
|
+
const commit = getSelectedInkCommit(state);
|
|
24515
|
+
if (commit) {
|
|
24516
|
+
return openProviderUrl(repo, { type: 'commit', commit: commit.hash });
|
|
24517
|
+
}
|
|
24518
|
+
}
|
|
24157
24519
|
const pr = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
|
|
24158
24520
|
if (pr) {
|
|
24159
24521
|
return openProviderUrl(repo, { type: 'pull-request', number: pr.number });
|
|
@@ -24241,9 +24603,21 @@ function LogInkApp(deps) {
|
|
|
24241
24603
|
}
|
|
24242
24604
|
const result = await handler();
|
|
24243
24605
|
dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
|
|
24244
|
-
//
|
|
24245
|
-
//
|
|
24246
|
-
|
|
24606
|
+
// Checkout-branch is the one workflow where we want a *visible*
|
|
24607
|
+
// refresh so the user sees the branches sidebar repaint with the
|
|
24608
|
+
// new current branch (per #806 follow-up). Snap the cursor to
|
|
24609
|
+
// position 0 first so when the refresh completes and the new
|
|
24610
|
+
// current branch lands at the top (per #809's pin-current rule),
|
|
24611
|
+
// the cursor is already there waiting.
|
|
24612
|
+
if (id === 'checkout-branch' && result?.ok) {
|
|
24613
|
+
dispatch({ type: 'resetBranchSelection' });
|
|
24614
|
+
await refreshContext();
|
|
24615
|
+
}
|
|
24616
|
+
else {
|
|
24617
|
+
// Silent refresh so the deleted item disappears from the list
|
|
24618
|
+
// without flickering the surfaces through a 'loading' phase.
|
|
24619
|
+
await refreshContext({ silent: true });
|
|
24620
|
+
}
|
|
24247
24621
|
}, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
|
|
24248
24622
|
state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
|
|
24249
24623
|
state.tagSort]);
|
|
@@ -24725,6 +25099,7 @@ function LogInkApp(deps) {
|
|
|
24725
25099
|
columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
|
|
24726
25100
|
rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
|
|
24727
25101
|
sidebarFocused: state.focus === 'sidebar',
|
|
25102
|
+
inspectorFocused: state.focus === 'detail',
|
|
24728
25103
|
});
|
|
24729
25104
|
if (layout.tooSmall) {
|
|
24730
25105
|
return h(Box, {
|
|
@@ -24739,7 +25114,7 @@ function LogInkApp(deps) {
|
|
|
24739
25114
|
if (showOnboarding) {
|
|
24740
25115
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
24741
25116
|
}
|
|
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));
|
|
25117
|
+
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
25118
|
}
|
|
24744
25119
|
function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
|
|
24745
25120
|
const { Box, Text } = components;
|
|
@@ -24792,7 +25167,7 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
|
|
|
24792
25167
|
? h(Text, { bold: true, color: theme.colors.accent }, `${prLabel}${titleSuffix}`)
|
|
24793
25168
|
: undefined, h(Text, { bold: true, color: modeColor }, ` ${mode}`), search ? h(Text, { dimColor: true }, ` ${truncate$1(search, 36)}`) : undefined);
|
|
24794
25169
|
}
|
|
24795
|
-
function renderSidebar(h, components, state, context, contextStatus, width, theme) {
|
|
25170
|
+
function renderSidebar(h, components, state, context, contextStatus, width, bodyRows, theme) {
|
|
24796
25171
|
const { Box, Text } = components;
|
|
24797
25172
|
const focused = state.focus === 'sidebar';
|
|
24798
25173
|
const tabs = getLogInkSidebarTabs();
|
|
@@ -24816,7 +25191,7 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
|
|
|
24816
25191
|
dimColor: !isActive,
|
|
24817
25192
|
}, headerText));
|
|
24818
25193
|
if (isActive) {
|
|
24819
|
-
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme));
|
|
25194
|
+
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
|
|
24820
25195
|
}
|
|
24821
25196
|
return blocks;
|
|
24822
25197
|
});
|
|
@@ -24835,7 +25210,15 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
|
|
|
24835
25210
|
* surface; every other tab falls through to `sidebarLines` for its
|
|
24836
25211
|
* string-based summary.
|
|
24837
25212
|
*/
|
|
24838
|
-
function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme) {
|
|
25213
|
+
function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme) {
|
|
25214
|
+
// Available rows for the active tab's list. The sidebar chrome
|
|
25215
|
+
// takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
|
|
25216
|
+
// spacers); the branches tab eats 3 more for its summary header
|
|
25217
|
+
// (Current / Worktree / spacer). Floor of 8 keeps short terminals
|
|
25218
|
+
// usable; tall terminals (40+ rows) get noticeably more items.
|
|
25219
|
+
const sidebarChrome = 10;
|
|
25220
|
+
const branchHeaderRows = tab === 'branches' ? 3 : 0;
|
|
25221
|
+
const visibleListCount = Math.max(8, bodyRows - sidebarChrome - branchHeaderRows);
|
|
24839
25222
|
if (tab === 'status') {
|
|
24840
25223
|
return renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme);
|
|
24841
25224
|
}
|
|
@@ -24860,7 +25243,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24860
25243
|
];
|
|
24861
25244
|
return [
|
|
24862
25245
|
...headerRows,
|
|
24863
|
-
...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches'),
|
|
25246
|
+
...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches', visibleListCount),
|
|
24864
25247
|
];
|
|
24865
25248
|
}
|
|
24866
25249
|
if (tab === 'tags') {
|
|
@@ -24871,7 +25254,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24871
25254
|
if (tags.length === 0) {
|
|
24872
25255
|
return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
|
|
24873
25256
|
}
|
|
24874
|
-
return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags');
|
|
25257
|
+
return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags', visibleListCount);
|
|
24875
25258
|
}
|
|
24876
25259
|
if (tab === 'stashes') {
|
|
24877
25260
|
if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
|
|
@@ -24881,7 +25264,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24881
25264
|
if (stashes.length === 0) {
|
|
24882
25265
|
return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
|
|
24883
25266
|
}
|
|
24884
|
-
return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes');
|
|
25267
|
+
return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes', visibleListCount);
|
|
24885
25268
|
}
|
|
24886
25269
|
// worktrees
|
|
24887
25270
|
if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
|
|
@@ -24895,7 +25278,7 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24895
25278
|
const marker = worktree.current ? '*' : ' ';
|
|
24896
25279
|
const wstate = worktree.dirty ? 'dirty' : 'clean';
|
|
24897
25280
|
return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
|
|
24898
|
-
}, 'tab-worktrees');
|
|
25281
|
+
}, 'tab-worktrees', visibleListCount);
|
|
24899
25282
|
}
|
|
24900
25283
|
/**
|
|
24901
25284
|
* Render a sliding-window list of selectable sidebar rows. The cursor
|
|
@@ -24904,10 +25287,10 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
24904
25287
|
* Sliding window keeps the cursor in view as the user navigates a long
|
|
24905
25288
|
* list; truncation hints surface the count of hidden rows.
|
|
24906
25289
|
*/
|
|
24907
|
-
function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix) {
|
|
25290
|
+
function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount) {
|
|
24908
25291
|
if (items.length === 0)
|
|
24909
25292
|
return [];
|
|
24910
|
-
const window = getSidebarVisibleWindow(items.length, selectedIndex);
|
|
25293
|
+
const window = getSidebarVisibleWindow(items.length, selectedIndex, visibleCount);
|
|
24911
25294
|
const elements = [];
|
|
24912
25295
|
if (window.truncatedAbove > 0) {
|
|
24913
25296
|
elements.push(h(Text, {
|
|
@@ -25808,7 +26191,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
25808
26191
|
}, truncate$1(line, 140)))
|
|
25809
26192
|
: []));
|
|
25810
26193
|
}
|
|
25811
|
-
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme) {
|
|
26194
|
+
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme) {
|
|
25812
26195
|
const focused = state.focus === 'detail';
|
|
25813
26196
|
if (state.showHelp) {
|
|
25814
26197
|
return renderHelpPanel(h, components, state, width, theme, focused);
|
|
@@ -25865,16 +26248,11 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
25865
26248
|
if (state.activeView === 'stash') {
|
|
25866
26249
|
return renderStashPreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
|
|
25867
26250
|
}
|
|
25868
|
-
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme, focused);
|
|
26251
|
+
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
|
|
25869
26252
|
}
|
|
25870
|
-
function renderHistoryInspector(h, components, state, context,
|
|
26253
|
+
function renderHistoryInspector(h, components, state, context, _contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, tabbed, theme, focused) {
|
|
25871
26254
|
const { Box, Text } = components;
|
|
25872
26255
|
const selected = getSelectedInkCommit(state);
|
|
25873
|
-
const workflowSections = getLogInkWorkflowSections({
|
|
25874
|
-
...context,
|
|
25875
|
-
contextLoading: isLogInkContextLoading(contextStatus),
|
|
25876
|
-
selectedCommit: selected,
|
|
25877
|
-
});
|
|
25878
26256
|
if (!detail) {
|
|
25879
26257
|
const fallbackLines = [
|
|
25880
26258
|
selected?.message || 'No commit selected.',
|
|
@@ -25890,7 +26268,7 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
|
|
|
25890
26268
|
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
|
|
25891
26269
|
key: `detail-${index}`,
|
|
25892
26270
|
dimColor: index > 1,
|
|
25893
|
-
}, truncate$1(line, width - 4))));
|
|
26271
|
+
}, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
|
|
25894
26272
|
}
|
|
25895
26273
|
const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
|
|
25896
26274
|
// P5.1 — link the commit hash and each ref out to GitHub when we know
|
|
@@ -25901,18 +26279,26 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
|
|
|
25901
26279
|
const refNodes = detail.refs.length
|
|
25902
26280
|
? renderInspectorRefs(h, Text, detail.refs, repository)
|
|
25903
26281
|
: null;
|
|
26282
|
+
// Inspector reorder (PR — drop duplicative Workflows trailer):
|
|
26283
|
+
// 1. Commit message (the headline of what you're looking at)
|
|
26284
|
+
// 2. Metadata (hash / author / date / refs / stats)
|
|
26285
|
+
// 3. Body preview (up to 8 lines now that the trailer is gone)
|
|
26286
|
+
// 4. Changed files list (cursored entry highlights)
|
|
26287
|
+
// 5. Actions cheat-sheet (per-entity keystrokes; destructive marked)
|
|
26288
|
+
// The Workflows: trailer that used to repeat the repo / branch /
|
|
26289
|
+
// status from the top header and left sidebar is intentionally gone.
|
|
25904
26290
|
const headerNodes = [
|
|
25905
26291
|
h(Text, { key: 'detail-msg' }, truncate$1(detail.message, width - 4)),
|
|
25906
26292
|
h(Text, { key: 'detail-spacer-1' }, ''),
|
|
25907
26293
|
h(Text, { key: 'detail-commit', dimColor: true }, 'Commit: ', commitLink),
|
|
25908
26294
|
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:
|
|
26295
|
+
h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
|
|
25910
26296
|
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
|
|
26297
|
+
? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
|
|
26298
|
+
: h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
|
|
26299
|
+
h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(`Stats: ${statLine}`, width - 4)),
|
|
25914
26300
|
h(Text, { key: 'detail-spacer-2' }, ''),
|
|
25915
|
-
...(detail.body ? detail.body.split('\n').slice(0,
|
|
26301
|
+
...(detail.body ? detail.body.split('\n').slice(0, 8) : ['No commit body.']).map((line, index) => h(Text, {
|
|
25916
26302
|
key: `detail-body-${index}`,
|
|
25917
26303
|
dimColor: true,
|
|
25918
26304
|
}, truncate$1(line, width - 4))),
|
|
@@ -25921,24 +26307,85 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
|
|
|
25921
26307
|
];
|
|
25922
26308
|
const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
|
|
25923
26309
|
const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
|
|
25924
|
-
|
|
25925
|
-
|
|
25926
|
-
|
|
25927
|
-
|
|
25928
|
-
|
|
25929
|
-
|
|
25930
|
-
|
|
25931
|
-
|
|
26310
|
+
// Tabbed mode (#806 follow-up — short terminals): render only the
|
|
26311
|
+
// active inspector tab with a `[Inspector] Actions` header so the
|
|
26312
|
+
// user knows what they're seeing and how to switch (`[/]` while
|
|
26313
|
+
// focus is on the inspector). Tall terminals stack both sections
|
|
26314
|
+
// as before.
|
|
26315
|
+
if (tabbed) {
|
|
26316
|
+
const activeTab = state.inspectorTab;
|
|
26317
|
+
const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
|
|
26318
|
+
bold: activeTab === 'inspector',
|
|
26319
|
+
dimColor: activeTab !== 'inspector',
|
|
26320
|
+
}, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), ' ', h(Text, {
|
|
26321
|
+
bold: activeTab === 'actions',
|
|
26322
|
+
dimColor: activeTab !== 'actions',
|
|
26323
|
+
}, activeTab === 'actions' ? '[Actions]' : ' Actions '));
|
|
26324
|
+
return h(Box, {
|
|
26325
|
+
borderColor: focusBorderColor(theme, focused),
|
|
26326
|
+
borderStyle: theme.borderStyle,
|
|
26327
|
+
flexDirection: 'column',
|
|
26328
|
+
width,
|
|
26329
|
+
paddingX: 1,
|
|
26330
|
+
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...(activeTab === 'inspector'
|
|
26331
|
+
? [...headerNodes, ...fileListNodes]
|
|
26332
|
+
: renderInspectorActionsSection(h, Text, 'history-commit', width, theme)));
|
|
26333
|
+
}
|
|
25932
26334
|
return h(Box, {
|
|
25933
26335
|
borderColor: focusBorderColor(theme, focused),
|
|
25934
26336
|
borderStyle: theme.borderStyle,
|
|
25935
26337
|
flexDirection: 'column',
|
|
25936
26338
|
width,
|
|
25937
26339
|
paddingX: 1,
|
|
25938
|
-
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...
|
|
25939
|
-
|
|
25940
|
-
|
|
25941
|
-
|
|
26340
|
+
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
|
|
26341
|
+
}
|
|
26342
|
+
/**
|
|
26343
|
+
* Render the trailing "Actions:" section that surfaces which keystrokes
|
|
26344
|
+
* apply to whatever the inspector is focused on. Keys are colored with
|
|
26345
|
+
* `theme.colors.accent` so they pop as the actionable element. Destructive
|
|
26346
|
+
* actions get the danger color plus a `[!]` marker so they don't blend
|
|
26347
|
+
* into the cherry-pick / yank rows.
|
|
26348
|
+
*
|
|
26349
|
+
* Truncates labels when the inspector is narrow (down to the 26-cell
|
|
26350
|
+
* minimum from `getLogInkLayout`) so an overflowing label never wraps and
|
|
26351
|
+
* collides with the next row.
|
|
26352
|
+
*/
|
|
26353
|
+
function renderInspectorActionsSection(h, Text, context, width, theme) {
|
|
26354
|
+
const actions = getInspectorActions(context);
|
|
26355
|
+
if (!actions.length)
|
|
26356
|
+
return [];
|
|
26357
|
+
// Width budget for each row: subtract padding + " " gutter, the key
|
|
26358
|
+
// column (left-padded to 5 cells so labels align), the " " gap
|
|
26359
|
+
// between key and label, and the optional " [!]" suffix (5 cells).
|
|
26360
|
+
const KEY_COLUMN = 5;
|
|
26361
|
+
const GAP = ' ';
|
|
26362
|
+
const DESTRUCTIVE_SUFFIX = ' [!]';
|
|
26363
|
+
const labelBudget = Math.max(4, width - 4 /* border + padX */ - KEY_COLUMN - GAP.length - DESTRUCTIVE_SUFFIX.length);
|
|
26364
|
+
const nodes = [
|
|
26365
|
+
h(Text, { key: 'actions-spacer' }, ''),
|
|
26366
|
+
h(Text, { key: 'actions-title' }, 'Actions:'),
|
|
26367
|
+
...actions.map((action, index) => {
|
|
26368
|
+
const keyCell = action.key.padEnd(KEY_COLUMN);
|
|
26369
|
+
const label = truncate$1(action.label, labelBudget);
|
|
26370
|
+
const children = [
|
|
26371
|
+
h(Text, {
|
|
26372
|
+
key: `actions-${index}-key`,
|
|
26373
|
+
color: action.destructive ? theme.colors.danger : theme.colors.accent,
|
|
26374
|
+
}, keyCell),
|
|
26375
|
+
GAP,
|
|
26376
|
+
label,
|
|
26377
|
+
];
|
|
26378
|
+
if (action.destructive) {
|
|
26379
|
+
children.push(h(Text, {
|
|
26380
|
+
key: `actions-${index}-mark`,
|
|
26381
|
+
color: theme.colors.danger,
|
|
26382
|
+
dimColor: false,
|
|
26383
|
+
}, DESTRUCTIVE_SUFFIX));
|
|
26384
|
+
}
|
|
26385
|
+
return h(Text, { key: `actions-${index}` }, ...children);
|
|
26386
|
+
}),
|
|
26387
|
+
];
|
|
26388
|
+
return nodes;
|
|
25942
26389
|
}
|
|
25943
26390
|
/**
|
|
25944
26391
|
* Build a commit URL for the repo when GitHub provider info is available.
|
|
@@ -26512,7 +26959,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
|
26512
26959
|
const git = options.git || getRepo();
|
|
26513
26960
|
const rows = options.rows || (await getLogRows(git, logArgv));
|
|
26514
26961
|
await startInkInteractiveLog(git, rows, {}, {
|
|
26515
|
-
appLabel: 'coco
|
|
26962
|
+
appLabel: 'coco',
|
|
26516
26963
|
idleTips: config.logTui?.idleTips,
|
|
26517
26964
|
initialView: 'history',
|
|
26518
26965
|
logArgv,
|
|
@@ -26525,7 +26972,7 @@ async function startCocoUi(argv) {
|
|
|
26525
26972
|
const logArgv = createLogArgvFromUiArgv(argv);
|
|
26526
26973
|
const rows = await getLogRows(git, logArgv);
|
|
26527
26974
|
await startInkInteractiveLog(git, rows, {}, {
|
|
26528
|
-
appLabel: 'coco
|
|
26975
|
+
appLabel: 'coco',
|
|
26529
26976
|
idleTips: config.logTui?.idleTips,
|
|
26530
26977
|
initialView: argv.view || 'history',
|
|
26531
26978
|
logArgv,
|