git-coco 0.45.0 → 0.46.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 +899 -20
- package/dist/index.js +899 -20
- package/package.json +4 -4
package/dist/index.esm.mjs
CHANGED
|
@@ -54,7 +54,7 @@ import { pathToFileURL } from 'url';
|
|
|
54
54
|
/**
|
|
55
55
|
* Current build version from package.json
|
|
56
56
|
*/
|
|
57
|
-
const BUILD_VERSION = "0.
|
|
57
|
+
const BUILD_VERSION = "0.46.0";
|
|
58
58
|
|
|
59
59
|
const isInteractive = (config) => {
|
|
60
60
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -14521,7 +14521,7 @@ const builder$3 = (yargs) => {
|
|
|
14521
14521
|
return yargs.options(options$3).usage(getCommandUsageHeader(command$3));
|
|
14522
14522
|
};
|
|
14523
14523
|
|
|
14524
|
-
const FIELD_SEPARATOR$
|
|
14524
|
+
const FIELD_SEPARATOR$3 = '\x1f';
|
|
14525
14525
|
// `%P` (parent hashes, space-separated) lets the TUI distinguish
|
|
14526
14526
|
// merge commits (parents.length > 1) from regular commits without a
|
|
14527
14527
|
// second round-trip to git. See #791 stage 3 — merge glyph + HEAD ring.
|
|
@@ -14574,13 +14574,13 @@ function parseLogOutput(output) {
|
|
|
14574
14574
|
.map((line) => line.trimEnd())
|
|
14575
14575
|
.filter(Boolean)
|
|
14576
14576
|
.map((line) => {
|
|
14577
|
-
if (!line.includes(FIELD_SEPARATOR$
|
|
14577
|
+
if (!line.includes(FIELD_SEPARATOR$3)) {
|
|
14578
14578
|
return {
|
|
14579
14579
|
type: 'graph',
|
|
14580
14580
|
graph: line,
|
|
14581
14581
|
};
|
|
14582
14582
|
}
|
|
14583
|
-
const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$
|
|
14583
|
+
const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$3);
|
|
14584
14584
|
return {
|
|
14585
14585
|
type: 'commit',
|
|
14586
14586
|
graph: graph.trimEnd(),
|
|
@@ -14655,7 +14655,7 @@ function parseNameStatus(output, numstat = []) {
|
|
|
14655
14655
|
function parseCommitDetail(metadata, files, numstatOutput = '') {
|
|
14656
14656
|
const [hash, shortHash, parentsStr, date, author, refs, message, body = ''] = metadata
|
|
14657
14657
|
.trimEnd()
|
|
14658
|
-
.split(FIELD_SEPARATOR$
|
|
14658
|
+
.split(FIELD_SEPARATOR$3);
|
|
14659
14659
|
const numstat = parseNumstat(numstatOutput);
|
|
14660
14660
|
return {
|
|
14661
14661
|
shortHash,
|
|
@@ -14795,14 +14795,14 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
|
|
|
14795
14795
|
};
|
|
14796
14796
|
}
|
|
14797
14797
|
|
|
14798
|
-
const FIELD_SEPARATOR$
|
|
14798
|
+
const FIELD_SEPARATOR$2 = '\x1f';
|
|
14799
14799
|
function parseBranchRefs(output) {
|
|
14800
14800
|
return output
|
|
14801
14801
|
.split('\n')
|
|
14802
14802
|
.map((line) => line.trimEnd())
|
|
14803
14803
|
.filter(Boolean)
|
|
14804
14804
|
.map((line) => {
|
|
14805
|
-
const [refName, shortName, hash, upstream, head, date, subject] = line.split(FIELD_SEPARATOR$
|
|
14805
|
+
const [refName, shortName, hash, upstream, head, date, subject] = line.split(FIELD_SEPARATOR$2);
|
|
14806
14806
|
if (!refName || !shortName) {
|
|
14807
14807
|
return undefined;
|
|
14808
14808
|
}
|
|
@@ -14842,7 +14842,7 @@ async function getBranchOverview(git) {
|
|
|
14842
14842
|
const [branchOutput, statusOutput, currentBranchOutput] = await Promise.all([
|
|
14843
14843
|
git.raw([
|
|
14844
14844
|
'for-each-ref',
|
|
14845
|
-
`--format=%(refname)${FIELD_SEPARATOR$
|
|
14845
|
+
`--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)`,
|
|
14846
14846
|
'refs/heads',
|
|
14847
14847
|
'refs/remotes',
|
|
14848
14848
|
]),
|
|
@@ -15392,10 +15392,12 @@ async function runCommitDraftWorkflow(input = {}) {
|
|
|
15392
15392
|
}
|
|
15393
15393
|
|
|
15394
15394
|
const LOG_INK_CONTEXT_KEYS = [
|
|
15395
|
+
'bisect',
|
|
15395
15396
|
'branches',
|
|
15396
15397
|
'operation',
|
|
15397
15398
|
'provider',
|
|
15398
15399
|
'pullRequest',
|
|
15400
|
+
'reflog',
|
|
15399
15401
|
'stashes',
|
|
15400
15402
|
'tags',
|
|
15401
15403
|
'worktree',
|
|
@@ -16115,6 +16117,45 @@ function getLogInkWorkflowActions() {
|
|
|
16115
16117
|
kind: 'normal',
|
|
16116
16118
|
requiresConfirmation: false,
|
|
16117
16119
|
},
|
|
16120
|
+
{
|
|
16121
|
+
// #784 — bisect workflow actions. All four are scoped per-view in
|
|
16122
|
+
// inkInput (active only when activeView === 'bisect') so the
|
|
16123
|
+
// single-letter keys stay free elsewhere. Empty `key` keeps them
|
|
16124
|
+
// palette-discoverable. Reset is the only destructive one — it
|
|
16125
|
+
// throws away the bisect state — so it routes through y-confirm;
|
|
16126
|
+
// good / bad / skip are recoverable via `git bisect log` and run
|
|
16127
|
+
// immediately.
|
|
16128
|
+
id: 'bisect-good',
|
|
16129
|
+
key: '',
|
|
16130
|
+
label: 'Bisect: mark good',
|
|
16131
|
+
description: 'Mark the current bisect candidate as good and advance to the next one.',
|
|
16132
|
+
kind: 'normal',
|
|
16133
|
+
requiresConfirmation: false,
|
|
16134
|
+
},
|
|
16135
|
+
{
|
|
16136
|
+
id: 'bisect-bad',
|
|
16137
|
+
key: '',
|
|
16138
|
+
label: 'Bisect: mark bad',
|
|
16139
|
+
description: 'Mark the current bisect candidate as bad and advance to the next one.',
|
|
16140
|
+
kind: 'normal',
|
|
16141
|
+
requiresConfirmation: false,
|
|
16142
|
+
},
|
|
16143
|
+
{
|
|
16144
|
+
id: 'bisect-skip',
|
|
16145
|
+
key: '',
|
|
16146
|
+
label: 'Bisect: skip candidate',
|
|
16147
|
+
description: 'Skip the current bisect candidate (e.g. it does not build) and advance.',
|
|
16148
|
+
kind: 'normal',
|
|
16149
|
+
requiresConfirmation: false,
|
|
16150
|
+
},
|
|
16151
|
+
{
|
|
16152
|
+
id: 'bisect-reset',
|
|
16153
|
+
key: '',
|
|
16154
|
+
label: 'Bisect: reset',
|
|
16155
|
+
description: 'End the bisect session and restore HEAD. Discards in-progress bisect state.',
|
|
16156
|
+
kind: 'destructive',
|
|
16157
|
+
requiresConfirmation: true,
|
|
16158
|
+
},
|
|
16118
16159
|
{
|
|
16119
16160
|
id: 'ai-commit-summary',
|
|
16120
16161
|
key: 'I',
|
|
@@ -16351,6 +16392,27 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
16351
16392
|
description: 'Push the conflict resolution helper view (available during merge/rebase/cherry-pick/revert).',
|
|
16352
16393
|
contexts: ['normal'],
|
|
16353
16394
|
},
|
|
16395
|
+
{
|
|
16396
|
+
id: 'navigateReflog',
|
|
16397
|
+
keys: ['gr'],
|
|
16398
|
+
label: 'reflog',
|
|
16399
|
+
description: 'Push the reflog browser view — chronological recovery log.',
|
|
16400
|
+
contexts: ['normal'],
|
|
16401
|
+
},
|
|
16402
|
+
{
|
|
16403
|
+
id: 'navigateBisect',
|
|
16404
|
+
keys: ['gB'],
|
|
16405
|
+
label: 'bisect',
|
|
16406
|
+
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.',
|
|
16407
|
+
contexts: ['normal'],
|
|
16408
|
+
},
|
|
16409
|
+
{
|
|
16410
|
+
id: 'markForCompare',
|
|
16411
|
+
keys: ['m'],
|
|
16412
|
+
label: 'mark compare',
|
|
16413
|
+
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.',
|
|
16414
|
+
contexts: ['commits'],
|
|
16415
|
+
},
|
|
16354
16416
|
{
|
|
16355
16417
|
id: 'navigateBack',
|
|
16356
16418
|
keys: ['<', 'esc'],
|
|
@@ -16481,6 +16543,8 @@ const GLOBAL_BINDING_IDS = [
|
|
|
16481
16543
|
'navigateWorktrees',
|
|
16482
16544
|
'navigatePullRequest',
|
|
16483
16545
|
'navigateConflicts',
|
|
16546
|
+
'navigateReflog',
|
|
16547
|
+
'navigateBisect',
|
|
16484
16548
|
'navigateBack',
|
|
16485
16549
|
];
|
|
16486
16550
|
const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
|
|
@@ -16608,6 +16672,15 @@ function getLogInkFooterHints(options) {
|
|
|
16608
16672
|
global: NORMAL_GLOBAL_HINTS,
|
|
16609
16673
|
};
|
|
16610
16674
|
}
|
|
16675
|
+
if (options.diffSource === 'compare') {
|
|
16676
|
+
// Compare-two-refs (#779): read-only diff with no per-file
|
|
16677
|
+
// cherry-pick or hunk apply (those don't make sense across
|
|
16678
|
+
// arbitrary refs). Just scroll + back out.
|
|
16679
|
+
return {
|
|
16680
|
+
contextual: ['j/k lines', splitToggleHint, 'esc back'],
|
|
16681
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16682
|
+
};
|
|
16683
|
+
}
|
|
16611
16684
|
return {
|
|
16612
16685
|
contextual: ['j/k hunks', 'space stage', 'z revert', 'o edit', 'e/c compose', 'y yank'],
|
|
16613
16686
|
global: NORMAL_GLOBAL_HINTS,
|
|
@@ -16620,14 +16693,26 @@ function getLogInkFooterHints(options) {
|
|
|
16620
16693
|
};
|
|
16621
16694
|
}
|
|
16622
16695
|
if (options.activeView === 'branches') {
|
|
16696
|
+
if (options.compareBaseSet) {
|
|
16697
|
+
return {
|
|
16698
|
+
contextual: ['↑/↓ branches', 'enter compare', 'm clear', 'esc back'],
|
|
16699
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16700
|
+
};
|
|
16701
|
+
}
|
|
16623
16702
|
return {
|
|
16624
|
-
contextual: ['↑/↓ branches', 'enter checkout', '+ new', 'D delete', 's sort', 'y yank'],
|
|
16703
|
+
contextual: ['↑/↓ branches', 'enter checkout', '+ new', 'D delete', 'm compare', 's sort', 'y yank'],
|
|
16625
16704
|
global: NORMAL_GLOBAL_HINTS,
|
|
16626
16705
|
};
|
|
16627
16706
|
}
|
|
16628
16707
|
if (options.activeView === 'tags') {
|
|
16708
|
+
if (options.compareBaseSet) {
|
|
16709
|
+
return {
|
|
16710
|
+
contextual: ['↑/↓ tags', 'enter compare', 'm clear', 'esc back'],
|
|
16711
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16712
|
+
};
|
|
16713
|
+
}
|
|
16629
16714
|
return {
|
|
16630
|
-
contextual: ['↑/↓ tags', '+ new', 'P push', 'T delete', 's sort', 'y yank'],
|
|
16715
|
+
contextual: ['↑/↓ tags', '+ new', 'P push', 'T delete', 'm compare', 's sort', 'y yank'],
|
|
16631
16716
|
global: NORMAL_GLOBAL_HINTS,
|
|
16632
16717
|
};
|
|
16633
16718
|
}
|
|
@@ -16659,6 +16744,28 @@ function getLogInkFooterHints(options) {
|
|
|
16659
16744
|
global: NORMAL_GLOBAL_HINTS,
|
|
16660
16745
|
};
|
|
16661
16746
|
}
|
|
16747
|
+
if (options.activeView === 'reflog') {
|
|
16748
|
+
return {
|
|
16749
|
+
contextual: ['↑/↓ entries', 'enter inspect', 'esc back'],
|
|
16750
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16751
|
+
};
|
|
16752
|
+
}
|
|
16753
|
+
if (options.activeView === 'bisect') {
|
|
16754
|
+
return {
|
|
16755
|
+
contextual: ['g good', 'b bad', 's skip', 'x reset', 'esc back'],
|
|
16756
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16757
|
+
};
|
|
16758
|
+
}
|
|
16759
|
+
if (options.compareBaseSet) {
|
|
16760
|
+
// History view with a compare base set — Enter is overridden to
|
|
16761
|
+
// open the compare diff; show the override + the bail-out key.
|
|
16762
|
+
// Mutate / new chips are dropped so the footer doesn't compete
|
|
16763
|
+
// with the active workflow.
|
|
16764
|
+
return {
|
|
16765
|
+
contextual: ['↑/↓ move', 'enter compare', 'm clear', 'esc back'],
|
|
16766
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16767
|
+
};
|
|
16768
|
+
}
|
|
16662
16769
|
return {
|
|
16663
16770
|
// History view default hints. Mutating ops (`c` cherry-pick, `R`
|
|
16664
16771
|
// revert, `Z` reset, `i` interactive-rebase) all route through a
|
|
@@ -16668,7 +16775,7 @@ function getLogInkFooterHints(options) {
|
|
|
16668
16775
|
// Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
|
|
16669
16776
|
// the footer stays scannable; full descriptions live in `?` help
|
|
16670
16777
|
// and the palette.
|
|
16671
|
-
contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', '
|
|
16778
|
+
contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'm compare', 'y/Y yank', '/ search'],
|
|
16672
16779
|
global: NORMAL_GLOBAL_HINTS,
|
|
16673
16780
|
};
|
|
16674
16781
|
}
|
|
@@ -17247,6 +17354,7 @@ function withPushedView(state, value) {
|
|
|
17247
17354
|
selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
|
|
17248
17355
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
17249
17356
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
17357
|
+
compareHead: value === 'diff' ? state.compareHead : undefined,
|
|
17250
17358
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
17251
17359
|
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
17252
17360
|
pendingKey: undefined,
|
|
@@ -17258,6 +17366,13 @@ function withPoppedView(state) {
|
|
|
17258
17366
|
}
|
|
17259
17367
|
const viewStack = state.viewStack.slice(0, -1);
|
|
17260
17368
|
const next = topOfStack(viewStack);
|
|
17369
|
+
// #779 — compareBase is "cleared when the diff view is popped." We
|
|
17370
|
+
// detect that case by checking if the *previous* top was 'diff'.
|
|
17371
|
+
// The compare workflow ends when the user backs out of the compare
|
|
17372
|
+
// diff; on the next mark they re-set the base. Other view pops
|
|
17373
|
+
// preserve compareBase so the user can move between branches / tags /
|
|
17374
|
+
// history while hunting for a head ref.
|
|
17375
|
+
const wasOnDiff = state.activeView === 'diff';
|
|
17261
17376
|
return {
|
|
17262
17377
|
...state,
|
|
17263
17378
|
activeView: next,
|
|
@@ -17270,6 +17385,8 @@ function withPoppedView(state) {
|
|
|
17270
17385
|
selectedWorktreeHunkIndex: next === 'diff' ? state.selectedWorktreeHunkIndex : 0,
|
|
17271
17386
|
diffSource: next === 'diff' ? state.diffSource : undefined,
|
|
17272
17387
|
stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
|
|
17388
|
+
compareBase: wasOnDiff ? undefined : state.compareBase,
|
|
17389
|
+
compareHead: next === 'diff' ? state.compareHead : undefined,
|
|
17273
17390
|
pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
|
|
17274
17391
|
statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
|
|
17275
17392
|
pendingKey: undefined,
|
|
@@ -17288,6 +17405,7 @@ function withReplacedView(state, value) {
|
|
|
17288
17405
|
selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
|
|
17289
17406
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
17290
17407
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
17408
|
+
compareHead: value === 'diff' ? state.compareHead : undefined,
|
|
17291
17409
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
17292
17410
|
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
17293
17411
|
pendingKey: undefined,
|
|
@@ -17308,6 +17426,11 @@ function withFilter$1(state, filter, promotedSelections) {
|
|
|
17308
17426
|
(filterChanged ? 0 : state.selectedTagIndex);
|
|
17309
17427
|
const stashIndex = promotedSelections?.stashIndex ??
|
|
17310
17428
|
(filterChanged ? 0 : state.selectedStashIndex);
|
|
17429
|
+
// Reflog (#781) snaps to 0 on filter change rather than rectifying.
|
|
17430
|
+
// The list is chronological and the user is unlikely to be tracking
|
|
17431
|
+
// a specific entry through filter changes — the simpler reset
|
|
17432
|
+
// matches the "find recovery target by typing" interaction.
|
|
17433
|
+
const reflogIndex = filterChanged ? 0 : state.selectedReflogIndex;
|
|
17311
17434
|
return {
|
|
17312
17435
|
...state,
|
|
17313
17436
|
filter,
|
|
@@ -17317,6 +17440,7 @@ function withFilter$1(state, filter, promotedSelections) {
|
|
|
17317
17440
|
selectedBranchIndex: branchIndex,
|
|
17318
17441
|
selectedTagIndex: tagIndex,
|
|
17319
17442
|
selectedStashIndex: stashIndex,
|
|
17443
|
+
selectedReflogIndex: reflogIndex,
|
|
17320
17444
|
diffPreviewOffset: 0,
|
|
17321
17445
|
pendingKey: undefined,
|
|
17322
17446
|
};
|
|
@@ -17406,6 +17530,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
17406
17530
|
selectedStashIndex: 0,
|
|
17407
17531
|
selectedWorktreeListIndex: 0,
|
|
17408
17532
|
selectedConflictFileIndex: 0,
|
|
17533
|
+
selectedReflogIndex: 0,
|
|
17409
17534
|
branchSort: DEFAULT_BRANCH_SORT_MODE,
|
|
17410
17535
|
tagSort: DEFAULT_TAG_SORT_MODE,
|
|
17411
17536
|
paletteFilter: '',
|
|
@@ -17653,6 +17778,12 @@ function applyLogInkAction(state, action) {
|
|
|
17653
17778
|
selectedStashIndex: clampIndex$1(state.selectedStashIndex + action.delta, action.count),
|
|
17654
17779
|
pendingKey: undefined,
|
|
17655
17780
|
};
|
|
17781
|
+
case 'moveReflog':
|
|
17782
|
+
return {
|
|
17783
|
+
...state,
|
|
17784
|
+
selectedReflogIndex: clampIndex$1(state.selectedReflogIndex + action.delta, action.count),
|
|
17785
|
+
pendingKey: undefined,
|
|
17786
|
+
};
|
|
17656
17787
|
case 'moveWorktreeListEntry':
|
|
17657
17788
|
return {
|
|
17658
17789
|
...state,
|
|
@@ -17877,6 +18008,31 @@ function applyLogInkAction(state, action) {
|
|
|
17877
18008
|
worktreeDiffOffset: 0,
|
|
17878
18009
|
};
|
|
17879
18010
|
}
|
|
18011
|
+
case 'navigateOpenDiffForCompare': {
|
|
18012
|
+
const next = withPushedView(state, 'diff');
|
|
18013
|
+
return {
|
|
18014
|
+
...next,
|
|
18015
|
+
diffSource: 'compare',
|
|
18016
|
+
compareBase: action.base,
|
|
18017
|
+
compareHead: action.head,
|
|
18018
|
+
// Reset scroll offset so the compare patch always opens at
|
|
18019
|
+
// the top — same reasoning as the stash branch above.
|
|
18020
|
+
diffPreviewOffset: 0,
|
|
18021
|
+
worktreeDiffOffset: 0,
|
|
18022
|
+
};
|
|
18023
|
+
}
|
|
18024
|
+
case 'setCompareBase':
|
|
18025
|
+
return {
|
|
18026
|
+
...state,
|
|
18027
|
+
compareBase: action.value,
|
|
18028
|
+
pendingKey: undefined,
|
|
18029
|
+
};
|
|
18030
|
+
case 'clearCompareBase':
|
|
18031
|
+
return {
|
|
18032
|
+
...state,
|
|
18033
|
+
compareBase: undefined,
|
|
18034
|
+
pendingKey: undefined,
|
|
18035
|
+
};
|
|
17880
18036
|
case 'navigateOpenComposeForFile': {
|
|
17881
18037
|
const next = withPushedView(state, 'status');
|
|
17882
18038
|
return {
|
|
@@ -18232,10 +18388,66 @@ function isStashActionTarget(state) {
|
|
|
18232
18388
|
return (state.activeView === 'stash' && state.focus === 'commits') ||
|
|
18233
18389
|
(state.focus === 'sidebar' && state.sidebarTab === 'stashes');
|
|
18234
18390
|
}
|
|
18391
|
+
/**
|
|
18392
|
+
* Reflog has no sidebar tab — only the dedicated promoted view (#781).
|
|
18393
|
+
* The condition stays as a single helper anyway so navigation handlers
|
|
18394
|
+
* can read it the same way they do for the other promoted views.
|
|
18395
|
+
*/
|
|
18396
|
+
function isReflogActionTarget(state) {
|
|
18397
|
+
return state.activeView === 'reflog' && state.focus === 'commits';
|
|
18398
|
+
}
|
|
18235
18399
|
function isWorktreeActionTarget(state) {
|
|
18236
18400
|
return (state.activeView === 'worktrees' && state.focus === 'commits') ||
|
|
18237
18401
|
(state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
|
|
18238
18402
|
}
|
|
18403
|
+
/**
|
|
18404
|
+
* Compare-flow target views (#779). The `m` mark + Enter-as-compare
|
|
18405
|
+
* overrides only fire on rows that represent a single ref the user
|
|
18406
|
+
* could pass to `git diff <ref>..<ref>` — branches, tags, and history
|
|
18407
|
+
* commits. The reflog view is intentionally excluded because reflog
|
|
18408
|
+
* entries are *moves* of HEAD, not refs a user typically diffs against.
|
|
18409
|
+
*/
|
|
18410
|
+
function isCompareFlowTarget(state) {
|
|
18411
|
+
if (state.focus !== 'commits')
|
|
18412
|
+
return false;
|
|
18413
|
+
return state.activeView === 'branches' ||
|
|
18414
|
+
state.activeView === 'tags' ||
|
|
18415
|
+
state.activeView === 'history';
|
|
18416
|
+
}
|
|
18417
|
+
/**
|
|
18418
|
+
* Resolve the cursored ref for the compare flow (#779). Pulls the
|
|
18419
|
+
* concrete ref + label off context for branches / tags, and reads the
|
|
18420
|
+
* commit row from state for history. Returns undefined when no usable
|
|
18421
|
+
* ref is under the cursor (e.g., the views are empty, or the focus is
|
|
18422
|
+
* on the synthetic "(+) new commit" row).
|
|
18423
|
+
*/
|
|
18424
|
+
function getCursoredCompareRef(state, context) {
|
|
18425
|
+
if (state.activeView === 'branches' && context.branchSelectedShortName) {
|
|
18426
|
+
return {
|
|
18427
|
+
kind: 'branch',
|
|
18428
|
+
ref: context.branchSelectedShortName,
|
|
18429
|
+
label: context.branchSelectedShortName,
|
|
18430
|
+
};
|
|
18431
|
+
}
|
|
18432
|
+
if (state.activeView === 'tags' && context.tagSelectedName) {
|
|
18433
|
+
return {
|
|
18434
|
+
kind: 'tag',
|
|
18435
|
+
ref: context.tagSelectedName,
|
|
18436
|
+
label: context.tagSelectedName,
|
|
18437
|
+
};
|
|
18438
|
+
}
|
|
18439
|
+
if (state.activeView === 'history' && !state.pendingCommitFocused) {
|
|
18440
|
+
const commit = state.filteredCommits[state.selectedIndex];
|
|
18441
|
+
if (commit) {
|
|
18442
|
+
return {
|
|
18443
|
+
kind: 'commit',
|
|
18444
|
+
ref: commit.hash,
|
|
18445
|
+
label: `${commit.shortHash} ${commit.message}`.trim(),
|
|
18446
|
+
};
|
|
18447
|
+
}
|
|
18448
|
+
}
|
|
18449
|
+
return undefined;
|
|
18450
|
+
}
|
|
18239
18451
|
/**
|
|
18240
18452
|
* Item count for the active sidebar tab — used by the generic
|
|
18241
18453
|
* sidebar-Enter handler to decide whether to defer to the per-entity
|
|
@@ -18335,6 +18547,20 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
18335
18547
|
return [action({ type: 'pushView', value: 'pull-request' })];
|
|
18336
18548
|
case 'navigateConflicts':
|
|
18337
18549
|
return [action({ type: 'pushView', value: 'conflicts' })];
|
|
18550
|
+
case 'navigateReflog':
|
|
18551
|
+
return [action({ type: 'pushView', value: 'reflog' })];
|
|
18552
|
+
case 'navigateBisect':
|
|
18553
|
+
return [action({ type: 'pushView', value: 'bisect' })];
|
|
18554
|
+
case 'markForCompare':
|
|
18555
|
+
// Palette context can't reach the cursored ref (filtered branch /
|
|
18556
|
+
// tag lists live in runtime state, not the reducer). Surface a
|
|
18557
|
+
// hint and let the user press `m` directly on the row. The
|
|
18558
|
+
// inline keypress handler further down in this file does the
|
|
18559
|
+
// actual work and has access to the necessary context.
|
|
18560
|
+
return [action({
|
|
18561
|
+
type: 'setStatus',
|
|
18562
|
+
value: 'open branches / tags / history and press m on the cursored ref',
|
|
18563
|
+
})];
|
|
18338
18564
|
case 'navigateBack':
|
|
18339
18565
|
return [action({ type: 'popView' })];
|
|
18340
18566
|
case 'openSelected': {
|
|
@@ -18812,6 +19038,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18812
19038
|
action({ type: 'setStatus', value: 'jumped to conflicts' }),
|
|
18813
19039
|
];
|
|
18814
19040
|
}
|
|
19041
|
+
// `gr` chord: jump to the reflog browser (#781). Recovery view —
|
|
19042
|
+
// chronological list of reflog entries with Enter to drill into the
|
|
19043
|
+
// commit-diff for the entry's hash. Loaded lazily by the runtime.
|
|
19044
|
+
if (state.pendingKey === 'g' && inputValue === 'r') {
|
|
19045
|
+
return [
|
|
19046
|
+
action({ type: 'pushView', value: 'reflog' }),
|
|
19047
|
+
action({ type: 'setStatus', value: 'jumped to reflog' }),
|
|
19048
|
+
];
|
|
19049
|
+
}
|
|
19050
|
+
// `gB` chord: jump to the bisect workflow view (#784). Capital B
|
|
19051
|
+
// disambiguates from `gb` (branches). Always navigates — even when
|
|
19052
|
+
// bisect is inactive — so the user can see the empty-state hint and
|
|
19053
|
+
// know how to start one. The view's surface tells them the next step.
|
|
19054
|
+
if (state.pendingKey === 'g' && inputValue === 'B') {
|
|
19055
|
+
return [
|
|
19056
|
+
action({ type: 'pushView', value: 'bisect' }),
|
|
19057
|
+
action({ type: 'setStatus', value: 'jumped to bisect' }),
|
|
19058
|
+
];
|
|
19059
|
+
}
|
|
18815
19060
|
// `gH` chord: apply the cursored hunk to the index (`git apply
|
|
18816
19061
|
// --cached`). Sibling of bare `H` which targets the worktree.
|
|
18817
19062
|
// Discoverable via the footer hint on diff views and the help
|
|
@@ -18851,6 +19096,29 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18851
19096
|
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
|
|
18852
19097
|
];
|
|
18853
19098
|
}
|
|
19099
|
+
// #784 — bisect view action keys. Scoped to `state.activeView ===
|
|
19100
|
+
// 'bisect' && state.focus === 'commits'` so the single-letter keys
|
|
19101
|
+
// stay free everywhere else. `g` and `b` collide with the global
|
|
19102
|
+
// chord prefix and the `gb` continuation respectively — placed
|
|
19103
|
+
// BEFORE the bare-`g` chord trigger below so a `g` keystroke on
|
|
19104
|
+
// the bisect view marks good rather than entering chord mode. The
|
|
19105
|
+
// user's path back out of bisect is `<` / `esc`, never a chord;
|
|
19106
|
+
// the in-bisect view itself can't navigate elsewhere via `g`-prefix
|
|
19107
|
+
// chords until the user exits with `esc` first.
|
|
19108
|
+
if (state.activeView === 'bisect' && state.focus === 'commits') {
|
|
19109
|
+
if (inputValue === 'g' && state.pendingKey !== 'g') {
|
|
19110
|
+
return [{ type: 'runWorkflowAction', id: 'bisect-good' }];
|
|
19111
|
+
}
|
|
19112
|
+
if (inputValue === 'b' && state.pendingKey !== 'g') {
|
|
19113
|
+
return [{ type: 'runWorkflowAction', id: 'bisect-bad' }];
|
|
19114
|
+
}
|
|
19115
|
+
if (inputValue === 's') {
|
|
19116
|
+
return [{ type: 'runWorkflowAction', id: 'bisect-skip' }];
|
|
19117
|
+
}
|
|
19118
|
+
if (inputValue === 'x') {
|
|
19119
|
+
return [action({ type: 'setPendingConfirmation', value: 'bisect-reset' })];
|
|
19120
|
+
}
|
|
19121
|
+
}
|
|
18854
19122
|
if (inputValue === 'g') {
|
|
18855
19123
|
if (state.pendingKey === 'g') {
|
|
18856
19124
|
return [
|
|
@@ -19118,6 +19386,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19118
19386
|
if (isStashActionTarget(state) && context.stashCount) {
|
|
19119
19387
|
return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
|
|
19120
19388
|
}
|
|
19389
|
+
if (isReflogActionTarget(state) && context.reflogCount) {
|
|
19390
|
+
return [action({ type: 'moveReflog', delta: -1, count: context.reflogCount })];
|
|
19391
|
+
}
|
|
19121
19392
|
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
19122
19393
|
return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
|
|
19123
19394
|
}
|
|
@@ -19199,6 +19470,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19199
19470
|
if (isStashActionTarget(state) && context.stashCount) {
|
|
19200
19471
|
return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
|
|
19201
19472
|
}
|
|
19473
|
+
if (isReflogActionTarget(state) && context.reflogCount) {
|
|
19474
|
+
return [action({ type: 'moveReflog', delta: 1, count: context.reflogCount })];
|
|
19475
|
+
}
|
|
19202
19476
|
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
19203
19477
|
return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
|
|
19204
19478
|
}
|
|
@@ -19270,6 +19544,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19270
19544
|
action({ type: 'setStatus', value: 'staging worktree changes' }),
|
|
19271
19545
|
];
|
|
19272
19546
|
}
|
|
19547
|
+
// Compare-flow Enter override (#779). When `compareBase` is set and
|
|
19548
|
+
// the user presses Enter on a branch / tag / history commit row, we
|
|
19549
|
+
// open the compare diff (base..head) instead of the row's normal
|
|
19550
|
+
// action (checkout / drill-in / diff). Scoped to compare-flow
|
|
19551
|
+
// targets so non-flow views keep their Enter intact. Runs BEFORE
|
|
19552
|
+
// the per-row Enter handlers below so the override wins, including
|
|
19553
|
+
// before the history-row drill-in.
|
|
19554
|
+
if (key.return && state.compareBase && isCompareFlowTarget(state)) {
|
|
19555
|
+
const head = getCursoredCompareRef(state, context);
|
|
19556
|
+
if (!head) {
|
|
19557
|
+
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
|
|
19558
|
+
}
|
|
19559
|
+
if (head.ref === state.compareBase.ref && head.kind === state.compareBase.kind) {
|
|
19560
|
+
return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one' })];
|
|
19561
|
+
}
|
|
19562
|
+
return [
|
|
19563
|
+
action({
|
|
19564
|
+
type: 'navigateOpenDiffForCompare',
|
|
19565
|
+
base: state.compareBase,
|
|
19566
|
+
head,
|
|
19567
|
+
}),
|
|
19568
|
+
action({ type: 'setStatus', value: `Comparing ${state.compareBase.label} → ${head.label}` }),
|
|
19569
|
+
];
|
|
19570
|
+
}
|
|
19273
19571
|
if (key.return &&
|
|
19274
19572
|
state.activeView === 'history' &&
|
|
19275
19573
|
state.focus === 'commits' &&
|
|
@@ -19286,6 +19584,28 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19286
19584
|
];
|
|
19287
19585
|
}
|
|
19288
19586
|
}
|
|
19587
|
+
// Enter on a reflog row drills into the diff for that entry's hash
|
|
19588
|
+
// (#781). Reuses `navigateOpenDiffForCommit`, which finds the commit
|
|
19589
|
+
// by hash in `state.filteredCommits` first and falls back to
|
|
19590
|
+
// `commitIndex` only when the hash isn't present. Reflog hashes that
|
|
19591
|
+
// exist in the loaded history (the common case) drill in cleanly;
|
|
19592
|
+
// dangling-commit hashes fall back to the index. The `commitIndex`
|
|
19593
|
+
// we pass is best-effort — index in `state.commits` if found, else
|
|
19594
|
+
// `state.selectedIndex` so the cursor stays sane on the diff view.
|
|
19595
|
+
if (key.return &&
|
|
19596
|
+
isReflogActionTarget(state) &&
|
|
19597
|
+
context.reflogSelectedHash) {
|
|
19598
|
+
const sha = context.reflogSelectedHash;
|
|
19599
|
+
const fallbackIndex = state.commits.findIndex((commit) => commit.hash === sha);
|
|
19600
|
+
return [
|
|
19601
|
+
action({
|
|
19602
|
+
type: 'navigateOpenDiffForCommit',
|
|
19603
|
+
sha,
|
|
19604
|
+
commitIndex: fallbackIndex >= 0 ? fallbackIndex : state.selectedIndex,
|
|
19605
|
+
}),
|
|
19606
|
+
action({ type: 'setStatus', value: `viewing diff for ${sha.slice(0, 7)}` }),
|
|
19607
|
+
];
|
|
19608
|
+
}
|
|
19289
19609
|
// Inspector Actions tab: Enter on the cursored action fires its
|
|
19290
19610
|
// associated event (cherry-pick / revert / yank / etc.). Wins over
|
|
19291
19611
|
// the file-list Enter below when the user has [/]-toggled to the
|
|
@@ -19465,6 +19785,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19465
19785
|
if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
|
|
19466
19786
|
return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
|
|
19467
19787
|
}
|
|
19788
|
+
// `m` marks (or un-marks) the cursored ref as the compare base
|
|
19789
|
+
// (#779). Scoped to compare-flow targets so it doesn't collide with
|
|
19790
|
+
// the `m` PR-merge handler further down. The toggle behavior — `m`
|
|
19791
|
+
// again on the same ref clears the base — gives the user a way to
|
|
19792
|
+
// bail out without remembering a separate cancel key.
|
|
19793
|
+
if (inputValue === 'm' && isCompareFlowTarget(state)) {
|
|
19794
|
+
const ref = getCursoredCompareRef(state, context);
|
|
19795
|
+
if (!ref) {
|
|
19796
|
+
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
|
|
19797
|
+
}
|
|
19798
|
+
if (state.compareBase && state.compareBase.ref === ref.ref && state.compareBase.kind === ref.kind) {
|
|
19799
|
+
return [
|
|
19800
|
+
action({ type: 'clearCompareBase' }),
|
|
19801
|
+
action({ type: 'setStatus', value: `Cleared compare base ${ref.label}` }),
|
|
19802
|
+
];
|
|
19803
|
+
}
|
|
19804
|
+
return [
|
|
19805
|
+
action({ type: 'setCompareBase', value: ref }),
|
|
19806
|
+
action({ type: 'setStatus', value: `Compare base: ${ref.label} — press enter on another ref to diff` }),
|
|
19807
|
+
];
|
|
19808
|
+
}
|
|
19468
19809
|
// Per-view worktree action: `D` removes the worktree AND deletes
|
|
19469
19810
|
// the branch it was tracking (#838). Scoped to the worktrees
|
|
19470
19811
|
// surface so it intercepts BEFORE the global workflow-by-key
|
|
@@ -20843,6 +21184,12 @@ function formatLogInkStatusEmpty({ hasChanges }) {
|
|
|
20843
21184
|
}
|
|
20844
21185
|
return 'Worktree clean. Press gh for history, gb for branches, gz for stash.';
|
|
20845
21186
|
}
|
|
21187
|
+
function formatLogInkReflogEmpty({ filter }) {
|
|
21188
|
+
if (filter.trim()) {
|
|
21189
|
+
return `No reflog entries match filter '${filter}'. Press ctrl+u to clear.`;
|
|
21190
|
+
}
|
|
21191
|
+
return 'No reflog entries. Activity in this repo will appear here over time.';
|
|
21192
|
+
}
|
|
20846
21193
|
function formatLogInkComposeEmpty({ hasStaged }) {
|
|
20847
21194
|
if (hasStaged) {
|
|
20848
21195
|
return undefined;
|
|
@@ -22472,14 +22819,14 @@ function deleteRemoteTag(git, tagName) {
|
|
|
22472
22819
|
return runAction$2(() => git.raw(['push', 'origin', `:${tagName}`]), `Deleted remote tag ${tagName}`);
|
|
22473
22820
|
}
|
|
22474
22821
|
|
|
22475
|
-
const FIELD_SEPARATOR = '\x1f';
|
|
22822
|
+
const FIELD_SEPARATOR$1 = '\x1f';
|
|
22476
22823
|
function parseTagRefs(output) {
|
|
22477
22824
|
return output
|
|
22478
22825
|
.split('\n')
|
|
22479
22826
|
.map((line) => line.trimEnd())
|
|
22480
22827
|
.filter(Boolean)
|
|
22481
22828
|
.map((line) => {
|
|
22482
|
-
const [name, hash, date, subject] = line.split(FIELD_SEPARATOR);
|
|
22829
|
+
const [name, hash, date, subject] = line.split(FIELD_SEPARATOR$1);
|
|
22483
22830
|
return {
|
|
22484
22831
|
name,
|
|
22485
22832
|
hash,
|
|
@@ -22491,7 +22838,7 @@ function parseTagRefs(output) {
|
|
|
22491
22838
|
async function getTagOverview(git) {
|
|
22492
22839
|
const output = await git.raw([
|
|
22493
22840
|
'for-each-ref',
|
|
22494
|
-
`--format=%(refname:short)${FIELD_SEPARATOR}%(objectname:short)${FIELD_SEPARATOR}%(creatordate:short)${FIELD_SEPARATOR}%(subject)`,
|
|
22841
|
+
`--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
|
|
22495
22842
|
'--sort=-creatordate',
|
|
22496
22843
|
'refs/tags',
|
|
22497
22844
|
]);
|
|
@@ -24750,6 +25097,231 @@ function formatPullRequestStateLine(pr) {
|
|
|
24750
25097
|
return parts.join(' · ');
|
|
24751
25098
|
}
|
|
24752
25099
|
|
|
25100
|
+
const EMPTY_STATUS = {
|
|
25101
|
+
active: false,
|
|
25102
|
+
currentSha: '',
|
|
25103
|
+
log: [],
|
|
25104
|
+
};
|
|
25105
|
+
async function bisectIsActive(git) {
|
|
25106
|
+
try {
|
|
25107
|
+
const path = (await git.revparse(['--git-path', 'BISECT_LOG'])).trim();
|
|
25108
|
+
return path.length > 0 && existsSync(path);
|
|
25109
|
+
}
|
|
25110
|
+
catch {
|
|
25111
|
+
return false;
|
|
25112
|
+
}
|
|
25113
|
+
}
|
|
25114
|
+
/**
|
|
25115
|
+
* Parse the output of `git bisect log` into structured entries. Each
|
|
25116
|
+
* entry corresponds to one user decision (start / good / bad / skip)
|
|
25117
|
+
* or the "# bad: [<sha>] <subject>" comment lines git emits for
|
|
25118
|
+
* traceability. Comment lines without a recognized prefix are dropped
|
|
25119
|
+
* — they're informational headers ("# status: ..."), not actions
|
|
25120
|
+
* the user took.
|
|
25121
|
+
*/
|
|
25122
|
+
function parseBisectLog(output) {
|
|
25123
|
+
const entries = [];
|
|
25124
|
+
for (const rawLine of output.split('\n')) {
|
|
25125
|
+
const line = rawLine.trimEnd();
|
|
25126
|
+
if (!line)
|
|
25127
|
+
continue;
|
|
25128
|
+
// Comment rows: "# good: [sha] subject" / "# bad: [sha] subject" /
|
|
25129
|
+
// "# first bad commit: ..." / "# status: ...". The first two carry
|
|
25130
|
+
// the most user-relevant info (which commits were marked) so we
|
|
25131
|
+
// promote them to typed entries; the rest fall through as raw
|
|
25132
|
+
// lines tagged 'unknown' so the renderer can dim them or hide
|
|
25133
|
+
// entirely.
|
|
25134
|
+
if (line.startsWith('#')) {
|
|
25135
|
+
const commentMatch = line.match(/^#\s+(good|bad|skip):\s+\[([^\]]+)\]\s*(.*)$/);
|
|
25136
|
+
if (commentMatch) {
|
|
25137
|
+
entries.push({
|
|
25138
|
+
kind: commentMatch[1],
|
|
25139
|
+
sha: commentMatch[2],
|
|
25140
|
+
subject: commentMatch[3] || undefined,
|
|
25141
|
+
raw: line,
|
|
25142
|
+
});
|
|
25143
|
+
continue;
|
|
25144
|
+
}
|
|
25145
|
+
entries.push({ kind: 'unknown', raw: line });
|
|
25146
|
+
continue;
|
|
25147
|
+
}
|
|
25148
|
+
// Command rows: "git bisect start", "git bisect good <sha>",
|
|
25149
|
+
// "git bisect bad <sha>", "git bisect skip <sha>".
|
|
25150
|
+
const commandMatch = line.match(/^git\s+bisect\s+(start|good|bad|skip)\s*(.*)$/);
|
|
25151
|
+
if (commandMatch) {
|
|
25152
|
+
const sha = commandMatch[2]?.trim().split(/\s+/)[0] || undefined;
|
|
25153
|
+
entries.push({
|
|
25154
|
+
kind: commandMatch[1],
|
|
25155
|
+
sha: sha || undefined,
|
|
25156
|
+
raw: line,
|
|
25157
|
+
});
|
|
25158
|
+
continue;
|
|
25159
|
+
}
|
|
25160
|
+
entries.push({ kind: 'unknown', raw: line });
|
|
25161
|
+
}
|
|
25162
|
+
return entries;
|
|
25163
|
+
}
|
|
25164
|
+
/**
|
|
25165
|
+
* Load the live bisect status. Best-effort — when bisect isn't
|
|
25166
|
+
* active the empty-status sentinel returns immediately so callers
|
|
25167
|
+
* don't pay for a `git bisect log` round-trip on every refresh.
|
|
25168
|
+
*/
|
|
25169
|
+
async function getBisectStatus(git) {
|
|
25170
|
+
if (!(await bisectIsActive(git))) {
|
|
25171
|
+
return EMPTY_STATUS;
|
|
25172
|
+
}
|
|
25173
|
+
let log = [];
|
|
25174
|
+
try {
|
|
25175
|
+
const output = await git.raw(['bisect', 'log']);
|
|
25176
|
+
log = parseBisectLog(output);
|
|
25177
|
+
}
|
|
25178
|
+
catch {
|
|
25179
|
+
// bisect log can fail on a freshly-started bisect with no decisions.
|
|
25180
|
+
// Treat the absence of a parseable log as "active but empty" rather
|
|
25181
|
+
// than non-active, so the surface still routes to the bisect view
|
|
25182
|
+
// and the user can see the badge.
|
|
25183
|
+
log = [];
|
|
25184
|
+
}
|
|
25185
|
+
let currentSha = '';
|
|
25186
|
+
try {
|
|
25187
|
+
currentSha = (await git.revparse(['HEAD'])).trim();
|
|
25188
|
+
}
|
|
25189
|
+
catch {
|
|
25190
|
+
currentSha = '';
|
|
25191
|
+
}
|
|
25192
|
+
return { active: true, currentSha, log };
|
|
25193
|
+
}
|
|
25194
|
+
|
|
25195
|
+
/**
|
|
25196
|
+
* Thin wrappers around `git bisect <verb>` for the TUI's in-bisect
|
|
25197
|
+
* action keys (#784). Each returns the raw stdout so the surface can
|
|
25198
|
+
* surface git's own "Bisecting: N revisions left to test after this
|
|
25199
|
+
* (roughly K steps)" hint as a status message — that wording is the
|
|
25200
|
+
* single most useful piece of feedback git emits during bisect, and
|
|
25201
|
+
* mirroring it keeps the TUI's status line authoritative.
|
|
25202
|
+
*
|
|
25203
|
+
* No try/catch here — git itself returns non-zero on user errors
|
|
25204
|
+
* (already-bisecting, no good ref, etc.) and `simple-git` surfaces
|
|
25205
|
+
* those as rejections. The runtime catches them and routes to the
|
|
25206
|
+
* status line.
|
|
25207
|
+
*/
|
|
25208
|
+
async function bisectGood(git, ref) {
|
|
25209
|
+
const args = ['bisect', 'good'];
|
|
25210
|
+
return git.raw(args);
|
|
25211
|
+
}
|
|
25212
|
+
async function bisectBad(git, ref) {
|
|
25213
|
+
const args = ['bisect', 'bad'];
|
|
25214
|
+
return git.raw(args);
|
|
25215
|
+
}
|
|
25216
|
+
async function bisectSkip(git, ref) {
|
|
25217
|
+
const args = ['bisect', 'skip'];
|
|
25218
|
+
return git.raw(args);
|
|
25219
|
+
}
|
|
25220
|
+
async function bisectReset(git) {
|
|
25221
|
+
return git.raw(['bisect', 'reset']);
|
|
25222
|
+
}
|
|
25223
|
+
/**
|
|
25224
|
+
* Pull the user-facing remaining-revisions hint out of `git bisect`
|
|
25225
|
+
* stdout. Looks for the canonical line:
|
|
25226
|
+
*
|
|
25227
|
+
* `Bisecting: N revisions left to test after this (roughly K steps)`
|
|
25228
|
+
*
|
|
25229
|
+
* Returns undefined when the line isn't present (e.g. the run
|
|
25230
|
+
* finished and git emitted a "<sha> is the first bad commit" line
|
|
25231
|
+
* instead). Callers fall back to an empty status update in that case.
|
|
25232
|
+
*/
|
|
25233
|
+
function extractBisectRemainingHint(stdout) {
|
|
25234
|
+
for (const line of stdout.split('\n').reverse()) {
|
|
25235
|
+
const trimmed = line.trim();
|
|
25236
|
+
if (trimmed.startsWith('Bisecting:'))
|
|
25237
|
+
return trimmed;
|
|
25238
|
+
if (trimmed.includes('is the first bad commit'))
|
|
25239
|
+
return trimmed;
|
|
25240
|
+
}
|
|
25241
|
+
return undefined;
|
|
25242
|
+
}
|
|
25243
|
+
|
|
25244
|
+
/**
|
|
25245
|
+
* Compare two refs (branches / tags / commits) and return the unified
|
|
25246
|
+
* patch as line-split string output (#779).
|
|
25247
|
+
*
|
|
25248
|
+
* Mirrors the stash-diff loader's contract — emits `string[]` so the
|
|
25249
|
+
* existing diff surface can render the lines through its standard
|
|
25250
|
+
* +/-/@@ coloring path. Two-dot syntax (`base..head`) gives the
|
|
25251
|
+
* "what changed on head, relative to base" view that's natural for
|
|
25252
|
+
* branch reviews and pre-merge sanity checks.
|
|
25253
|
+
*
|
|
25254
|
+
* Defensive about input — both refs are passed as-is to git, so the
|
|
25255
|
+
* caller is responsible for providing a git-resolvable form
|
|
25256
|
+
* (branch shortName, tag name, or commit hash). On any git error
|
|
25257
|
+
* (unknown ref, etc.) the runtime's `safe()` wrapper at the call
|
|
25258
|
+
* site catches the throw and the surface falls back to a "no diff"
|
|
25259
|
+
* hint.
|
|
25260
|
+
*/
|
|
25261
|
+
async function getCompareDiff(git, base, head) {
|
|
25262
|
+
return (await git.raw(['diff', `${base}..${head}`]))
|
|
25263
|
+
.split('\n')
|
|
25264
|
+
.map((line) => line.replace(/\r$/, ''));
|
|
25265
|
+
}
|
|
25266
|
+
|
|
25267
|
+
const FIELD_SEPARATOR = '\x1f';
|
|
25268
|
+
/**
|
|
25269
|
+
* Default fetch limit. 200 entries is enough to span weeks of normal
|
|
25270
|
+
* activity for an active repo while keeping the load fast — `git reflog`
|
|
25271
|
+
* is local-only so even 1000+ entries is sub-second, but 200 keeps the
|
|
25272
|
+
* rendered list bounded for terminals.
|
|
25273
|
+
*/
|
|
25274
|
+
const DEFAULT_REFLOG_LIMIT = 200;
|
|
25275
|
+
function parseReflogOverview(output) {
|
|
25276
|
+
return output
|
|
25277
|
+
.split('\n')
|
|
25278
|
+
.map((line) => line.trimEnd())
|
|
25279
|
+
.filter(Boolean)
|
|
25280
|
+
.map((line) => {
|
|
25281
|
+
const [selector, hash, relativeDate, subject] = line.split(FIELD_SEPARATOR);
|
|
25282
|
+
return {
|
|
25283
|
+
selector: selector || '',
|
|
25284
|
+
hash: hash || '',
|
|
25285
|
+
relativeDate: relativeDate || '',
|
|
25286
|
+
subject: subject || '',
|
|
25287
|
+
};
|
|
25288
|
+
});
|
|
25289
|
+
}
|
|
25290
|
+
async function getReflogOverview(git, limit = DEFAULT_REFLOG_LIMIT) {
|
|
25291
|
+
const output = await git.raw([
|
|
25292
|
+
'reflog',
|
|
25293
|
+
`--max-count=${limit}`,
|
|
25294
|
+
`--pretty=format:%gd${FIELD_SEPARATOR}%h${FIELD_SEPARATOR}%cr${FIELD_SEPARATOR}%gs`,
|
|
25295
|
+
]);
|
|
25296
|
+
return {
|
|
25297
|
+
entries: parseReflogOverview(output),
|
|
25298
|
+
};
|
|
25299
|
+
}
|
|
25300
|
+
/**
|
|
25301
|
+
* Pull the action prefix off a reflog subject. Reflog subjects follow
|
|
25302
|
+
* a `<verb>[ qualifier]: <message>` pattern emitted by git itself —
|
|
25303
|
+
* "commit: ...", "commit (amend): ...", "checkout: moving from main
|
|
25304
|
+
* to feature", "merge feature: ...", "reset: moving to HEAD~1", etc.
|
|
25305
|
+
*
|
|
25306
|
+
* For display we want the verb (and any parenthetical qualifier) on
|
|
25307
|
+
* its own so the view can render a fixed-width `action` column and
|
|
25308
|
+
* keep the rest of the message left-aligned.
|
|
25309
|
+
*
|
|
25310
|
+
* Defensive: if the subject has no colon, the whole string is treated
|
|
25311
|
+
* as the action and the message is empty. This keeps the renderer
|
|
25312
|
+
* from crashing on a malformed entry.
|
|
25313
|
+
*/
|
|
25314
|
+
function splitReflogSubject(subject) {
|
|
25315
|
+
const colonIndex = subject.indexOf(':');
|
|
25316
|
+
if (colonIndex === -1) {
|
|
25317
|
+
return { action: subject.trim(), message: '' };
|
|
25318
|
+
}
|
|
25319
|
+
return {
|
|
25320
|
+
action: subject.slice(0, colonIndex).trim(),
|
|
25321
|
+
message: subject.slice(colonIndex + 1).trim(),
|
|
25322
|
+
};
|
|
25323
|
+
}
|
|
25324
|
+
|
|
24753
25325
|
function sectionLines(title, diff) {
|
|
24754
25326
|
const lines = diff.split('\n').map((line) => line.trimEnd());
|
|
24755
25327
|
return [
|
|
@@ -24862,7 +25434,7 @@ async function safe(promise) {
|
|
|
24862
25434
|
}
|
|
24863
25435
|
}
|
|
24864
25436
|
async function loadLogInkContext(git) {
|
|
24865
|
-
const [branches, pullRequest, tags, worktree, stashes, worktreeList, operation, provider] = await Promise.all([
|
|
25437
|
+
const [branches, pullRequest, tags, worktree, stashes, worktreeList, operation, provider, reflog, bisect] = await Promise.all([
|
|
24866
25438
|
safe(getBranchOverview(git)),
|
|
24867
25439
|
safe(getPullRequestOverview(git)),
|
|
24868
25440
|
safe(getTagOverview(git)),
|
|
@@ -24871,12 +25443,16 @@ async function loadLogInkContext(git) {
|
|
|
24871
25443
|
safe(getWorktreeListOverview(git)),
|
|
24872
25444
|
safe(getGitOperationOverview(git)),
|
|
24873
25445
|
safe(getProviderOverview(git)),
|
|
25446
|
+
safe(getReflogOverview(git)),
|
|
25447
|
+
safe(getBisectStatus(git)),
|
|
24874
25448
|
]);
|
|
24875
25449
|
return {
|
|
25450
|
+
bisect,
|
|
24876
25451
|
branches,
|
|
24877
25452
|
operation,
|
|
24878
25453
|
provider,
|
|
24879
25454
|
pullRequest,
|
|
25455
|
+
reflog,
|
|
24880
25456
|
stashes,
|
|
24881
25457
|
tags,
|
|
24882
25458
|
worktree,
|
|
@@ -24902,6 +25478,14 @@ function loadLogInkContextEntries(git) {
|
|
|
24902
25478
|
key: 'tags',
|
|
24903
25479
|
load: () => safe(getTagOverview(git)),
|
|
24904
25480
|
},
|
|
25481
|
+
{
|
|
25482
|
+
key: 'reflog',
|
|
25483
|
+
load: () => safe(getReflogOverview(git)),
|
|
25484
|
+
},
|
|
25485
|
+
{
|
|
25486
|
+
key: 'bisect',
|
|
25487
|
+
load: () => safe(getBisectStatus(git)),
|
|
25488
|
+
},
|
|
24905
25489
|
{
|
|
24906
25490
|
key: 'worktree',
|
|
24907
25491
|
load: () => safe(getWorktreeOverview(git)),
|
|
@@ -25290,6 +25874,10 @@ function LogInkApp(deps) {
|
|
|
25290
25874
|
// colors match the commit-diff path.
|
|
25291
25875
|
const [stashDiffLines, setStashDiffLines] = React.useState(undefined);
|
|
25292
25876
|
const [stashDiffLoading, setStashDiffLoading] = React.useState(false);
|
|
25877
|
+
// #779 — compare-two-refs diff state. Loaded lazily when the diff
|
|
25878
|
+
// view becomes active with `diffSource === 'compare'`.
|
|
25879
|
+
const [compareDiffLines, setCompareDiffLines] = React.useState(undefined);
|
|
25880
|
+
const [compareDiffLoading, setCompareDiffLoading] = React.useState(false);
|
|
25293
25881
|
const [hasMoreCommits, setHasMoreCommits] = React.useState(() => (Boolean(logArgv?.interactive && !logArgv.limit) &&
|
|
25294
25882
|
getCommitRows(rows).length >= LOG_INTERACTIVE_DEFAULT_LIMIT));
|
|
25295
25883
|
const [loadingMoreCommits, setLoadingMoreCommits] = React.useState(false);
|
|
@@ -25384,6 +25972,12 @@ function LogInkApp(deps) {
|
|
|
25384
25972
|
return all;
|
|
25385
25973
|
return all.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || ''], state.filter));
|
|
25386
25974
|
}, [context.worktreeList?.worktrees, state.filter]);
|
|
25975
|
+
const filteredReflogList = React.useMemo(() => {
|
|
25976
|
+
const all = context.reflog?.entries || [];
|
|
25977
|
+
if (!state.filter)
|
|
25978
|
+
return all;
|
|
25979
|
+
return all.filter((entry) => matchesPromotedFilter([entry.selector, entry.hash, entry.relativeDate, entry.subject], state.filter));
|
|
25980
|
+
}, [context.reflog?.entries, state.filter]);
|
|
25387
25981
|
const dispatch = React.useCallback((action) => {
|
|
25388
25982
|
setState((current) => applyLogInkAction(current, action));
|
|
25389
25983
|
}, []);
|
|
@@ -25594,6 +26188,41 @@ function LogInkApp(deps) {
|
|
|
25594
26188
|
})();
|
|
25595
26189
|
return () => { active = false; };
|
|
25596
26190
|
}, [git, state.activeView, state.diffSource, state.stashDiffRef]);
|
|
26191
|
+
// #779 — load `git diff <base>..<head>` once the diff view becomes
|
|
26192
|
+
// active with diffSource='compare'. Mirrors the stash loader's
|
|
26193
|
+
// shape; the surface renders the lines via the same +/-/@@ coloring
|
|
26194
|
+
// path. On unknown ref / git error, `safe()` swallows and the
|
|
26195
|
+
// surface falls back to a "no diff" hint.
|
|
26196
|
+
const compareBaseRef = state.compareBase?.ref;
|
|
26197
|
+
const compareHeadRef = state.compareHead?.ref;
|
|
26198
|
+
React.useEffect(() => {
|
|
26199
|
+
if (state.activeView !== 'diff' ||
|
|
26200
|
+
state.diffSource !== 'compare' ||
|
|
26201
|
+
!compareBaseRef ||
|
|
26202
|
+
!compareHeadRef) {
|
|
26203
|
+
return;
|
|
26204
|
+
}
|
|
26205
|
+
let active = true;
|
|
26206
|
+
setCompareDiffLoading(true);
|
|
26207
|
+
void (async () => {
|
|
26208
|
+
const lines = await safe(getCompareDiff(git, compareBaseRef, compareHeadRef));
|
|
26209
|
+
if (active) {
|
|
26210
|
+
setCompareDiffLines(lines || []);
|
|
26211
|
+
setCompareDiffLoading(false);
|
|
26212
|
+
}
|
|
26213
|
+
})();
|
|
26214
|
+
return () => { active = false; };
|
|
26215
|
+
}, [git, state.activeView, state.diffSource, compareBaseRef, compareHeadRef]);
|
|
26216
|
+
// Reset compare-diff state whenever the diff view exits. Without
|
|
26217
|
+
// this, opening a new compare immediately after closing one would
|
|
26218
|
+
// briefly show the previous comparison's lines while the new
|
|
26219
|
+
// loader runs.
|
|
26220
|
+
React.useEffect(() => {
|
|
26221
|
+
if (state.diffSource !== 'compare') {
|
|
26222
|
+
setCompareDiffLines(undefined);
|
|
26223
|
+
setCompareDiffLoading(false);
|
|
26224
|
+
}
|
|
26225
|
+
}, [state.diffSource]);
|
|
25597
26226
|
React.useEffect(() => {
|
|
25598
26227
|
let active = true;
|
|
25599
26228
|
async function loadWorktreeHunks() {
|
|
@@ -26104,6 +26733,50 @@ function LogInkApp(deps) {
|
|
|
26104
26733
|
return { ok: false, message: 'No stash selected' };
|
|
26105
26734
|
return popStash(git, stash);
|
|
26106
26735
|
},
|
|
26736
|
+
'bisect-good': async () => {
|
|
26737
|
+
if (!context.bisect?.active)
|
|
26738
|
+
return { ok: false, message: 'No bisect in progress' };
|
|
26739
|
+
try {
|
|
26740
|
+
const stdout = await bisectGood(git);
|
|
26741
|
+
return { ok: true, message: extractBisectRemainingHint(stdout) || 'Marked good' };
|
|
26742
|
+
}
|
|
26743
|
+
catch (error) {
|
|
26744
|
+
return { ok: false, message: `Bisect good failed: ${error.message}` };
|
|
26745
|
+
}
|
|
26746
|
+
},
|
|
26747
|
+
'bisect-bad': async () => {
|
|
26748
|
+
if (!context.bisect?.active)
|
|
26749
|
+
return { ok: false, message: 'No bisect in progress' };
|
|
26750
|
+
try {
|
|
26751
|
+
const stdout = await bisectBad(git);
|
|
26752
|
+
return { ok: true, message: extractBisectRemainingHint(stdout) || 'Marked bad' };
|
|
26753
|
+
}
|
|
26754
|
+
catch (error) {
|
|
26755
|
+
return { ok: false, message: `Bisect bad failed: ${error.message}` };
|
|
26756
|
+
}
|
|
26757
|
+
},
|
|
26758
|
+
'bisect-skip': async () => {
|
|
26759
|
+
if (!context.bisect?.active)
|
|
26760
|
+
return { ok: false, message: 'No bisect in progress' };
|
|
26761
|
+
try {
|
|
26762
|
+
const stdout = await bisectSkip(git);
|
|
26763
|
+
return { ok: true, message: extractBisectRemainingHint(stdout) || 'Skipped' };
|
|
26764
|
+
}
|
|
26765
|
+
catch (error) {
|
|
26766
|
+
return { ok: false, message: `Bisect skip failed: ${error.message}` };
|
|
26767
|
+
}
|
|
26768
|
+
},
|
|
26769
|
+
'bisect-reset': async () => {
|
|
26770
|
+
if (!context.bisect?.active)
|
|
26771
|
+
return { ok: false, message: 'No bisect in progress' };
|
|
26772
|
+
try {
|
|
26773
|
+
await bisectReset(git);
|
|
26774
|
+
return { ok: true, message: 'Bisect reset' };
|
|
26775
|
+
}
|
|
26776
|
+
catch (error) {
|
|
26777
|
+
return { ok: false, message: `Bisect reset failed: ${error.message}` };
|
|
26778
|
+
}
|
|
26779
|
+
},
|
|
26107
26780
|
'checkout-file-from-stash': async () => {
|
|
26108
26781
|
const path = payload?.trim();
|
|
26109
26782
|
const ref = state.stashDiffRef;
|
|
@@ -26776,9 +27449,13 @@ function LogInkApp(deps) {
|
|
|
26776
27449
|
// perf pass) — reading them here is O(1) instead of O(branches +
|
|
26777
27450
|
// tags + stashes + worktrees) per keystroke.
|
|
26778
27451
|
const branchVisibleCount = filteredBranchList.length;
|
|
27452
|
+
const branchSelectedShortName = filteredBranchList[Math.min(state.selectedBranchIndex, Math.max(0, filteredBranchList.length - 1))]?.shortName;
|
|
26779
27453
|
const tagVisibleCount = filteredTagList.length;
|
|
27454
|
+
const tagSelectedName = filteredTagList[Math.min(state.selectedTagIndex, Math.max(0, filteredTagList.length - 1))]?.name;
|
|
26780
27455
|
const stashVisibleCount = filteredStashList.length;
|
|
26781
27456
|
const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
|
|
27457
|
+
const reflogVisibleCount = filteredReflogList.length;
|
|
27458
|
+
const reflogSelectedHash = filteredReflogList[Math.min(state.selectedReflogIndex, Math.max(0, filteredReflogList.length - 1))]?.hash;
|
|
26782
27459
|
const worktreeVisibleCount = filteredWorktreeList.length;
|
|
26783
27460
|
// When the diff view is showing a stash patch, swap the previewLineCount
|
|
26784
27461
|
// to the stash diff length so the existing pageDetailPreview path
|
|
@@ -26803,8 +27480,12 @@ function LogInkApp(deps) {
|
|
|
26803
27480
|
worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
|
|
26804
27481
|
commitDiffHunkOffsets,
|
|
26805
27482
|
branchCount: branchVisibleCount,
|
|
27483
|
+
branchSelectedShortName,
|
|
26806
27484
|
tagCount: tagVisibleCount,
|
|
27485
|
+
tagSelectedName,
|
|
26807
27486
|
stashCount: stashVisibleCount,
|
|
27487
|
+
reflogCount: reflogVisibleCount,
|
|
27488
|
+
reflogSelectedHash,
|
|
26808
27489
|
stashSelectedRef,
|
|
26809
27490
|
stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
|
|
26810
27491
|
stashDiffSelectedPath,
|
|
@@ -26910,12 +27591,15 @@ function LogInkApp(deps) {
|
|
|
26910
27591
|
if (showOnboarding) {
|
|
26911
27592
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
26912
27593
|
}
|
|
26913
|
-
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));
|
|
27594
|
+
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, 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));
|
|
26914
27595
|
}
|
|
26915
27596
|
function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
|
|
26916
27597
|
const { Box, Text } = components;
|
|
26917
27598
|
const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
|
|
26918
|
-
|
|
27599
|
+
// #784 — surface bisect-in-progress in the title bar so users entering
|
|
27600
|
+
// the TUI mid-bisect see it immediately, before they navigate to gB.
|
|
27601
|
+
const dirtyBase = context.branches?.dirty ? 'dirty' : 'clean';
|
|
27602
|
+
const dirty = context.bisect?.active ? `${dirtyBase} · BISECTING` : dirtyBase;
|
|
26919
27603
|
const repo = context.provider?.repository.owner && context.provider.repository.name
|
|
26920
27604
|
? `${context.provider.repository.owner}/${context.provider.repository.name}`
|
|
26921
27605
|
: 'local repository';
|
|
@@ -27170,12 +27854,12 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
|
|
|
27170
27854
|
: []),
|
|
27171
27855
|
];
|
|
27172
27856
|
}
|
|
27173
|
-
function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
|
|
27857
|
+
function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
|
|
27174
27858
|
if (state.activeView === 'status') {
|
|
27175
27859
|
return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
27176
27860
|
}
|
|
27177
27861
|
if (state.activeView === 'diff') {
|
|
27178
|
-
return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme);
|
|
27862
|
+
return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme);
|
|
27179
27863
|
}
|
|
27180
27864
|
if (state.activeView === 'compose') {
|
|
27181
27865
|
return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
@@ -27186,6 +27870,12 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
27186
27870
|
if (state.activeView === 'tags') {
|
|
27187
27871
|
return renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
27188
27872
|
}
|
|
27873
|
+
if (state.activeView === 'reflog') {
|
|
27874
|
+
return renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
27875
|
+
}
|
|
27876
|
+
if (state.activeView === 'bisect') {
|
|
27877
|
+
return renderBisectSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
27878
|
+
}
|
|
27189
27879
|
if (state.activeView === 'stash') {
|
|
27190
27880
|
return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
27191
27881
|
}
|
|
@@ -27813,6 +28503,150 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
27813
28503
|
width,
|
|
27814
28504
|
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
|
|
27815
28505
|
}
|
|
28506
|
+
/**
|
|
28507
|
+
* Promoted reflog browser (#781). Mirrors `renderTagsSurface` visually
|
|
28508
|
+
* — same header / filter affordance / footer hint conventions — but
|
|
28509
|
+
* lays out four columns per row: relative date, action prefix, short
|
|
28510
|
+
* hash, and message. Filtering matches against all four (so typing
|
|
28511
|
+
* "checkout" narrows to checkout entries, "abc" narrows to a hash).
|
|
28512
|
+
*
|
|
28513
|
+
* Per-row layout uses fixed column widths derived from the visible
|
|
28514
|
+
* window so short-action rows don't leave a wide gutter and long
|
|
28515
|
+
* actions don't push the message off-screen. The cap mirrors the
|
|
28516
|
+
* tags surface's name-column treatment.
|
|
28517
|
+
*/
|
|
28518
|
+
function renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
28519
|
+
const { Box, Text } = components;
|
|
28520
|
+
const focused = state.focus === 'commits';
|
|
28521
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'reflog');
|
|
28522
|
+
const allEntries = context.reflog?.entries || [];
|
|
28523
|
+
const entries = state.filter
|
|
28524
|
+
? allEntries.filter((entry) => matchesPromotedFilter([entry.selector, entry.hash, entry.relativeDate, entry.subject], state.filter))
|
|
28525
|
+
: allEntries;
|
|
28526
|
+
const selected = Math.max(0, Math.min(state.selectedReflogIndex, Math.max(0, entries.length - 1)));
|
|
28527
|
+
const listRows = Math.max(4, bodyRows - 4);
|
|
28528
|
+
const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
|
|
28529
|
+
const visible = entries.slice(startIndex, startIndex + listRows);
|
|
28530
|
+
const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
|
|
28531
|
+
const headerRight = loading
|
|
28532
|
+
? 'loading reflog'
|
|
28533
|
+
: `${entries.length}/${allEntries.length} entries${filterLabel}`;
|
|
28534
|
+
const emptyLabel = formatLogInkReflogEmpty({ filter: state.filter });
|
|
28535
|
+
const loadingLabel = formatLogInkLoading({ resource: 'reflog' });
|
|
28536
|
+
// Column widths derived from the visible window. The hash column is
|
|
28537
|
+
// fixed (short SHA is always 7 chars) and the date column caps so
|
|
28538
|
+
// "X minutes ago" / "Y hours ago" stays readable without dominating
|
|
28539
|
+
// the row. Action column scales to the longest visible action so
|
|
28540
|
+
// commit / checkout / merge align cleanly.
|
|
28541
|
+
const splitVisible = visible.map((entry) => ({
|
|
28542
|
+
entry,
|
|
28543
|
+
parts: splitReflogSubject(entry.subject),
|
|
28544
|
+
}));
|
|
28545
|
+
const dateColWidth = splitVisible.length === 0
|
|
28546
|
+
? 16
|
|
28547
|
+
: Math.min(20, Math.max(6, ...splitVisible.map(({ entry }) => entry.relativeDate.length)));
|
|
28548
|
+
const actionColWidth = splitVisible.length === 0
|
|
28549
|
+
? 12
|
|
28550
|
+
: Math.min(24, Math.max(6, ...splitVisible.map(({ parts }) => parts.action.length)));
|
|
28551
|
+
const hashColWidth = 8;
|
|
28552
|
+
const lines = loading
|
|
28553
|
+
? [h(Text, { key: 'reflog-loading', dimColor: true }, loadingLabel)]
|
|
28554
|
+
: entries.length === 0
|
|
28555
|
+
? [h(Text, { key: 'reflog-empty', dimColor: true }, emptyLabel)]
|
|
28556
|
+
: splitVisible.map(({ entry, parts }, offset) => {
|
|
28557
|
+
const index = startIndex + offset;
|
|
28558
|
+
const isSelected = index === selected;
|
|
28559
|
+
const cursor = isSelected ? '>' : ' ';
|
|
28560
|
+
const datePadded = truncate$1(entry.relativeDate, dateColWidth).padEnd(dateColWidth);
|
|
28561
|
+
const actionPadded = truncate$1(parts.action, actionColWidth).padEnd(actionColWidth);
|
|
28562
|
+
const hashPadded = truncate$1(entry.hash, hashColWidth).padEnd(hashColWidth);
|
|
28563
|
+
const message = parts.message || entry.subject;
|
|
28564
|
+
const lineText = truncate$1(`${cursor} ${datePadded} ${actionPadded} ${hashPadded} ${message}`, Math.max(20, width - 4));
|
|
28565
|
+
return h(Text, {
|
|
28566
|
+
key: `reflog-${index}`,
|
|
28567
|
+
bold: isSelected,
|
|
28568
|
+
dimColor: !isSelected,
|
|
28569
|
+
}, lineText);
|
|
28570
|
+
});
|
|
28571
|
+
return h(Box, {
|
|
28572
|
+
borderColor: focusBorderColor(theme, focused),
|
|
28573
|
+
borderStyle: theme.borderStyle,
|
|
28574
|
+
flexDirection: 'column',
|
|
28575
|
+
flexShrink: 0,
|
|
28576
|
+
paddingX: 1,
|
|
28577
|
+
width,
|
|
28578
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Reflog', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
|
|
28579
|
+
}
|
|
28580
|
+
/**
|
|
28581
|
+
* Bisect workflow surface (#784). Shows the current candidate commit
|
|
28582
|
+
* (HEAD), a parsed view of recent decisions from `git bisect log`, and
|
|
28583
|
+
* the four action keys (g good, b bad, s skip, x reset).
|
|
28584
|
+
*
|
|
28585
|
+
* When bisect is inactive, the surface renders an empty-state hint
|
|
28586
|
+
* pointing the user at the CLI to start one. The view stays
|
|
28587
|
+
* navigable so the user can read the documentation before starting
|
|
28588
|
+
* — they can't break anything from here.
|
|
28589
|
+
*/
|
|
28590
|
+
function renderBisectSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
28591
|
+
const { Box, Text } = components;
|
|
28592
|
+
const focused = state.focus === 'commits';
|
|
28593
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
|
|
28594
|
+
const bisect = context.bisect;
|
|
28595
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
28596
|
+
const lines = [];
|
|
28597
|
+
if (loading) {
|
|
28598
|
+
lines.push(h(Text, { key: 'bisect-loading', dimColor: true }, truncate$1('· Loading bisect status…', width - 4)));
|
|
28599
|
+
}
|
|
28600
|
+
else if (!bisect?.active) {
|
|
28601
|
+
// No bisect active. Surface the CLI on-ramp — starting from the
|
|
28602
|
+
// TUI is intentionally out of scope for this PR (#784 follow-up).
|
|
28603
|
+
// The user is expected to enter via `git bisect start <bad> <good>`
|
|
28604
|
+
// and re-open `coco ui`; once bisect is active this view drives
|
|
28605
|
+
// the rest.
|
|
28606
|
+
lines.push(h(Text, { key: 'bisect-empty-1', bold: true }, truncate$1('No bisect in progress.', width - 4)));
|
|
28607
|
+
lines.push(h(Text, { key: 'bisect-empty-2' }, ''));
|
|
28608
|
+
lines.push(h(Text, { key: 'bisect-empty-3' }, truncate$1('Start one from the shell with:', width - 4)));
|
|
28609
|
+
lines.push(h(Text, { key: 'bisect-empty-4', color: accent }, truncate$1(' git bisect start <bad-ref> <good-ref>', width - 4)));
|
|
28610
|
+
lines.push(h(Text, { key: 'bisect-empty-5' }, ''));
|
|
28611
|
+
lines.push(h(Text, { key: 'bisect-empty-6', dimColor: true }, truncate$1('coco will pick up the active bisect on the next refresh — actions will become available here.', width - 4)));
|
|
28612
|
+
}
|
|
28613
|
+
else {
|
|
28614
|
+
// Active bisect. Two-section body: current candidate, recent
|
|
28615
|
+
// decisions. Action keys live in the footer.
|
|
28616
|
+
const headerSha = bisect.currentSha ? bisect.currentSha.slice(0, 8) : '<unknown>';
|
|
28617
|
+
lines.push(h(Text, { key: 'bisect-active-title', bold: true }, truncate$1(`Bisecting · current candidate ${headerSha}`, width - 4)));
|
|
28618
|
+
lines.push(h(Text, { key: 'bisect-active-spacer' }, ''));
|
|
28619
|
+
const decisions = bisect.log.filter((entry) => entry.kind === 'good' || entry.kind === 'bad' || entry.kind === 'skip');
|
|
28620
|
+
if (decisions.length === 0) {
|
|
28621
|
+
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)));
|
|
28622
|
+
}
|
|
28623
|
+
else {
|
|
28624
|
+
lines.push(h(Text, { key: 'bisect-decisions-header', bold: true }, truncate$1(`Decisions (${decisions.length}):`, width - 4)));
|
|
28625
|
+
const recent = decisions.slice(-Math.max(4, bodyRows - 8));
|
|
28626
|
+
for (const entry of recent) {
|
|
28627
|
+
const kindLabel = entry.kind.toUpperCase().padEnd(5);
|
|
28628
|
+
const sha = (entry.sha || '<unknown>').padEnd(8);
|
|
28629
|
+
const subject = entry.subject || '';
|
|
28630
|
+
const text = ` ${kindLabel} ${sha} ${subject}`;
|
|
28631
|
+
lines.push(h(Text, {
|
|
28632
|
+
key: `bisect-entry-${entry.raw}`,
|
|
28633
|
+
dimColor: entry.kind === 'skip',
|
|
28634
|
+
bold: entry.kind === 'bad',
|
|
28635
|
+
}, truncate$1(text, width - 4)));
|
|
28636
|
+
}
|
|
28637
|
+
}
|
|
28638
|
+
lines.push(h(Text, { key: 'bisect-action-spacer' }, ''));
|
|
28639
|
+
lines.push(h(Text, { key: 'bisect-action-hint', dimColor: true }, truncate$1('Actions: g good · b bad · s skip · x reset', width - 4)));
|
|
28640
|
+
}
|
|
28641
|
+
return h(Box, {
|
|
28642
|
+
borderColor: focusBorderColor(theme, focused),
|
|
28643
|
+
borderStyle: theme.borderStyle,
|
|
28644
|
+
flexDirection: 'column',
|
|
28645
|
+
flexShrink: 0,
|
|
28646
|
+
paddingX: 1,
|
|
28647
|
+
width,
|
|
28648
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Bisect', focused)), h(Text, { dimColor: true }, bisect?.active ? 'BISECTING' : 'inactive')), ...lines);
|
|
28649
|
+
}
|
|
27816
28650
|
function renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
27817
28651
|
const { Box, Text } = components;
|
|
27818
28652
|
const focused = state.focus === 'commits';
|
|
@@ -28018,7 +28852,7 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
|
|
|
28018
28852
|
h(Text, { key: 'promoted-filter-input', color: accent }, `filter: ${state.filter}_`),
|
|
28019
28853
|
];
|
|
28020
28854
|
}
|
|
28021
|
-
function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme) {
|
|
28855
|
+
function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme) {
|
|
28022
28856
|
const { Box, Text } = components;
|
|
28023
28857
|
const focused = state.focus === 'commits';
|
|
28024
28858
|
const worktree = context.worktree;
|
|
@@ -28109,6 +28943,50 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
28109
28943
|
dimColor: index > 0,
|
|
28110
28944
|
}, truncate$1(line, width - 4))), ...stashBodyNodes);
|
|
28111
28945
|
}
|
|
28946
|
+
// Compare-two-refs branch (#779). Mirrors the stash diff above but
|
|
28947
|
+
// sourced from `git diff <base>..<head>`. No per-file cherry-pick or
|
|
28948
|
+
// hunk apply — comparing arbitrary refs doesn't have a sensible
|
|
28949
|
+
// mutate-from-here flow, so the surface is read-only navigation.
|
|
28950
|
+
if (state.diffSource === 'compare') {
|
|
28951
|
+
const lines = compareDiffLines || [];
|
|
28952
|
+
const splitActive = isSplitDiffViable(state, width);
|
|
28953
|
+
const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
|
|
28954
|
+
const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
28955
|
+
const baseLabel = state.compareBase?.label || state.compareBase?.ref || '<base>';
|
|
28956
|
+
const headLabel = state.compareHead?.label || state.compareHead?.ref || '<head>';
|
|
28957
|
+
const compareTitle = `${baseLabel} → ${headLabel}`;
|
|
28958
|
+
const baseHeaderLines = compareDiffLoading
|
|
28959
|
+
? [`Loading diff for ${compareTitle}...`]
|
|
28960
|
+
: lines.length && (lines.length > 1 || lines[0])
|
|
28961
|
+
? [
|
|
28962
|
+
compareTitle,
|
|
28963
|
+
`Lines ${Math.min(state.diffPreviewOffset + 1, lines.length)}-${Math.min(state.diffPreviewOffset + visibleLines.length, lines.length)}/${lines.length}`,
|
|
28964
|
+
'',
|
|
28965
|
+
]
|
|
28966
|
+
: ['No diff to display — refs may resolve to the same tree.'];
|
|
28967
|
+
const headerLines = splitRequestedButTooNarrow
|
|
28968
|
+
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
28969
|
+
: baseHeaderLines;
|
|
28970
|
+
const compareBodyNodes = compareDiffLoading || !lines.length || (lines.length === 1 && !lines[0])
|
|
28971
|
+
? []
|
|
28972
|
+
: splitActive
|
|
28973
|
+
? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'compare-diff-split')
|
|
28974
|
+
: visibleLines.map((line, index) => h(Text, {
|
|
28975
|
+
key: `compare-diff-line-${state.diffPreviewOffset + index}`,
|
|
28976
|
+
...diffLineProps(line, theme),
|
|
28977
|
+
}, truncate$1(line, width - 4)));
|
|
28978
|
+
return h(Box, {
|
|
28979
|
+
borderColor: focusBorderColor(theme, focused),
|
|
28980
|
+
borderStyle: theme.borderStyle,
|
|
28981
|
+
flexDirection: 'column',
|
|
28982
|
+
flexShrink: 0,
|
|
28983
|
+
paddingX: 1,
|
|
28984
|
+
width,
|
|
28985
|
+
}, 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, {
|
|
28986
|
+
key: `compare-diff-header-${index}`,
|
|
28987
|
+
dimColor: index > 0,
|
|
28988
|
+
}, truncate$1(line, width - 4))), ...compareBodyNodes);
|
|
28989
|
+
}
|
|
28112
28990
|
// diffSource disambiguates: 'commit' was set when the user opened the
|
|
28113
28991
|
// diff via history → Enter (read-only commit-diff explore), 'worktree'
|
|
28114
28992
|
// was set when they came from status → Enter (stage / hunk / revert).
|
|
@@ -28967,6 +29845,7 @@ function renderFooter(h, components, state, context, theme, idleTip) {
|
|
|
28967
29845
|
showHelp: state.showHelp,
|
|
28968
29846
|
sidebarTab: state.sidebarTab,
|
|
28969
29847
|
sidebarItemCount,
|
|
29848
|
+
compareBaseSet: Boolean(state.compareBase),
|
|
28970
29849
|
});
|
|
28971
29850
|
// Real status messages always win; idle tips only fill the slot when it
|
|
28972
29851
|
// would otherwise be empty.
|