git-coco 0.45.0 → 0.47.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/README.md +8 -6
- package/dist/index.esm.mjs +989 -20
- package/dist/index.js +989 -20
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
|
|
|
78
78
|
/**
|
|
79
79
|
* Current build version from package.json
|
|
80
80
|
*/
|
|
81
|
-
const BUILD_VERSION = "0.
|
|
81
|
+
const BUILD_VERSION = "0.47.0";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -14545,7 +14545,7 @@ const builder$3 = (yargs) => {
|
|
|
14545
14545
|
return yargs.options(options$3).usage(getCommandUsageHeader(command$3));
|
|
14546
14546
|
};
|
|
14547
14547
|
|
|
14548
|
-
const FIELD_SEPARATOR$
|
|
14548
|
+
const FIELD_SEPARATOR$3 = '\x1f';
|
|
14549
14549
|
// `%P` (parent hashes, space-separated) lets the TUI distinguish
|
|
14550
14550
|
// merge commits (parents.length > 1) from regular commits without a
|
|
14551
14551
|
// second round-trip to git. See #791 stage 3 — merge glyph + HEAD ring.
|
|
@@ -14598,13 +14598,13 @@ function parseLogOutput(output) {
|
|
|
14598
14598
|
.map((line) => line.trimEnd())
|
|
14599
14599
|
.filter(Boolean)
|
|
14600
14600
|
.map((line) => {
|
|
14601
|
-
if (!line.includes(FIELD_SEPARATOR$
|
|
14601
|
+
if (!line.includes(FIELD_SEPARATOR$3)) {
|
|
14602
14602
|
return {
|
|
14603
14603
|
type: 'graph',
|
|
14604
14604
|
graph: line,
|
|
14605
14605
|
};
|
|
14606
14606
|
}
|
|
14607
|
-
const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$
|
|
14607
|
+
const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$3);
|
|
14608
14608
|
return {
|
|
14609
14609
|
type: 'commit',
|
|
14610
14610
|
graph: graph.trimEnd(),
|
|
@@ -14679,7 +14679,7 @@ function parseNameStatus(output, numstat = []) {
|
|
|
14679
14679
|
function parseCommitDetail(metadata, files, numstatOutput = '') {
|
|
14680
14680
|
const [hash, shortHash, parentsStr, date, author, refs, message, body = ''] = metadata
|
|
14681
14681
|
.trimEnd()
|
|
14682
|
-
.split(FIELD_SEPARATOR$
|
|
14682
|
+
.split(FIELD_SEPARATOR$3);
|
|
14683
14683
|
const numstat = parseNumstat(numstatOutput);
|
|
14684
14684
|
return {
|
|
14685
14685
|
shortHash,
|
|
@@ -14819,14 +14819,14 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
|
|
|
14819
14819
|
};
|
|
14820
14820
|
}
|
|
14821
14821
|
|
|
14822
|
-
const FIELD_SEPARATOR$
|
|
14822
|
+
const FIELD_SEPARATOR$2 = '\x1f';
|
|
14823
14823
|
function parseBranchRefs(output) {
|
|
14824
14824
|
return output
|
|
14825
14825
|
.split('\n')
|
|
14826
14826
|
.map((line) => line.trimEnd())
|
|
14827
14827
|
.filter(Boolean)
|
|
14828
14828
|
.map((line) => {
|
|
14829
|
-
const [refName, shortName, hash, upstream, head, date, subject] = line.split(FIELD_SEPARATOR$
|
|
14829
|
+
const [refName, shortName, hash, upstream, head, date, subject] = line.split(FIELD_SEPARATOR$2);
|
|
14830
14830
|
if (!refName || !shortName) {
|
|
14831
14831
|
return undefined;
|
|
14832
14832
|
}
|
|
@@ -14866,7 +14866,7 @@ async function getBranchOverview(git) {
|
|
|
14866
14866
|
const [branchOutput, statusOutput, currentBranchOutput] = await Promise.all([
|
|
14867
14867
|
git.raw([
|
|
14868
14868
|
'for-each-ref',
|
|
14869
|
-
`--format=%(refname)${FIELD_SEPARATOR$
|
|
14869
|
+
`--format=%(refname)${FIELD_SEPARATOR$2}%(refname:short)${FIELD_SEPARATOR$2}%(objectname:short)${FIELD_SEPARATOR$2}%(upstream:short)${FIELD_SEPARATOR$2}%(HEAD)${FIELD_SEPARATOR$2}%(committerdate:short)${FIELD_SEPARATOR$2}%(contents:subject)`,
|
|
14870
14870
|
'refs/heads',
|
|
14871
14871
|
'refs/remotes',
|
|
14872
14872
|
]),
|
|
@@ -15416,10 +15416,12 @@ async function runCommitDraftWorkflow(input = {}) {
|
|
|
15416
15416
|
}
|
|
15417
15417
|
|
|
15418
15418
|
const LOG_INK_CONTEXT_KEYS = [
|
|
15419
|
+
'bisect',
|
|
15419
15420
|
'branches',
|
|
15420
15421
|
'operation',
|
|
15421
15422
|
'provider',
|
|
15422
15423
|
'pullRequest',
|
|
15424
|
+
'reflog',
|
|
15423
15425
|
'stashes',
|
|
15424
15426
|
'tags',
|
|
15425
15427
|
'worktree',
|
|
@@ -16139,6 +16141,45 @@ function getLogInkWorkflowActions() {
|
|
|
16139
16141
|
kind: 'normal',
|
|
16140
16142
|
requiresConfirmation: false,
|
|
16141
16143
|
},
|
|
16144
|
+
{
|
|
16145
|
+
// #784 — bisect workflow actions. All four are scoped per-view in
|
|
16146
|
+
// inkInput (active only when activeView === 'bisect') so the
|
|
16147
|
+
// single-letter keys stay free elsewhere. Empty `key` keeps them
|
|
16148
|
+
// palette-discoverable. Reset is the only destructive one — it
|
|
16149
|
+
// throws away the bisect state — so it routes through y-confirm;
|
|
16150
|
+
// good / bad / skip are recoverable via `git bisect log` and run
|
|
16151
|
+
// immediately.
|
|
16152
|
+
id: 'bisect-good',
|
|
16153
|
+
key: '',
|
|
16154
|
+
label: 'Bisect: mark good',
|
|
16155
|
+
description: 'Mark the current bisect candidate as good and advance to the next one.',
|
|
16156
|
+
kind: 'normal',
|
|
16157
|
+
requiresConfirmation: false,
|
|
16158
|
+
},
|
|
16159
|
+
{
|
|
16160
|
+
id: 'bisect-bad',
|
|
16161
|
+
key: '',
|
|
16162
|
+
label: 'Bisect: mark bad',
|
|
16163
|
+
description: 'Mark the current bisect candidate as bad and advance to the next one.',
|
|
16164
|
+
kind: 'normal',
|
|
16165
|
+
requiresConfirmation: false,
|
|
16166
|
+
},
|
|
16167
|
+
{
|
|
16168
|
+
id: 'bisect-skip',
|
|
16169
|
+
key: '',
|
|
16170
|
+
label: 'Bisect: skip candidate',
|
|
16171
|
+
description: 'Skip the current bisect candidate (e.g. it does not build) and advance.',
|
|
16172
|
+
kind: 'normal',
|
|
16173
|
+
requiresConfirmation: false,
|
|
16174
|
+
},
|
|
16175
|
+
{
|
|
16176
|
+
id: 'bisect-reset',
|
|
16177
|
+
key: '',
|
|
16178
|
+
label: 'Bisect: reset',
|
|
16179
|
+
description: 'End the bisect session and restore HEAD. Discards in-progress bisect state.',
|
|
16180
|
+
kind: 'destructive',
|
|
16181
|
+
requiresConfirmation: true,
|
|
16182
|
+
},
|
|
16142
16183
|
{
|
|
16143
16184
|
id: 'ai-commit-summary',
|
|
16144
16185
|
key: 'I',
|
|
@@ -16375,6 +16416,27 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
16375
16416
|
description: 'Push the conflict resolution helper view (available during merge/rebase/cherry-pick/revert).',
|
|
16376
16417
|
contexts: ['normal'],
|
|
16377
16418
|
},
|
|
16419
|
+
{
|
|
16420
|
+
id: 'navigateReflog',
|
|
16421
|
+
keys: ['gr'],
|
|
16422
|
+
label: 'reflog',
|
|
16423
|
+
description: 'Push the reflog browser view — chronological recovery log.',
|
|
16424
|
+
contexts: ['normal'],
|
|
16425
|
+
},
|
|
16426
|
+
{
|
|
16427
|
+
id: 'navigateBisect',
|
|
16428
|
+
keys: ['gB'],
|
|
16429
|
+
label: 'bisect',
|
|
16430
|
+
description: 'Push the bisect workflow view (#784). Capital B disambiguates from gb (branches). Available whenever a bisect is in progress; surfaces the current candidate and the good / bad / skip / reset action keys.',
|
|
16431
|
+
contexts: ['normal'],
|
|
16432
|
+
},
|
|
16433
|
+
{
|
|
16434
|
+
id: 'markForCompare',
|
|
16435
|
+
keys: ['m'],
|
|
16436
|
+
label: 'mark compare',
|
|
16437
|
+
description: 'Mark the cursored ref (branch / tag / commit) as the base for a compare-two-refs diff (#779). Press again on the same ref to clear; with a base set, Enter on another ref opens the compare diff.',
|
|
16438
|
+
contexts: ['commits'],
|
|
16439
|
+
},
|
|
16378
16440
|
{
|
|
16379
16441
|
id: 'navigateBack',
|
|
16380
16442
|
keys: ['<', 'esc'],
|
|
@@ -16505,6 +16567,8 @@ const GLOBAL_BINDING_IDS = [
|
|
|
16505
16567
|
'navigateWorktrees',
|
|
16506
16568
|
'navigatePullRequest',
|
|
16507
16569
|
'navigateConflicts',
|
|
16570
|
+
'navigateReflog',
|
|
16571
|
+
'navigateBisect',
|
|
16508
16572
|
'navigateBack',
|
|
16509
16573
|
];
|
|
16510
16574
|
const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
|
|
@@ -16632,6 +16696,15 @@ function getLogInkFooterHints(options) {
|
|
|
16632
16696
|
global: NORMAL_GLOBAL_HINTS,
|
|
16633
16697
|
};
|
|
16634
16698
|
}
|
|
16699
|
+
if (options.diffSource === 'compare') {
|
|
16700
|
+
// Compare-two-refs (#779): read-only diff with no per-file
|
|
16701
|
+
// cherry-pick or hunk apply (those don't make sense across
|
|
16702
|
+
// arbitrary refs). Just scroll + back out.
|
|
16703
|
+
return {
|
|
16704
|
+
contextual: ['j/k lines', splitToggleHint, 'esc back'],
|
|
16705
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16706
|
+
};
|
|
16707
|
+
}
|
|
16635
16708
|
return {
|
|
16636
16709
|
contextual: ['j/k hunks', 'space stage', 'z revert', 'o edit', 'e/c compose', 'y yank'],
|
|
16637
16710
|
global: NORMAL_GLOBAL_HINTS,
|
|
@@ -16644,14 +16717,26 @@ function getLogInkFooterHints(options) {
|
|
|
16644
16717
|
};
|
|
16645
16718
|
}
|
|
16646
16719
|
if (options.activeView === 'branches') {
|
|
16720
|
+
if (options.compareBaseSet) {
|
|
16721
|
+
return {
|
|
16722
|
+
contextual: ['↑/↓ branches', 'enter compare', 'm clear', 'esc back'],
|
|
16723
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16724
|
+
};
|
|
16725
|
+
}
|
|
16647
16726
|
return {
|
|
16648
|
-
contextual: ['↑/↓ branches', 'enter checkout', '+ new', 'D delete', 's sort', 'y yank'],
|
|
16727
|
+
contextual: ['↑/↓ branches', 'enter checkout', '+ new', 'D delete', 'm compare', 's sort', 'y yank'],
|
|
16649
16728
|
global: NORMAL_GLOBAL_HINTS,
|
|
16650
16729
|
};
|
|
16651
16730
|
}
|
|
16652
16731
|
if (options.activeView === 'tags') {
|
|
16732
|
+
if (options.compareBaseSet) {
|
|
16733
|
+
return {
|
|
16734
|
+
contextual: ['↑/↓ tags', 'enter compare', 'm clear', 'esc back'],
|
|
16735
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16736
|
+
};
|
|
16737
|
+
}
|
|
16653
16738
|
return {
|
|
16654
|
-
contextual: ['↑/↓ tags', '+ new', 'P push', 'T delete', 's sort', 'y yank'],
|
|
16739
|
+
contextual: ['↑/↓ tags', '+ new', 'P push', 'T delete', 'm compare', 's sort', 'y yank'],
|
|
16655
16740
|
global: NORMAL_GLOBAL_HINTS,
|
|
16656
16741
|
};
|
|
16657
16742
|
}
|
|
@@ -16683,6 +16768,28 @@ function getLogInkFooterHints(options) {
|
|
|
16683
16768
|
global: NORMAL_GLOBAL_HINTS,
|
|
16684
16769
|
};
|
|
16685
16770
|
}
|
|
16771
|
+
if (options.activeView === 'reflog') {
|
|
16772
|
+
return {
|
|
16773
|
+
contextual: ['↑/↓ entries', 'enter inspect', 'esc back'],
|
|
16774
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16775
|
+
};
|
|
16776
|
+
}
|
|
16777
|
+
if (options.activeView === 'bisect') {
|
|
16778
|
+
return {
|
|
16779
|
+
contextual: ['g good', 'b bad', 's skip', 'x reset', 'esc back'],
|
|
16780
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16781
|
+
};
|
|
16782
|
+
}
|
|
16783
|
+
if (options.compareBaseSet) {
|
|
16784
|
+
// History view with a compare base set — Enter is overridden to
|
|
16785
|
+
// open the compare diff; show the override + the bail-out key.
|
|
16786
|
+
// Mutate / new chips are dropped so the footer doesn't compete
|
|
16787
|
+
// with the active workflow.
|
|
16788
|
+
return {
|
|
16789
|
+
contextual: ['↑/↓ move', 'enter compare', 'm clear', 'esc back'],
|
|
16790
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16791
|
+
};
|
|
16792
|
+
}
|
|
16686
16793
|
return {
|
|
16687
16794
|
// History view default hints. Mutating ops (`c` cherry-pick, `R`
|
|
16688
16795
|
// revert, `Z` reset, `i` interactive-rebase) all route through a
|
|
@@ -16692,7 +16799,7 @@ function getLogInkFooterHints(options) {
|
|
|
16692
16799
|
// Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
|
|
16693
16800
|
// the footer stays scannable; full descriptions live in `?` help
|
|
16694
16801
|
// and the palette.
|
|
16695
|
-
contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', '
|
|
16802
|
+
contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'm compare', 'y/Y yank', '/ search'],
|
|
16696
16803
|
global: NORMAL_GLOBAL_HINTS,
|
|
16697
16804
|
};
|
|
16698
16805
|
}
|
|
@@ -17271,6 +17378,7 @@ function withPushedView(state, value) {
|
|
|
17271
17378
|
selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
|
|
17272
17379
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
17273
17380
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
17381
|
+
compareHead: value === 'diff' ? state.compareHead : undefined,
|
|
17274
17382
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
17275
17383
|
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
17276
17384
|
pendingKey: undefined,
|
|
@@ -17282,6 +17390,13 @@ function withPoppedView(state) {
|
|
|
17282
17390
|
}
|
|
17283
17391
|
const viewStack = state.viewStack.slice(0, -1);
|
|
17284
17392
|
const next = topOfStack(viewStack);
|
|
17393
|
+
// #779 — compareBase is "cleared when the diff view is popped." We
|
|
17394
|
+
// detect that case by checking if the *previous* top was 'diff'.
|
|
17395
|
+
// The compare workflow ends when the user backs out of the compare
|
|
17396
|
+
// diff; on the next mark they re-set the base. Other view pops
|
|
17397
|
+
// preserve compareBase so the user can move between branches / tags /
|
|
17398
|
+
// history while hunting for a head ref.
|
|
17399
|
+
const wasOnDiff = state.activeView === 'diff';
|
|
17285
17400
|
return {
|
|
17286
17401
|
...state,
|
|
17287
17402
|
activeView: next,
|
|
@@ -17294,6 +17409,8 @@ function withPoppedView(state) {
|
|
|
17294
17409
|
selectedWorktreeHunkIndex: next === 'diff' ? state.selectedWorktreeHunkIndex : 0,
|
|
17295
17410
|
diffSource: next === 'diff' ? state.diffSource : undefined,
|
|
17296
17411
|
stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
|
|
17412
|
+
compareBase: wasOnDiff ? undefined : state.compareBase,
|
|
17413
|
+
compareHead: next === 'diff' ? state.compareHead : undefined,
|
|
17297
17414
|
pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
|
|
17298
17415
|
statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
|
|
17299
17416
|
pendingKey: undefined,
|
|
@@ -17312,6 +17429,7 @@ function withReplacedView(state, value) {
|
|
|
17312
17429
|
selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
|
|
17313
17430
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
17314
17431
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
17432
|
+
compareHead: value === 'diff' ? state.compareHead : undefined,
|
|
17315
17433
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
17316
17434
|
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
17317
17435
|
pendingKey: undefined,
|
|
@@ -17332,6 +17450,11 @@ function withFilter$1(state, filter, promotedSelections) {
|
|
|
17332
17450
|
(filterChanged ? 0 : state.selectedTagIndex);
|
|
17333
17451
|
const stashIndex = promotedSelections?.stashIndex ??
|
|
17334
17452
|
(filterChanged ? 0 : state.selectedStashIndex);
|
|
17453
|
+
// Reflog (#781) snaps to 0 on filter change rather than rectifying.
|
|
17454
|
+
// The list is chronological and the user is unlikely to be tracking
|
|
17455
|
+
// a specific entry through filter changes — the simpler reset
|
|
17456
|
+
// matches the "find recovery target by typing" interaction.
|
|
17457
|
+
const reflogIndex = filterChanged ? 0 : state.selectedReflogIndex;
|
|
17335
17458
|
return {
|
|
17336
17459
|
...state,
|
|
17337
17460
|
filter,
|
|
@@ -17341,6 +17464,7 @@ function withFilter$1(state, filter, promotedSelections) {
|
|
|
17341
17464
|
selectedBranchIndex: branchIndex,
|
|
17342
17465
|
selectedTagIndex: tagIndex,
|
|
17343
17466
|
selectedStashIndex: stashIndex,
|
|
17467
|
+
selectedReflogIndex: reflogIndex,
|
|
17344
17468
|
diffPreviewOffset: 0,
|
|
17345
17469
|
pendingKey: undefined,
|
|
17346
17470
|
};
|
|
@@ -17430,6 +17554,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
17430
17554
|
selectedStashIndex: 0,
|
|
17431
17555
|
selectedWorktreeListIndex: 0,
|
|
17432
17556
|
selectedConflictFileIndex: 0,
|
|
17557
|
+
selectedReflogIndex: 0,
|
|
17433
17558
|
branchSort: DEFAULT_BRANCH_SORT_MODE,
|
|
17434
17559
|
tagSort: DEFAULT_TAG_SORT_MODE,
|
|
17435
17560
|
paletteFilter: '',
|
|
@@ -17677,6 +17802,12 @@ function applyLogInkAction(state, action) {
|
|
|
17677
17802
|
selectedStashIndex: clampIndex$1(state.selectedStashIndex + action.delta, action.count),
|
|
17678
17803
|
pendingKey: undefined,
|
|
17679
17804
|
};
|
|
17805
|
+
case 'moveReflog':
|
|
17806
|
+
return {
|
|
17807
|
+
...state,
|
|
17808
|
+
selectedReflogIndex: clampIndex$1(state.selectedReflogIndex + action.delta, action.count),
|
|
17809
|
+
pendingKey: undefined,
|
|
17810
|
+
};
|
|
17680
17811
|
case 'moveWorktreeListEntry':
|
|
17681
17812
|
return {
|
|
17682
17813
|
...state,
|
|
@@ -17901,6 +18032,31 @@ function applyLogInkAction(state, action) {
|
|
|
17901
18032
|
worktreeDiffOffset: 0,
|
|
17902
18033
|
};
|
|
17903
18034
|
}
|
|
18035
|
+
case 'navigateOpenDiffForCompare': {
|
|
18036
|
+
const next = withPushedView(state, 'diff');
|
|
18037
|
+
return {
|
|
18038
|
+
...next,
|
|
18039
|
+
diffSource: 'compare',
|
|
18040
|
+
compareBase: action.base,
|
|
18041
|
+
compareHead: action.head,
|
|
18042
|
+
// Reset scroll offset so the compare patch always opens at
|
|
18043
|
+
// the top — same reasoning as the stash branch above.
|
|
18044
|
+
diffPreviewOffset: 0,
|
|
18045
|
+
worktreeDiffOffset: 0,
|
|
18046
|
+
};
|
|
18047
|
+
}
|
|
18048
|
+
case 'setCompareBase':
|
|
18049
|
+
return {
|
|
18050
|
+
...state,
|
|
18051
|
+
compareBase: action.value,
|
|
18052
|
+
pendingKey: undefined,
|
|
18053
|
+
};
|
|
18054
|
+
case 'clearCompareBase':
|
|
18055
|
+
return {
|
|
18056
|
+
...state,
|
|
18057
|
+
compareBase: undefined,
|
|
18058
|
+
pendingKey: undefined,
|
|
18059
|
+
};
|
|
17904
18060
|
case 'navigateOpenComposeForFile': {
|
|
17905
18061
|
const next = withPushedView(state, 'status');
|
|
17906
18062
|
return {
|
|
@@ -18256,10 +18412,66 @@ function isStashActionTarget(state) {
|
|
|
18256
18412
|
return (state.activeView === 'stash' && state.focus === 'commits') ||
|
|
18257
18413
|
(state.focus === 'sidebar' && state.sidebarTab === 'stashes');
|
|
18258
18414
|
}
|
|
18415
|
+
/**
|
|
18416
|
+
* Reflog has no sidebar tab — only the dedicated promoted view (#781).
|
|
18417
|
+
* The condition stays as a single helper anyway so navigation handlers
|
|
18418
|
+
* can read it the same way they do for the other promoted views.
|
|
18419
|
+
*/
|
|
18420
|
+
function isReflogActionTarget(state) {
|
|
18421
|
+
return state.activeView === 'reflog' && state.focus === 'commits';
|
|
18422
|
+
}
|
|
18259
18423
|
function isWorktreeActionTarget(state) {
|
|
18260
18424
|
return (state.activeView === 'worktrees' && state.focus === 'commits') ||
|
|
18261
18425
|
(state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
|
|
18262
18426
|
}
|
|
18427
|
+
/**
|
|
18428
|
+
* Compare-flow target views (#779). The `m` mark + Enter-as-compare
|
|
18429
|
+
* overrides only fire on rows that represent a single ref the user
|
|
18430
|
+
* could pass to `git diff <ref>..<ref>` — branches, tags, and history
|
|
18431
|
+
* commits. The reflog view is intentionally excluded because reflog
|
|
18432
|
+
* entries are *moves* of HEAD, not refs a user typically diffs against.
|
|
18433
|
+
*/
|
|
18434
|
+
function isCompareFlowTarget(state) {
|
|
18435
|
+
if (state.focus !== 'commits')
|
|
18436
|
+
return false;
|
|
18437
|
+
return state.activeView === 'branches' ||
|
|
18438
|
+
state.activeView === 'tags' ||
|
|
18439
|
+
state.activeView === 'history';
|
|
18440
|
+
}
|
|
18441
|
+
/**
|
|
18442
|
+
* Resolve the cursored ref for the compare flow (#779). Pulls the
|
|
18443
|
+
* concrete ref + label off context for branches / tags, and reads the
|
|
18444
|
+
* commit row from state for history. Returns undefined when no usable
|
|
18445
|
+
* ref is under the cursor (e.g., the views are empty, or the focus is
|
|
18446
|
+
* on the synthetic "(+) new commit" row).
|
|
18447
|
+
*/
|
|
18448
|
+
function getCursoredCompareRef(state, context) {
|
|
18449
|
+
if (state.activeView === 'branches' && context.branchSelectedShortName) {
|
|
18450
|
+
return {
|
|
18451
|
+
kind: 'branch',
|
|
18452
|
+
ref: context.branchSelectedShortName,
|
|
18453
|
+
label: context.branchSelectedShortName,
|
|
18454
|
+
};
|
|
18455
|
+
}
|
|
18456
|
+
if (state.activeView === 'tags' && context.tagSelectedName) {
|
|
18457
|
+
return {
|
|
18458
|
+
kind: 'tag',
|
|
18459
|
+
ref: context.tagSelectedName,
|
|
18460
|
+
label: context.tagSelectedName,
|
|
18461
|
+
};
|
|
18462
|
+
}
|
|
18463
|
+
if (state.activeView === 'history' && !state.pendingCommitFocused) {
|
|
18464
|
+
const commit = state.filteredCommits[state.selectedIndex];
|
|
18465
|
+
if (commit) {
|
|
18466
|
+
return {
|
|
18467
|
+
kind: 'commit',
|
|
18468
|
+
ref: commit.hash,
|
|
18469
|
+
label: `${commit.shortHash} ${commit.message}`.trim(),
|
|
18470
|
+
};
|
|
18471
|
+
}
|
|
18472
|
+
}
|
|
18473
|
+
return undefined;
|
|
18474
|
+
}
|
|
18263
18475
|
/**
|
|
18264
18476
|
* Item count for the active sidebar tab — used by the generic
|
|
18265
18477
|
* sidebar-Enter handler to decide whether to defer to the per-entity
|
|
@@ -18359,6 +18571,20 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
18359
18571
|
return [action({ type: 'pushView', value: 'pull-request' })];
|
|
18360
18572
|
case 'navigateConflicts':
|
|
18361
18573
|
return [action({ type: 'pushView', value: 'conflicts' })];
|
|
18574
|
+
case 'navigateReflog':
|
|
18575
|
+
return [action({ type: 'pushView', value: 'reflog' })];
|
|
18576
|
+
case 'navigateBisect':
|
|
18577
|
+
return [action({ type: 'pushView', value: 'bisect' })];
|
|
18578
|
+
case 'markForCompare':
|
|
18579
|
+
// Palette context can't reach the cursored ref (filtered branch /
|
|
18580
|
+
// tag lists live in runtime state, not the reducer). Surface a
|
|
18581
|
+
// hint and let the user press `m` directly on the row. The
|
|
18582
|
+
// inline keypress handler further down in this file does the
|
|
18583
|
+
// actual work and has access to the necessary context.
|
|
18584
|
+
return [action({
|
|
18585
|
+
type: 'setStatus',
|
|
18586
|
+
value: 'open branches / tags / history and press m on the cursored ref',
|
|
18587
|
+
})];
|
|
18362
18588
|
case 'navigateBack':
|
|
18363
18589
|
return [action({ type: 'popView' })];
|
|
18364
18590
|
case 'openSelected': {
|
|
@@ -18836,6 +19062,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18836
19062
|
action({ type: 'setStatus', value: 'jumped to conflicts' }),
|
|
18837
19063
|
];
|
|
18838
19064
|
}
|
|
19065
|
+
// `gr` chord: jump to the reflog browser (#781). Recovery view —
|
|
19066
|
+
// chronological list of reflog entries with Enter to drill into the
|
|
19067
|
+
// commit-diff for the entry's hash. Loaded lazily by the runtime.
|
|
19068
|
+
if (state.pendingKey === 'g' && inputValue === 'r') {
|
|
19069
|
+
return [
|
|
19070
|
+
action({ type: 'pushView', value: 'reflog' }),
|
|
19071
|
+
action({ type: 'setStatus', value: 'jumped to reflog' }),
|
|
19072
|
+
];
|
|
19073
|
+
}
|
|
19074
|
+
// `gB` chord: jump to the bisect workflow view (#784). Capital B
|
|
19075
|
+
// disambiguates from `gb` (branches). Always navigates — even when
|
|
19076
|
+
// bisect is inactive — so the user can see the empty-state hint and
|
|
19077
|
+
// know how to start one. The view's surface tells them the next step.
|
|
19078
|
+
if (state.pendingKey === 'g' && inputValue === 'B') {
|
|
19079
|
+
return [
|
|
19080
|
+
action({ type: 'pushView', value: 'bisect' }),
|
|
19081
|
+
action({ type: 'setStatus', value: 'jumped to bisect' }),
|
|
19082
|
+
];
|
|
19083
|
+
}
|
|
18839
19084
|
// `gH` chord: apply the cursored hunk to the index (`git apply
|
|
18840
19085
|
// --cached`). Sibling of bare `H` which targets the worktree.
|
|
18841
19086
|
// Discoverable via the footer hint on diff views and the help
|
|
@@ -18875,6 +19120,29 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18875
19120
|
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
|
|
18876
19121
|
];
|
|
18877
19122
|
}
|
|
19123
|
+
// #784 — bisect view action keys. Scoped to `state.activeView ===
|
|
19124
|
+
// 'bisect' && state.focus === 'commits'` so the single-letter keys
|
|
19125
|
+
// stay free everywhere else. `g` and `b` collide with the global
|
|
19126
|
+
// chord prefix and the `gb` continuation respectively — placed
|
|
19127
|
+
// BEFORE the bare-`g` chord trigger below so a `g` keystroke on
|
|
19128
|
+
// the bisect view marks good rather than entering chord mode. The
|
|
19129
|
+
// user's path back out of bisect is `<` / `esc`, never a chord;
|
|
19130
|
+
// the in-bisect view itself can't navigate elsewhere via `g`-prefix
|
|
19131
|
+
// chords until the user exits with `esc` first.
|
|
19132
|
+
if (state.activeView === 'bisect' && state.focus === 'commits') {
|
|
19133
|
+
if (inputValue === 'g' && state.pendingKey !== 'g') {
|
|
19134
|
+
return [{ type: 'runWorkflowAction', id: 'bisect-good' }];
|
|
19135
|
+
}
|
|
19136
|
+
if (inputValue === 'b' && state.pendingKey !== 'g') {
|
|
19137
|
+
return [{ type: 'runWorkflowAction', id: 'bisect-bad' }];
|
|
19138
|
+
}
|
|
19139
|
+
if (inputValue === 's') {
|
|
19140
|
+
return [{ type: 'runWorkflowAction', id: 'bisect-skip' }];
|
|
19141
|
+
}
|
|
19142
|
+
if (inputValue === 'x') {
|
|
19143
|
+
return [action({ type: 'setPendingConfirmation', value: 'bisect-reset' })];
|
|
19144
|
+
}
|
|
19145
|
+
}
|
|
18878
19146
|
if (inputValue === 'g') {
|
|
18879
19147
|
if (state.pendingKey === 'g') {
|
|
18880
19148
|
return [
|
|
@@ -19142,6 +19410,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19142
19410
|
if (isStashActionTarget(state) && context.stashCount) {
|
|
19143
19411
|
return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
|
|
19144
19412
|
}
|
|
19413
|
+
if (isReflogActionTarget(state) && context.reflogCount) {
|
|
19414
|
+
return [action({ type: 'moveReflog', delta: -1, count: context.reflogCount })];
|
|
19415
|
+
}
|
|
19145
19416
|
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
19146
19417
|
return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
|
|
19147
19418
|
}
|
|
@@ -19223,6 +19494,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19223
19494
|
if (isStashActionTarget(state) && context.stashCount) {
|
|
19224
19495
|
return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
|
|
19225
19496
|
}
|
|
19497
|
+
if (isReflogActionTarget(state) && context.reflogCount) {
|
|
19498
|
+
return [action({ type: 'moveReflog', delta: 1, count: context.reflogCount })];
|
|
19499
|
+
}
|
|
19226
19500
|
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
19227
19501
|
return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
|
|
19228
19502
|
}
|
|
@@ -19294,6 +19568,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19294
19568
|
action({ type: 'setStatus', value: 'staging worktree changes' }),
|
|
19295
19569
|
];
|
|
19296
19570
|
}
|
|
19571
|
+
// Compare-flow Enter override (#779). When `compareBase` is set and
|
|
19572
|
+
// the user presses Enter on a branch / tag / history commit row, we
|
|
19573
|
+
// open the compare diff (base..head) instead of the row's normal
|
|
19574
|
+
// action (checkout / drill-in / diff). Scoped to compare-flow
|
|
19575
|
+
// targets so non-flow views keep their Enter intact. Runs BEFORE
|
|
19576
|
+
// the per-row Enter handlers below so the override wins, including
|
|
19577
|
+
// before the history-row drill-in.
|
|
19578
|
+
if (key.return && state.compareBase && isCompareFlowTarget(state)) {
|
|
19579
|
+
const head = getCursoredCompareRef(state, context);
|
|
19580
|
+
if (!head) {
|
|
19581
|
+
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
|
|
19582
|
+
}
|
|
19583
|
+
if (head.ref === state.compareBase.ref && head.kind === state.compareBase.kind) {
|
|
19584
|
+
return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one' })];
|
|
19585
|
+
}
|
|
19586
|
+
return [
|
|
19587
|
+
action({
|
|
19588
|
+
type: 'navigateOpenDiffForCompare',
|
|
19589
|
+
base: state.compareBase,
|
|
19590
|
+
head,
|
|
19591
|
+
}),
|
|
19592
|
+
action({ type: 'setStatus', value: `Comparing ${state.compareBase.label} → ${head.label}` }),
|
|
19593
|
+
];
|
|
19594
|
+
}
|
|
19297
19595
|
if (key.return &&
|
|
19298
19596
|
state.activeView === 'history' &&
|
|
19299
19597
|
state.focus === 'commits' &&
|
|
@@ -19310,6 +19608,28 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19310
19608
|
];
|
|
19311
19609
|
}
|
|
19312
19610
|
}
|
|
19611
|
+
// Enter on a reflog row drills into the diff for that entry's hash
|
|
19612
|
+
// (#781). Reuses `navigateOpenDiffForCommit`, which finds the commit
|
|
19613
|
+
// by hash in `state.filteredCommits` first and falls back to
|
|
19614
|
+
// `commitIndex` only when the hash isn't present. Reflog hashes that
|
|
19615
|
+
// exist in the loaded history (the common case) drill in cleanly;
|
|
19616
|
+
// dangling-commit hashes fall back to the index. The `commitIndex`
|
|
19617
|
+
// we pass is best-effort — index in `state.commits` if found, else
|
|
19618
|
+
// `state.selectedIndex` so the cursor stays sane on the diff view.
|
|
19619
|
+
if (key.return &&
|
|
19620
|
+
isReflogActionTarget(state) &&
|
|
19621
|
+
context.reflogSelectedHash) {
|
|
19622
|
+
const sha = context.reflogSelectedHash;
|
|
19623
|
+
const fallbackIndex = state.commits.findIndex((commit) => commit.hash === sha);
|
|
19624
|
+
return [
|
|
19625
|
+
action({
|
|
19626
|
+
type: 'navigateOpenDiffForCommit',
|
|
19627
|
+
sha,
|
|
19628
|
+
commitIndex: fallbackIndex >= 0 ? fallbackIndex : state.selectedIndex,
|
|
19629
|
+
}),
|
|
19630
|
+
action({ type: 'setStatus', value: `viewing diff for ${sha.slice(0, 7)}` }),
|
|
19631
|
+
];
|
|
19632
|
+
}
|
|
19313
19633
|
// Inspector Actions tab: Enter on the cursored action fires its
|
|
19314
19634
|
// associated event (cherry-pick / revert / yank / etc.). Wins over
|
|
19315
19635
|
// the file-list Enter below when the user has [/]-toggled to the
|
|
@@ -19489,6 +19809,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19489
19809
|
if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
|
|
19490
19810
|
return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
|
|
19491
19811
|
}
|
|
19812
|
+
// `m` marks (or un-marks) the cursored ref as the compare base
|
|
19813
|
+
// (#779). Scoped to compare-flow targets so it doesn't collide with
|
|
19814
|
+
// the `m` PR-merge handler further down. The toggle behavior — `m`
|
|
19815
|
+
// again on the same ref clears the base — gives the user a way to
|
|
19816
|
+
// bail out without remembering a separate cancel key.
|
|
19817
|
+
if (inputValue === 'm' && isCompareFlowTarget(state)) {
|
|
19818
|
+
const ref = getCursoredCompareRef(state, context);
|
|
19819
|
+
if (!ref) {
|
|
19820
|
+
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
|
|
19821
|
+
}
|
|
19822
|
+
if (state.compareBase && state.compareBase.ref === ref.ref && state.compareBase.kind === ref.kind) {
|
|
19823
|
+
return [
|
|
19824
|
+
action({ type: 'clearCompareBase' }),
|
|
19825
|
+
action({ type: 'setStatus', value: `Cleared compare base ${ref.label}` }),
|
|
19826
|
+
];
|
|
19827
|
+
}
|
|
19828
|
+
return [
|
|
19829
|
+
action({ type: 'setCompareBase', value: ref }),
|
|
19830
|
+
action({ type: 'setStatus', value: `Compare base: ${ref.label} — press enter on another ref to diff` }),
|
|
19831
|
+
];
|
|
19832
|
+
}
|
|
19492
19833
|
// Per-view worktree action: `D` removes the worktree AND deletes
|
|
19493
19834
|
// the branch it was tracking (#838). Scoped to the worktrees
|
|
19494
19835
|
// surface so it intercepts BEFORE the global workflow-by-key
|
|
@@ -20867,6 +21208,12 @@ function formatLogInkStatusEmpty({ hasChanges }) {
|
|
|
20867
21208
|
}
|
|
20868
21209
|
return 'Worktree clean. Press gh for history, gb for branches, gz for stash.';
|
|
20869
21210
|
}
|
|
21211
|
+
function formatLogInkReflogEmpty({ filter }) {
|
|
21212
|
+
if (filter.trim()) {
|
|
21213
|
+
return `No reflog entries match filter '${filter}'. Press ctrl+u to clear.`;
|
|
21214
|
+
}
|
|
21215
|
+
return 'No reflog entries. Activity in this repo will appear here over time.';
|
|
21216
|
+
}
|
|
20870
21217
|
function formatLogInkComposeEmpty({ hasStaged }) {
|
|
20871
21218
|
if (hasStaged) {
|
|
20872
21219
|
return undefined;
|
|
@@ -22496,14 +22843,14 @@ function deleteRemoteTag(git, tagName) {
|
|
|
22496
22843
|
return runAction$2(() => git.raw(['push', 'origin', `:${tagName}`]), `Deleted remote tag ${tagName}`);
|
|
22497
22844
|
}
|
|
22498
22845
|
|
|
22499
|
-
const FIELD_SEPARATOR = '\x1f';
|
|
22846
|
+
const FIELD_SEPARATOR$1 = '\x1f';
|
|
22500
22847
|
function parseTagRefs(output) {
|
|
22501
22848
|
return output
|
|
22502
22849
|
.split('\n')
|
|
22503
22850
|
.map((line) => line.trimEnd())
|
|
22504
22851
|
.filter(Boolean)
|
|
22505
22852
|
.map((line) => {
|
|
22506
|
-
const [name, hash, date, subject] = line.split(FIELD_SEPARATOR);
|
|
22853
|
+
const [name, hash, date, subject] = line.split(FIELD_SEPARATOR$1);
|
|
22507
22854
|
return {
|
|
22508
22855
|
name,
|
|
22509
22856
|
hash,
|
|
@@ -22515,7 +22862,7 @@ function parseTagRefs(output) {
|
|
|
22515
22862
|
async function getTagOverview(git) {
|
|
22516
22863
|
const output = await git.raw([
|
|
22517
22864
|
'for-each-ref',
|
|
22518
|
-
`--format=%(refname:short)${FIELD_SEPARATOR}%(objectname:short)${FIELD_SEPARATOR}%(creatordate:short)${FIELD_SEPARATOR}%(subject)`,
|
|
22865
|
+
`--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
|
|
22519
22866
|
'--sort=-creatordate',
|
|
22520
22867
|
'refs/tags',
|
|
22521
22868
|
]);
|
|
@@ -24774,6 +25121,231 @@ function formatPullRequestStateLine(pr) {
|
|
|
24774
25121
|
return parts.join(' · ');
|
|
24775
25122
|
}
|
|
24776
25123
|
|
|
25124
|
+
const EMPTY_STATUS = {
|
|
25125
|
+
active: false,
|
|
25126
|
+
currentSha: '',
|
|
25127
|
+
log: [],
|
|
25128
|
+
};
|
|
25129
|
+
async function bisectIsActive(git) {
|
|
25130
|
+
try {
|
|
25131
|
+
const path = (await git.revparse(['--git-path', 'BISECT_LOG'])).trim();
|
|
25132
|
+
return path.length > 0 && fs.existsSync(path);
|
|
25133
|
+
}
|
|
25134
|
+
catch {
|
|
25135
|
+
return false;
|
|
25136
|
+
}
|
|
25137
|
+
}
|
|
25138
|
+
/**
|
|
25139
|
+
* Parse the output of `git bisect log` into structured entries. Each
|
|
25140
|
+
* entry corresponds to one user decision (start / good / bad / skip)
|
|
25141
|
+
* or the "# bad: [<sha>] <subject>" comment lines git emits for
|
|
25142
|
+
* traceability. Comment lines without a recognized prefix are dropped
|
|
25143
|
+
* — they're informational headers ("# status: ..."), not actions
|
|
25144
|
+
* the user took.
|
|
25145
|
+
*/
|
|
25146
|
+
function parseBisectLog(output) {
|
|
25147
|
+
const entries = [];
|
|
25148
|
+
for (const rawLine of output.split('\n')) {
|
|
25149
|
+
const line = rawLine.trimEnd();
|
|
25150
|
+
if (!line)
|
|
25151
|
+
continue;
|
|
25152
|
+
// Comment rows: "# good: [sha] subject" / "# bad: [sha] subject" /
|
|
25153
|
+
// "# first bad commit: ..." / "# status: ...". The first two carry
|
|
25154
|
+
// the most user-relevant info (which commits were marked) so we
|
|
25155
|
+
// promote them to typed entries; the rest fall through as raw
|
|
25156
|
+
// lines tagged 'unknown' so the renderer can dim them or hide
|
|
25157
|
+
// entirely.
|
|
25158
|
+
if (line.startsWith('#')) {
|
|
25159
|
+
const commentMatch = line.match(/^#\s+(good|bad|skip):\s+\[([^\]]+)\]\s*(.*)$/);
|
|
25160
|
+
if (commentMatch) {
|
|
25161
|
+
entries.push({
|
|
25162
|
+
kind: commentMatch[1],
|
|
25163
|
+
sha: commentMatch[2],
|
|
25164
|
+
subject: commentMatch[3] || undefined,
|
|
25165
|
+
raw: line,
|
|
25166
|
+
});
|
|
25167
|
+
continue;
|
|
25168
|
+
}
|
|
25169
|
+
entries.push({ kind: 'unknown', raw: line });
|
|
25170
|
+
continue;
|
|
25171
|
+
}
|
|
25172
|
+
// Command rows: "git bisect start", "git bisect good <sha>",
|
|
25173
|
+
// "git bisect bad <sha>", "git bisect skip <sha>".
|
|
25174
|
+
const commandMatch = line.match(/^git\s+bisect\s+(start|good|bad|skip)\s*(.*)$/);
|
|
25175
|
+
if (commandMatch) {
|
|
25176
|
+
const sha = commandMatch[2]?.trim().split(/\s+/)[0] || undefined;
|
|
25177
|
+
entries.push({
|
|
25178
|
+
kind: commandMatch[1],
|
|
25179
|
+
sha: sha || undefined,
|
|
25180
|
+
raw: line,
|
|
25181
|
+
});
|
|
25182
|
+
continue;
|
|
25183
|
+
}
|
|
25184
|
+
entries.push({ kind: 'unknown', raw: line });
|
|
25185
|
+
}
|
|
25186
|
+
return entries;
|
|
25187
|
+
}
|
|
25188
|
+
/**
|
|
25189
|
+
* Load the live bisect status. Best-effort — when bisect isn't
|
|
25190
|
+
* active the empty-status sentinel returns immediately so callers
|
|
25191
|
+
* don't pay for a `git bisect log` round-trip on every refresh.
|
|
25192
|
+
*/
|
|
25193
|
+
async function getBisectStatus(git) {
|
|
25194
|
+
if (!(await bisectIsActive(git))) {
|
|
25195
|
+
return EMPTY_STATUS;
|
|
25196
|
+
}
|
|
25197
|
+
let log = [];
|
|
25198
|
+
try {
|
|
25199
|
+
const output = await git.raw(['bisect', 'log']);
|
|
25200
|
+
log = parseBisectLog(output);
|
|
25201
|
+
}
|
|
25202
|
+
catch {
|
|
25203
|
+
// bisect log can fail on a freshly-started bisect with no decisions.
|
|
25204
|
+
// Treat the absence of a parseable log as "active but empty" rather
|
|
25205
|
+
// than non-active, so the surface still routes to the bisect view
|
|
25206
|
+
// and the user can see the badge.
|
|
25207
|
+
log = [];
|
|
25208
|
+
}
|
|
25209
|
+
let currentSha = '';
|
|
25210
|
+
try {
|
|
25211
|
+
currentSha = (await git.revparse(['HEAD'])).trim();
|
|
25212
|
+
}
|
|
25213
|
+
catch {
|
|
25214
|
+
currentSha = '';
|
|
25215
|
+
}
|
|
25216
|
+
return { active: true, currentSha, log };
|
|
25217
|
+
}
|
|
25218
|
+
|
|
25219
|
+
/**
|
|
25220
|
+
* Thin wrappers around `git bisect <verb>` for the TUI's in-bisect
|
|
25221
|
+
* action keys (#784). Each returns the raw stdout so the surface can
|
|
25222
|
+
* surface git's own "Bisecting: N revisions left to test after this
|
|
25223
|
+
* (roughly K steps)" hint as a status message — that wording is the
|
|
25224
|
+
* single most useful piece of feedback git emits during bisect, and
|
|
25225
|
+
* mirroring it keeps the TUI's status line authoritative.
|
|
25226
|
+
*
|
|
25227
|
+
* No try/catch here — git itself returns non-zero on user errors
|
|
25228
|
+
* (already-bisecting, no good ref, etc.) and `simple-git` surfaces
|
|
25229
|
+
* those as rejections. The runtime catches them and routes to the
|
|
25230
|
+
* status line.
|
|
25231
|
+
*/
|
|
25232
|
+
async function bisectGood(git, ref) {
|
|
25233
|
+
const args = ['bisect', 'good'];
|
|
25234
|
+
return git.raw(args);
|
|
25235
|
+
}
|
|
25236
|
+
async function bisectBad(git, ref) {
|
|
25237
|
+
const args = ['bisect', 'bad'];
|
|
25238
|
+
return git.raw(args);
|
|
25239
|
+
}
|
|
25240
|
+
async function bisectSkip(git, ref) {
|
|
25241
|
+
const args = ['bisect', 'skip'];
|
|
25242
|
+
return git.raw(args);
|
|
25243
|
+
}
|
|
25244
|
+
async function bisectReset(git) {
|
|
25245
|
+
return git.raw(['bisect', 'reset']);
|
|
25246
|
+
}
|
|
25247
|
+
/**
|
|
25248
|
+
* Pull the user-facing remaining-revisions hint out of `git bisect`
|
|
25249
|
+
* stdout. Looks for the canonical line:
|
|
25250
|
+
*
|
|
25251
|
+
* `Bisecting: N revisions left to test after this (roughly K steps)`
|
|
25252
|
+
*
|
|
25253
|
+
* Returns undefined when the line isn't present (e.g. the run
|
|
25254
|
+
* finished and git emitted a "<sha> is the first bad commit" line
|
|
25255
|
+
* instead). Callers fall back to an empty status update in that case.
|
|
25256
|
+
*/
|
|
25257
|
+
function extractBisectRemainingHint(stdout) {
|
|
25258
|
+
for (const line of stdout.split('\n').reverse()) {
|
|
25259
|
+
const trimmed = line.trim();
|
|
25260
|
+
if (trimmed.startsWith('Bisecting:'))
|
|
25261
|
+
return trimmed;
|
|
25262
|
+
if (trimmed.includes('is the first bad commit'))
|
|
25263
|
+
return trimmed;
|
|
25264
|
+
}
|
|
25265
|
+
return undefined;
|
|
25266
|
+
}
|
|
25267
|
+
|
|
25268
|
+
/**
|
|
25269
|
+
* Compare two refs (branches / tags / commits) and return the unified
|
|
25270
|
+
* patch as line-split string output (#779).
|
|
25271
|
+
*
|
|
25272
|
+
* Mirrors the stash-diff loader's contract — emits `string[]` so the
|
|
25273
|
+
* existing diff surface can render the lines through its standard
|
|
25274
|
+
* +/-/@@ coloring path. Two-dot syntax (`base..head`) gives the
|
|
25275
|
+
* "what changed on head, relative to base" view that's natural for
|
|
25276
|
+
* branch reviews and pre-merge sanity checks.
|
|
25277
|
+
*
|
|
25278
|
+
* Defensive about input — both refs are passed as-is to git, so the
|
|
25279
|
+
* caller is responsible for providing a git-resolvable form
|
|
25280
|
+
* (branch shortName, tag name, or commit hash). On any git error
|
|
25281
|
+
* (unknown ref, etc.) the runtime's `safe()` wrapper at the call
|
|
25282
|
+
* site catches the throw and the surface falls back to a "no diff"
|
|
25283
|
+
* hint.
|
|
25284
|
+
*/
|
|
25285
|
+
async function getCompareDiff(git, base, head) {
|
|
25286
|
+
return (await git.raw(['diff', `${base}..${head}`]))
|
|
25287
|
+
.split('\n')
|
|
25288
|
+
.map((line) => line.replace(/\r$/, ''));
|
|
25289
|
+
}
|
|
25290
|
+
|
|
25291
|
+
const FIELD_SEPARATOR = '\x1f';
|
|
25292
|
+
/**
|
|
25293
|
+
* Default fetch limit. 200 entries is enough to span weeks of normal
|
|
25294
|
+
* activity for an active repo while keeping the load fast — `git reflog`
|
|
25295
|
+
* is local-only so even 1000+ entries is sub-second, but 200 keeps the
|
|
25296
|
+
* rendered list bounded for terminals.
|
|
25297
|
+
*/
|
|
25298
|
+
const DEFAULT_REFLOG_LIMIT = 200;
|
|
25299
|
+
function parseReflogOverview(output) {
|
|
25300
|
+
return output
|
|
25301
|
+
.split('\n')
|
|
25302
|
+
.map((line) => line.trimEnd())
|
|
25303
|
+
.filter(Boolean)
|
|
25304
|
+
.map((line) => {
|
|
25305
|
+
const [selector, hash, relativeDate, subject] = line.split(FIELD_SEPARATOR);
|
|
25306
|
+
return {
|
|
25307
|
+
selector: selector || '',
|
|
25308
|
+
hash: hash || '',
|
|
25309
|
+
relativeDate: relativeDate || '',
|
|
25310
|
+
subject: subject || '',
|
|
25311
|
+
};
|
|
25312
|
+
});
|
|
25313
|
+
}
|
|
25314
|
+
async function getReflogOverview(git, limit = DEFAULT_REFLOG_LIMIT) {
|
|
25315
|
+
const output = await git.raw([
|
|
25316
|
+
'reflog',
|
|
25317
|
+
`--max-count=${limit}`,
|
|
25318
|
+
`--pretty=format:%gd${FIELD_SEPARATOR}%h${FIELD_SEPARATOR}%cr${FIELD_SEPARATOR}%gs`,
|
|
25319
|
+
]);
|
|
25320
|
+
return {
|
|
25321
|
+
entries: parseReflogOverview(output),
|
|
25322
|
+
};
|
|
25323
|
+
}
|
|
25324
|
+
/**
|
|
25325
|
+
* Pull the action prefix off a reflog subject. Reflog subjects follow
|
|
25326
|
+
* a `<verb>[ qualifier]: <message>` pattern emitted by git itself —
|
|
25327
|
+
* "commit: ...", "commit (amend): ...", "checkout: moving from main
|
|
25328
|
+
* to feature", "merge feature: ...", "reset: moving to HEAD~1", etc.
|
|
25329
|
+
*
|
|
25330
|
+
* For display we want the verb (and any parenthetical qualifier) on
|
|
25331
|
+
* its own so the view can render a fixed-width `action` column and
|
|
25332
|
+
* keep the rest of the message left-aligned.
|
|
25333
|
+
*
|
|
25334
|
+
* Defensive: if the subject has no colon, the whole string is treated
|
|
25335
|
+
* as the action and the message is empty. This keeps the renderer
|
|
25336
|
+
* from crashing on a malformed entry.
|
|
25337
|
+
*/
|
|
25338
|
+
function splitReflogSubject(subject) {
|
|
25339
|
+
const colonIndex = subject.indexOf(':');
|
|
25340
|
+
if (colonIndex === -1) {
|
|
25341
|
+
return { action: subject.trim(), message: '' };
|
|
25342
|
+
}
|
|
25343
|
+
return {
|
|
25344
|
+
action: subject.slice(0, colonIndex).trim(),
|
|
25345
|
+
message: subject.slice(colonIndex + 1).trim(),
|
|
25346
|
+
};
|
|
25347
|
+
}
|
|
25348
|
+
|
|
24777
25349
|
function sectionLines(title, diff) {
|
|
24778
25350
|
const lines = diff.split('\n').map((line) => line.trimEnd());
|
|
24779
25351
|
return [
|
|
@@ -24886,7 +25458,7 @@ async function safe(promise) {
|
|
|
24886
25458
|
}
|
|
24887
25459
|
}
|
|
24888
25460
|
async function loadLogInkContext(git) {
|
|
24889
|
-
const [branches, pullRequest, tags, worktree, stashes, worktreeList, operation, provider] = await Promise.all([
|
|
25461
|
+
const [branches, pullRequest, tags, worktree, stashes, worktreeList, operation, provider, reflog, bisect] = await Promise.all([
|
|
24890
25462
|
safe(getBranchOverview(git)),
|
|
24891
25463
|
safe(getPullRequestOverview(git)),
|
|
24892
25464
|
safe(getTagOverview(git)),
|
|
@@ -24895,12 +25467,16 @@ async function loadLogInkContext(git) {
|
|
|
24895
25467
|
safe(getWorktreeListOverview(git)),
|
|
24896
25468
|
safe(getGitOperationOverview(git)),
|
|
24897
25469
|
safe(getProviderOverview(git)),
|
|
25470
|
+
safe(getReflogOverview(git)),
|
|
25471
|
+
safe(getBisectStatus(git)),
|
|
24898
25472
|
]);
|
|
24899
25473
|
return {
|
|
25474
|
+
bisect,
|
|
24900
25475
|
branches,
|
|
24901
25476
|
operation,
|
|
24902
25477
|
provider,
|
|
24903
25478
|
pullRequest,
|
|
25479
|
+
reflog,
|
|
24904
25480
|
stashes,
|
|
24905
25481
|
tags,
|
|
24906
25482
|
worktree,
|
|
@@ -24926,6 +25502,14 @@ function loadLogInkContextEntries(git) {
|
|
|
24926
25502
|
key: 'tags',
|
|
24927
25503
|
load: () => safe(getTagOverview(git)),
|
|
24928
25504
|
},
|
|
25505
|
+
{
|
|
25506
|
+
key: 'reflog',
|
|
25507
|
+
load: () => safe(getReflogOverview(git)),
|
|
25508
|
+
},
|
|
25509
|
+
{
|
|
25510
|
+
key: 'bisect',
|
|
25511
|
+
load: () => safe(getBisectStatus(git)),
|
|
25512
|
+
},
|
|
24929
25513
|
{
|
|
24930
25514
|
key: 'worktree',
|
|
24931
25515
|
load: () => safe(getWorktreeOverview(git)),
|
|
@@ -25314,6 +25898,10 @@ function LogInkApp(deps) {
|
|
|
25314
25898
|
// colors match the commit-diff path.
|
|
25315
25899
|
const [stashDiffLines, setStashDiffLines] = React.useState(undefined);
|
|
25316
25900
|
const [stashDiffLoading, setStashDiffLoading] = React.useState(false);
|
|
25901
|
+
// #779 — compare-two-refs diff state. Loaded lazily when the diff
|
|
25902
|
+
// view becomes active with `diffSource === 'compare'`.
|
|
25903
|
+
const [compareDiffLines, setCompareDiffLines] = React.useState(undefined);
|
|
25904
|
+
const [compareDiffLoading, setCompareDiffLoading] = React.useState(false);
|
|
25317
25905
|
const [hasMoreCommits, setHasMoreCommits] = React.useState(() => (Boolean(logArgv?.interactive && !logArgv.limit) &&
|
|
25318
25906
|
getCommitRows(rows).length >= LOG_INTERACTIVE_DEFAULT_LIMIT));
|
|
25319
25907
|
const [loadingMoreCommits, setLoadingMoreCommits] = React.useState(false);
|
|
@@ -25408,6 +25996,12 @@ function LogInkApp(deps) {
|
|
|
25408
25996
|
return all;
|
|
25409
25997
|
return all.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || ''], state.filter));
|
|
25410
25998
|
}, [context.worktreeList?.worktrees, state.filter]);
|
|
25999
|
+
const filteredReflogList = React.useMemo(() => {
|
|
26000
|
+
const all = context.reflog?.entries || [];
|
|
26001
|
+
if (!state.filter)
|
|
26002
|
+
return all;
|
|
26003
|
+
return all.filter((entry) => matchesPromotedFilter([entry.selector, entry.hash, entry.relativeDate, entry.subject], state.filter));
|
|
26004
|
+
}, [context.reflog?.entries, state.filter]);
|
|
25411
26005
|
const dispatch = React.useCallback((action) => {
|
|
25412
26006
|
setState((current) => applyLogInkAction(current, action));
|
|
25413
26007
|
}, []);
|
|
@@ -25618,6 +26212,70 @@ function LogInkApp(deps) {
|
|
|
25618
26212
|
})();
|
|
25619
26213
|
return () => { active = false; };
|
|
25620
26214
|
}, [git, state.activeView, state.diffSource, state.stashDiffRef]);
|
|
26215
|
+
// #879 (item 2) — load commit detail for the active bisect's
|
|
26216
|
+
// current candidate so the bisect surface can show "what changed
|
|
26217
|
+
// here" alongside the decision keys. Mirrors the history-detail
|
|
26218
|
+
// loader's shape but keyed on `bisect.currentSha` and only fires
|
|
26219
|
+
// when the bisect view is active. Best-effort: any failure leaves
|
|
26220
|
+
// the surface in its non-detail mode (decision log only) — never
|
|
26221
|
+
// crash the workstation because git couldn't resolve a sha.
|
|
26222
|
+
const [bisectCandidateDetail, setBisectCandidateDetail] = React.useState(undefined);
|
|
26223
|
+
const [bisectCandidateLoading, setBisectCandidateLoading] = React.useState(false);
|
|
26224
|
+
const bisectCandidateSha = state.activeView === 'bisect' && context.bisect?.active
|
|
26225
|
+
? context.bisect.currentSha
|
|
26226
|
+
: '';
|
|
26227
|
+
React.useEffect(() => {
|
|
26228
|
+
if (!bisectCandidateSha) {
|
|
26229
|
+
setBisectCandidateDetail(undefined);
|
|
26230
|
+
setBisectCandidateLoading(false);
|
|
26231
|
+
return;
|
|
26232
|
+
}
|
|
26233
|
+
let active = true;
|
|
26234
|
+
setBisectCandidateLoading(true);
|
|
26235
|
+
void (async () => {
|
|
26236
|
+
const next = await safe(getCommitDetail(git, bisectCandidateSha));
|
|
26237
|
+
if (active) {
|
|
26238
|
+
setBisectCandidateDetail(next);
|
|
26239
|
+
setBisectCandidateLoading(false);
|
|
26240
|
+
}
|
|
26241
|
+
})();
|
|
26242
|
+
return () => { active = false; };
|
|
26243
|
+
}, [git, bisectCandidateSha]);
|
|
26244
|
+
// #779 — load `git diff <base>..<head>` once the diff view becomes
|
|
26245
|
+
// active with diffSource='compare'. Mirrors the stash loader's
|
|
26246
|
+
// shape; the surface renders the lines via the same +/-/@@ coloring
|
|
26247
|
+
// path. On unknown ref / git error, `safe()` swallows and the
|
|
26248
|
+
// surface falls back to a "no diff" hint.
|
|
26249
|
+
const compareBaseRef = state.compareBase?.ref;
|
|
26250
|
+
const compareHeadRef = state.compareHead?.ref;
|
|
26251
|
+
React.useEffect(() => {
|
|
26252
|
+
if (state.activeView !== 'diff' ||
|
|
26253
|
+
state.diffSource !== 'compare' ||
|
|
26254
|
+
!compareBaseRef ||
|
|
26255
|
+
!compareHeadRef) {
|
|
26256
|
+
return;
|
|
26257
|
+
}
|
|
26258
|
+
let active = true;
|
|
26259
|
+
setCompareDiffLoading(true);
|
|
26260
|
+
void (async () => {
|
|
26261
|
+
const lines = await safe(getCompareDiff(git, compareBaseRef, compareHeadRef));
|
|
26262
|
+
if (active) {
|
|
26263
|
+
setCompareDiffLines(lines || []);
|
|
26264
|
+
setCompareDiffLoading(false);
|
|
26265
|
+
}
|
|
26266
|
+
})();
|
|
26267
|
+
return () => { active = false; };
|
|
26268
|
+
}, [git, state.activeView, state.diffSource, compareBaseRef, compareHeadRef]);
|
|
26269
|
+
// Reset compare-diff state whenever the diff view exits. Without
|
|
26270
|
+
// this, opening a new compare immediately after closing one would
|
|
26271
|
+
// briefly show the previous comparison's lines while the new
|
|
26272
|
+
// loader runs.
|
|
26273
|
+
React.useEffect(() => {
|
|
26274
|
+
if (state.diffSource !== 'compare') {
|
|
26275
|
+
setCompareDiffLines(undefined);
|
|
26276
|
+
setCompareDiffLoading(false);
|
|
26277
|
+
}
|
|
26278
|
+
}, [state.diffSource]);
|
|
25621
26279
|
React.useEffect(() => {
|
|
25622
26280
|
let active = true;
|
|
25623
26281
|
async function loadWorktreeHunks() {
|
|
@@ -26128,6 +26786,50 @@ function LogInkApp(deps) {
|
|
|
26128
26786
|
return { ok: false, message: 'No stash selected' };
|
|
26129
26787
|
return popStash(git, stash);
|
|
26130
26788
|
},
|
|
26789
|
+
'bisect-good': async () => {
|
|
26790
|
+
if (!context.bisect?.active)
|
|
26791
|
+
return { ok: false, message: 'No bisect in progress' };
|
|
26792
|
+
try {
|
|
26793
|
+
const stdout = await bisectGood(git);
|
|
26794
|
+
return { ok: true, message: extractBisectRemainingHint(stdout) || 'Marked good' };
|
|
26795
|
+
}
|
|
26796
|
+
catch (error) {
|
|
26797
|
+
return { ok: false, message: `Bisect good failed: ${error.message}` };
|
|
26798
|
+
}
|
|
26799
|
+
},
|
|
26800
|
+
'bisect-bad': async () => {
|
|
26801
|
+
if (!context.bisect?.active)
|
|
26802
|
+
return { ok: false, message: 'No bisect in progress' };
|
|
26803
|
+
try {
|
|
26804
|
+
const stdout = await bisectBad(git);
|
|
26805
|
+
return { ok: true, message: extractBisectRemainingHint(stdout) || 'Marked bad' };
|
|
26806
|
+
}
|
|
26807
|
+
catch (error) {
|
|
26808
|
+
return { ok: false, message: `Bisect bad failed: ${error.message}` };
|
|
26809
|
+
}
|
|
26810
|
+
},
|
|
26811
|
+
'bisect-skip': async () => {
|
|
26812
|
+
if (!context.bisect?.active)
|
|
26813
|
+
return { ok: false, message: 'No bisect in progress' };
|
|
26814
|
+
try {
|
|
26815
|
+
const stdout = await bisectSkip(git);
|
|
26816
|
+
return { ok: true, message: extractBisectRemainingHint(stdout) || 'Skipped' };
|
|
26817
|
+
}
|
|
26818
|
+
catch (error) {
|
|
26819
|
+
return { ok: false, message: `Bisect skip failed: ${error.message}` };
|
|
26820
|
+
}
|
|
26821
|
+
},
|
|
26822
|
+
'bisect-reset': async () => {
|
|
26823
|
+
if (!context.bisect?.active)
|
|
26824
|
+
return { ok: false, message: 'No bisect in progress' };
|
|
26825
|
+
try {
|
|
26826
|
+
await bisectReset(git);
|
|
26827
|
+
return { ok: true, message: 'Bisect reset' };
|
|
26828
|
+
}
|
|
26829
|
+
catch (error) {
|
|
26830
|
+
return { ok: false, message: `Bisect reset failed: ${error.message}` };
|
|
26831
|
+
}
|
|
26832
|
+
},
|
|
26131
26833
|
'checkout-file-from-stash': async () => {
|
|
26132
26834
|
const path = payload?.trim();
|
|
26133
26835
|
const ref = state.stashDiffRef;
|
|
@@ -26800,9 +27502,13 @@ function LogInkApp(deps) {
|
|
|
26800
27502
|
// perf pass) — reading them here is O(1) instead of O(branches +
|
|
26801
27503
|
// tags + stashes + worktrees) per keystroke.
|
|
26802
27504
|
const branchVisibleCount = filteredBranchList.length;
|
|
27505
|
+
const branchSelectedShortName = filteredBranchList[Math.min(state.selectedBranchIndex, Math.max(0, filteredBranchList.length - 1))]?.shortName;
|
|
26803
27506
|
const tagVisibleCount = filteredTagList.length;
|
|
27507
|
+
const tagSelectedName = filteredTagList[Math.min(state.selectedTagIndex, Math.max(0, filteredTagList.length - 1))]?.name;
|
|
26804
27508
|
const stashVisibleCount = filteredStashList.length;
|
|
26805
27509
|
const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
|
|
27510
|
+
const reflogVisibleCount = filteredReflogList.length;
|
|
27511
|
+
const reflogSelectedHash = filteredReflogList[Math.min(state.selectedReflogIndex, Math.max(0, filteredReflogList.length - 1))]?.hash;
|
|
26806
27512
|
const worktreeVisibleCount = filteredWorktreeList.length;
|
|
26807
27513
|
// When the diff view is showing a stash patch, swap the previewLineCount
|
|
26808
27514
|
// to the stash diff length so the existing pageDetailPreview path
|
|
@@ -26827,8 +27533,12 @@ function LogInkApp(deps) {
|
|
|
26827
27533
|
worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
|
|
26828
27534
|
commitDiffHunkOffsets,
|
|
26829
27535
|
branchCount: branchVisibleCount,
|
|
27536
|
+
branchSelectedShortName,
|
|
26830
27537
|
tagCount: tagVisibleCount,
|
|
27538
|
+
tagSelectedName,
|
|
26831
27539
|
stashCount: stashVisibleCount,
|
|
27540
|
+
reflogCount: reflogVisibleCount,
|
|
27541
|
+
reflogSelectedHash,
|
|
26832
27542
|
stashSelectedRef,
|
|
26833
27543
|
stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
|
|
26834
27544
|
stashDiffSelectedPath,
|
|
@@ -26934,12 +27644,15 @@ function LogInkApp(deps) {
|
|
|
26934
27644
|
if (showOnboarding) {
|
|
26935
27645
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
26936
27646
|
}
|
|
26937
|
-
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));
|
|
27647
|
+
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, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, 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));
|
|
26938
27648
|
}
|
|
26939
27649
|
function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
|
|
26940
27650
|
const { Box, Text } = components;
|
|
26941
27651
|
const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
|
|
26942
|
-
|
|
27652
|
+
// #784 — surface bisect-in-progress in the title bar so users entering
|
|
27653
|
+
// the TUI mid-bisect see it immediately, before they navigate to gB.
|
|
27654
|
+
const dirtyBase = context.branches?.dirty ? 'dirty' : 'clean';
|
|
27655
|
+
const dirty = context.bisect?.active ? `${dirtyBase} · BISECTING` : dirtyBase;
|
|
26943
27656
|
const repo = context.provider?.repository.owner && context.provider.repository.name
|
|
26944
27657
|
? `${context.provider.repository.owner}/${context.provider.repository.name}`
|
|
26945
27658
|
: 'local repository';
|
|
@@ -27194,12 +27907,12 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
|
|
|
27194
27907
|
: []),
|
|
27195
27908
|
];
|
|
27196
27909
|
}
|
|
27197
|
-
function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
|
|
27910
|
+
function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
|
|
27198
27911
|
if (state.activeView === 'status') {
|
|
27199
27912
|
return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
27200
27913
|
}
|
|
27201
27914
|
if (state.activeView === 'diff') {
|
|
27202
|
-
return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme);
|
|
27915
|
+
return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme);
|
|
27203
27916
|
}
|
|
27204
27917
|
if (state.activeView === 'compose') {
|
|
27205
27918
|
return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
@@ -27210,6 +27923,12 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
27210
27923
|
if (state.activeView === 'tags') {
|
|
27211
27924
|
return renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
27212
27925
|
}
|
|
27926
|
+
if (state.activeView === 'reflog') {
|
|
27927
|
+
return renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
27928
|
+
}
|
|
27929
|
+
if (state.activeView === 'bisect') {
|
|
27930
|
+
return renderBisectSurface(h, components, state, context, contextStatus, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme);
|
|
27931
|
+
}
|
|
27213
27932
|
if (state.activeView === 'stash') {
|
|
27214
27933
|
return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
27215
27934
|
}
|
|
@@ -27837,6 +28556,211 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
27837
28556
|
width,
|
|
27838
28557
|
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
|
|
27839
28558
|
}
|
|
28559
|
+
/**
|
|
28560
|
+
* Promoted reflog browser (#781). Mirrors `renderTagsSurface` visually
|
|
28561
|
+
* — same header / filter affordance / footer hint conventions — but
|
|
28562
|
+
* lays out four columns per row: relative date, action prefix, short
|
|
28563
|
+
* hash, and message. Filtering matches against all four (so typing
|
|
28564
|
+
* "checkout" narrows to checkout entries, "abc" narrows to a hash).
|
|
28565
|
+
*
|
|
28566
|
+
* Per-row layout uses fixed column widths derived from the visible
|
|
28567
|
+
* window so short-action rows don't leave a wide gutter and long
|
|
28568
|
+
* actions don't push the message off-screen. The cap mirrors the
|
|
28569
|
+
* tags surface's name-column treatment.
|
|
28570
|
+
*/
|
|
28571
|
+
function renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
28572
|
+
const { Box, Text } = components;
|
|
28573
|
+
const focused = state.focus === 'commits';
|
|
28574
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'reflog');
|
|
28575
|
+
const allEntries = context.reflog?.entries || [];
|
|
28576
|
+
const entries = state.filter
|
|
28577
|
+
? allEntries.filter((entry) => matchesPromotedFilter([entry.selector, entry.hash, entry.relativeDate, entry.subject], state.filter))
|
|
28578
|
+
: allEntries;
|
|
28579
|
+
const selected = Math.max(0, Math.min(state.selectedReflogIndex, Math.max(0, entries.length - 1)));
|
|
28580
|
+
const listRows = Math.max(4, bodyRows - 4);
|
|
28581
|
+
const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
|
|
28582
|
+
const visible = entries.slice(startIndex, startIndex + listRows);
|
|
28583
|
+
const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
|
|
28584
|
+
const headerRight = loading
|
|
28585
|
+
? 'loading reflog'
|
|
28586
|
+
: `${entries.length}/${allEntries.length} entries${filterLabel}`;
|
|
28587
|
+
const emptyLabel = formatLogInkReflogEmpty({ filter: state.filter });
|
|
28588
|
+
const loadingLabel = formatLogInkLoading({ resource: 'reflog' });
|
|
28589
|
+
// Column widths derived from the visible window. The hash column is
|
|
28590
|
+
// fixed (short SHA is always 7 chars) and the date column caps so
|
|
28591
|
+
// "X minutes ago" / "Y hours ago" stays readable without dominating
|
|
28592
|
+
// the row. Action column scales to the longest visible action so
|
|
28593
|
+
// commit / checkout / merge align cleanly.
|
|
28594
|
+
const splitVisible = visible.map((entry) => ({
|
|
28595
|
+
entry,
|
|
28596
|
+
parts: splitReflogSubject(entry.subject),
|
|
28597
|
+
}));
|
|
28598
|
+
const dateColWidth = splitVisible.length === 0
|
|
28599
|
+
? 16
|
|
28600
|
+
: Math.min(20, Math.max(6, ...splitVisible.map(({ entry }) => entry.relativeDate.length)));
|
|
28601
|
+
const actionColWidth = splitVisible.length === 0
|
|
28602
|
+
? 12
|
|
28603
|
+
: Math.min(24, Math.max(6, ...splitVisible.map(({ parts }) => parts.action.length)));
|
|
28604
|
+
const hashColWidth = 8;
|
|
28605
|
+
const lines = loading
|
|
28606
|
+
? [h(Text, { key: 'reflog-loading', dimColor: true }, loadingLabel)]
|
|
28607
|
+
: entries.length === 0
|
|
28608
|
+
? [h(Text, { key: 'reflog-empty', dimColor: true }, emptyLabel)]
|
|
28609
|
+
: splitVisible.map(({ entry, parts }, offset) => {
|
|
28610
|
+
const index = startIndex + offset;
|
|
28611
|
+
const isSelected = index === selected;
|
|
28612
|
+
const cursor = isSelected ? '>' : ' ';
|
|
28613
|
+
const datePadded = truncate$1(entry.relativeDate, dateColWidth).padEnd(dateColWidth);
|
|
28614
|
+
const actionPadded = truncate$1(parts.action, actionColWidth).padEnd(actionColWidth);
|
|
28615
|
+
const hashPadded = truncate$1(entry.hash, hashColWidth).padEnd(hashColWidth);
|
|
28616
|
+
const message = parts.message || entry.subject;
|
|
28617
|
+
const lineText = truncate$1(`${cursor} ${datePadded} ${actionPadded} ${hashPadded} ${message}`, Math.max(20, width - 4));
|
|
28618
|
+
return h(Text, {
|
|
28619
|
+
key: `reflog-${index}`,
|
|
28620
|
+
bold: isSelected,
|
|
28621
|
+
dimColor: !isSelected,
|
|
28622
|
+
}, lineText);
|
|
28623
|
+
});
|
|
28624
|
+
return h(Box, {
|
|
28625
|
+
borderColor: focusBorderColor(theme, focused),
|
|
28626
|
+
borderStyle: theme.borderStyle,
|
|
28627
|
+
flexDirection: 'column',
|
|
28628
|
+
flexShrink: 0,
|
|
28629
|
+
paddingX: 1,
|
|
28630
|
+
width,
|
|
28631
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Reflog', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
|
|
28632
|
+
}
|
|
28633
|
+
/**
|
|
28634
|
+
* Bisect workflow surface (#784). Shows the current candidate commit
|
|
28635
|
+
* (HEAD), a parsed view of recent decisions from `git bisect log`, and
|
|
28636
|
+
* the four action keys (g good, b bad, s skip, x reset).
|
|
28637
|
+
*
|
|
28638
|
+
* When bisect is inactive, the surface renders an empty-state hint
|
|
28639
|
+
* pointing the user at the CLI to start one. The view stays
|
|
28640
|
+
* navigable so the user can read the documentation before starting
|
|
28641
|
+
* — they can't break anything from here.
|
|
28642
|
+
*/
|
|
28643
|
+
function renderBisectSurface(h, components, state, context, contextStatus, candidateDetail, candidateLoading, bodyRows, width, theme) {
|
|
28644
|
+
const { Box, Text } = components;
|
|
28645
|
+
const focused = state.focus === 'commits';
|
|
28646
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
|
|
28647
|
+
const bisect = context.bisect;
|
|
28648
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
28649
|
+
const lines = [];
|
|
28650
|
+
if (loading) {
|
|
28651
|
+
lines.push(h(Text, { key: 'bisect-loading', dimColor: true }, truncate$1('· Loading bisect status…', width - 4)));
|
|
28652
|
+
}
|
|
28653
|
+
else if (!bisect?.active) {
|
|
28654
|
+
// Empty-state explainer (#879). Teaches the bisect workflow in
|
|
28655
|
+
// ~30 seconds: what it is, how it works, how to start one (CLI
|
|
28656
|
+
// entry remains the supported on-ramp until the in-TUI start
|
|
28657
|
+
// child item lands), and a tip about picking the good anchor.
|
|
28658
|
+
// Bisect is a rarely-used feature even for experienced users —
|
|
28659
|
+
// shipping it with terse copy assumes muscle memory the median
|
|
28660
|
+
// user doesn't have.
|
|
28661
|
+
const empty = [
|
|
28662
|
+
{ key: 'title', text: 'Bisect — find the commit that introduced a bug.', opts: { bold: true } },
|
|
28663
|
+
{ key: 'spacer-1', text: '' },
|
|
28664
|
+
{ key: 'how-h', text: 'How it works', opts: { bold: true } },
|
|
28665
|
+
{ key: 'how-1', text: ' Binary search through history. You mark commits as "good" (bug' },
|
|
28666
|
+
{ key: 'how-2', text: ' not present) or "bad" (bug present); git narrows the range until' },
|
|
28667
|
+
{ key: 'how-3', text: ' it identifies the first bad commit.' },
|
|
28668
|
+
{ key: 'spacer-2', text: '' },
|
|
28669
|
+
{ key: 'start-h', text: 'How to start', opts: { bold: true } },
|
|
28670
|
+
{ key: 'start-1', text: ' From your shell:' },
|
|
28671
|
+
{ key: 'start-2', text: ' git bisect start <bad-ref> <good-ref>', opts: { accent: true } },
|
|
28672
|
+
{ key: 'start-3', text: ' Then come back here — coco picks up the active bisect and gives' },
|
|
28673
|
+
{ key: 'start-4', text: ' you single-keystroke controls:' },
|
|
28674
|
+
{ key: 'start-5', text: ' g mark good s skip (e.g. doesn\'t build)', opts: { accent: true } },
|
|
28675
|
+
{ key: 'start-6', text: ' b mark bad x reset / cancel', opts: { accent: true } },
|
|
28676
|
+
{ key: 'spacer-3', text: '' },
|
|
28677
|
+
{ key: 'tip-h', text: 'Tip', opts: { bold: true } },
|
|
28678
|
+
{ key: 'tip-1', text: ' Pick a recent release tag as your "good" anchor if you don\'t' },
|
|
28679
|
+
{ key: 'tip-2', text: ' remember when the bug appeared. Tags are visible from the tags' },
|
|
28680
|
+
{ key: 'tip-3', text: ' view (g t).' },
|
|
28681
|
+
];
|
|
28682
|
+
for (const row of empty) {
|
|
28683
|
+
lines.push(h(Text, {
|
|
28684
|
+
key: `bisect-empty-${row.key}`,
|
|
28685
|
+
bold: row.opts?.bold,
|
|
28686
|
+
dimColor: row.opts?.dim,
|
|
28687
|
+
color: row.opts?.accent ? accent : undefined,
|
|
28688
|
+
}, truncate$1(row.text, width - 4)));
|
|
28689
|
+
}
|
|
28690
|
+
}
|
|
28691
|
+
else {
|
|
28692
|
+
// Active bisect. Three-section body: current candidate (sha +
|
|
28693
|
+
// commit summary so the user can judge the diff at a glance),
|
|
28694
|
+
// recent decisions, action hints. Action keys live in the footer.
|
|
28695
|
+
const headerSha = bisect.currentSha ? bisect.currentSha.slice(0, 8) : '<unknown>';
|
|
28696
|
+
lines.push(h(Text, { key: 'bisect-active-title', bold: true }, truncate$1(`Bisecting · current candidate ${headerSha}`, width - 4)));
|
|
28697
|
+
// #879 (item 2) — render commit detail for the current candidate.
|
|
28698
|
+
// Lets the user judge "does this look like it would cause the bug?"
|
|
28699
|
+
// before they run their tests, instead of dropping to shell to
|
|
28700
|
+
// git show. Loading is brief (one git show invocation) and the
|
|
28701
|
+
// surface falls back to just the sha header when the detail
|
|
28702
|
+
// hasn't arrived yet (or git rejected the lookup).
|
|
28703
|
+
if (candidateLoading) {
|
|
28704
|
+
lines.push(h(Text, { key: 'bisect-candidate-loading', dimColor: true }, truncate$1(' loading commit detail…', width - 4)));
|
|
28705
|
+
}
|
|
28706
|
+
else if (candidateDetail) {
|
|
28707
|
+
const { author, date, message, body, stats, files } = candidateDetail;
|
|
28708
|
+
lines.push(h(Text, { key: 'bisect-candidate-subject' }, truncate$1(` ${message}`, width - 4)));
|
|
28709
|
+
lines.push(h(Text, { key: 'bisect-candidate-author', dimColor: true }, truncate$1(` ${author} · ${date}`, width - 4)));
|
|
28710
|
+
// Body line — first non-empty line of the commit body, truncated.
|
|
28711
|
+
// Skip the noisy preamble (subject + blank line) by taking the
|
|
28712
|
+
// first paragraph after the title; body===subject is common for
|
|
28713
|
+
// single-line commits and we filter that out.
|
|
28714
|
+
const firstBodyLine = (body || '')
|
|
28715
|
+
.split('\n')
|
|
28716
|
+
.map((line) => line.trim())
|
|
28717
|
+
.find((line) => line.length > 0 && line !== message);
|
|
28718
|
+
if (firstBodyLine) {
|
|
28719
|
+
lines.push(h(Text, { key: 'bisect-candidate-body', dimColor: true }, truncate$1(` ${firstBodyLine}`, width - 4)));
|
|
28720
|
+
}
|
|
28721
|
+
// Stats summary: total file count + +/- numbers, then a few
|
|
28722
|
+
// file names so the user sees scope at a glance. Cap the
|
|
28723
|
+
// file-name list at 3 entries to keep the section bounded.
|
|
28724
|
+
lines.push(h(Text, { key: 'bisect-candidate-stats' }, truncate$1(` ${stats.filesChanged} file${stats.filesChanged === 1 ? '' : 's'} · +${stats.insertions} / -${stats.deletions}`, width - 4)));
|
|
28725
|
+
const sampleFiles = files.slice(0, 3).map((file) => file.path);
|
|
28726
|
+
if (sampleFiles.length > 0) {
|
|
28727
|
+
const overflow = files.length > sampleFiles.length ? ` (+${files.length - sampleFiles.length} more)` : '';
|
|
28728
|
+
lines.push(h(Text, { key: 'bisect-candidate-files', dimColor: true }, truncate$1(` ${sampleFiles.join(', ')}${overflow}`, width - 4)));
|
|
28729
|
+
}
|
|
28730
|
+
}
|
|
28731
|
+
// Spacer separates the candidate section from decisions.
|
|
28732
|
+
lines.push(h(Text, { key: 'bisect-active-spacer' }, ''));
|
|
28733
|
+
const decisions = bisect.log.filter((entry) => entry.kind === 'good' || entry.kind === 'bad' || entry.kind === 'skip');
|
|
28734
|
+
if (decisions.length === 0) {
|
|
28735
|
+
lines.push(h(Text, { key: 'bisect-no-decisions', dimColor: true }, truncate$1('No decisions logged yet — press g (good) or b (bad) to record one.', width - 4)));
|
|
28736
|
+
}
|
|
28737
|
+
else {
|
|
28738
|
+
lines.push(h(Text, { key: 'bisect-decisions-header', bold: true }, truncate$1(`Decisions (${decisions.length}):`, width - 4)));
|
|
28739
|
+
const recent = decisions.slice(-Math.max(4, bodyRows - 8));
|
|
28740
|
+
for (const entry of recent) {
|
|
28741
|
+
const kindLabel = entry.kind.toUpperCase().padEnd(5);
|
|
28742
|
+
const sha = (entry.sha || '<unknown>').padEnd(8);
|
|
28743
|
+
const subject = entry.subject || '';
|
|
28744
|
+
const text = ` ${kindLabel} ${sha} ${subject}`;
|
|
28745
|
+
lines.push(h(Text, {
|
|
28746
|
+
key: `bisect-entry-${entry.raw}`,
|
|
28747
|
+
dimColor: entry.kind === 'skip',
|
|
28748
|
+
bold: entry.kind === 'bad',
|
|
28749
|
+
}, truncate$1(text, width - 4)));
|
|
28750
|
+
}
|
|
28751
|
+
}
|
|
28752
|
+
lines.push(h(Text, { key: 'bisect-action-spacer' }, ''));
|
|
28753
|
+
lines.push(h(Text, { key: 'bisect-action-hint', dimColor: true }, truncate$1('Actions: g good · b bad · s skip · x reset', width - 4)));
|
|
28754
|
+
}
|
|
28755
|
+
return h(Box, {
|
|
28756
|
+
borderColor: focusBorderColor(theme, focused),
|
|
28757
|
+
borderStyle: theme.borderStyle,
|
|
28758
|
+
flexDirection: 'column',
|
|
28759
|
+
flexShrink: 0,
|
|
28760
|
+
paddingX: 1,
|
|
28761
|
+
width,
|
|
28762
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Bisect', focused)), h(Text, { dimColor: true }, bisect?.active ? 'BISECTING' : 'inactive')), ...lines);
|
|
28763
|
+
}
|
|
27840
28764
|
function renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
27841
28765
|
const { Box, Text } = components;
|
|
27842
28766
|
const focused = state.focus === 'commits';
|
|
@@ -28042,7 +28966,7 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
|
|
|
28042
28966
|
h(Text, { key: 'promoted-filter-input', color: accent }, `filter: ${state.filter}_`),
|
|
28043
28967
|
];
|
|
28044
28968
|
}
|
|
28045
|
-
function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme) {
|
|
28969
|
+
function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme) {
|
|
28046
28970
|
const { Box, Text } = components;
|
|
28047
28971
|
const focused = state.focus === 'commits';
|
|
28048
28972
|
const worktree = context.worktree;
|
|
@@ -28133,6 +29057,50 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
28133
29057
|
dimColor: index > 0,
|
|
28134
29058
|
}, truncate$1(line, width - 4))), ...stashBodyNodes);
|
|
28135
29059
|
}
|
|
29060
|
+
// Compare-two-refs branch (#779). Mirrors the stash diff above but
|
|
29061
|
+
// sourced from `git diff <base>..<head>`. No per-file cherry-pick or
|
|
29062
|
+
// hunk apply — comparing arbitrary refs doesn't have a sensible
|
|
29063
|
+
// mutate-from-here flow, so the surface is read-only navigation.
|
|
29064
|
+
if (state.diffSource === 'compare') {
|
|
29065
|
+
const lines = compareDiffLines || [];
|
|
29066
|
+
const splitActive = isSplitDiffViable(state, width);
|
|
29067
|
+
const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
|
|
29068
|
+
const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
29069
|
+
const baseLabel = state.compareBase?.label || state.compareBase?.ref || '<base>';
|
|
29070
|
+
const headLabel = state.compareHead?.label || state.compareHead?.ref || '<head>';
|
|
29071
|
+
const compareTitle = `${baseLabel} → ${headLabel}`;
|
|
29072
|
+
const baseHeaderLines = compareDiffLoading
|
|
29073
|
+
? [`Loading diff for ${compareTitle}...`]
|
|
29074
|
+
: lines.length && (lines.length > 1 || lines[0])
|
|
29075
|
+
? [
|
|
29076
|
+
compareTitle,
|
|
29077
|
+
`Lines ${Math.min(state.diffPreviewOffset + 1, lines.length)}-${Math.min(state.diffPreviewOffset + visibleLines.length, lines.length)}/${lines.length}`,
|
|
29078
|
+
'',
|
|
29079
|
+
]
|
|
29080
|
+
: ['No diff to display — refs may resolve to the same tree.'];
|
|
29081
|
+
const headerLines = splitRequestedButTooNarrow
|
|
29082
|
+
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
29083
|
+
: baseHeaderLines;
|
|
29084
|
+
const compareBodyNodes = compareDiffLoading || !lines.length || (lines.length === 1 && !lines[0])
|
|
29085
|
+
? []
|
|
29086
|
+
: splitActive
|
|
29087
|
+
? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'compare-diff-split')
|
|
29088
|
+
: visibleLines.map((line, index) => h(Text, {
|
|
29089
|
+
key: `compare-diff-line-${state.diffPreviewOffset + index}`,
|
|
29090
|
+
...diffLineProps(line, theme),
|
|
29091
|
+
}, truncate$1(line, width - 4)));
|
|
29092
|
+
return h(Box, {
|
|
29093
|
+
borderColor: focusBorderColor(theme, focused),
|
|
29094
|
+
borderStyle: theme.borderStyle,
|
|
29095
|
+
flexDirection: 'column',
|
|
29096
|
+
flexShrink: 0,
|
|
29097
|
+
paddingX: 1,
|
|
29098
|
+
width,
|
|
29099
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle(splitActive ? 'Compare (split)' : 'Compare', focused)), h(Text, { dimColor: true }, truncate$1(compareTitle, Math.max(20, Math.floor(width / 2))))), ...headerLines.map((line, index) => h(Text, {
|
|
29100
|
+
key: `compare-diff-header-${index}`,
|
|
29101
|
+
dimColor: index > 0,
|
|
29102
|
+
}, truncate$1(line, width - 4))), ...compareBodyNodes);
|
|
29103
|
+
}
|
|
28136
29104
|
// diffSource disambiguates: 'commit' was set when the user opened the
|
|
28137
29105
|
// diff via history → Enter (read-only commit-diff explore), 'worktree'
|
|
28138
29106
|
// was set when they came from status → Enter (stage / hunk / revert).
|
|
@@ -28991,6 +29959,7 @@ function renderFooter(h, components, state, context, theme, idleTip) {
|
|
|
28991
29959
|
showHelp: state.showHelp,
|
|
28992
29960
|
sidebarTab: state.sidebarTab,
|
|
28993
29961
|
sidebarItemCount,
|
|
29962
|
+
compareBaseSet: Boolean(state.compareBase),
|
|
28994
29963
|
});
|
|
28995
29964
|
// Real status messages always win; idle tips only fill the slot when it
|
|
28996
29965
|
// would otherwise be empty.
|