git-coco 0.37.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.esm.mjs +2304 -257
- package/dist/index.js +2303 -256
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -43,6 +43,7 @@ var path$1 = require('node:path');
|
|
|
43
43
|
var crypto = require('node:crypto');
|
|
44
44
|
var readline = require('readline');
|
|
45
45
|
var util$1 = require('util');
|
|
46
|
+
var crypto$1 = require('crypto');
|
|
46
47
|
var url = require('url');
|
|
47
48
|
|
|
48
49
|
function _interopNamespaceDefault(e) {
|
|
@@ -77,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
|
|
|
77
78
|
/**
|
|
78
79
|
* Current build version from package.json
|
|
79
80
|
*/
|
|
80
|
-
const BUILD_VERSION = "0.
|
|
81
|
+
const BUILD_VERSION = "0.38.0";
|
|
81
82
|
|
|
82
83
|
const isInteractive = (config) => {
|
|
83
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -13539,8 +13540,11 @@ const builder$3 = (yargs) => {
|
|
|
13539
13540
|
};
|
|
13540
13541
|
|
|
13541
13542
|
const FIELD_SEPARATOR$2 = '\x1f';
|
|
13542
|
-
|
|
13543
|
-
|
|
13543
|
+
// `%P` (parent hashes, space-separated) lets the TUI distinguish
|
|
13544
|
+
// merge commits (parents.length > 1) from regular commits without a
|
|
13545
|
+
// second round-trip to git. See #791 stage 3 — merge glyph + HEAD ring.
|
|
13546
|
+
const LOG_FORMAT = `%x1f%h%x1f%H%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s`;
|
|
13547
|
+
const DETAIL_FORMAT = `%H%x1f%h%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s%x1f%b`;
|
|
13544
13548
|
const LOG_DEFAULT_LIMIT = 30;
|
|
13545
13549
|
const LOG_INTERACTIVE_DEFAULT_LIMIT = 300;
|
|
13546
13550
|
function toArray(value) {
|
|
@@ -13594,12 +13598,13 @@ function parseLogOutput(output) {
|
|
|
13594
13598
|
graph: line,
|
|
13595
13599
|
};
|
|
13596
13600
|
}
|
|
13597
|
-
const [graph, shortHash, hash, date, author, refs, message] = line.split(FIELD_SEPARATOR$2);
|
|
13601
|
+
const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$2);
|
|
13598
13602
|
return {
|
|
13599
13603
|
type: 'commit',
|
|
13600
13604
|
graph: graph.trimEnd(),
|
|
13601
13605
|
shortHash,
|
|
13602
13606
|
hash,
|
|
13607
|
+
parents: parentsStr ? parentsStr.trim().split(' ').filter(Boolean) : [],
|
|
13603
13608
|
date,
|
|
13604
13609
|
author,
|
|
13605
13610
|
refs: cleanRefs(refs),
|
|
@@ -13666,13 +13671,14 @@ function parseNameStatus(output, numstat = []) {
|
|
|
13666
13671
|
});
|
|
13667
13672
|
}
|
|
13668
13673
|
function parseCommitDetail(metadata, files, numstatOutput = '') {
|
|
13669
|
-
const [hash, shortHash, date, author, refs, message, body = ''] = metadata
|
|
13674
|
+
const [hash, shortHash, parentsStr, date, author, refs, message, body = ''] = metadata
|
|
13670
13675
|
.trimEnd()
|
|
13671
13676
|
.split(FIELD_SEPARATOR$2);
|
|
13672
13677
|
const numstat = parseNumstat(numstatOutput);
|
|
13673
13678
|
return {
|
|
13674
13679
|
shortHash,
|
|
13675
13680
|
hash,
|
|
13681
|
+
parents: parentsStr ? parentsStr.trim().split(' ').filter(Boolean) : [],
|
|
13676
13682
|
date,
|
|
13677
13683
|
author,
|
|
13678
13684
|
refs: cleanRefs(refs),
|
|
@@ -13726,6 +13732,24 @@ function buildLogArgs(argv, options = {}) {
|
|
|
13726
13732
|
}
|
|
13727
13733
|
return args;
|
|
13728
13734
|
}
|
|
13735
|
+
/**
|
|
13736
|
+
* Build merged `LogArgv` for the interactive TUI's `g` graph toggle.
|
|
13737
|
+
*
|
|
13738
|
+
* The TUI tracks a transient `fullGraph` boolean; toggling it must produce
|
|
13739
|
+
* a fresh fetch with the right `view` so the renderer actually has graph
|
|
13740
|
+
* topology to draw. When switching to full mode we override `view` to
|
|
13741
|
+
* `'full'` (which `buildLogArgs` already maps to `--all`, dropping
|
|
13742
|
+
* `--first-parent`/`--no-merges`). When switching back we honor the user's
|
|
13743
|
+
* original `view` from argv, defaulting to `'compact'`.
|
|
13744
|
+
*
|
|
13745
|
+
* Pure helper so the effect that calls it stays trivially testable.
|
|
13746
|
+
*/
|
|
13747
|
+
function buildToggleGraphArgs(argv, fullGraph) {
|
|
13748
|
+
if (fullGraph) {
|
|
13749
|
+
return { ...argv, view: 'full' };
|
|
13750
|
+
}
|
|
13751
|
+
return { ...argv, view: argv.view ?? 'compact' };
|
|
13752
|
+
}
|
|
13729
13753
|
async function getLogRows(git, argv, options = {}) {
|
|
13730
13754
|
return parseLogOutput(await git.raw(buildLogArgs(argv, options)));
|
|
13731
13755
|
}
|
|
@@ -13967,7 +13991,7 @@ function splitCommitDraft(draft) {
|
|
|
13967
13991
|
body,
|
|
13968
13992
|
};
|
|
13969
13993
|
}
|
|
13970
|
-
function compactOutputLines$
|
|
13994
|
+
function compactOutputLines$5(output) {
|
|
13971
13995
|
return output
|
|
13972
13996
|
.split('\n')
|
|
13973
13997
|
.map((line) => line.trim())
|
|
@@ -13975,14 +13999,14 @@ function compactOutputLines$4(output) {
|
|
|
13975
13999
|
}
|
|
13976
14000
|
function formatManualCommitFailure(error) {
|
|
13977
14001
|
if (error instanceof PreCommitHookError) {
|
|
13978
|
-
const details = compactOutputLines$
|
|
14002
|
+
const details = compactOutputLines$5(error.hookOutput);
|
|
13979
14003
|
return {
|
|
13980
14004
|
ok: false,
|
|
13981
14005
|
message: `Commit blocked by hook: ${details[0] || 'hook failed'}`,
|
|
13982
14006
|
details: details.slice(1, 6),
|
|
13983
14007
|
};
|
|
13984
14008
|
}
|
|
13985
|
-
const details = compactOutputLines$
|
|
14009
|
+
const details = compactOutputLines$5(error.message);
|
|
13986
14010
|
return {
|
|
13987
14011
|
ok: false,
|
|
13988
14012
|
message: details[0] || 'Commit failed.',
|
|
@@ -14283,7 +14307,7 @@ function formatCommitWorkflowMessage(action, output) {
|
|
|
14283
14307
|
}
|
|
14284
14308
|
return 'Generated commit message.';
|
|
14285
14309
|
}
|
|
14286
|
-
function compactOutputLines$
|
|
14310
|
+
function compactOutputLines$4(output) {
|
|
14287
14311
|
return output
|
|
14288
14312
|
.split('\n')
|
|
14289
14313
|
.map((line) => line.trim())
|
|
@@ -14291,14 +14315,14 @@ function compactOutputLines$3(output) {
|
|
|
14291
14315
|
}
|
|
14292
14316
|
function formatCommitFailure(error) {
|
|
14293
14317
|
if (error instanceof PreCommitHookError) {
|
|
14294
|
-
const details = compactOutputLines$
|
|
14318
|
+
const details = compactOutputLines$4(error.hookOutput);
|
|
14295
14319
|
return {
|
|
14296
14320
|
ok: false,
|
|
14297
14321
|
message: `Commit blocked by hook: ${details[0] || 'hook failed'}`,
|
|
14298
14322
|
details: details.slice(1, 6),
|
|
14299
14323
|
};
|
|
14300
14324
|
}
|
|
14301
|
-
const details = compactOutputLines$
|
|
14325
|
+
const details = compactOutputLines$4(error.message);
|
|
14302
14326
|
return {
|
|
14303
14327
|
ok: false,
|
|
14304
14328
|
message: details[0] || 'Commit action failed.',
|
|
@@ -14331,7 +14355,7 @@ async function runCommitWorkflow({ action, git = getRepo(), noVerify = false, })
|
|
|
14331
14355
|
}
|
|
14332
14356
|
catch (error) {
|
|
14333
14357
|
if (isCommandExitError(error)) {
|
|
14334
|
-
const lines = compactOutputLines$
|
|
14358
|
+
const lines = compactOutputLines$4(output || error.message);
|
|
14335
14359
|
return {
|
|
14336
14360
|
ok: error.code === 0,
|
|
14337
14361
|
message: lines[0] || error.message,
|
|
@@ -14373,7 +14397,7 @@ async function runCommitDraftWorkflow(input = {}) {
|
|
|
14373
14397
|
}
|
|
14374
14398
|
catch (error) {
|
|
14375
14399
|
if (isCommandExitError(error)) {
|
|
14376
|
-
const lines = compactOutputLines$
|
|
14400
|
+
const lines = compactOutputLines$4(error.message);
|
|
14377
14401
|
return {
|
|
14378
14402
|
ok: error.code === 0,
|
|
14379
14403
|
message: lines[0] || error.message,
|
|
@@ -14411,6 +14435,274 @@ function isLogInkContextKeyLoading(status, key) {
|
|
|
14411
14435
|
return status[key] === 'loading';
|
|
14412
14436
|
}
|
|
14413
14437
|
|
|
14438
|
+
/**
|
|
14439
|
+
* The chars `git log --graph` emits for branch topology — `*`, `|`, `\`,
|
|
14440
|
+
* `/`, `_`, ` `. ASCII-only output is bulletproof for legacy terminals
|
|
14441
|
+
* but the angles read poorly when many branches overlap.
|
|
14442
|
+
*
|
|
14443
|
+
* `substituteGraphChars` walks the row left-to-right with one-char
|
|
14444
|
+
* lookahead so it can recognize git's two-char junction patterns and
|
|
14445
|
+
* emit proper box-drawing junctions (├╮ / ├╯) instead of overlapping
|
|
14446
|
+
* pipes (│╲ / │╱). Anything that isn't part of a recognized pattern
|
|
14447
|
+
* falls back to the legacy 1-to-1 substitution.
|
|
14448
|
+
*
|
|
14449
|
+
* `theme.ascii` (TERM=dumb / vt100) bypasses substitution entirely so
|
|
14450
|
+
* legacy terminals get the raw `git log --graph` output. `theme.noColor`
|
|
14451
|
+
* is orthogonal — Unicode chars still render, just without color.
|
|
14452
|
+
*
|
|
14453
|
+
* Kept ASCII-only intentionally:
|
|
14454
|
+
* - alphanumerics (commit refs / annotations git sometimes injects)
|
|
14455
|
+
* - parens / brackets (HEAD decoration markers, not part of the graph)
|
|
14456
|
+
* - hyphens / colons (likewise)
|
|
14457
|
+
*/
|
|
14458
|
+
const ASCII_TO_UNICODE_MAP = {
|
|
14459
|
+
'*': '●',
|
|
14460
|
+
'|': '│',
|
|
14461
|
+
'/': '╱',
|
|
14462
|
+
'\\': '╲',
|
|
14463
|
+
'_': '─',
|
|
14464
|
+
};
|
|
14465
|
+
const DEFAULT_COMMIT_GLYPH = '●';
|
|
14466
|
+
/**
|
|
14467
|
+
* #791 stage 3 — distinct glyphs for merges and HEAD so they stand
|
|
14468
|
+
* out from the run of regular commits. `◆` (filled diamond) flags a
|
|
14469
|
+
* merge commit (`parents.length > 1`); `◉` (fisheye) flags HEAD
|
|
14470
|
+
* regardless of parent count. Both render at the same column width as
|
|
14471
|
+
* `●` so graph alignment stays intact across mixed commit types.
|
|
14472
|
+
*/
|
|
14473
|
+
const MERGE_COMMIT_GLYPH = '◆';
|
|
14474
|
+
const HEAD_COMMIT_GLYPH = '◉';
|
|
14475
|
+
/**
|
|
14476
|
+
* Recognized 2-char junction patterns. The key is the bigram git emits
|
|
14477
|
+
* (lane char + spacer char); the value is the box-drawing pair we render.
|
|
14478
|
+
*
|
|
14479
|
+
* - `|\` (fork): trunk lane gains a right-T (├) and the spacer becomes
|
|
14480
|
+
* the upper-right corner (╮) starting the new lane below.
|
|
14481
|
+
* - `|/` (converge): trunk lane gains a right-T (├) and the spacer
|
|
14482
|
+
* becomes the upper-left corner (╯) absorbing the side lane from
|
|
14483
|
+
* above.
|
|
14484
|
+
*
|
|
14485
|
+
* `*\` and `* /` (commit-row variants) are handled the same way, but
|
|
14486
|
+
* the commit glyph itself stays configurable via `commitGlyph` so
|
|
14487
|
+
* stage 3 can swap in `◆` / `◉` for merges and HEAD.
|
|
14488
|
+
*/
|
|
14489
|
+
const PIPE_FORK = '├╮';
|
|
14490
|
+
const PIPE_CONVERGE = '├╯';
|
|
14491
|
+
const FORK_SPACER = '╮';
|
|
14492
|
+
const CONVERGE_SPACER = '╯';
|
|
14493
|
+
function substituteGraphChars(graph, options) {
|
|
14494
|
+
if (options.ascii) {
|
|
14495
|
+
return graph;
|
|
14496
|
+
}
|
|
14497
|
+
const commitGlyph = options.commitGlyph ?? DEFAULT_COMMIT_GLYPH;
|
|
14498
|
+
let output = '';
|
|
14499
|
+
let i = 0;
|
|
14500
|
+
while (i < graph.length) {
|
|
14501
|
+
const a = graph[i];
|
|
14502
|
+
const b = i + 1 < graph.length ? graph[i + 1] : '';
|
|
14503
|
+
if (a === '|' && b === '\\') {
|
|
14504
|
+
output += PIPE_FORK;
|
|
14505
|
+
i += 2;
|
|
14506
|
+
continue;
|
|
14507
|
+
}
|
|
14508
|
+
if (a === '|' && b === '/') {
|
|
14509
|
+
output += PIPE_CONVERGE;
|
|
14510
|
+
i += 2;
|
|
14511
|
+
continue;
|
|
14512
|
+
}
|
|
14513
|
+
if (a === '*' && b === '\\') {
|
|
14514
|
+
output += commitGlyph + FORK_SPACER;
|
|
14515
|
+
i += 2;
|
|
14516
|
+
continue;
|
|
14517
|
+
}
|
|
14518
|
+
if (a === '*' && b === '/') {
|
|
14519
|
+
output += commitGlyph + CONVERGE_SPACER;
|
|
14520
|
+
i += 2;
|
|
14521
|
+
continue;
|
|
14522
|
+
}
|
|
14523
|
+
if (a === '*') {
|
|
14524
|
+
output += commitGlyph;
|
|
14525
|
+
}
|
|
14526
|
+
else {
|
|
14527
|
+
output += ASCII_TO_UNICODE_MAP[a] ?? a;
|
|
14528
|
+
}
|
|
14529
|
+
i += 1;
|
|
14530
|
+
}
|
|
14531
|
+
return output;
|
|
14532
|
+
}
|
|
14533
|
+
|
|
14534
|
+
/**
|
|
14535
|
+
* Lane tracking + per-lane coloring for the Ink log TUI graph (#791
|
|
14536
|
+
* stage 2).
|
|
14537
|
+
*
|
|
14538
|
+
* `git log --graph` emits topology in 2-char patterns where every even
|
|
14539
|
+
* position is a lane column (`*`, `|`, ` `) and every odd position is
|
|
14540
|
+
* a spacer that may carry a connector (`\`, `/`, `_`). To color graph
|
|
14541
|
+
* chars by which logical lane they belong to, we walk the rows
|
|
14542
|
+
* left-to-right tracking which lane id occupies each column and apply
|
|
14543
|
+
* git's emission rules:
|
|
14544
|
+
*
|
|
14545
|
+
* - `|\` (fork): the spacer spawns a new lane id at column +1 below.
|
|
14546
|
+
* - `|/` (converge): the spacer absorbs the lane at column +1 into
|
|
14547
|
+
* this column; the absorbed lane disappears in the next row.
|
|
14548
|
+
* - `*` is treated like `|` for lane purposes (a commit lives on a
|
|
14549
|
+
* lane and connects through the row).
|
|
14550
|
+
*
|
|
14551
|
+
* Lane ids are stable across rows for the same column unless one of
|
|
14552
|
+
* the transition patterns above fires. Other shifts (multi-step `_`
|
|
14553
|
+
* crossings, octopus merges) degrade gracefully — uncovered chars
|
|
14554
|
+
* just fall back to `undefined` lane id, so they render in the muted
|
|
14555
|
+
* graph color rather than a wrong lane color.
|
|
14556
|
+
*
|
|
14557
|
+
* The segment builder collapses adjacent characters with the same
|
|
14558
|
+
* lane id into one `LaneSegment` so the renderer emits one Text span
|
|
14559
|
+
* per visually-distinct color region instead of per-char.
|
|
14560
|
+
*/
|
|
14561
|
+
function createLaneTrackerState() {
|
|
14562
|
+
return { columnLanes: new Map(), nextLaneId: 0 };
|
|
14563
|
+
}
|
|
14564
|
+
/**
|
|
14565
|
+
* Walk a single graph row left-to-right, mutating the tracker so the
|
|
14566
|
+
* next row sees the updated column → lane id map. Returns lane
|
|
14567
|
+
* segments ready for the renderer. When `options.ascii` is true the
|
|
14568
|
+
* tracker is left untouched and the row is emitted as a single
|
|
14569
|
+
* lane-less segment so legacy terminals get raw ASCII output with no
|
|
14570
|
+
* coloring.
|
|
14571
|
+
*/
|
|
14572
|
+
function renderGraphRowSegments(graph, tracker, options) {
|
|
14573
|
+
if (options.ascii) {
|
|
14574
|
+
return [{ text: graph, laneId: undefined }];
|
|
14575
|
+
}
|
|
14576
|
+
const commitGlyph = options.commitGlyph ?? DEFAULT_COMMIT_GLYPH;
|
|
14577
|
+
const segments = [];
|
|
14578
|
+
const push = (text, laneId) => {
|
|
14579
|
+
const last = segments[segments.length - 1];
|
|
14580
|
+
if (last && last.laneId === laneId) {
|
|
14581
|
+
last.text += text;
|
|
14582
|
+
}
|
|
14583
|
+
else {
|
|
14584
|
+
segments.push({ text, laneId });
|
|
14585
|
+
}
|
|
14586
|
+
};
|
|
14587
|
+
let i = 0;
|
|
14588
|
+
while (i < graph.length) {
|
|
14589
|
+
const c = graph[i];
|
|
14590
|
+
const next = i + 1 < graph.length ? graph[i + 1] : '';
|
|
14591
|
+
const col = i >> 1;
|
|
14592
|
+
const isSpacer = (i & 1) === 1;
|
|
14593
|
+
if (c === ' ') {
|
|
14594
|
+
push(' ', undefined);
|
|
14595
|
+
i += 1;
|
|
14596
|
+
continue;
|
|
14597
|
+
}
|
|
14598
|
+
if (!isSpacer && (c === '|' || c === '*')) {
|
|
14599
|
+
if (!tracker.columnLanes.has(col)) {
|
|
14600
|
+
tracker.columnLanes.set(col, tracker.nextLaneId++);
|
|
14601
|
+
}
|
|
14602
|
+
const laneId = tracker.columnLanes.get(col);
|
|
14603
|
+
const glyph = c === '|' ? '│' : commitGlyph;
|
|
14604
|
+
if (next === '\\') {
|
|
14605
|
+
const newLaneId = tracker.nextLaneId++;
|
|
14606
|
+
tracker.columnLanes.set(col + 1, newLaneId);
|
|
14607
|
+
push(c === '|' ? '├' : commitGlyph, laneId);
|
|
14608
|
+
push('╮', newLaneId);
|
|
14609
|
+
i += 2;
|
|
14610
|
+
continue;
|
|
14611
|
+
}
|
|
14612
|
+
if (next === '/') {
|
|
14613
|
+
const absorbedLaneId = tracker.columnLanes.get(col + 1);
|
|
14614
|
+
push(c === '|' ? '├' : commitGlyph, laneId);
|
|
14615
|
+
push('╯', absorbedLaneId);
|
|
14616
|
+
tracker.columnLanes.delete(col + 1);
|
|
14617
|
+
i += 2;
|
|
14618
|
+
continue;
|
|
14619
|
+
}
|
|
14620
|
+
push(glyph, laneId);
|
|
14621
|
+
i += 1;
|
|
14622
|
+
continue;
|
|
14623
|
+
}
|
|
14624
|
+
// Non-lane chars (standalone `\`, `/`, `_`, decorations) — substitute
|
|
14625
|
+
// 1-to-1 and leave the lane id undefined so they render in the muted
|
|
14626
|
+
// fallback color.
|
|
14627
|
+
push(ASCII_TO_UNICODE_MAP[c] ?? c, undefined);
|
|
14628
|
+
i += 1;
|
|
14629
|
+
}
|
|
14630
|
+
return segments;
|
|
14631
|
+
}
|
|
14632
|
+
/**
|
|
14633
|
+
* Run the tracker over `count` rows starting from `state.rows[0]` so
|
|
14634
|
+
* downstream callers can resume tracking from a specific window
|
|
14635
|
+
* without re-scanning. Used by `getVisibleLogInkHistory` to keep lane
|
|
14636
|
+
* ids stable across scrolling — without this, each scroll would
|
|
14637
|
+
* re-color lanes from a fresh tracker.
|
|
14638
|
+
*/
|
|
14639
|
+
function advanceTrackerThrough(graphs, tracker, count) {
|
|
14640
|
+
for (let i = 0; i < count && i < graphs.length; i++) {
|
|
14641
|
+
renderGraphRowSegments(graphs[i], tracker, { ascii: false });
|
|
14642
|
+
}
|
|
14643
|
+
}
|
|
14644
|
+
/**
|
|
14645
|
+
* Theme-aware lane palette. Default uses bright ANSI named colors that
|
|
14646
|
+
* render reliably on 16-color terminals; catppuccin / gruvbox lift
|
|
14647
|
+
* accent hues from their respective palettes so the graph stays
|
|
14648
|
+
* coherent with the surrounding chrome.
|
|
14649
|
+
*
|
|
14650
|
+
* Selecting 8 colors gives enough variety to distinguish lanes in
|
|
14651
|
+
* practice (most repos peak at 3-4 simultaneous lanes); the modulo
|
|
14652
|
+
* lookup wraps cleanly for the rare case of more.
|
|
14653
|
+
*/
|
|
14654
|
+
const DEFAULT_LANE_PALETTE = [
|
|
14655
|
+
'cyan', 'magenta', 'yellow', 'green', 'blue', 'red', 'cyanBright', 'magentaBright',
|
|
14656
|
+
];
|
|
14657
|
+
const CATPPUCCIN_LANE_PALETTE = [
|
|
14658
|
+
'#89b4fa', '#f5c2e7', '#f9e2af', '#a6e3a1', '#cba6f7', '#fab387', '#94e2d5', '#f5e0dc',
|
|
14659
|
+
];
|
|
14660
|
+
const GRUVBOX_LANE_PALETTE = [
|
|
14661
|
+
'#83a598', '#d3869b', '#fabd2f', '#b8bb26', '#d65d0e', '#fb4934', '#8ec07c', '#fe8019',
|
|
14662
|
+
];
|
|
14663
|
+
function getLanePalette(theme) {
|
|
14664
|
+
if (theme.noColor) {
|
|
14665
|
+
return [];
|
|
14666
|
+
}
|
|
14667
|
+
const accent = theme.colors.accent;
|
|
14668
|
+
if (accent === '#89b4fa') {
|
|
14669
|
+
return CATPPUCCIN_LANE_PALETTE;
|
|
14670
|
+
}
|
|
14671
|
+
if (accent === '#83a598') {
|
|
14672
|
+
return GRUVBOX_LANE_PALETTE;
|
|
14673
|
+
}
|
|
14674
|
+
return DEFAULT_LANE_PALETTE;
|
|
14675
|
+
}
|
|
14676
|
+
function getLaneColor(laneId, theme) {
|
|
14677
|
+
if (laneId === undefined) {
|
|
14678
|
+
return undefined;
|
|
14679
|
+
}
|
|
14680
|
+
const palette = getLanePalette(theme);
|
|
14681
|
+
if (palette.length === 0) {
|
|
14682
|
+
return undefined;
|
|
14683
|
+
}
|
|
14684
|
+
return palette[laneId % palette.length];
|
|
14685
|
+
}
|
|
14686
|
+
|
|
14687
|
+
/**
|
|
14688
|
+
* Pick the commit glyph based on parent count + HEAD-ness so the
|
|
14689
|
+
* renderer can flag merges and the current head visually. HEAD wins
|
|
14690
|
+
* over merge when both apply (HEAD on a merge commit) — the ◉ ring
|
|
14691
|
+
* is the more salient signal and the user can still see the merge
|
|
14692
|
+
* via the lane topology.
|
|
14693
|
+
*/
|
|
14694
|
+
function commitGlyphFor(commit) {
|
|
14695
|
+
if (isHeadCommit$1(commit)) {
|
|
14696
|
+
return HEAD_COMMIT_GLYPH;
|
|
14697
|
+
}
|
|
14698
|
+
if (commit.parents.length > 1) {
|
|
14699
|
+
return MERGE_COMMIT_GLYPH;
|
|
14700
|
+
}
|
|
14701
|
+
return DEFAULT_COMMIT_GLYPH;
|
|
14702
|
+
}
|
|
14703
|
+
function isHeadCommit$1(commit) {
|
|
14704
|
+
return commit.refs.some((ref) => ref === 'HEAD' || ref.startsWith('HEAD ->'));
|
|
14705
|
+
}
|
|
14414
14706
|
function clampWindowStart(index, count, visibleCount) {
|
|
14415
14707
|
return Math.max(0, Math.min(index - Math.floor(visibleCount / 2), Math.max(0, count - visibleCount)));
|
|
14416
14708
|
}
|
|
@@ -14426,6 +14718,11 @@ function toCompactItems(state, visibleCount) {
|
|
|
14426
14718
|
type: 'commit',
|
|
14427
14719
|
commit,
|
|
14428
14720
|
graph: '*',
|
|
14721
|
+
// Compact mode skips lane tracking (no topology to color) but still
|
|
14722
|
+
// wants the merge / HEAD glyph so the user can spot them at a
|
|
14723
|
+
// glance. Lane id stays undefined so the segment renders muted —
|
|
14724
|
+
// matching the legacy compact appearance, just with a richer glyph.
|
|
14725
|
+
laneSegments: [{ text: commitGlyphFor(commit), laneId: undefined }],
|
|
14429
14726
|
selected: start + offset === state.selectedIndex,
|
|
14430
14727
|
}));
|
|
14431
14728
|
}
|
|
@@ -14436,17 +14733,28 @@ function toFullGraphItems(state, visibleCount) {
|
|
|
14436
14733
|
const selected = state.filteredCommits[state.selectedIndex];
|
|
14437
14734
|
const selectedRowIndex = state.rows.findIndex((row) => isSelectedCommit(row, selected));
|
|
14438
14735
|
const start = clampWindowStart(selectedRowIndex >= 0 ? selectedRowIndex : 0, state.rows.length, visibleCount);
|
|
14736
|
+
// Lane tracking is order-dependent — fast-forward the tracker through
|
|
14737
|
+
// every row above the visible window so lane ids stay stable as the
|
|
14738
|
+
// user scrolls. Without this, scrolling would re-color lanes from a
|
|
14739
|
+
// fresh tracker each time.
|
|
14740
|
+
const tracker = createLaneTrackerState();
|
|
14741
|
+
const allGraphs = state.rows.map((row) => (row.type === 'commit' ? row.graph || '*' : row.graph));
|
|
14742
|
+
advanceTrackerThrough(allGraphs, tracker, start);
|
|
14439
14743
|
return state.rows.slice(start, start + visibleCount).map((row) => {
|
|
14440
14744
|
if (row.type === 'graph') {
|
|
14441
14745
|
return {
|
|
14442
14746
|
type: 'graph',
|
|
14443
14747
|
graph: row.graph,
|
|
14748
|
+
laneSegments: renderGraphRowSegments(row.graph, tracker, { ascii: false }),
|
|
14444
14749
|
};
|
|
14445
14750
|
}
|
|
14751
|
+
const graph = row.graph || '*';
|
|
14752
|
+
const commitGlyph = commitGlyphFor(row);
|
|
14446
14753
|
return {
|
|
14447
14754
|
type: 'commit',
|
|
14448
14755
|
commit: row,
|
|
14449
|
-
graph
|
|
14756
|
+
graph,
|
|
14757
|
+
laneSegments: renderGraphRowSegments(graph, tracker, { ascii: false, commitGlyph }),
|
|
14450
14758
|
selected: isSelectedCommit(row, selected),
|
|
14451
14759
|
};
|
|
14452
14760
|
});
|
|
@@ -14595,6 +14903,34 @@ function getLogInkWorkflowActions() {
|
|
|
14595
14903
|
kind: 'destructive',
|
|
14596
14904
|
requiresConfirmation: true,
|
|
14597
14905
|
},
|
|
14906
|
+
{
|
|
14907
|
+
// Per-view-only: scoped to commit-diff and stash-diff explores in
|
|
14908
|
+
// inkInput (key: H). The action is non-destructive in the sense
|
|
14909
|
+
// that `git apply` won't lose any data — `git apply -R` undoes
|
|
14910
|
+
// it cleanly — so it bypasses the y-confirm path. The patch text
|
|
14911
|
+
// travels via the action's `payload` field. Empty key keeps the
|
|
14912
|
+
// workflow palette-discoverable without registering a global
|
|
14913
|
+
// hotkey (the palette path can't synthesize the patch text and
|
|
14914
|
+
// surfaces a hint instead — actual dispatch is from H in diff
|
|
14915
|
+
// view).
|
|
14916
|
+
id: 'apply-hunk-worktree',
|
|
14917
|
+
key: '',
|
|
14918
|
+
label: 'Apply hunk to worktree',
|
|
14919
|
+
description: 'Extract the hunk under the cursor and apply it to the working tree via `git apply`.',
|
|
14920
|
+
kind: 'normal',
|
|
14921
|
+
requiresConfirmation: false,
|
|
14922
|
+
},
|
|
14923
|
+
{
|
|
14924
|
+
// Sibling of `apply-hunk-worktree` — same extraction path, but
|
|
14925
|
+
// `git apply --cached` so the patch lands in the index without
|
|
14926
|
+
// touching the worktree. Bound to the `gH` chord in inkInput.
|
|
14927
|
+
id: 'apply-hunk-index',
|
|
14928
|
+
key: '',
|
|
14929
|
+
label: 'Apply hunk to index',
|
|
14930
|
+
description: 'Extract the hunk under the cursor and apply it to the index via `git apply --cached`.',
|
|
14931
|
+
kind: 'normal',
|
|
14932
|
+
requiresConfirmation: false,
|
|
14933
|
+
},
|
|
14598
14934
|
{
|
|
14599
14935
|
id: 'open-pr',
|
|
14600
14936
|
key: 'O',
|
|
@@ -14687,6 +15023,88 @@ function getLogInkWorkflowActions() {
|
|
|
14687
15023
|
kind: 'destructive',
|
|
14688
15024
|
requiresConfirmation: true,
|
|
14689
15025
|
},
|
|
15026
|
+
// #783 — full PR action panel. All five entries are palette-only
|
|
15027
|
+
// (`key: ''`) — actual dispatch is per-view scoped in inkInput so
|
|
15028
|
+
// the keys stay free outside the pull-request view. Merge / close /
|
|
15029
|
+
// approve / request-changes route through the y-confirm path
|
|
15030
|
+
// because each is irreversible (or near-irreversible) once gh
|
|
15031
|
+
// publishes it; comment is a free-form prompt with no extra
|
|
15032
|
+
// confirmation since the body itself is the affirmative action.
|
|
15033
|
+
{
|
|
15034
|
+
id: 'merge-pr',
|
|
15035
|
+
key: '',
|
|
15036
|
+
label: 'Merge pull request',
|
|
15037
|
+
description: 'Merge the current branch\'s pull request (prompts for merge / squash / rebase, then confirms).',
|
|
15038
|
+
kind: 'destructive',
|
|
15039
|
+
requiresConfirmation: true,
|
|
15040
|
+
},
|
|
15041
|
+
{
|
|
15042
|
+
id: 'close-pr',
|
|
15043
|
+
key: '',
|
|
15044
|
+
label: 'Close pull request',
|
|
15045
|
+
description: 'Close the current pull request without merging.',
|
|
15046
|
+
kind: 'destructive',
|
|
15047
|
+
requiresConfirmation: true,
|
|
15048
|
+
},
|
|
15049
|
+
{
|
|
15050
|
+
id: 'approve-pr',
|
|
15051
|
+
key: '',
|
|
15052
|
+
label: 'Approve pull request',
|
|
15053
|
+
description: 'Submit an approving review on the current pull request.',
|
|
15054
|
+
kind: 'normal',
|
|
15055
|
+
requiresConfirmation: true,
|
|
15056
|
+
},
|
|
15057
|
+
{
|
|
15058
|
+
id: 'request-changes-pr',
|
|
15059
|
+
key: '',
|
|
15060
|
+
label: 'Request changes on pull request',
|
|
15061
|
+
description: 'Submit a change-request review (prompts for the review body, then confirms).',
|
|
15062
|
+
kind: 'normal',
|
|
15063
|
+
requiresConfirmation: true,
|
|
15064
|
+
},
|
|
15065
|
+
{
|
|
15066
|
+
id: 'comment-pr',
|
|
15067
|
+
key: '',
|
|
15068
|
+
label: 'Comment on pull request',
|
|
15069
|
+
description: 'Add a comment to the current pull request (prompts for body).',
|
|
15070
|
+
kind: 'normal',
|
|
15071
|
+
requiresConfirmation: false,
|
|
15072
|
+
},
|
|
15073
|
+
{
|
|
15074
|
+
// Per-view-only: scoped to the history view in inkInput so `R`
|
|
15075
|
+
// doesn't fire elsewhere (it's also `R` for rename in branches
|
|
15076
|
+
// and delete-remote-tag in tags). Empty key keeps it
|
|
15077
|
+
// palette-discoverable without registering a global hotkey.
|
|
15078
|
+
id: 'revert-commit',
|
|
15079
|
+
key: '',
|
|
15080
|
+
label: 'Revert commit',
|
|
15081
|
+
description: 'Revert the cursored commit by adding an inverse commit on top of HEAD.',
|
|
15082
|
+
kind: 'destructive',
|
|
15083
|
+
requiresConfirmation: true,
|
|
15084
|
+
},
|
|
15085
|
+
{
|
|
15086
|
+
// Per-view-only: scoped to the history view in inkInput. Triggers
|
|
15087
|
+
// a mode prompt (soft / mixed / hard) before the reset runs so
|
|
15088
|
+
// `Z` alone never silently rewrites history.
|
|
15089
|
+
id: 'reset-to-commit',
|
|
15090
|
+
key: '',
|
|
15091
|
+
label: 'Reset to commit',
|
|
15092
|
+
description: 'Move the current branch tip to the cursored commit (prompts for soft / mixed / hard).',
|
|
15093
|
+
kind: 'destructive',
|
|
15094
|
+
requiresConfirmation: true,
|
|
15095
|
+
},
|
|
15096
|
+
{
|
|
15097
|
+
// Per-view-only: scoped to the history view in inkInput. `i`
|
|
15098
|
+
// (lowercase) is used instead of `I` so the existing `I`
|
|
15099
|
+
// ai-commit-summary workflow stays reachable on the history
|
|
15100
|
+
// view — `i` matches the `git rebase -i` flag mnemonic anyway.
|
|
15101
|
+
id: 'interactive-rebase',
|
|
15102
|
+
key: '',
|
|
15103
|
+
label: 'Interactive rebase',
|
|
15104
|
+
description: 'Start an interactive rebase from the cursored commit (opens $GIT_EDITOR for the todo list).',
|
|
15105
|
+
kind: 'destructive',
|
|
15106
|
+
requiresConfirmation: true,
|
|
15107
|
+
},
|
|
14690
15108
|
{
|
|
14691
15109
|
id: 'ai-commit-summary',
|
|
14692
15110
|
key: 'I',
|
|
@@ -14708,6 +15126,15 @@ function getLogInkWorkflowActions() {
|
|
|
14708
15126
|
];
|
|
14709
15127
|
}
|
|
14710
15128
|
function getLogInkWorkflowActionByKey(inputValue) {
|
|
15129
|
+
// Workflow actions with an empty `key` are palette-only — they
|
|
15130
|
+
// exist so the command palette can surface them but should never
|
|
15131
|
+
// match a raw keystroke. Without this guard, any unbound key
|
|
15132
|
+
// (left/right arrow, function keys) that arrives with an empty
|
|
15133
|
+
// inputValue would `find()` the first empty-key entry —
|
|
15134
|
+
// `cherry-pick-commit` — and pop its confirmation dialog.
|
|
15135
|
+
if (!inputValue) {
|
|
15136
|
+
return undefined;
|
|
15137
|
+
}
|
|
14711
15138
|
return getLogInkWorkflowActions().find((action) => action.key === inputValue);
|
|
14712
15139
|
}
|
|
14713
15140
|
function getLogInkWorkflowActionById(id) {
|
|
@@ -14837,6 +15264,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
14837
15264
|
description: 'Toggle compact and full graph display.',
|
|
14838
15265
|
contexts: ['normal', 'commits'],
|
|
14839
15266
|
},
|
|
15267
|
+
{
|
|
15268
|
+
id: 'toggleDiffViewMode',
|
|
15269
|
+
keys: ['d'],
|
|
15270
|
+
label: 'split/unified',
|
|
15271
|
+
description: 'Toggle the diff view between unified and side-by-side split rendering. Falls back to unified on narrow terminals.',
|
|
15272
|
+
contexts: ['commits'],
|
|
15273
|
+
},
|
|
14840
15274
|
{
|
|
14841
15275
|
id: 'navigateHome',
|
|
14842
15276
|
keys: ['gh'],
|
|
@@ -14893,6 +15327,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
14893
15327
|
description: 'Push the linked worktrees view.',
|
|
14894
15328
|
contexts: ['normal'],
|
|
14895
15329
|
},
|
|
15330
|
+
{
|
|
15331
|
+
id: 'navigatePullRequest',
|
|
15332
|
+
keys: ['gp'],
|
|
15333
|
+
label: 'pull request',
|
|
15334
|
+
description: 'Push the dedicated pull-request action panel for the current branch.',
|
|
15335
|
+
contexts: ['normal'],
|
|
15336
|
+
},
|
|
14896
15337
|
{
|
|
14897
15338
|
id: 'navigateBack',
|
|
14898
15339
|
keys: ['<', 'esc'],
|
|
@@ -15021,6 +15462,7 @@ const GLOBAL_BINDING_IDS = [
|
|
|
15021
15462
|
'navigateTags',
|
|
15022
15463
|
'navigateStash',
|
|
15023
15464
|
'navigateWorktrees',
|
|
15465
|
+
'navigatePullRequest',
|
|
15024
15466
|
'navigateBack',
|
|
15025
15467
|
];
|
|
15026
15468
|
const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
|
|
@@ -15081,8 +15523,37 @@ function getLogInkFooterHints(options) {
|
|
|
15081
15523
|
};
|
|
15082
15524
|
}
|
|
15083
15525
|
if (options.focus === 'sidebar') {
|
|
15526
|
+
// Per-tab hints when the active tab has selectable items — the user
|
|
15527
|
+
// can act on the cursored entity without leaving the workstation
|
|
15528
|
+
// view. Status tab + empty content tabs fall back to the generic
|
|
15529
|
+
// "enter open" hint that drills into the dedicated view.
|
|
15530
|
+
const itemsPresent = (options.sidebarItemCount ?? 0) > 0;
|
|
15531
|
+
if (itemsPresent && options.sidebarTab === 'branches') {
|
|
15532
|
+
return {
|
|
15533
|
+
contextual: ['↑/↓ branches', '←/→ tab', 'enter checkout', 'D delete', 'R rename', 'u upstream'],
|
|
15534
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15535
|
+
};
|
|
15536
|
+
}
|
|
15537
|
+
if (itemsPresent && options.sidebarTab === 'stashes') {
|
|
15538
|
+
return {
|
|
15539
|
+
contextual: ['↑/↓ stashes', '←/→ tab', 'enter diff', 'a apply', 'p pop', 'X drop'],
|
|
15540
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15541
|
+
};
|
|
15542
|
+
}
|
|
15543
|
+
if (itemsPresent && options.sidebarTab === 'tags') {
|
|
15544
|
+
return {
|
|
15545
|
+
contextual: ['↑/↓ tags', '←/→ tab', '+ new', 'P push', 'T delete'],
|
|
15546
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15547
|
+
};
|
|
15548
|
+
}
|
|
15549
|
+
if (itemsPresent && options.sidebarTab === 'worktrees') {
|
|
15550
|
+
return {
|
|
15551
|
+
contextual: ['↑/↓ worktrees', '←/→ tab', 'W remove'],
|
|
15552
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15553
|
+
};
|
|
15554
|
+
}
|
|
15084
15555
|
return {
|
|
15085
|
-
contextual: ['
|
|
15556
|
+
contextual: ['←/→ tab', '1-5 jump', 'enter open', 'tab focus'],
|
|
15086
15557
|
global: NORMAL_GLOBAL_HINTS,
|
|
15087
15558
|
};
|
|
15088
15559
|
}
|
|
@@ -15099,17 +15570,23 @@ function getLogInkFooterHints(options) {
|
|
|
15099
15570
|
};
|
|
15100
15571
|
}
|
|
15101
15572
|
if (options.activeView === 'diff') {
|
|
15573
|
+
// Surface what `d` will switch *to* — labels the next mode rather
|
|
15574
|
+
// than the current one so the hint reads as a verb. The split-mode
|
|
15575
|
+
// hint is only shown for the read-only diff sources (commit/stash);
|
|
15576
|
+
// the worktree diff stays unified-only for now.
|
|
15577
|
+
const splitToggleHint = options.diffViewMode === 'split' ? 'd unified' : 'd split';
|
|
15102
15578
|
if (options.diffSource === 'stash') {
|
|
15103
15579
|
return {
|
|
15104
|
-
contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'o edit', 'y yank', 'esc back'],
|
|
15580
|
+
contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', 'o edit', splitToggleHint, 'y yank', 'esc back'],
|
|
15105
15581
|
global: NORMAL_GLOBAL_HINTS,
|
|
15106
15582
|
};
|
|
15107
15583
|
}
|
|
15108
15584
|
if (options.diffSource === 'commit') {
|
|
15109
15585
|
// Commit-diff explore: read-only diff, but `c` cherry-picks the
|
|
15110
|
-
// cursored file from the commit into the worktree
|
|
15586
|
+
// cursored file from the commit into the worktree, and `H`
|
|
15587
|
+
// (or `gH` for index) applies just the cursored hunk.
|
|
15111
15588
|
return {
|
|
15112
|
-
contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'y/Y yank', 'esc back'],
|
|
15589
|
+
contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'y/Y yank', 'esc back'],
|
|
15113
15590
|
global: NORMAL_GLOBAL_HINTS,
|
|
15114
15591
|
};
|
|
15115
15592
|
}
|
|
@@ -15148,8 +15625,23 @@ function getLogInkFooterHints(options) {
|
|
|
15148
15625
|
global: NORMAL_GLOBAL_HINTS,
|
|
15149
15626
|
};
|
|
15150
15627
|
}
|
|
15628
|
+
if (options.activeView === 'pull-request') {
|
|
15629
|
+
return {
|
|
15630
|
+
// #783 — full PR action panel. Five mutating ops scoped to this
|
|
15631
|
+
// view: m / x / a / R / c, plus O for open-in-browser (already
|
|
15632
|
+
// a global). Each routes through y-confirm or an input prompt;
|
|
15633
|
+
// none fire silently.
|
|
15634
|
+
contextual: ['m merge', 'x close', 'a approve', 'R changes', 'c comment', 'O open', 'esc back'],
|
|
15635
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15636
|
+
};
|
|
15637
|
+
}
|
|
15151
15638
|
return {
|
|
15152
|
-
|
|
15639
|
+
// History view default hints. Mutating ops (`c` cherry-pick, `R`
|
|
15640
|
+
// revert, `Z` reset, `i` interactive-rebase) all route through a
|
|
15641
|
+
// y-confirm or mode prompt — none fire silently from the keystroke.
|
|
15642
|
+
// Grouped into a compact `c/R/Z/i mutate` chip so the footer stays
|
|
15643
|
+
// scannable; full descriptions live in `?` help and the palette.
|
|
15644
|
+
contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'y/Y yank', '/ search', 'gg/G top/bottom'],
|
|
15153
15645
|
global: NORMAL_GLOBAL_HINTS,
|
|
15154
15646
|
};
|
|
15155
15647
|
}
|
|
@@ -15292,39 +15784,6 @@ function filterLogInkPaletteCommands(commands, filter, recent) {
|
|
|
15292
15784
|
.map((entry) => entry.command);
|
|
15293
15785
|
}
|
|
15294
15786
|
|
|
15295
|
-
/**
|
|
15296
|
-
* The chars `git log --graph` emits for branch topology — `*`, `|`, `\`,
|
|
15297
|
-
* `/`, `_`, ` `. ASCII-only output is bulletproof for legacy terminals
|
|
15298
|
-
* but the angles read poorly when many branches overlap.
|
|
15299
|
-
*
|
|
15300
|
-
* `substituteGraphChars` swaps them for box-drawing / geometric Unicode
|
|
15301
|
-
* equivalents when the terminal can render them; falls back to ASCII
|
|
15302
|
-
* under `theme.ascii` (TERM=dumb / vt100) and `theme.noColor` is
|
|
15303
|
-
* orthogonal — the Unicode chars are still rendered, just without color.
|
|
15304
|
-
*
|
|
15305
|
-
* Kept ASCII-only intentionally:
|
|
15306
|
-
* - alphanumerics (commit refs / annotations git sometimes injects)
|
|
15307
|
-
* - parens / brackets (HEAD decoration markers, not part of the graph)
|
|
15308
|
-
* - hyphens / colons (likewise)
|
|
15309
|
-
*/
|
|
15310
|
-
const ASCII_TO_UNICODE = {
|
|
15311
|
-
'*': '●',
|
|
15312
|
-
'|': '│',
|
|
15313
|
-
'/': '╱',
|
|
15314
|
-
'\\': '╲',
|
|
15315
|
-
'_': '─',
|
|
15316
|
-
};
|
|
15317
|
-
function substituteGraphChars(graph, options) {
|
|
15318
|
-
if (options.ascii) {
|
|
15319
|
-
return graph;
|
|
15320
|
-
}
|
|
15321
|
-
let output = '';
|
|
15322
|
-
for (const character of graph) {
|
|
15323
|
-
output += ASCII_TO_UNICODE[character] ?? character;
|
|
15324
|
-
}
|
|
15325
|
-
return output;
|
|
15326
|
-
}
|
|
15327
|
-
|
|
15328
15787
|
/**
|
|
15329
15788
|
* OSC 8 hyperlink helpers for the Ink TUI (P5.1).
|
|
15330
15789
|
*
|
|
@@ -15396,6 +15855,84 @@ function formatHyperlink(text, url, env = process.env) {
|
|
|
15396
15855
|
return `${OSC_PREFIX}${url}${ST}${text}${OSC_PREFIX}${ST}`;
|
|
15397
15856
|
}
|
|
15398
15857
|
|
|
15858
|
+
/**
|
|
15859
|
+
* Extract a single hunk from a unified-patch diff so it can be fed to
|
|
15860
|
+
* `git apply` (or `git apply --cached`) for a hunk-level cherry-pick.
|
|
15861
|
+
*
|
|
15862
|
+
* The TUI's diff explore views render two flavors of patch text:
|
|
15863
|
+
*
|
|
15864
|
+
* - stash-diff: full `git stash show -p` output, which includes
|
|
15865
|
+
* `diff --git`, `---`, `+++`, and one or more `@@ ... @@` hunks
|
|
15866
|
+
* per file.
|
|
15867
|
+
* - commit-diff: the per-file `filePreview.hunks` array, which is
|
|
15868
|
+
* hunks-only (no `diff --git` / `---` / `+++` headers).
|
|
15869
|
+
*
|
|
15870
|
+
* Either way, this helper walks `lines` from `cursorOffset` backwards
|
|
15871
|
+
* to find the most recent `@@` header, walks forward to the end of
|
|
15872
|
+
* that hunk's body, and synthesizes a fresh `diff --git` /
|
|
15873
|
+
* `---` / `+++` set using the caller-provided path. The output is a
|
|
15874
|
+
* complete, self-contained patch suitable for `git apply` without
|
|
15875
|
+
* having to preserve original headers from `lines`.
|
|
15876
|
+
*/
|
|
15877
|
+
const HUNK_HEADER_PREFIX = '@@';
|
|
15878
|
+
const DIFF_GIT_PREFIX = 'diff --git ';
|
|
15879
|
+
/**
|
|
15880
|
+
* Find the index of the `@@` hunk header at or before `cursorOffset`.
|
|
15881
|
+
* Returns -1 when the cursor sits before the first hunk in the patch
|
|
15882
|
+
* (i.e. on a `diff --git` / `---` / `+++` header line) — caller treats
|
|
15883
|
+
* that as "no hunk at cursor" and surfaces a status message.
|
|
15884
|
+
*/
|
|
15885
|
+
function findHunkHeaderAtOrBefore(lines, cursorOffset) {
|
|
15886
|
+
const start = Math.min(cursorOffset, lines.length - 1);
|
|
15887
|
+
for (let i = start; i >= 0; i -= 1) {
|
|
15888
|
+
if (lines[i]?.startsWith(HUNK_HEADER_PREFIX)) {
|
|
15889
|
+
return i;
|
|
15890
|
+
}
|
|
15891
|
+
}
|
|
15892
|
+
return -1;
|
|
15893
|
+
}
|
|
15894
|
+
/**
|
|
15895
|
+
* Walk forward from a hunk header to either the next `@@` header or
|
|
15896
|
+
* the next `diff --git` line — that's where this hunk's body ends.
|
|
15897
|
+
* The end index is exclusive (the line at `endIndex` is NOT part of
|
|
15898
|
+
* this hunk).
|
|
15899
|
+
*/
|
|
15900
|
+
function findHunkBodyEnd(lines, headerIndex) {
|
|
15901
|
+
for (let i = headerIndex + 1; i < lines.length; i += 1) {
|
|
15902
|
+
const line = lines[i];
|
|
15903
|
+
if (line?.startsWith(HUNK_HEADER_PREFIX) || line?.startsWith(DIFF_GIT_PREFIX)) {
|
|
15904
|
+
return i;
|
|
15905
|
+
}
|
|
15906
|
+
}
|
|
15907
|
+
return lines.length;
|
|
15908
|
+
}
|
|
15909
|
+
function extractDiffHunk(input) {
|
|
15910
|
+
const { lines, cursorOffset, path } = input;
|
|
15911
|
+
if (!lines.length || !path) {
|
|
15912
|
+
return null;
|
|
15913
|
+
}
|
|
15914
|
+
const headerIndex = findHunkHeaderAtOrBefore(lines, cursorOffset);
|
|
15915
|
+
if (headerIndex < 0) {
|
|
15916
|
+
return null;
|
|
15917
|
+
}
|
|
15918
|
+
const bodyEnd = findHunkBodyEnd(lines, headerIndex);
|
|
15919
|
+
// Header itself + at least one body line. An empty hunk body would
|
|
15920
|
+
// mean the patch is malformed and `git apply` would reject it; bail
|
|
15921
|
+
// out early so the caller can surface a clear status message.
|
|
15922
|
+
if (bodyEnd <= headerIndex + 1) {
|
|
15923
|
+
return null;
|
|
15924
|
+
}
|
|
15925
|
+
const hunkLines = lines.slice(headerIndex, bodyEnd);
|
|
15926
|
+
const patchText = [
|
|
15927
|
+
`diff --git a/${path} b/${path}`,
|
|
15928
|
+
`--- a/${path}`,
|
|
15929
|
+
`+++ b/${path}`,
|
|
15930
|
+
...hunkLines,
|
|
15931
|
+
'',
|
|
15932
|
+
].join('\n');
|
|
15933
|
+
return { patchText };
|
|
15934
|
+
}
|
|
15935
|
+
|
|
15399
15936
|
/**
|
|
15400
15937
|
* Sort modes for the promoted views (P4.2).
|
|
15401
15938
|
*
|
|
@@ -15765,6 +16302,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
15765
16302
|
sidebarTab: 'status',
|
|
15766
16303
|
userSidebarTab: 'status',
|
|
15767
16304
|
statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
|
|
16305
|
+
diffViewMode: 'unified',
|
|
15768
16306
|
};
|
|
15769
16307
|
}
|
|
15770
16308
|
function getSelectedInkCommit(state) {
|
|
@@ -15902,6 +16440,7 @@ function applyLogInkAction(state, action) {
|
|
|
15902
16440
|
kind: action.kind,
|
|
15903
16441
|
label: action.label,
|
|
15904
16442
|
value: action.initial || '',
|
|
16443
|
+
multiline: action.multiline,
|
|
15905
16444
|
},
|
|
15906
16445
|
pendingKey: undefined,
|
|
15907
16446
|
};
|
|
@@ -15934,6 +16473,27 @@ function applyLogInkAction(state, action) {
|
|
|
15934
16473
|
}
|
|
15935
16474
|
case 'setHistoryFetchArgs':
|
|
15936
16475
|
return { ...state, historyFetchArgs: action.value, pendingKey: undefined };
|
|
16476
|
+
case 'toggleDiffViewMode':
|
|
16477
|
+
// Reset the scroll offsets so the new mode opens at the top — long
|
|
16478
|
+
// lines wrap differently in split mode (the renderer truncates per
|
|
16479
|
+
// column instead of per row), so the saved offset can land on a
|
|
16480
|
+
// different visual line. Snap to the top is simpler than mapping
|
|
16481
|
+
// unified offsets to split offsets.
|
|
16482
|
+
return {
|
|
16483
|
+
...state,
|
|
16484
|
+
diffViewMode: state.diffViewMode === 'unified' ? 'split' : 'unified',
|
|
16485
|
+
diffPreviewOffset: 0,
|
|
16486
|
+
worktreeDiffOffset: 0,
|
|
16487
|
+
pendingKey: undefined,
|
|
16488
|
+
};
|
|
16489
|
+
case 'setDiffViewMode':
|
|
16490
|
+
return {
|
|
16491
|
+
...state,
|
|
16492
|
+
diffViewMode: action.value,
|
|
16493
|
+
diffPreviewOffset: 0,
|
|
16494
|
+
worktreeDiffOffset: 0,
|
|
16495
|
+
pendingKey: undefined,
|
|
16496
|
+
};
|
|
15937
16497
|
case 'moveToBottom':
|
|
15938
16498
|
return {
|
|
15939
16499
|
...state,
|
|
@@ -16212,12 +16772,151 @@ function applyLogInkAction(state, action) {
|
|
|
16212
16772
|
}
|
|
16213
16773
|
}
|
|
16214
16774
|
|
|
16775
|
+
/**
|
|
16776
|
+
* In-sidebar selection helpers (#791 follow-up — sidebar entity ops).
|
|
16777
|
+
*
|
|
16778
|
+
* The workstation sidebar's branches / tags / stashes / worktrees tabs
|
|
16779
|
+
* used to be read-only previews — to act on an entity the user had to
|
|
16780
|
+
* drill into the dedicated promoted view. With the per-entity ops
|
|
16781
|
+
* gated to also fire on `state.focus === 'sidebar'` plus a matching
|
|
16782
|
+
* `sidebarTab`, j/k navigates the visible list inside the sidebar
|
|
16783
|
+
* itself, Enter performs the primary action (checkout / open diff),
|
|
16784
|
+
* and the existing per-view secondary keys (a/p/X/D/R/u/+P) are now
|
|
16785
|
+
* reachable without leaving the workstation view.
|
|
16786
|
+
*
|
|
16787
|
+
* The sidebar accordion is short — the visible window for an active
|
|
16788
|
+
* tab is capped (defaults below) so a long branch list doesn't
|
|
16789
|
+
* collapse the rest of the chrome. When the cursor scrolls past the
|
|
16790
|
+
* visible window, this module produces a sliding window that keeps it
|
|
16791
|
+
* in view; the dedicated view stays the right home for "show me all
|
|
16792
|
+
* 80 branches at once."
|
|
16793
|
+
*/
|
|
16794
|
+
const DEFAULT_SIDEBAR_VISIBLE = 8;
|
|
16795
|
+
/**
|
|
16796
|
+
* Compute the sliding window so that `selected` stays inside it while
|
|
16797
|
+
* the window remains anchored at the top whenever possible (so short
|
|
16798
|
+
* lists don't scroll for no reason). When the cursor moves past the
|
|
16799
|
+
* window, the window slides just enough to keep the cursor in view —
|
|
16800
|
+
* matching the commit history's `clampWindowStart` behaviour for
|
|
16801
|
+
* familiarity.
|
|
16802
|
+
*/
|
|
16803
|
+
function getSidebarVisibleWindow(total, selected, visible = DEFAULT_SIDEBAR_VISIBLE) {
|
|
16804
|
+
const size = Math.max(1, Math.min(visible, total));
|
|
16805
|
+
if (total <= visible) {
|
|
16806
|
+
return { start: 0, size, truncatedAbove: 0, truncatedBelow: 0 };
|
|
16807
|
+
}
|
|
16808
|
+
const half = Math.floor(size / 2);
|
|
16809
|
+
const idealStart = selected - half;
|
|
16810
|
+
const maxStart = total - size;
|
|
16811
|
+
const start = Math.max(0, Math.min(idealStart, maxStart));
|
|
16812
|
+
return {
|
|
16813
|
+
start,
|
|
16814
|
+
size,
|
|
16815
|
+
truncatedAbove: start,
|
|
16816
|
+
truncatedBelow: total - (start + size),
|
|
16817
|
+
};
|
|
16818
|
+
}
|
|
16819
|
+
/**
|
|
16820
|
+
* True when an in-sidebar action (j/k move, Enter checkout, etc.)
|
|
16821
|
+
* should fire instead of the generic drill-in / tab-cycle behaviour.
|
|
16822
|
+
*
|
|
16823
|
+
* Status tab is excluded because its preview shows worktree files —
|
|
16824
|
+
* those have their own selection model in the dedicated status view
|
|
16825
|
+
* and the sidebar doesn't surface them as selectable rows.
|
|
16826
|
+
*/
|
|
16827
|
+
function sidebarTabHasSelectableItems(sidebarTab, itemCount) {
|
|
16828
|
+
if (!itemCount || itemCount <= 0)
|
|
16829
|
+
return false;
|
|
16830
|
+
return sidebarTab === 'branches' ||
|
|
16831
|
+
sidebarTab === 'tags' ||
|
|
16832
|
+
sidebarTab === 'stashes' ||
|
|
16833
|
+
sidebarTab === 'worktrees';
|
|
16834
|
+
}
|
|
16835
|
+
|
|
16215
16836
|
function action(actionValue) {
|
|
16216
16837
|
return {
|
|
16217
16838
|
type: 'action',
|
|
16218
16839
|
action: actionValue,
|
|
16219
16840
|
};
|
|
16220
16841
|
}
|
|
16842
|
+
/**
|
|
16843
|
+
* Build the events needed to apply the hunk under the diff cursor. The
|
|
16844
|
+
* runtime workflow handler expects payload format `<target>\n<patch>`
|
|
16845
|
+
* — splitting on the first newline keeps the patch body intact for
|
|
16846
|
+
* targets like `worktree` and `index` (no newlines in the prefix).
|
|
16847
|
+
*
|
|
16848
|
+
* Returns [] when the user isn't on a commit-diff / stash-diff explore,
|
|
16849
|
+
* or when no hunk can be extracted at the current cursor offset
|
|
16850
|
+
* (e.g. cursor sits on a `diff --git` header before the first `@@`).
|
|
16851
|
+
* Callers fall back to a contextual status message when this returns [].
|
|
16852
|
+
*/
|
|
16853
|
+
function buildApplyHunkEvents(state, context, target) {
|
|
16854
|
+
if (state.activeView !== 'diff')
|
|
16855
|
+
return [];
|
|
16856
|
+
if (state.diffSource !== 'commit' && state.diffSource !== 'stash')
|
|
16857
|
+
return [];
|
|
16858
|
+
const lines = context.diffLinesForHunkApply;
|
|
16859
|
+
if (!lines || lines.length === 0)
|
|
16860
|
+
return [];
|
|
16861
|
+
const path = state.diffSource === 'stash'
|
|
16862
|
+
? context.stashDiffSelectedPath
|
|
16863
|
+
: context.commitDiffSelectedPath;
|
|
16864
|
+
if (!path)
|
|
16865
|
+
return [];
|
|
16866
|
+
const extracted = extractDiffHunk({
|
|
16867
|
+
lines,
|
|
16868
|
+
cursorOffset: state.diffPreviewOffset,
|
|
16869
|
+
path,
|
|
16870
|
+
});
|
|
16871
|
+
if (!extracted)
|
|
16872
|
+
return [];
|
|
16873
|
+
const id = target === 'index' ? 'apply-hunk-index' : 'apply-hunk-worktree';
|
|
16874
|
+
return [{
|
|
16875
|
+
type: 'runWorkflowAction',
|
|
16876
|
+
id,
|
|
16877
|
+
payload: `${target}\n${extracted.patchText}`,
|
|
16878
|
+
}];
|
|
16879
|
+
}
|
|
16880
|
+
/**
|
|
16881
|
+
* Per-entity action-target predicates. The promoted views (`branches`,
|
|
16882
|
+
* `tags`, `stash`, `worktrees`) each scope a set of ops to their
|
|
16883
|
+
* dedicated surface. The same ops also fire when the user has the
|
|
16884
|
+
* sidebar focused on the matching tab — that's how in-sidebar
|
|
16885
|
+
* selection (#791 follow-up) lets the user checkout / apply / drop
|
|
16886
|
+
* without leaving the workstation view.
|
|
16887
|
+
*/
|
|
16888
|
+
function isBranchActionTarget(state) {
|
|
16889
|
+
return (state.activeView === 'branches' && state.focus === 'commits') ||
|
|
16890
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
16891
|
+
}
|
|
16892
|
+
function isTagActionTarget(state) {
|
|
16893
|
+
return (state.activeView === 'tags' && state.focus === 'commits') ||
|
|
16894
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
16895
|
+
}
|
|
16896
|
+
function isStashActionTarget(state) {
|
|
16897
|
+
return (state.activeView === 'stash' && state.focus === 'commits') ||
|
|
16898
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'stashes');
|
|
16899
|
+
}
|
|
16900
|
+
function isWorktreeActionTarget(state) {
|
|
16901
|
+
return (state.activeView === 'worktrees' && state.focus === 'commits') ||
|
|
16902
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
|
|
16903
|
+
}
|
|
16904
|
+
/**
|
|
16905
|
+
* Item count for the active sidebar tab — used by the generic
|
|
16906
|
+
* sidebar-Enter handler to decide whether to defer to the per-entity
|
|
16907
|
+
* Enter (when items are present and the user is cursoring through
|
|
16908
|
+
* them) or to drill into the dedicated view (when the tab is empty
|
|
16909
|
+
* or has no per-entity Enter handler defined).
|
|
16910
|
+
*/
|
|
16911
|
+
function getSidebarItemCount(sidebarTab, context) {
|
|
16912
|
+
switch (sidebarTab) {
|
|
16913
|
+
case 'branches': return context.branchCount;
|
|
16914
|
+
case 'tags': return context.tagCount;
|
|
16915
|
+
case 'stashes': return context.stashCount;
|
|
16916
|
+
case 'worktrees': return context.worktreeListCount;
|
|
16917
|
+
default: return undefined;
|
|
16918
|
+
}
|
|
16919
|
+
}
|
|
16221
16920
|
/**
|
|
16222
16921
|
* Translate a palette command into the same events its keystroke would have
|
|
16223
16922
|
* produced. Phase 6 makes `:` a real launcher: this is the single mapping
|
|
@@ -16297,6 +16996,8 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
16297
16996
|
return [action({ type: 'pushView', value: 'stash' })];
|
|
16298
16997
|
case 'navigateWorktrees':
|
|
16299
16998
|
return [action({ type: 'pushView', value: 'worktrees' })];
|
|
16999
|
+
case 'navigatePullRequest':
|
|
17000
|
+
return [action({ type: 'pushView', value: 'pull-request' })];
|
|
16300
17001
|
case 'navigateBack':
|
|
16301
17002
|
return [action({ type: 'popView' })];
|
|
16302
17003
|
case 'openSelected': {
|
|
@@ -16354,10 +17055,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
16354
17055
|
case 'clearSearch':
|
|
16355
17056
|
return [action({ type: 'clearFilter' })];
|
|
16356
17057
|
case 'cycleSort':
|
|
16357
|
-
if (state
|
|
17058
|
+
if (isBranchActionTarget(state)) {
|
|
16358
17059
|
return [action({ type: 'cycleBranchSort' })];
|
|
16359
17060
|
}
|
|
16360
|
-
if (state
|
|
17061
|
+
if (isTagActionTarget(state)) {
|
|
16361
17062
|
return [action({ type: 'cycleTagSort' })];
|
|
16362
17063
|
}
|
|
16363
17064
|
return [action({
|
|
@@ -16394,6 +17095,77 @@ function hasUnsavedComposeDraft(state) {
|
|
|
16394
17095
|
}
|
|
16395
17096
|
return Boolean(compose.summary.trim() || compose.body.trim());
|
|
16396
17097
|
}
|
|
17098
|
+
/**
|
|
17099
|
+
* Submit the active input prompt — used by Enter on single-line
|
|
17100
|
+
* prompts and by Ctrl+D on multi-line prompts (#806). Most prompt
|
|
17101
|
+
* kinds dispatch a workflow whose id matches the kind
|
|
17102
|
+
* (`create-branch`, `rename-branch`, etc.). A few are exceptions:
|
|
17103
|
+
* - `reset-mode` (#777) collects soft/mixed/hard and forwards the
|
|
17104
|
+
* mode as the payload to `reset-to-commit`.
|
|
17105
|
+
* - `pr-merge-strategy` (#783) validates the strategy and routes to
|
|
17106
|
+
* `merge-pr` via the y-confirm path.
|
|
17107
|
+
* - `pr-comment` dispatches `comment-pr` directly — the body itself
|
|
17108
|
+
* is the affirmative action.
|
|
17109
|
+
* - `pr-request-changes` routes to `request-changes-pr` via
|
|
17110
|
+
* y-confirm because the review is publicly visible.
|
|
17111
|
+
* Each exception validates here so a typo doesn't surface as a
|
|
17112
|
+
* "workflow not yet wired" status downstream.
|
|
17113
|
+
*
|
|
17114
|
+
* Empty values yield a hint instead of a no-op so the user knows what
|
|
17115
|
+
* to do — the same UX whether they pressed Enter (single-line) or
|
|
17116
|
+
* Ctrl+D (multi-line).
|
|
17117
|
+
*/
|
|
17118
|
+
function submitInputPrompt(state) {
|
|
17119
|
+
if (!state.inputPrompt)
|
|
17120
|
+
return [];
|
|
17121
|
+
const value = state.inputPrompt.value.trim();
|
|
17122
|
+
if (!value) {
|
|
17123
|
+
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
|
|
17124
|
+
}
|
|
17125
|
+
if (state.inputPrompt.kind === 'reset-mode') {
|
|
17126
|
+
const mode = value.toLowerCase();
|
|
17127
|
+
if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
|
|
17128
|
+
return [action({
|
|
17129
|
+
type: 'setStatus',
|
|
17130
|
+
value: `Unknown reset mode: ${value}. Use soft, mixed, or hard.`,
|
|
17131
|
+
})];
|
|
17132
|
+
}
|
|
17133
|
+
return [
|
|
17134
|
+
{ type: 'runWorkflowAction', id: 'reset-to-commit', payload: mode },
|
|
17135
|
+
action({ type: 'closeInputPrompt' }),
|
|
17136
|
+
];
|
|
17137
|
+
}
|
|
17138
|
+
if (state.inputPrompt.kind === 'pr-merge-strategy') {
|
|
17139
|
+
const strategy = value.toLowerCase();
|
|
17140
|
+
if (strategy !== 'merge' && strategy !== 'squash' && strategy !== 'rebase') {
|
|
17141
|
+
return [action({
|
|
17142
|
+
type: 'setStatus',
|
|
17143
|
+
value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
|
|
17144
|
+
})];
|
|
17145
|
+
}
|
|
17146
|
+
return [
|
|
17147
|
+
action({ type: 'setPendingConfirmation', value: 'merge-pr', payload: strategy }),
|
|
17148
|
+
action({ type: 'closeInputPrompt' }),
|
|
17149
|
+
];
|
|
17150
|
+
}
|
|
17151
|
+
if (state.inputPrompt.kind === 'pr-comment') {
|
|
17152
|
+
return [
|
|
17153
|
+
{ type: 'runWorkflowAction', id: 'comment-pr', payload: value },
|
|
17154
|
+
action({ type: 'closeInputPrompt' }),
|
|
17155
|
+
];
|
|
17156
|
+
}
|
|
17157
|
+
if (state.inputPrompt.kind === 'pr-request-changes') {
|
|
17158
|
+
return [
|
|
17159
|
+
action({ type: 'setPendingConfirmation', value: 'request-changes-pr', payload: value }),
|
|
17160
|
+
action({ type: 'closeInputPrompt' }),
|
|
17161
|
+
];
|
|
17162
|
+
}
|
|
17163
|
+
const id = state.inputPrompt.kind;
|
|
17164
|
+
return [
|
|
17165
|
+
{ type: 'runWorkflowAction', id, payload: value },
|
|
17166
|
+
action({ type: 'closeInputPrompt' }),
|
|
17167
|
+
];
|
|
17168
|
+
}
|
|
16397
17169
|
function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
16398
17170
|
if (key.ctrl && inputValue === 'c') {
|
|
16399
17171
|
if (hasUnsavedComposeDraft(state) && !state.pendingMutationConfirmation) {
|
|
@@ -16406,22 +17178,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16406
17178
|
// filter/confirmation/compose handlers so a prompt opened from inside
|
|
16407
17179
|
// any of those still captures focus cleanly.
|
|
16408
17180
|
if (state.inputPrompt) {
|
|
17181
|
+
const isMultiline = Boolean(state.inputPrompt.multiline);
|
|
16409
17182
|
if (key.escape) {
|
|
16410
17183
|
return [
|
|
16411
17184
|
action({ type: 'closeInputPrompt' }),
|
|
16412
17185
|
action({ type: 'setStatus', value: 'cancelled' }),
|
|
16413
17186
|
];
|
|
16414
17187
|
}
|
|
17188
|
+
// Multi-line prompts (#806): Ctrl+D submits (Unix EOF convention,
|
|
17189
|
+
// mirrors `git commit -m -` and HEREDOC patterns). Plain Enter
|
|
17190
|
+
// inserts a newline so the user can compose review bodies / PR
|
|
17191
|
+
// comments naturally without opening $EDITOR.
|
|
17192
|
+
if (isMultiline && key.ctrl && inputValue === 'd') {
|
|
17193
|
+
return submitInputPrompt(state);
|
|
17194
|
+
}
|
|
17195
|
+
if (isMultiline && key.return) {
|
|
17196
|
+
return [action({ type: 'appendInputPrompt', value: '\n' })];
|
|
17197
|
+
}
|
|
16415
17198
|
if (key.return) {
|
|
16416
|
-
|
|
16417
|
-
if (!value) {
|
|
16418
|
-
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
|
|
16419
|
-
}
|
|
16420
|
-
const id = state.inputPrompt.kind;
|
|
16421
|
-
return [
|
|
16422
|
-
{ type: 'runWorkflowAction', id, payload: value },
|
|
16423
|
-
action({ type: 'closeInputPrompt' }),
|
|
16424
|
-
];
|
|
17199
|
+
return submitInputPrompt(state);
|
|
16425
17200
|
}
|
|
16426
17201
|
if (key.backspace || key.delete) {
|
|
16427
17202
|
return [action({ type: 'backspaceInputPrompt' })];
|
|
@@ -16673,14 +17448,40 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16673
17448
|
}
|
|
16674
17449
|
if (state.pendingKey === 'g' && inputValue === 'z') {
|
|
16675
17450
|
return [
|
|
16676
|
-
action({ type: 'pushView', value: 'stash' }),
|
|
16677
|
-
action({ type: 'setStatus', value: 'jumped to stash' }),
|
|
17451
|
+
action({ type: 'pushView', value: 'stash' }),
|
|
17452
|
+
action({ type: 'setStatus', value: 'jumped to stash' }),
|
|
17453
|
+
];
|
|
17454
|
+
}
|
|
17455
|
+
if (state.pendingKey === 'g' && inputValue === 'w') {
|
|
17456
|
+
return [
|
|
17457
|
+
action({ type: 'pushView', value: 'worktrees' }),
|
|
17458
|
+
action({ type: 'setStatus', value: 'jumped to worktrees' }),
|
|
17459
|
+
];
|
|
17460
|
+
}
|
|
17461
|
+
// `gp` jumps to the dedicated pull-request action panel (#783).
|
|
17462
|
+
// Lowercase `p` matches the pattern of other navigation chords
|
|
17463
|
+
// (gh / gs / gd / gc / gb / gt / gz / gw). The panel renders the
|
|
17464
|
+
// current branch's PR via `gh pr view --json` enriched fields and
|
|
17465
|
+
// exposes m / x / a / R / c action keys scoped to the view.
|
|
17466
|
+
if (state.pendingKey === 'g' && inputValue === 'p') {
|
|
17467
|
+
return [
|
|
17468
|
+
action({ type: 'pushView', value: 'pull-request' }),
|
|
17469
|
+
action({ type: 'setStatus', value: 'jumped to pull request' }),
|
|
16678
17470
|
];
|
|
16679
17471
|
}
|
|
16680
|
-
|
|
17472
|
+
// `gH` chord: apply the cursored hunk to the index (`git apply
|
|
17473
|
+
// --cached`). Sibling of bare `H` which targets the worktree.
|
|
17474
|
+
// Discoverable via the footer hint on diff views and the help
|
|
17475
|
+
// overlay; the explicit chord keeps `H` (single keystroke) for
|
|
17476
|
+
// the more common worktree case.
|
|
17477
|
+
if (state.pendingKey === 'g' && inputValue === 'H') {
|
|
17478
|
+
const events = buildApplyHunkEvents(state, context, 'index');
|
|
17479
|
+
if (events.length) {
|
|
17480
|
+
return [action({ type: 'setPendingKey', value: undefined }), ...events];
|
|
17481
|
+
}
|
|
16681
17482
|
return [
|
|
16682
|
-
action({ type: '
|
|
16683
|
-
action({ type: 'setStatus', value: '
|
|
17483
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
17484
|
+
action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
|
|
16684
17485
|
];
|
|
16685
17486
|
}
|
|
16686
17487
|
if (inputValue === 'g') {
|
|
@@ -16692,6 +17493,22 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16692
17493
|
}
|
|
16693
17494
|
return [action({ type: 'setPendingKey', value: 'g' })];
|
|
16694
17495
|
}
|
|
17496
|
+
// `d` on the diff view toggles between unified and side-by-side split
|
|
17497
|
+
// rendering (#785). Scoped to the diff view so the letter stays free
|
|
17498
|
+
// for other surfaces. The chord branch above already claimed `gd`,
|
|
17499
|
+
// so by the time we get here `pendingKey` is not `g`.
|
|
17500
|
+
if (inputValue === 'd' && state.activeView === 'diff') {
|
|
17501
|
+
const next = state.diffViewMode === 'unified' ? 'split' : 'unified';
|
|
17502
|
+
return [
|
|
17503
|
+
action({ type: 'toggleDiffViewMode' }),
|
|
17504
|
+
action({
|
|
17505
|
+
type: 'setStatus',
|
|
17506
|
+
value: next === 'split'
|
|
17507
|
+
? 'Switched to side-by-side diff'
|
|
17508
|
+
: 'Switched to unified diff',
|
|
17509
|
+
}),
|
|
17510
|
+
];
|
|
17511
|
+
}
|
|
16695
17512
|
if (inputValue === '\\') {
|
|
16696
17513
|
return [action({ type: 'toggleGraph' })];
|
|
16697
17514
|
}
|
|
@@ -16714,10 +17531,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16714
17531
|
return [{ type: 'refreshContext' }];
|
|
16715
17532
|
}
|
|
16716
17533
|
if (inputValue === 's') {
|
|
16717
|
-
if (state
|
|
17534
|
+
if (isBranchActionTarget(state)) {
|
|
16718
17535
|
return [action({ type: 'cycleBranchSort' })];
|
|
16719
17536
|
}
|
|
16720
|
-
if (state
|
|
17537
|
+
if (isTagActionTarget(state)) {
|
|
16721
17538
|
return [action({ type: 'cycleTagSort' })];
|
|
16722
17539
|
}
|
|
16723
17540
|
// Falls through so other views (history/status/diff/compose/stash) still
|
|
@@ -16788,6 +17605,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16788
17605
|
if (key.tab) {
|
|
16789
17606
|
return [action({ type: key.shift ? 'focusPrevious' : 'focusNext' })];
|
|
16790
17607
|
}
|
|
17608
|
+
// ←/→ on the sidebar switch tabs (Status ↔ Branches ↔ Tags ↔
|
|
17609
|
+
// Stashes ↔ Worktrees) — the horizontal axis is "between tabs", the
|
|
17610
|
+
// vertical axis (↑/↓ below) is "within the active tab's items".
|
|
17611
|
+
// [/] still works as a keyboard alternative for users who prefer
|
|
17612
|
+
// non-arrow keys.
|
|
17613
|
+
if (key.leftArrow && state.focus === 'sidebar') {
|
|
17614
|
+
return [action({ type: 'previousSidebarTab' })];
|
|
17615
|
+
}
|
|
17616
|
+
if (key.rightArrow && state.focus === 'sidebar') {
|
|
17617
|
+
return [action({ type: 'nextSidebarTab' })];
|
|
17618
|
+
}
|
|
16791
17619
|
if (key.upArrow || inputValue === 'k') {
|
|
16792
17620
|
if (state.focus === 'detail' && context.detailFileCount) {
|
|
16793
17621
|
return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
|
|
@@ -16817,16 +17645,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16817
17645
|
previewLineCount: context.previewLineCount,
|
|
16818
17646
|
})];
|
|
16819
17647
|
}
|
|
16820
|
-
if (state
|
|
17648
|
+
if (isBranchActionTarget(state) && context.branchCount) {
|
|
16821
17649
|
return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
|
|
16822
17650
|
}
|
|
16823
|
-
if (state
|
|
17651
|
+
if (isTagActionTarget(state) && context.tagCount) {
|
|
16824
17652
|
return [action({ type: 'moveTag', delta: -1, count: context.tagCount })];
|
|
16825
17653
|
}
|
|
16826
|
-
if (state
|
|
17654
|
+
if (isStashActionTarget(state) && context.stashCount) {
|
|
16827
17655
|
return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
|
|
16828
17656
|
}
|
|
16829
|
-
if (state
|
|
17657
|
+
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
16830
17658
|
return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
|
|
16831
17659
|
}
|
|
16832
17660
|
if (state.activeView === 'history' &&
|
|
@@ -16836,6 +17664,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16836
17664
|
context.worktreeDirty) {
|
|
16837
17665
|
return [action({ type: 'focusPendingCommit' })];
|
|
16838
17666
|
}
|
|
17667
|
+
// Sidebar fallback: when no entity claim above succeeds (status
|
|
17668
|
+
// tab or empty content tab), ↑ falls through to cycling sidebar
|
|
17669
|
+
// tabs so the user always has a way to navigate. With ←/→ above
|
|
17670
|
+
// already handling tab switching, this is mostly a vim-style
|
|
17671
|
+
// safety net for `k`.
|
|
16839
17672
|
return [
|
|
16840
17673
|
action(state.focus === 'sidebar'
|
|
16841
17674
|
? { type: 'previousSidebarTab' }
|
|
@@ -16870,16 +17703,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16870
17703
|
previewLineCount: context.previewLineCount,
|
|
16871
17704
|
})];
|
|
16872
17705
|
}
|
|
16873
|
-
if (state
|
|
17706
|
+
if (isBranchActionTarget(state) && context.branchCount) {
|
|
16874
17707
|
return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
|
|
16875
17708
|
}
|
|
16876
|
-
if (state
|
|
17709
|
+
if (isTagActionTarget(state) && context.tagCount) {
|
|
16877
17710
|
return [action({ type: 'moveTag', delta: 1, count: context.tagCount })];
|
|
16878
17711
|
}
|
|
16879
|
-
if (state
|
|
17712
|
+
if (isStashActionTarget(state) && context.stashCount) {
|
|
16880
17713
|
return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
|
|
16881
17714
|
}
|
|
16882
|
-
if (state
|
|
17715
|
+
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
16883
17716
|
return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
|
|
16884
17717
|
}
|
|
16885
17718
|
return [
|
|
@@ -16983,30 +17816,42 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16983
17816
|
}
|
|
16984
17817
|
}
|
|
16985
17818
|
// Enter on a sidebar tab drills into the corresponding promoted view
|
|
16986
|
-
// (status / branches / tags / stash)
|
|
16987
|
-
//
|
|
16988
|
-
//
|
|
16989
|
-
// the
|
|
17819
|
+
// (status / branches / tags / stash) — but only when the sidebar tab
|
|
17820
|
+
// either has no per-entity Enter handler defined (status, tags,
|
|
17821
|
+
// worktrees) or has zero items (so the dedicated view's empty-state
|
|
17822
|
+
// tells the user what to do next).
|
|
17823
|
+
//
|
|
17824
|
+
// When the sidebar IS focused on a content tab WITH items, this
|
|
17825
|
+
// handler defers to the per-entity Enter below (checkout-branch for
|
|
17826
|
+
// branches, navigateOpenDiffForStash for stashes) so the user can
|
|
17827
|
+
// act on the cursored item without leaving the workstation view —
|
|
17828
|
+
// the in-sidebar selection win from #791 follow-up.
|
|
16990
17829
|
//
|
|
16991
|
-
// The
|
|
16992
|
-
// list — otherwise ↑/↓ keep
|
|
16993
|
-
//
|
|
17830
|
+
// The drill-in moves focus out of the sidebar into the newly opened
|
|
17831
|
+
// list — otherwise ↑/↓ keep navigating the sidebar instead of the
|
|
17832
|
+
// just-opened view, which made the drill-in feel half-done.
|
|
16994
17833
|
if (key.return && state.focus === 'sidebar') {
|
|
16995
|
-
const
|
|
16996
|
-
|
|
16997
|
-
|
|
16998
|
-
|
|
16999
|
-
|
|
17000
|
-
|
|
17001
|
-
|
|
17002
|
-
|
|
17003
|
-
|
|
17004
|
-
|
|
17005
|
-
|
|
17006
|
-
|
|
17007
|
-
|
|
17834
|
+
const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
|
|
17835
|
+
const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
|
|
17836
|
+
sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
|
|
17837
|
+
if (!hasInSidebarPrimaryAction) {
|
|
17838
|
+
const tabToView = {
|
|
17839
|
+
status: 'status',
|
|
17840
|
+
branches: 'branches',
|
|
17841
|
+
tags: 'tags',
|
|
17842
|
+
stashes: 'stash',
|
|
17843
|
+
worktrees: 'worktrees',
|
|
17844
|
+
};
|
|
17845
|
+
const target = tabToView[state.sidebarTab];
|
|
17846
|
+
if (target) {
|
|
17847
|
+
return [
|
|
17848
|
+
action({ type: 'pushView', value: target }),
|
|
17849
|
+
action({ type: 'setFocus', value: 'commits' }),
|
|
17850
|
+
];
|
|
17851
|
+
}
|
|
17852
|
+
return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
|
|
17008
17853
|
}
|
|
17009
|
-
|
|
17854
|
+
// Fall through — per-entity Enter handler below claims the keystroke.
|
|
17010
17855
|
}
|
|
17011
17856
|
if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
|
|
17012
17857
|
return [action({
|
|
@@ -17015,8 +17860,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17015
17860
|
})];
|
|
17016
17861
|
}
|
|
17017
17862
|
// Enter on a branch row checks the branch out. Non-destructive workflow
|
|
17018
|
-
// action — no confirmation prompt.
|
|
17019
|
-
|
|
17863
|
+
// action — no confirmation prompt. Fires from either the dedicated
|
|
17864
|
+
// branches view or from the sidebar when the branches tab is focused
|
|
17865
|
+
// with items.
|
|
17866
|
+
if (key.return && isBranchActionTarget(state) && context.branchCount) {
|
|
17020
17867
|
return [{ type: 'runWorkflowAction', id: 'checkout-branch' }];
|
|
17021
17868
|
}
|
|
17022
17869
|
// `+` opens a create-branch / create-tag prompt depending on context.
|
|
@@ -17043,32 +17890,33 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17043
17890
|
}
|
|
17044
17891
|
// Per-view stash actions: `a` apply (keep the stash), `p` pop (apply
|
|
17045
17892
|
// then drop). Drop is the existing destructive `X` workflow which
|
|
17046
|
-
// routes through the y-confirm path. Scoped to the stash
|
|
17047
|
-
// letters stay free elsewhere
|
|
17048
|
-
|
|
17893
|
+
// routes through the y-confirm path. Scoped to the stash target so
|
|
17894
|
+
// the letters stay free elsewhere — the target predicate also fires
|
|
17895
|
+
// when the sidebar's stashes tab is focused with items.
|
|
17896
|
+
if (inputValue === 'a' && isStashActionTarget(state) && context.stashCount) {
|
|
17049
17897
|
return [{ type: 'runWorkflowAction', id: 'apply-stash' }];
|
|
17050
17898
|
}
|
|
17051
|
-
if (inputValue === 'p' && state
|
|
17899
|
+
if (inputValue === 'p' && isStashActionTarget(state) && context.stashCount) {
|
|
17052
17900
|
return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
|
|
17053
17901
|
}
|
|
17054
17902
|
// Per-view tag action: `P` pushes the selected tag to origin. Letter
|
|
17055
|
-
// is scoped to the tags
|
|
17903
|
+
// is scoped to the tags target so it doesn't collide with `p` for
|
|
17056
17904
|
// pop-stash. Note: this also takes precedence over the global
|
|
17057
17905
|
// push-current-branch workflow's `P` key.
|
|
17058
|
-
if (inputValue === 'P' && state
|
|
17906
|
+
if (inputValue === 'P' && isTagActionTarget(state) && context.tagCount) {
|
|
17059
17907
|
return [{ type: 'runWorkflowAction', id: 'push-tag' }];
|
|
17060
17908
|
}
|
|
17061
17909
|
// Per-view branches actions: `R` renames the selected branch, `u`
|
|
17062
17910
|
// sets its upstream. Both open the input prompt so the user can type
|
|
17063
17911
|
// the new value. Pre-fills are handled by the prompt's `initial`.
|
|
17064
|
-
if (inputValue === 'R' && state
|
|
17912
|
+
if (inputValue === 'R' && isBranchActionTarget(state) && context.branchCount) {
|
|
17065
17913
|
return [action({
|
|
17066
17914
|
type: 'openInputPrompt',
|
|
17067
17915
|
kind: 'rename-branch',
|
|
17068
17916
|
label: 'Rename branch to',
|
|
17069
17917
|
})];
|
|
17070
17918
|
}
|
|
17071
|
-
if (inputValue === 'u' && state
|
|
17919
|
+
if (inputValue === 'u' && isBranchActionTarget(state) && context.branchCount) {
|
|
17072
17920
|
return [action({
|
|
17073
17921
|
type: 'openInputPrompt',
|
|
17074
17922
|
kind: 'set-upstream',
|
|
@@ -17076,11 +17924,48 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17076
17924
|
})];
|
|
17077
17925
|
}
|
|
17078
17926
|
// Per-view tag action: `R` deletes the tag from the remote (after
|
|
17079
|
-
// confirmation). Scoped per-
|
|
17080
|
-
// (especially the `R` rename binding on the branches
|
|
17081
|
-
if (inputValue === 'R' && state
|
|
17927
|
+
// confirmation). Scoped per-target so this letter is free elsewhere
|
|
17928
|
+
// (especially the `R` rename binding on the branches target).
|
|
17929
|
+
if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
|
|
17082
17930
|
return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
|
|
17083
17931
|
}
|
|
17932
|
+
// #783 — full PR action panel keys, scoped to the pull-request view.
|
|
17933
|
+
// All five wrap a `gh pr <verb>` invocation; merge / request-changes /
|
|
17934
|
+
// comment open prompts first, the rest route through the y-confirm
|
|
17935
|
+
// path because they're irreversible (or near-irreversible).
|
|
17936
|
+
if (inputValue === 'm' && state.activeView === 'pull-request') {
|
|
17937
|
+
return [action({
|
|
17938
|
+
type: 'openInputPrompt',
|
|
17939
|
+
kind: 'pr-merge-strategy',
|
|
17940
|
+
label: 'Merge strategy (merge / squash / rebase)',
|
|
17941
|
+
})];
|
|
17942
|
+
}
|
|
17943
|
+
if (inputValue === 'x' && state.activeView === 'pull-request') {
|
|
17944
|
+
return [action({ type: 'setPendingConfirmation', value: 'close-pr' })];
|
|
17945
|
+
}
|
|
17946
|
+
if (inputValue === 'a' && state.activeView === 'pull-request') {
|
|
17947
|
+
return [action({ type: 'setPendingConfirmation', value: 'approve-pr' })];
|
|
17948
|
+
}
|
|
17949
|
+
if (inputValue === 'R' && state.activeView === 'pull-request') {
|
|
17950
|
+
// Free-form review body — multi-line so the reviewer can structure
|
|
17951
|
+
// their feedback naturally without opening $EDITOR (#806).
|
|
17952
|
+
return [action({
|
|
17953
|
+
type: 'openInputPrompt',
|
|
17954
|
+
kind: 'pr-request-changes',
|
|
17955
|
+
label: 'Request changes — review body (Enter newline · Ctrl+D submit)',
|
|
17956
|
+
multiline: true,
|
|
17957
|
+
})];
|
|
17958
|
+
}
|
|
17959
|
+
if (inputValue === 'c' && state.activeView === 'pull-request') {
|
|
17960
|
+
// Free-form comment body — multi-line for the same reason as
|
|
17961
|
+
// pr-request-changes.
|
|
17962
|
+
return [action({
|
|
17963
|
+
type: 'openInputPrompt',
|
|
17964
|
+
kind: 'pr-comment',
|
|
17965
|
+
label: 'Comment body (Enter newline · Ctrl+D submit)',
|
|
17966
|
+
multiline: true,
|
|
17967
|
+
})];
|
|
17968
|
+
}
|
|
17084
17969
|
// Global stash hotkey: `S` opens a stash-message prompt and
|
|
17085
17970
|
// `createStash` runs once submitted. Available everywhere there's
|
|
17086
17971
|
// not a more modal handler in front of it.
|
|
@@ -17138,6 +18023,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17138
18023
|
payload: `${context.commitDiffSelectedSha} ${context.commitDiffSelectedPath}`,
|
|
17139
18024
|
})];
|
|
17140
18025
|
}
|
|
18026
|
+
// `H` on a commit-diff or stash-diff explore extracts the hunk under
|
|
18027
|
+
// the cursor and applies it to the working tree (`git apply`). The
|
|
18028
|
+
// sibling `gH` chord targets the index (`git apply --cached`). Both
|
|
18029
|
+
// bypass the y-confirm path because `git apply` is non-destructive
|
|
18030
|
+
// (it'll fail loudly on conflict and `git apply -R` undoes a clean
|
|
18031
|
+
// apply).
|
|
18032
|
+
if (inputValue === 'H') {
|
|
18033
|
+
const events = buildApplyHunkEvents(state, context, 'worktree');
|
|
18034
|
+
if (events.length) {
|
|
18035
|
+
return events;
|
|
18036
|
+
}
|
|
18037
|
+
if (state.activeView === 'diff' && (state.diffSource === 'commit' || state.diffSource === 'stash')) {
|
|
18038
|
+
return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first' })];
|
|
18039
|
+
}
|
|
18040
|
+
}
|
|
17141
18041
|
// `c` on the history view cherry-picks the full selected commit on
|
|
17142
18042
|
// top of the current branch. Routed through the y-confirm flow since
|
|
17143
18043
|
// it can produce conflicts and is a real working-tree mutation.
|
|
@@ -17148,6 +18048,46 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17148
18048
|
!state.pendingCommitFocused) {
|
|
17149
18049
|
return [action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' })];
|
|
17150
18050
|
}
|
|
18051
|
+
// `R` reverts the cursored commit by adding an inverse commit on top
|
|
18052
|
+
// of HEAD. Same y-confirm gate as cherry-pick — non-rewriting but
|
|
18053
|
+
// still a real mutation.
|
|
18054
|
+
if (inputValue === 'R' &&
|
|
18055
|
+
state.activeView === 'history' &&
|
|
18056
|
+
state.focus === 'commits' &&
|
|
18057
|
+
state.filteredCommits.length > 0 &&
|
|
18058
|
+
!state.pendingCommitFocused) {
|
|
18059
|
+
return [action({ type: 'setPendingConfirmation', value: 'revert-commit' })];
|
|
18060
|
+
}
|
|
18061
|
+
// `Z` resets the current branch tip to the cursored commit. Opens a
|
|
18062
|
+
// mode prompt (soft / mixed / hard) instead of jumping straight to
|
|
18063
|
+
// confirmation because the choice changes the destructiveness
|
|
18064
|
+
// dramatically — `--hard` discards working-tree changes. The prompt
|
|
18065
|
+
// submission special-cases `kind === 'reset-mode'` to forward the
|
|
18066
|
+
// mode through `reset-to-commit` (see prompt-submit handler above).
|
|
18067
|
+
// No `initial` value: existing prompts append to initial rather than
|
|
18068
|
+
// replacing it, which would surprise the user typing the mode.
|
|
18069
|
+
if (inputValue === 'Z' &&
|
|
18070
|
+
state.activeView === 'history' &&
|
|
18071
|
+
state.focus === 'commits' &&
|
|
18072
|
+
state.filteredCommits.length > 0 &&
|
|
18073
|
+
!state.pendingCommitFocused) {
|
|
18074
|
+
return [action({
|
|
18075
|
+
type: 'openInputPrompt',
|
|
18076
|
+
kind: 'reset-mode',
|
|
18077
|
+
label: 'Reset mode (soft / mixed / hard)',
|
|
18078
|
+
})];
|
|
18079
|
+
}
|
|
18080
|
+
// `i` (lowercase) starts an interactive rebase from the cursored
|
|
18081
|
+
// commit's parent. Lowercase keeps the existing global `I`
|
|
18082
|
+
// ai-commit-summary workflow reachable on the history view; `i`
|
|
18083
|
+
// also matches the `git rebase -i` flag mnemonic.
|
|
18084
|
+
if (inputValue === 'i' &&
|
|
18085
|
+
state.activeView === 'history' &&
|
|
18086
|
+
state.focus === 'commits' &&
|
|
18087
|
+
state.filteredCommits.length > 0 &&
|
|
18088
|
+
!state.pendingCommitFocused) {
|
|
18089
|
+
return [action({ type: 'setPendingConfirmation', value: 'interactive-rebase' })];
|
|
18090
|
+
}
|
|
17151
18091
|
// `y` / `Y` yank the contextually relevant identifier from the active
|
|
17152
18092
|
// view to the system clipboard:
|
|
17153
18093
|
// history → cursored commit hash (Y for short hash)
|
|
@@ -17163,13 +18103,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17163
18103
|
if (state.activeView === 'history' && state.filteredCommits.length > 0) {
|
|
17164
18104
|
return [{ type: 'yankFromActiveView', short }];
|
|
17165
18105
|
}
|
|
17166
|
-
if (state
|
|
18106
|
+
if (isBranchActionTarget(state) && context.branchCount) {
|
|
17167
18107
|
return [{ type: 'yankFromActiveView' }];
|
|
17168
18108
|
}
|
|
17169
|
-
if (state
|
|
18109
|
+
if (isTagActionTarget(state) && context.tagCount) {
|
|
17170
18110
|
return [{ type: 'yankFromActiveView' }];
|
|
17171
18111
|
}
|
|
17172
|
-
if (state
|
|
18112
|
+
if (isStashActionTarget(state) && context.stashCount && context.stashSelectedRef) {
|
|
17173
18113
|
return [{ type: 'yankFromActiveView' }];
|
|
17174
18114
|
}
|
|
17175
18115
|
if (state.activeView === 'status' && context.worktreeSelectedPath) {
|
|
@@ -17187,8 +18127,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17187
18127
|
// Enter on a stash row pushes the diff view scoped to that stash.
|
|
17188
18128
|
// The runtime loads `git stash show -p <ref>` once the view is
|
|
17189
18129
|
// active. The stash ref is passed via the action so we don't need a
|
|
17190
|
-
// context lookup here.
|
|
17191
|
-
|
|
18130
|
+
// context lookup here. Fires from either the dedicated stash view or
|
|
18131
|
+
// from the sidebar when the stashes tab is focused with items.
|
|
18132
|
+
if (key.return && isStashActionTarget(state) && context.stashCount && context.stashSelectedRef) {
|
|
17192
18133
|
return [action({
|
|
17193
18134
|
type: 'navigateOpenDiffForStash',
|
|
17194
18135
|
ref: context.stashSelectedRef,
|
|
@@ -17249,7 +18190,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17249
18190
|
* fall back to "already seen" so we never block startup.
|
|
17250
18191
|
*/
|
|
17251
18192
|
const MARKER_BASENAME = 'onboarding.seen';
|
|
17252
|
-
function resolveCacheDir$
|
|
18193
|
+
function resolveCacheDir$2() {
|
|
17253
18194
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
17254
18195
|
if (xdg && xdg.trim().length > 0) {
|
|
17255
18196
|
return path__namespace$1.join(xdg, 'coco');
|
|
@@ -17257,7 +18198,7 @@ function resolveCacheDir$1() {
|
|
|
17257
18198
|
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
|
|
17258
18199
|
}
|
|
17259
18200
|
function getOnboardingMarkerPath() {
|
|
17260
|
-
return path__namespace$1.join(resolveCacheDir$
|
|
18201
|
+
return path__namespace$1.join(resolveCacheDir$2(), MARKER_BASENAME);
|
|
17261
18202
|
}
|
|
17262
18203
|
function hasSeenOnboarding() {
|
|
17263
18204
|
try {
|
|
@@ -17281,6 +18222,52 @@ function markOnboardingSeen() {
|
|
|
17281
18222
|
}
|
|
17282
18223
|
}
|
|
17283
18224
|
|
|
18225
|
+
/**
|
|
18226
|
+
* Persist the user's preferred diff view mode (unified vs side-by-side
|
|
18227
|
+
* split — #785) per repo. Mirrors `inkSidebarPersistence.ts` so the
|
|
18228
|
+
* cache layout, error model, and key derivation stay consistent across
|
|
18229
|
+
* settings: best-effort, XDG-friendly, no PII in the cache filename.
|
|
18230
|
+
*/
|
|
18231
|
+
const VALID_MODES = ['unified', 'split'];
|
|
18232
|
+
function resolveCacheDir$1() {
|
|
18233
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
18234
|
+
if (xdg && xdg.trim().length > 0) {
|
|
18235
|
+
return path__namespace$1.join(xdg, 'coco');
|
|
18236
|
+
}
|
|
18237
|
+
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
|
|
18238
|
+
}
|
|
18239
|
+
function repoKey$1(repoPath) {
|
|
18240
|
+
// sha1 is used here as a non-security cache-key derivation — we just
|
|
18241
|
+
// need a deterministic short identifier for the marker filename. No
|
|
18242
|
+
// PII or auth context is hashed.
|
|
18243
|
+
// DevSkim: ignore DS126858
|
|
18244
|
+
return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
18245
|
+
}
|
|
18246
|
+
function getDiffViewModeMarkerPath(repoPath) {
|
|
18247
|
+
return path__namespace$1.join(resolveCacheDir$1(), `diff-view-mode.${repoKey$1(repoPath)}`);
|
|
18248
|
+
}
|
|
18249
|
+
function getSavedDiffViewMode(repoPath) {
|
|
18250
|
+
try {
|
|
18251
|
+
const raw = fs__namespace$1.readFileSync(getDiffViewModeMarkerPath(repoPath), 'utf8').trim();
|
|
18252
|
+
return VALID_MODES.includes(raw)
|
|
18253
|
+
? raw
|
|
18254
|
+
: undefined;
|
|
18255
|
+
}
|
|
18256
|
+
catch {
|
|
18257
|
+
return undefined;
|
|
18258
|
+
}
|
|
18259
|
+
}
|
|
18260
|
+
function saveDiffViewMode(repoPath, mode) {
|
|
18261
|
+
const marker = getDiffViewModeMarkerPath(repoPath);
|
|
18262
|
+
try {
|
|
18263
|
+
fs__namespace$1.mkdirSync(path__namespace$1.dirname(marker), { recursive: true });
|
|
18264
|
+
fs__namespace$1.writeFileSync(marker, mode);
|
|
18265
|
+
}
|
|
18266
|
+
catch {
|
|
18267
|
+
// Best-effort persistence; swallow.
|
|
18268
|
+
}
|
|
18269
|
+
}
|
|
18270
|
+
|
|
17284
18271
|
/**
|
|
17285
18272
|
* Persist which sidebar tab the user last had active, keyed per repo so
|
|
17286
18273
|
* switching projects doesn't reset every other repo's preference. The
|
|
@@ -17340,6 +18327,144 @@ function saveSidebarTab(repoPath, tab) {
|
|
|
17340
18327
|
}
|
|
17341
18328
|
}
|
|
17342
18329
|
|
|
18330
|
+
/**
|
|
18331
|
+
* Pair-alignment helper for the side-by-side diff view (#785).
|
|
18332
|
+
*
|
|
18333
|
+
* Takes the unified-diff line array that the renderer already paints (one
|
|
18334
|
+
* line per element, the leading character drives `+`/`-`/context coloring)
|
|
18335
|
+
* and re-shapes it into two-column rows the split renderer can lay out
|
|
18336
|
+
* without further parsing. Pure / synchronous so it can be exercised from
|
|
18337
|
+
* tests without spinning up Ink.
|
|
18338
|
+
*
|
|
18339
|
+
* Algorithm:
|
|
18340
|
+
* 1. Walk lines in order. `@@` headers seed a new hunk and reset the
|
|
18341
|
+
* `oldLineNo` / `newLineNo` cursors from the header range.
|
|
18342
|
+
* 2. Inside a hunk, group the consecutive runs of `-` and `+` lines that
|
|
18343
|
+
* follow each other. Each run of removals + the immediately-following
|
|
18344
|
+
* run of additions forms a "change block" that pairs up element-wise:
|
|
18345
|
+
* row[i] = { left: removals[i], right: additions[i] }. When one side
|
|
18346
|
+
* is shorter, pad with `kind: 'empty'` rows so the columns stay
|
|
18347
|
+
* aligned.
|
|
18348
|
+
* 3. Context lines emit as a paired row with the same text on both
|
|
18349
|
+
* sides and the synthesized line numbers from each cursor.
|
|
18350
|
+
* 4. Diff metadata (`diff `, `index `, `--- `, `+++ `, etc.) emit as
|
|
18351
|
+
* `kind: 'header'` rows so the split view still has a section break.
|
|
18352
|
+
* 5. A context line that interrupts a change block forces the in-flight
|
|
18353
|
+
* block to flush before the context row is emitted — pairs are never
|
|
18354
|
+
* drawn across context boundaries (matches lazygit / fugitive
|
|
18355
|
+
* behavior, and is what the issue specifies).
|
|
18356
|
+
*
|
|
18357
|
+
* Long lines are not wrapped here — the renderer truncates per column at
|
|
18358
|
+
* paint time so this helper stays pure and trivially testable.
|
|
18359
|
+
*/
|
|
18360
|
+
const EMPTY_LEFT = { text: '', kind: 'empty' };
|
|
18361
|
+
const EMPTY_RIGHT = { text: '', kind: 'empty' };
|
|
18362
|
+
/**
|
|
18363
|
+
* Parse the start line numbers out of an `@@ -A,B +C,D @@` header. Returns
|
|
18364
|
+
* `[oldStart, newStart]`; either falls back to 1 when the header is
|
|
18365
|
+
* malformed (which only happens with synthetic / hand-crafted patches).
|
|
18366
|
+
*/
|
|
18367
|
+
function parseHunkHeader(line) {
|
|
18368
|
+
const match = /@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/.exec(line);
|
|
18369
|
+
if (!match) {
|
|
18370
|
+
return [1, 1];
|
|
18371
|
+
}
|
|
18372
|
+
return [Number(match[1]) || 1, Number(match[2]) || 1];
|
|
18373
|
+
}
|
|
18374
|
+
function isDiffHeader(line) {
|
|
18375
|
+
return (line.startsWith('diff ') ||
|
|
18376
|
+
line.startsWith('index ') ||
|
|
18377
|
+
line.startsWith('--- ') ||
|
|
18378
|
+
line.startsWith('+++ ') ||
|
|
18379
|
+
line.startsWith('similarity ') ||
|
|
18380
|
+
line.startsWith('rename ') ||
|
|
18381
|
+
line.startsWith('copy ') ||
|
|
18382
|
+
line.startsWith('new file ') ||
|
|
18383
|
+
line.startsWith('deleted file ') ||
|
|
18384
|
+
line.startsWith('old mode ') ||
|
|
18385
|
+
line.startsWith('new mode ') ||
|
|
18386
|
+
line.startsWith('Binary files '));
|
|
18387
|
+
}
|
|
18388
|
+
/**
|
|
18389
|
+
* Flush a pending change block (removals + additions accumulated from a
|
|
18390
|
+
* contiguous `-`/`+` run) into paired rows. Pads the shorter side with
|
|
18391
|
+
* empty placeholders so columns stay aligned.
|
|
18392
|
+
*/
|
|
18393
|
+
function flushChangeBlock(removals, additions, rows) {
|
|
18394
|
+
const max = Math.max(removals.length, additions.length);
|
|
18395
|
+
for (let i = 0; i < max; i++) {
|
|
18396
|
+
const left = removals[i] || EMPTY_LEFT;
|
|
18397
|
+
const right = additions[i] || EMPTY_RIGHT;
|
|
18398
|
+
rows.push({ left, right });
|
|
18399
|
+
}
|
|
18400
|
+
removals.length = 0;
|
|
18401
|
+
additions.length = 0;
|
|
18402
|
+
}
|
|
18403
|
+
function buildSplitDiffRows(unifiedLines) {
|
|
18404
|
+
const rows = [];
|
|
18405
|
+
let oldLineNo = 0;
|
|
18406
|
+
let newLineNo = 0;
|
|
18407
|
+
let inHunk = false;
|
|
18408
|
+
const removals = [];
|
|
18409
|
+
const additions = [];
|
|
18410
|
+
const flushHeader = (text) => {
|
|
18411
|
+
flushChangeBlock(removals, additions, rows);
|
|
18412
|
+
rows.push({
|
|
18413
|
+
left: { text, kind: 'header' },
|
|
18414
|
+
right: { text, kind: 'header' },
|
|
18415
|
+
});
|
|
18416
|
+
};
|
|
18417
|
+
for (const raw of unifiedLines) {
|
|
18418
|
+
if (raw.startsWith('@@')) {
|
|
18419
|
+
flushChangeBlock(removals, additions, rows);
|
|
18420
|
+
const [oldStart, newStart] = parseHunkHeader(raw);
|
|
18421
|
+
oldLineNo = oldStart;
|
|
18422
|
+
newLineNo = newStart;
|
|
18423
|
+
inHunk = true;
|
|
18424
|
+
rows.push({
|
|
18425
|
+
left: { text: raw, kind: 'header' },
|
|
18426
|
+
right: { text: raw, kind: 'header' },
|
|
18427
|
+
});
|
|
18428
|
+
continue;
|
|
18429
|
+
}
|
|
18430
|
+
if (!inHunk || isDiffHeader(raw)) {
|
|
18431
|
+
flushHeader(raw);
|
|
18432
|
+
continue;
|
|
18433
|
+
}
|
|
18434
|
+
if (raw.startsWith('-')) {
|
|
18435
|
+
removals.push({
|
|
18436
|
+
text: raw.slice(1),
|
|
18437
|
+
lineNumber: oldLineNo,
|
|
18438
|
+
kind: 'remove',
|
|
18439
|
+
});
|
|
18440
|
+
oldLineNo += 1;
|
|
18441
|
+
continue;
|
|
18442
|
+
}
|
|
18443
|
+
if (raw.startsWith('+')) {
|
|
18444
|
+
additions.push({
|
|
18445
|
+
text: raw.slice(1),
|
|
18446
|
+
lineNumber: newLineNo,
|
|
18447
|
+
kind: 'add',
|
|
18448
|
+
});
|
|
18449
|
+
newLineNo += 1;
|
|
18450
|
+
continue;
|
|
18451
|
+
}
|
|
18452
|
+
// Context line (or `` marker, which we
|
|
18453
|
+
// treat like a context row so it lands on both sides — readers
|
|
18454
|
+
// expect to see it in either column).
|
|
18455
|
+
flushChangeBlock(removals, additions, rows);
|
|
18456
|
+
const text = raw.startsWith(' ') ? raw.slice(1) : raw;
|
|
18457
|
+
rows.push({
|
|
18458
|
+
left: { text, lineNumber: oldLineNo, kind: 'context' },
|
|
18459
|
+
right: { text, lineNumber: newLineNo, kind: 'context' },
|
|
18460
|
+
});
|
|
18461
|
+
oldLineNo += 1;
|
|
18462
|
+
newLineNo += 1;
|
|
18463
|
+
}
|
|
18464
|
+
flushChangeBlock(removals, additions, rows);
|
|
18465
|
+
return rows;
|
|
18466
|
+
}
|
|
18467
|
+
|
|
17343
18468
|
/**
|
|
17344
18469
|
* Promoted-view selection rectification on filter changes (P4.5).
|
|
17345
18470
|
*
|
|
@@ -17761,7 +18886,8 @@ function createLogInkTheme(options = {}) {
|
|
|
17761
18886
|
/**
|
|
17762
18887
|
* Format a branch's relationship to its upstream.
|
|
17763
18888
|
* - no upstream → "no upstream"
|
|
17764
|
-
* - even → "
|
|
18889
|
+
* - even → "" (the boring default — keep the row tight; the row
|
|
18890
|
+
* marker already encodes "synced")
|
|
17765
18891
|
* - divergent → "↑<ahead> ↓<behind> <upstream>" (only the non-zero side
|
|
17766
18892
|
* is rendered so the line stays tight). ASCII mode falls back to the
|
|
17767
18893
|
* legacy `+N/-N` form.
|
|
@@ -17771,7 +18897,7 @@ function formatBranchDivergence(branch, options = {}) {
|
|
|
17771
18897
|
return 'no upstream';
|
|
17772
18898
|
}
|
|
17773
18899
|
if (branch.ahead === 0 && branch.behind === 0) {
|
|
17774
|
-
return
|
|
18900
|
+
return '';
|
|
17775
18901
|
}
|
|
17776
18902
|
if (options.ascii) {
|
|
17777
18903
|
return `+${branch.ahead}/-${branch.behind} ${branch.upstream}`;
|
|
@@ -17785,14 +18911,76 @@ function formatBranchDivergence(branch, options = {}) {
|
|
|
17785
18911
|
}
|
|
17786
18912
|
/**
|
|
17787
18913
|
* Single-cell marker shown to the left of a branch name in lists.
|
|
17788
|
-
*
|
|
18914
|
+
*
|
|
18915
|
+
* - `*` — current branch (regardless of remote state)
|
|
18916
|
+
* - `◌` — no upstream
|
|
18917
|
+
* - `≡` — has upstream + synced (ahead === 0 && behind === 0)
|
|
18918
|
+
* - `↕` — has upstream + diverged (any non-zero ahead/behind)
|
|
18919
|
+
* - ` ` — fallback / no info
|
|
18920
|
+
*
|
|
18921
|
+
* ASCII fallbacks (legible without box-drawing/arrow glyphs):
|
|
18922
|
+
* - `?` for "no upstream", `=` for synced, `~` for diverged.
|
|
17789
18923
|
*/
|
|
17790
18924
|
function branchRowMarker(branch, options = {}) {
|
|
17791
18925
|
if (branch.current)
|
|
17792
18926
|
return '*';
|
|
17793
18927
|
if (!branch.upstream)
|
|
17794
18928
|
return options.ascii ? '?' : '◌';
|
|
17795
|
-
|
|
18929
|
+
const ahead = branch.ahead ?? 0;
|
|
18930
|
+
const behind = branch.behind ?? 0;
|
|
18931
|
+
if (ahead === 0 && behind === 0) {
|
|
18932
|
+
return options.ascii ? '=' : '≡';
|
|
18933
|
+
}
|
|
18934
|
+
return options.ascii ? '~' : '↕';
|
|
18935
|
+
}
|
|
18936
|
+
/**
|
|
18937
|
+
* Compact, human-friendly relative timestamp for the branch row.
|
|
18938
|
+
* Inputs:
|
|
18939
|
+
* - `iso` — committer-date in `YYYY-MM-DD` form (as produced by
|
|
18940
|
+
* `for-each-ref` with `committerdate:short`).
|
|
18941
|
+
* - `now` — reference instant; pass it explicitly so callers can pin it
|
|
18942
|
+
* for deterministic tests.
|
|
18943
|
+
*
|
|
18944
|
+
* Outputs (rounded toward the nearest unit):
|
|
18945
|
+
* - `today`, `1d ago`, `2d ago` … up to 13d
|
|
18946
|
+
* - `2w ago` … up to 8w
|
|
18947
|
+
* - `2mo ago` … up to 12mo
|
|
18948
|
+
* - `2y ago` for older
|
|
18949
|
+
* - `''` for malformed inputs (caller renders nothing).
|
|
18950
|
+
*
|
|
18951
|
+
* "in the future" inputs (clock skew, bad data) collapse to `today`.
|
|
18952
|
+
*/
|
|
18953
|
+
function formatBranchLastTouched(iso, now) {
|
|
18954
|
+
if (!iso)
|
|
18955
|
+
return '';
|
|
18956
|
+
// Tolerate either `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS…` ISO strings.
|
|
18957
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
|
18958
|
+
if (!match)
|
|
18959
|
+
return '';
|
|
18960
|
+
const year = Number.parseInt(match[1], 10);
|
|
18961
|
+
const month = Number.parseInt(match[2], 10);
|
|
18962
|
+
const day = Number.parseInt(match[3], 10);
|
|
18963
|
+
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day))
|
|
18964
|
+
return '';
|
|
18965
|
+
// Compare at day granularity in UTC so a branch touched "yesterday"
|
|
18966
|
+
// never reads "today" depending on the operator's timezone.
|
|
18967
|
+
const branchUtc = Date.UTC(year, month - 1, day);
|
|
18968
|
+
const nowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
18969
|
+
const diffMs = nowUtc - branchUtc;
|
|
18970
|
+
const oneDay = 24 * 60 * 60 * 1000;
|
|
18971
|
+
const days = Math.floor(diffMs / oneDay);
|
|
18972
|
+
if (days <= 0)
|
|
18973
|
+
return 'today';
|
|
18974
|
+
if (days < 14)
|
|
18975
|
+
return `${days}d ago`;
|
|
18976
|
+
const weeks = Math.floor(days / 7);
|
|
18977
|
+
if (weeks < 9)
|
|
18978
|
+
return `${weeks}w ago`;
|
|
18979
|
+
const months = Math.floor(days / 30);
|
|
18980
|
+
if (months < 12)
|
|
18981
|
+
return `${months}mo ago`;
|
|
18982
|
+
const years = Math.floor(days / 365);
|
|
18983
|
+
return `${years}y ago`;
|
|
17796
18984
|
}
|
|
17797
18985
|
/**
|
|
17798
18986
|
* Pick the glyph + color for a PR state badge.
|
|
@@ -18222,7 +19410,7 @@ function createChangelogArgv(input) {
|
|
|
18222
19410
|
...input,
|
|
18223
19411
|
};
|
|
18224
19412
|
}
|
|
18225
|
-
function compactOutputLines$
|
|
19413
|
+
function compactOutputLines$3(output) {
|
|
18226
19414
|
return output
|
|
18227
19415
|
.split('\n')
|
|
18228
19416
|
.map((line) => line.trim())
|
|
@@ -18246,7 +19434,7 @@ async function captureStdout(action) {
|
|
|
18246
19434
|
}
|
|
18247
19435
|
}
|
|
18248
19436
|
function formatCapturedAiOutput(output) {
|
|
18249
|
-
const lines = compactOutputLines$
|
|
19437
|
+
const lines = compactOutputLines$3(output);
|
|
18250
19438
|
const telemetry = lines.filter((line) => line.includes('[llm:summary]'));
|
|
18251
19439
|
const content = lines.filter((line) => !line.includes('[llm]') && !line.includes('[llm:summary]'));
|
|
18252
19440
|
const editable = content.join('\n');
|
|
@@ -18543,8 +19731,65 @@ function parsePullRequestInfo(output) {
|
|
|
18543
19731
|
if (!trimmed) {
|
|
18544
19732
|
return undefined;
|
|
18545
19733
|
}
|
|
18546
|
-
|
|
19734
|
+
const raw = JSON.parse(trimmed);
|
|
19735
|
+
const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
|
|
19736
|
+
? String(raw.author.login)
|
|
19737
|
+
: undefined;
|
|
19738
|
+
return {
|
|
19739
|
+
number: raw.number,
|
|
19740
|
+
title: raw.title,
|
|
19741
|
+
url: raw.url,
|
|
19742
|
+
state: raw.state,
|
|
19743
|
+
isDraft: raw.isDraft,
|
|
19744
|
+
headRefName: raw.headRefName,
|
|
19745
|
+
baseRefName: raw.baseRefName,
|
|
19746
|
+
body: typeof raw.body === 'string' ? raw.body : undefined,
|
|
19747
|
+
author,
|
|
19748
|
+
reviewDecision: typeof raw.reviewDecision === 'string' ? raw.reviewDecision : undefined,
|
|
19749
|
+
mergeable: typeof raw.mergeable === 'string' ? raw.mergeable : undefined,
|
|
19750
|
+
mergeStateStatus: typeof raw.mergeStateStatus === 'string' ? raw.mergeStateStatus : undefined,
|
|
19751
|
+
statusCheckRollup: Array.isArray(raw.statusCheckRollup)
|
|
19752
|
+
? raw.statusCheckRollup.map((entry) => ({
|
|
19753
|
+
name: String(entry.name || entry.context || 'check'),
|
|
19754
|
+
status: typeof entry.status === 'string' ? entry.status : undefined,
|
|
19755
|
+
conclusion: typeof entry.conclusion === 'string' ? entry.conclusion : undefined,
|
|
19756
|
+
}))
|
|
19757
|
+
: undefined,
|
|
19758
|
+
reviews: Array.isArray(raw.reviews)
|
|
19759
|
+
? raw.reviews.map((entry) => {
|
|
19760
|
+
const author = entry.author && typeof entry.author === 'object' && 'login' in entry.author
|
|
19761
|
+
? String(entry.author.login)
|
|
19762
|
+
: '';
|
|
19763
|
+
return {
|
|
19764
|
+
author,
|
|
19765
|
+
state: typeof entry.state === 'string' ? entry.state : '',
|
|
19766
|
+
};
|
|
19767
|
+
}).filter((review) => review.author)
|
|
19768
|
+
: undefined,
|
|
19769
|
+
};
|
|
18547
19770
|
}
|
|
19771
|
+
/**
|
|
19772
|
+
* `gh pr view --json` field list. Centralized so the data fetcher and
|
|
19773
|
+
* any future re-fetch (e.g., refresh after a merge action) request the
|
|
19774
|
+
* same shape — the parser depends on every field being present, even
|
|
19775
|
+
* if optional, so they're safe to deserialize.
|
|
19776
|
+
*/
|
|
19777
|
+
const PULL_REQUEST_VIEW_JSON_FIELDS = [
|
|
19778
|
+
'number',
|
|
19779
|
+
'title',
|
|
19780
|
+
'url',
|
|
19781
|
+
'state',
|
|
19782
|
+
'isDraft',
|
|
19783
|
+
'headRefName',
|
|
19784
|
+
'baseRefName',
|
|
19785
|
+
'body',
|
|
19786
|
+
'author',
|
|
19787
|
+
'reviewDecision',
|
|
19788
|
+
'mergeable',
|
|
19789
|
+
'mergeStateStatus',
|
|
19790
|
+
'statusCheckRollup',
|
|
19791
|
+
'reviews',
|
|
19792
|
+
].join(',');
|
|
18548
19793
|
async function getPullRequestOverview(git, runner = defaultGhRunner) {
|
|
18549
19794
|
const [repository, currentBranchOutput] = await Promise.all([
|
|
18550
19795
|
getGitHubRepository(git),
|
|
@@ -18576,7 +19821,7 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
|
|
|
18576
19821
|
'pr',
|
|
18577
19822
|
'view',
|
|
18578
19823
|
'--json',
|
|
18579
|
-
|
|
19824
|
+
PULL_REQUEST_VIEW_JSON_FIELDS,
|
|
18580
19825
|
]);
|
|
18581
19826
|
return {
|
|
18582
19827
|
available: true,
|
|
@@ -18743,7 +19988,7 @@ function providerBranchName(branch) {
|
|
|
18743
19988
|
return branch.shortName;
|
|
18744
19989
|
}
|
|
18745
19990
|
|
|
18746
|
-
function compactOutputLines$
|
|
19991
|
+
function compactOutputLines$2(output) {
|
|
18747
19992
|
return output
|
|
18748
19993
|
.split('\n')
|
|
18749
19994
|
.map((line) => line.trim())
|
|
@@ -18758,7 +20003,7 @@ async function runAction$5(action, successMessage) {
|
|
|
18758
20003
|
};
|
|
18759
20004
|
}
|
|
18760
20005
|
catch (error) {
|
|
18761
|
-
const lines = compactOutputLines$
|
|
20006
|
+
const lines = compactOutputLines$2(error.message);
|
|
18762
20007
|
return {
|
|
18763
20008
|
ok: false,
|
|
18764
20009
|
message: lines[0] || 'History action failed.',
|
|
@@ -18900,7 +20145,7 @@ async function compareCommits(git, from, to) {
|
|
|
18900
20145
|
}
|
|
18901
20146
|
try {
|
|
18902
20147
|
const output = await git.raw(['diff', '--stat', '--color=never', `${from.hash}..${to.hash}`]);
|
|
18903
|
-
const lines = compactOutputLines$
|
|
20148
|
+
const lines = compactOutputLines$2(output);
|
|
18904
20149
|
return {
|
|
18905
20150
|
ok: true,
|
|
18906
20151
|
message: `Compared ${from.shortHash}..${to.shortHash}`,
|
|
@@ -19067,6 +20312,61 @@ function createPullRequest(input, runner = defaultGhRunner) {
|
|
|
19067
20312
|
};
|
|
19068
20313
|
});
|
|
19069
20314
|
}
|
|
20315
|
+
function isPullRequestMergeStrategy(value) {
|
|
20316
|
+
return value === 'merge' || value === 'squash' || value === 'rebase';
|
|
20317
|
+
}
|
|
20318
|
+
function buildMergePullRequestArgs(strategy) {
|
|
20319
|
+
// `--auto` and `--admin` are intentionally omitted — they're rarely
|
|
20320
|
+
// what a user wants from a TUI and require explicit gh auth scopes.
|
|
20321
|
+
// `--delete-branch` is opt-in via a future flag; default leaves the
|
|
20322
|
+
// branch in place so the user can verify before cleanup.
|
|
20323
|
+
return ['pr', 'merge', `--${strategy}`];
|
|
20324
|
+
}
|
|
20325
|
+
function mergePullRequest(strategy, runner = defaultGhRunner) {
|
|
20326
|
+
return runGhAction(runner, buildMergePullRequestArgs(strategy), (output) => ({
|
|
20327
|
+
ok: true,
|
|
20328
|
+
message: output.trim() || `Merged pull request with ${strategy}`,
|
|
20329
|
+
}));
|
|
20330
|
+
}
|
|
20331
|
+
function closePullRequest(runner = defaultGhRunner) {
|
|
20332
|
+
return runGhAction(runner, ['pr', 'close'], (output) => ({
|
|
20333
|
+
ok: true,
|
|
20334
|
+
message: output.trim() || 'Closed pull request',
|
|
20335
|
+
}));
|
|
20336
|
+
}
|
|
20337
|
+
/**
|
|
20338
|
+
* `gh pr review --approve` requires the user's gh auth to have scope
|
|
20339
|
+
* to write reviews — same scope that the in-browser approve button
|
|
20340
|
+
* uses. The runner surfaces auth failures via the standard error path.
|
|
20341
|
+
*/
|
|
20342
|
+
function approvePullRequest(runner = defaultGhRunner) {
|
|
20343
|
+
return runGhAction(runner, ['pr', 'review', '--approve'], (output) => ({
|
|
20344
|
+
ok: true,
|
|
20345
|
+
message: output.trim() || 'Approved pull request',
|
|
20346
|
+
}));
|
|
20347
|
+
}
|
|
20348
|
+
/**
|
|
20349
|
+
* Request changes — `gh pr review` requires a body with this verb so
|
|
20350
|
+
* the empty-body case is rejected upstream by the input prompt.
|
|
20351
|
+
*/
|
|
20352
|
+
function requestChangesPullRequest(body, runner = defaultGhRunner) {
|
|
20353
|
+
if (!body.trim()) {
|
|
20354
|
+
return Promise.resolve({ ok: false, message: 'Review body required for change-request' });
|
|
20355
|
+
}
|
|
20356
|
+
return runGhAction(runner, ['pr', 'review', '--request-changes', '--body', body], (output) => ({
|
|
20357
|
+
ok: true,
|
|
20358
|
+
message: output.trim() || 'Requested changes',
|
|
20359
|
+
}));
|
|
20360
|
+
}
|
|
20361
|
+
function commentPullRequest(body, runner = defaultGhRunner) {
|
|
20362
|
+
if (!body.trim()) {
|
|
20363
|
+
return Promise.resolve({ ok: false, message: 'Comment body required' });
|
|
20364
|
+
}
|
|
20365
|
+
return runGhAction(runner, ['pr', 'comment', '--body', body], (output) => ({
|
|
20366
|
+
ok: true,
|
|
20367
|
+
message: output.trim() || 'Comment added',
|
|
20368
|
+
}));
|
|
20369
|
+
}
|
|
19070
20370
|
|
|
19071
20371
|
async function runAction$4(action, successMessage) {
|
|
19072
20372
|
try {
|
|
@@ -19691,7 +20991,7 @@ function applyLogTuiAction(state, action) {
|
|
|
19691
20991
|
}
|
|
19692
20992
|
}
|
|
19693
20993
|
|
|
19694
|
-
function compactOutputLines(output) {
|
|
20994
|
+
function compactOutputLines$1(output) {
|
|
19695
20995
|
return output
|
|
19696
20996
|
.split('\n')
|
|
19697
20997
|
.map((line) => line.trim())
|
|
@@ -19706,7 +21006,7 @@ async function runAction(action, successMessage) {
|
|
|
19706
21006
|
};
|
|
19707
21007
|
}
|
|
19708
21008
|
catch (error) {
|
|
19709
|
-
const details = compactOutputLines(error.message);
|
|
21009
|
+
const details = compactOutputLines$1(error.message);
|
|
19710
21010
|
return {
|
|
19711
21011
|
ok: false,
|
|
19712
21012
|
message: details[0] || 'Git operation action failed.',
|
|
@@ -21355,35 +22655,303 @@ async function startInteractiveLog(git, rows, streams = {}) {
|
|
|
21355
22655
|
applyAndRender(applyLogTuiAction(state, { type: 'clearFilter' }));
|
|
21356
22656
|
}
|
|
21357
22657
|
};
|
|
21358
|
-
input.on('keypress', onKeypress);
|
|
21359
|
-
void Promise.all([
|
|
21360
|
-
refreshBranches(),
|
|
21361
|
-
refreshPullRequest(),
|
|
21362
|
-
refreshTags(),
|
|
21363
|
-
refreshWorktree(),
|
|
21364
|
-
refreshStashes(),
|
|
21365
|
-
refreshWorktreeList(),
|
|
21366
|
-
refreshOperationOverview(),
|
|
21367
|
-
refreshProviderOverview(),
|
|
21368
|
-
])
|
|
21369
|
-
.then(async () => {
|
|
21370
|
-
await refreshStatusHunks();
|
|
21371
|
-
await render();
|
|
21372
|
-
})
|
|
21373
|
-
.catch(() => {
|
|
21374
|
-
branches = undefined;
|
|
21375
|
-
pullRequest = undefined;
|
|
21376
|
-
tags = undefined;
|
|
21377
|
-
worktree = undefined;
|
|
21378
|
-
statusHunks = undefined;
|
|
21379
|
-
stashes = undefined;
|
|
21380
|
-
worktreeList = undefined;
|
|
21381
|
-
operationOverview = undefined;
|
|
21382
|
-
providerOverview = undefined;
|
|
21383
|
-
});
|
|
21384
|
-
void render();
|
|
22658
|
+
input.on('keypress', onKeypress);
|
|
22659
|
+
void Promise.all([
|
|
22660
|
+
refreshBranches(),
|
|
22661
|
+
refreshPullRequest(),
|
|
22662
|
+
refreshTags(),
|
|
22663
|
+
refreshWorktree(),
|
|
22664
|
+
refreshStashes(),
|
|
22665
|
+
refreshWorktreeList(),
|
|
22666
|
+
refreshOperationOverview(),
|
|
22667
|
+
refreshProviderOverview(),
|
|
22668
|
+
])
|
|
22669
|
+
.then(async () => {
|
|
22670
|
+
await refreshStatusHunks();
|
|
22671
|
+
await render();
|
|
22672
|
+
})
|
|
22673
|
+
.catch(() => {
|
|
22674
|
+
branches = undefined;
|
|
22675
|
+
pullRequest = undefined;
|
|
22676
|
+
tags = undefined;
|
|
22677
|
+
worktree = undefined;
|
|
22678
|
+
statusHunks = undefined;
|
|
22679
|
+
stashes = undefined;
|
|
22680
|
+
worktreeList = undefined;
|
|
22681
|
+
operationOverview = undefined;
|
|
22682
|
+
providerOverview = undefined;
|
|
22683
|
+
});
|
|
22684
|
+
void render();
|
|
22685
|
+
});
|
|
22686
|
+
}
|
|
22687
|
+
|
|
22688
|
+
function compactOutputLines(output) {
|
|
22689
|
+
return output
|
|
22690
|
+
.split('\n')
|
|
22691
|
+
.map((line) => line.trim())
|
|
22692
|
+
.filter(Boolean);
|
|
22693
|
+
}
|
|
22694
|
+
async function safeUnlink(path) {
|
|
22695
|
+
try {
|
|
22696
|
+
await fs.promises.unlink(path);
|
|
22697
|
+
}
|
|
22698
|
+
catch (error) {
|
|
22699
|
+
// ENOENT is fine — the temp file was never created or already
|
|
22700
|
+
// cleaned up. Anything else we silently swallow because the
|
|
22701
|
+
// worst-case impact is a single ~1KB file in $TMPDIR.
|
|
22702
|
+
error.code;
|
|
22703
|
+
}
|
|
22704
|
+
}
|
|
22705
|
+
/**
|
|
22706
|
+
* Write a unified-diff patch to a temp file and feed it to
|
|
22707
|
+
* `git apply` (or `git apply --cached` when target === 'index').
|
|
22708
|
+
*
|
|
22709
|
+
* This is the runner behind the `apply-hunk-worktree` /
|
|
22710
|
+
* `apply-hunk-index` workflow actions — the input handler builds
|
|
22711
|
+
* `patchText` from the cursored hunk via `extractDiffHunk` and the
|
|
22712
|
+
* runtime hands it here.
|
|
22713
|
+
*
|
|
22714
|
+
* `--whitespace=nowarn` keeps `git apply` quiet about trailing
|
|
22715
|
+
* whitespace differences (the most common false positive when the
|
|
22716
|
+
* patch comes from a stash made on a different platform). Real
|
|
22717
|
+
* conflicts still surface via the non-zero exit code.
|
|
22718
|
+
*
|
|
22719
|
+
* The patch is written to a temp file rather than piped on stdin
|
|
22720
|
+
* because some `simple-git` adapters don't expose a clean stdin
|
|
22721
|
+
* channel for `git.raw`; the tempfile path keeps the runner
|
|
22722
|
+
* portable across environments.
|
|
22723
|
+
*/
|
|
22724
|
+
async function applyHunkPatch(git, patchText, options) {
|
|
22725
|
+
if (!patchText.trim()) {
|
|
22726
|
+
return {
|
|
22727
|
+
ok: false,
|
|
22728
|
+
message: 'No hunk under cursor to apply.',
|
|
22729
|
+
};
|
|
22730
|
+
}
|
|
22731
|
+
const targetLabel = options.target === 'index' ? 'index' : 'worktree';
|
|
22732
|
+
const tempPath = path.join(os.tmpdir(), `coco-hunk-${crypto$1.randomUUID()}.patch`);
|
|
22733
|
+
try {
|
|
22734
|
+
await fs.promises.writeFile(tempPath, patchText, 'utf8');
|
|
22735
|
+
const args = ['apply'];
|
|
22736
|
+
if (options.target === 'index') {
|
|
22737
|
+
args.push('--cached');
|
|
22738
|
+
}
|
|
22739
|
+
args.push('--whitespace=nowarn');
|
|
22740
|
+
args.push(tempPath);
|
|
22741
|
+
try {
|
|
22742
|
+
await git.raw(args);
|
|
22743
|
+
return {
|
|
22744
|
+
ok: true,
|
|
22745
|
+
message: `Applied hunk to ${targetLabel}`,
|
|
22746
|
+
};
|
|
22747
|
+
}
|
|
22748
|
+
catch (error) {
|
|
22749
|
+
const lines = compactOutputLines(error.message);
|
|
22750
|
+
return {
|
|
22751
|
+
ok: false,
|
|
22752
|
+
message: lines[0] || `Failed to apply hunk to ${targetLabel}`,
|
|
22753
|
+
details: lines.slice(1, 6),
|
|
22754
|
+
};
|
|
22755
|
+
}
|
|
22756
|
+
}
|
|
22757
|
+
catch (error) {
|
|
22758
|
+
return {
|
|
22759
|
+
ok: false,
|
|
22760
|
+
message: `Could not stage hunk for apply: ${error.message}`,
|
|
22761
|
+
};
|
|
22762
|
+
}
|
|
22763
|
+
finally {
|
|
22764
|
+
await safeUnlink(tempPath);
|
|
22765
|
+
}
|
|
22766
|
+
}
|
|
22767
|
+
|
|
22768
|
+
function formatStashHeaderIdentity(ref, stashes) {
|
|
22769
|
+
if (!ref) {
|
|
22770
|
+
return { subtitle: 'no stash', bodyLine: 'Stash:' };
|
|
22771
|
+
}
|
|
22772
|
+
const index = stashes?.findIndex((entry) => entry.ref === ref) ?? -1;
|
|
22773
|
+
const entry = index >= 0 ? stashes[index] : undefined;
|
|
22774
|
+
if (!entry) {
|
|
22775
|
+
return {
|
|
22776
|
+
subtitle: ref,
|
|
22777
|
+
bodyLine: `Stash: ${ref}`,
|
|
22778
|
+
};
|
|
22779
|
+
}
|
|
22780
|
+
const onBranch = entry.branch && entry.branch !== '<unknown>' ? ` on ${entry.branch}` : '';
|
|
22781
|
+
const message = entry.message?.trim() || '(no message)';
|
|
22782
|
+
return {
|
|
22783
|
+
subtitle: `@{${index}} ${message}${onBranch}`,
|
|
22784
|
+
bodyLine: `Stash: ${ref}${onBranch} — ${message}`,
|
|
22785
|
+
};
|
|
22786
|
+
}
|
|
22787
|
+
|
|
22788
|
+
/**
|
|
22789
|
+
* Normalize gh's two parallel signals (`status` for in-flight check
|
|
22790
|
+
* runs, `conclusion` for completed runs and status contexts) into a
|
|
22791
|
+
* single status enum the renderer can map to a glyph + color.
|
|
22792
|
+
*/
|
|
22793
|
+
function normalizePullRequestCheckStatus(check) {
|
|
22794
|
+
const status = (check.status || '').toUpperCase();
|
|
22795
|
+
const conclusion = (check.conclusion || '').toUpperCase();
|
|
22796
|
+
// In-flight check runs: gh emits `status: IN_PROGRESS|QUEUED` with
|
|
22797
|
+
// no conclusion yet. `PENDING` covers status-context runs that are
|
|
22798
|
+
// still waiting on a reporter.
|
|
22799
|
+
if (!conclusion && (status === 'IN_PROGRESS' || status === 'QUEUED' || status === 'PENDING')) {
|
|
22800
|
+
return 'pending';
|
|
22801
|
+
}
|
|
22802
|
+
switch (conclusion || status) {
|
|
22803
|
+
case 'SUCCESS':
|
|
22804
|
+
return 'success';
|
|
22805
|
+
case 'FAILURE':
|
|
22806
|
+
case 'ERROR':
|
|
22807
|
+
case 'TIMED_OUT':
|
|
22808
|
+
case 'ACTION_REQUIRED':
|
|
22809
|
+
return 'failure';
|
|
22810
|
+
case 'NEUTRAL':
|
|
22811
|
+
return 'neutral';
|
|
22812
|
+
case 'SKIPPED':
|
|
22813
|
+
case 'CANCELLED':
|
|
22814
|
+
return 'skipped';
|
|
22815
|
+
default:
|
|
22816
|
+
return 'pending';
|
|
22817
|
+
}
|
|
22818
|
+
}
|
|
22819
|
+
/**
|
|
22820
|
+
* Glyph for a normalized check status. ASCII fallbacks keep the panel
|
|
22821
|
+
* usable on legacy terminals where the geometric shapes block isn't
|
|
22822
|
+
* rendered.
|
|
22823
|
+
*/
|
|
22824
|
+
function pullRequestCheckGlyph(status, options = {}) {
|
|
22825
|
+
if (options.ascii) {
|
|
22826
|
+
switch (status) {
|
|
22827
|
+
case 'success': return '+';
|
|
22828
|
+
case 'failure': return 'x';
|
|
22829
|
+
case 'pending': return '.';
|
|
22830
|
+
case 'neutral': return '-';
|
|
22831
|
+
case 'skipped': return '/';
|
|
22832
|
+
}
|
|
22833
|
+
}
|
|
22834
|
+
switch (status) {
|
|
22835
|
+
case 'success': return '✓';
|
|
22836
|
+
case 'failure': return '✗';
|
|
22837
|
+
case 'pending': return '◌';
|
|
22838
|
+
case 'neutral': return '○';
|
|
22839
|
+
case 'skipped': return '∼';
|
|
22840
|
+
}
|
|
22841
|
+
}
|
|
22842
|
+
function summarizePullRequestChecks(checks) {
|
|
22843
|
+
const summary = {
|
|
22844
|
+
total: 0, success: 0, failure: 0, pending: 0, neutral: 0, skipped: 0,
|
|
22845
|
+
};
|
|
22846
|
+
if (!checks)
|
|
22847
|
+
return summary;
|
|
22848
|
+
for (const check of checks) {
|
|
22849
|
+
summary.total += 1;
|
|
22850
|
+
summary[normalizePullRequestCheckStatus(check)] += 1;
|
|
22851
|
+
}
|
|
22852
|
+
return summary;
|
|
22853
|
+
}
|
|
22854
|
+
/**
|
|
22855
|
+
* One-line summary like `5 checks · 4 ✓ · 1 ◌` for the panel header.
|
|
22856
|
+
* Hides zero-count categories so the line stays scannable.
|
|
22857
|
+
*/
|
|
22858
|
+
function formatPullRequestChecksSummary(summary, options = {}) {
|
|
22859
|
+
if (summary.total === 0) {
|
|
22860
|
+
return 'No status checks reported';
|
|
22861
|
+
}
|
|
22862
|
+
const parts = [`${summary.total} ${summary.total === 1 ? 'check' : 'checks'}`];
|
|
22863
|
+
const push = (count, status) => {
|
|
22864
|
+
if (count > 0)
|
|
22865
|
+
parts.push(`${count} ${pullRequestCheckGlyph(status, options)}`);
|
|
22866
|
+
};
|
|
22867
|
+
push(summary.success, 'success');
|
|
22868
|
+
push(summary.failure, 'failure');
|
|
22869
|
+
push(summary.pending, 'pending');
|
|
22870
|
+
push(summary.neutral, 'neutral');
|
|
22871
|
+
push(summary.skipped, 'skipped');
|
|
22872
|
+
return parts.join(' · ');
|
|
22873
|
+
}
|
|
22874
|
+
function buildPullRequestCheckRows(checks, options = {}) {
|
|
22875
|
+
if (!checks)
|
|
22876
|
+
return [];
|
|
22877
|
+
return checks.map((check) => {
|
|
22878
|
+
const status = normalizePullRequestCheckStatus(check);
|
|
22879
|
+
return {
|
|
22880
|
+
glyph: pullRequestCheckGlyph(status, options),
|
|
22881
|
+
name: check.name,
|
|
22882
|
+
status,
|
|
22883
|
+
detail: (check.conclusion || check.status || '').toLowerCase(),
|
|
22884
|
+
};
|
|
21385
22885
|
});
|
|
21386
22886
|
}
|
|
22887
|
+
function summarizePullRequestReviews(reviews, reviewDecision) {
|
|
22888
|
+
const summary = {
|
|
22889
|
+
total: 0, approved: 0, changesRequested: 0, commented: 0, dismissed: 0, pending: 0,
|
|
22890
|
+
decisionLabel: reviewDecision || undefined,
|
|
22891
|
+
};
|
|
22892
|
+
if (!reviews)
|
|
22893
|
+
return summary;
|
|
22894
|
+
for (const review of reviews) {
|
|
22895
|
+
summary.total += 1;
|
|
22896
|
+
switch (review.state.toUpperCase()) {
|
|
22897
|
+
case 'APPROVED':
|
|
22898
|
+
summary.approved += 1;
|
|
22899
|
+
break;
|
|
22900
|
+
case 'CHANGES_REQUESTED':
|
|
22901
|
+
summary.changesRequested += 1;
|
|
22902
|
+
break;
|
|
22903
|
+
case 'COMMENTED':
|
|
22904
|
+
summary.commented += 1;
|
|
22905
|
+
break;
|
|
22906
|
+
case 'DISMISSED':
|
|
22907
|
+
summary.dismissed += 1;
|
|
22908
|
+
break;
|
|
22909
|
+
case 'PENDING':
|
|
22910
|
+
summary.pending += 1;
|
|
22911
|
+
break;
|
|
22912
|
+
}
|
|
22913
|
+
}
|
|
22914
|
+
return summary;
|
|
22915
|
+
}
|
|
22916
|
+
function formatPullRequestReviewsSummary(summary) {
|
|
22917
|
+
const decision = summary.decisionLabel
|
|
22918
|
+
? summary.decisionLabel.replace(/_/g, ' ').toLowerCase()
|
|
22919
|
+
: undefined;
|
|
22920
|
+
if (summary.total === 0) {
|
|
22921
|
+
return decision ? `No reviews · ${decision}` : 'No reviews submitted';
|
|
22922
|
+
}
|
|
22923
|
+
const parts = [`${summary.total} ${summary.total === 1 ? 'review' : 'reviews'}`];
|
|
22924
|
+
if (summary.approved > 0)
|
|
22925
|
+
parts.push(`${summary.approved} approved`);
|
|
22926
|
+
if (summary.changesRequested > 0)
|
|
22927
|
+
parts.push(`${summary.changesRequested} changes requested`);
|
|
22928
|
+
if (summary.commented > 0)
|
|
22929
|
+
parts.push(`${summary.commented} commented`);
|
|
22930
|
+
if (summary.pending > 0)
|
|
22931
|
+
parts.push(`${summary.pending} pending`);
|
|
22932
|
+
if (summary.dismissed > 0)
|
|
22933
|
+
parts.push(`${summary.dismissed} dismissed`);
|
|
22934
|
+
if (decision)
|
|
22935
|
+
parts.push(`decision: ${decision}`);
|
|
22936
|
+
return parts.join(' · ');
|
|
22937
|
+
}
|
|
22938
|
+
/**
|
|
22939
|
+
* One-line state badge for the header, e.g. `OPEN · draft` or `MERGED`.
|
|
22940
|
+
* Mergeable / merge-state is appended as a secondary chip when the PR
|
|
22941
|
+
* is open so the user sees `MERGEABLE` / `CONFLICTING` at a glance.
|
|
22942
|
+
*/
|
|
22943
|
+
function formatPullRequestStateLine(pr) {
|
|
22944
|
+
const parts = [pr.state];
|
|
22945
|
+
if (pr.isDraft)
|
|
22946
|
+
parts.push('draft');
|
|
22947
|
+
if (pr.state === 'OPEN' && pr.mergeable) {
|
|
22948
|
+
parts.push(pr.mergeable.toLowerCase());
|
|
22949
|
+
}
|
|
22950
|
+
if (pr.state === 'OPEN' && pr.mergeStateStatus && pr.mergeStateStatus !== 'CLEAN') {
|
|
22951
|
+
parts.push(pr.mergeStateStatus.toLowerCase());
|
|
22952
|
+
}
|
|
22953
|
+
return parts.join(' · ');
|
|
22954
|
+
}
|
|
21387
22955
|
|
|
21388
22956
|
function sectionLines(title, diff) {
|
|
21389
22957
|
const lines = diff.split('\n').map((line) => line.trimEnd());
|
|
@@ -21600,6 +23168,89 @@ function diffLineProps(line, theme) {
|
|
|
21600
23168
|
}
|
|
21601
23169
|
return {};
|
|
21602
23170
|
}
|
|
23171
|
+
/**
|
|
23172
|
+
* Minimum terminal width below which the split diff falls back to
|
|
23173
|
+
* unified rendering (#785). Each column needs ~50 columns for code to
|
|
23174
|
+
* read comfortably plus border + padding overhead, so anything narrower
|
|
23175
|
+
* than ~120 columns gets the unified view regardless of the user's
|
|
23176
|
+
* preference. The preference is preserved — switching back to a wide
|
|
23177
|
+
* terminal restores split mode automatically.
|
|
23178
|
+
*/
|
|
23179
|
+
const MIN_SPLIT_DIFF_WIDTH = 120;
|
|
23180
|
+
function isSplitDiffViable(state, width) {
|
|
23181
|
+
return state.diffViewMode === 'split' && width >= MIN_SPLIT_DIFF_WIDTH;
|
|
23182
|
+
}
|
|
23183
|
+
/**
|
|
23184
|
+
* Style props for one side of a split-diff row, derived from the row's
|
|
23185
|
+
* `kind` rather than the leading character (because the helper has
|
|
23186
|
+
* already stripped the leading +/-/space). Keeps the colors aligned with
|
|
23187
|
+
* `diffLineProps`.
|
|
23188
|
+
*/
|
|
23189
|
+
function splitDiffSideProps(kind, theme) {
|
|
23190
|
+
if (kind === 'header') {
|
|
23191
|
+
if (theme.noColor)
|
|
23192
|
+
return { dimColor: true };
|
|
23193
|
+
return { color: theme.colors.accent };
|
|
23194
|
+
}
|
|
23195
|
+
if (kind === 'empty') {
|
|
23196
|
+
return { dimColor: true };
|
|
23197
|
+
}
|
|
23198
|
+
if (theme.noColor) {
|
|
23199
|
+
return { dimColor: kind === 'context' };
|
|
23200
|
+
}
|
|
23201
|
+
if (kind === 'add')
|
|
23202
|
+
return { color: theme.colors.gitAdded };
|
|
23203
|
+
if (kind === 'remove')
|
|
23204
|
+
return { color: theme.colors.gitDeleted };
|
|
23205
|
+
return {};
|
|
23206
|
+
}
|
|
23207
|
+
/**
|
|
23208
|
+
* Format one column of a split-diff row: an optional 4-digit line
|
|
23209
|
+
* number prefix + the line text, padded/truncated to the column width.
|
|
23210
|
+
* Empty rows render a faint `·` placeholder so the alignment gap is
|
|
23211
|
+
* visible at a glance.
|
|
23212
|
+
*/
|
|
23213
|
+
function formatSplitDiffCell(side, columnWidth) {
|
|
23214
|
+
if (side.kind === 'empty') {
|
|
23215
|
+
const placeholder = ' · ';
|
|
23216
|
+
return placeholder.padEnd(columnWidth);
|
|
23217
|
+
}
|
|
23218
|
+
if (side.kind === 'header') {
|
|
23219
|
+
return truncate$1(side.text, columnWidth).padEnd(columnWidth);
|
|
23220
|
+
}
|
|
23221
|
+
const lineNo = side.lineNumber !== undefined ? String(side.lineNumber).padStart(4) : ' ';
|
|
23222
|
+
// Strip the trailing newline that some diffs include. Keeps column
|
|
23223
|
+
// widths predictable.
|
|
23224
|
+
const text = side.text.replace(/\n$/, '');
|
|
23225
|
+
// 4 digits + 1 space gutter = 5 chars; reserve that off the column
|
|
23226
|
+
// before truncating the text.
|
|
23227
|
+
const textRoom = Math.max(1, columnWidth - 5);
|
|
23228
|
+
return `${lineNo} ${truncate$1(text, textRoom)}`.padEnd(columnWidth);
|
|
23229
|
+
}
|
|
23230
|
+
/**
|
|
23231
|
+
* Render the split-diff body as a list of two-column rows. The caller
|
|
23232
|
+
* is responsible for slicing the unified-line array to the visible
|
|
23233
|
+
* window — the helper just transforms that slice into Ink nodes.
|
|
23234
|
+
*/
|
|
23235
|
+
function renderSplitDiffBody(h, components, unifiedSlice, startOffset, width, theme, keyPrefix) {
|
|
23236
|
+
const { Box, Text } = components;
|
|
23237
|
+
const rows = buildSplitDiffRows(unifiedSlice);
|
|
23238
|
+
// Reserve 3 columns of gutter (1 left padding from the Box + 1 column
|
|
23239
|
+
// separator + 1 right padding) so neither side touches the border.
|
|
23240
|
+
const usable = Math.max(20, width - 4);
|
|
23241
|
+
const gutter = 1;
|
|
23242
|
+
const half = Math.max(10, Math.floor((usable - gutter) / 2));
|
|
23243
|
+
return rows.map((row, index) => {
|
|
23244
|
+
const leftProps = splitDiffSideProps(row.left.kind, theme);
|
|
23245
|
+
const rightProps = splitDiffSideProps(row.right.kind, theme);
|
|
23246
|
+
const leftText = formatSplitDiffCell(row.left, half);
|
|
23247
|
+
const rightText = formatSplitDiffCell(row.right, half);
|
|
23248
|
+
return h(Box, {
|
|
23249
|
+
key: `${keyPrefix}-${startOffset + index}`,
|
|
23250
|
+
flexDirection: 'row',
|
|
23251
|
+
}, h(Box, { width: half, flexShrink: 0 }, h(Text, leftProps, leftText)), h(Box, { width: gutter, flexShrink: 0 }, h(Text, { dimColor: true }, ' ')), h(Box, { width: half, flexShrink: 0 }, h(Text, rightProps, rightText)));
|
|
23252
|
+
});
|
|
23253
|
+
}
|
|
21603
23254
|
/**
|
|
21604
23255
|
* Pick a theme color for a single name-status code (`A`, `M`, `D`,
|
|
21605
23256
|
* `R100`, etc.) so the inspector and commit-diff file list render with
|
|
@@ -21653,68 +23304,6 @@ function sidebarTabLabel(tab) {
|
|
|
21653
23304
|
return tab;
|
|
21654
23305
|
}
|
|
21655
23306
|
}
|
|
21656
|
-
function sidebarLines(context, contextStatus, tab, width, state, theme) {
|
|
21657
|
-
if (tab === 'status') {
|
|
21658
|
-
const worktree = context.worktree;
|
|
21659
|
-
if (isLogInkContextKeyLoading(contextStatus, 'worktree')) {
|
|
21660
|
-
return ['Loading status...'];
|
|
21661
|
-
}
|
|
21662
|
-
if (!worktree) {
|
|
21663
|
-
return ['Status unavailable'];
|
|
21664
|
-
}
|
|
21665
|
-
return [
|
|
21666
|
-
`${worktree.stagedCount} staged`,
|
|
21667
|
-
`${worktree.unstagedCount} unstaged`,
|
|
21668
|
-
`${worktree.untrackedCount} untracked`,
|
|
21669
|
-
'',
|
|
21670
|
-
...worktree.files.slice(0, 12).map((file) => `${file.indexStatus}${file.worktreeStatus} ${truncate$1(file.path, width - 3)}`),
|
|
21671
|
-
];
|
|
21672
|
-
}
|
|
21673
|
-
if (tab === 'branches') {
|
|
21674
|
-
const branches = context.branches;
|
|
21675
|
-
if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
|
|
21676
|
-
return ['Loading branches...'];
|
|
21677
|
-
}
|
|
21678
|
-
if (!branches) {
|
|
21679
|
-
return ['Branches unavailable'];
|
|
21680
|
-
}
|
|
21681
|
-
const sortedBranches = sortBranches(branches.localBranches, state.branchSort);
|
|
21682
|
-
return [
|
|
21683
|
-
`Current: ${branches.currentBranch || '<detached>'}`,
|
|
21684
|
-
branches.dirty ? 'Worktree: dirty' : 'Worktree: clean',
|
|
21685
|
-
'',
|
|
21686
|
-
...sortedBranches.slice(0, 8).map((branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${truncate$1(branch.shortName, width - 4)}`),
|
|
21687
|
-
...sortedBranches.slice(0, 4).map((branch) => ` ${truncate$1(formatBranchDivergence(branch, { ascii: theme.ascii }), width - 2)}`),
|
|
21688
|
-
];
|
|
21689
|
-
}
|
|
21690
|
-
if (tab === 'tags') {
|
|
21691
|
-
if (isLogInkContextKeyLoading(contextStatus, 'tags')) {
|
|
21692
|
-
return ['Loading tags...'];
|
|
21693
|
-
}
|
|
21694
|
-
const sortedTags = sortTags(context.tags?.tags || [], state.tagSort);
|
|
21695
|
-
return sortedTags.length
|
|
21696
|
-
? sortedTags.slice(0, 12).map((tag) => `${truncate$1(tag.name, 16)} ${truncate$1(tag.subject, Math.max(8, width - 18))}`)
|
|
21697
|
-
: ['No tags found'];
|
|
21698
|
-
}
|
|
21699
|
-
if (tab === 'stashes') {
|
|
21700
|
-
if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
|
|
21701
|
-
return ['Loading stashes...'];
|
|
21702
|
-
}
|
|
21703
|
-
return context.stashes?.stashes.length
|
|
21704
|
-
? context.stashes.stashes.slice(0, 12).map((stash) => `${stash.ref} ${truncate$1(stash.message, Math.max(8, width - stash.ref.length - 1))}`)
|
|
21705
|
-
: ['No stashes found'];
|
|
21706
|
-
}
|
|
21707
|
-
if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
|
|
21708
|
-
return ['Loading worktrees...'];
|
|
21709
|
-
}
|
|
21710
|
-
return context.worktreeList?.worktrees.length
|
|
21711
|
-
? context.worktreeList.worktrees.slice(0, 12).map((worktree) => {
|
|
21712
|
-
const marker = worktree.current ? '*' : ' ';
|
|
21713
|
-
const state = worktree.dirty ? 'dirty' : 'clean';
|
|
21714
|
-
return `${marker} ${truncate$1(worktree.branch || worktree.path, Math.max(8, width - 8))} ${state}`;
|
|
21715
|
-
})
|
|
21716
|
-
: ['No linked worktrees'];
|
|
21717
|
-
}
|
|
21718
23307
|
async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
21719
23308
|
const input = streams.input || process.stdin;
|
|
21720
23309
|
const output = streams.output || process.stdout;
|
|
@@ -22056,6 +23645,13 @@ function LogInkApp(deps) {
|
|
|
22056
23645
|
if (saved && saved !== state.userSidebarTab) {
|
|
22057
23646
|
dispatch({ type: 'restoreSidebarTab', value: saved });
|
|
22058
23647
|
}
|
|
23648
|
+
// Diff view mode persistence (#785). Same per-repo cache pattern
|
|
23649
|
+
// as the sidebar tab — restore the user's last preference if
|
|
23650
|
+
// they had one. New repos / fresh installs default to unified.
|
|
23651
|
+
const savedDiffMode = getSavedDiffViewMode(repoRoot);
|
|
23652
|
+
if (savedDiffMode && savedDiffMode !== state.diffViewMode) {
|
|
23653
|
+
dispatch({ type: 'setDiffViewMode', value: savedDiffMode });
|
|
23654
|
+
}
|
|
22059
23655
|
}
|
|
22060
23656
|
catch {
|
|
22061
23657
|
// Not in a worktree, or revparse failed; nothing to restore.
|
|
@@ -22069,6 +23665,12 @@ function LogInkApp(deps) {
|
|
|
22069
23665
|
return;
|
|
22070
23666
|
saveSidebarTab(repoRoot, state.userSidebarTab);
|
|
22071
23667
|
}, [state.userSidebarTab]);
|
|
23668
|
+
React.useEffect(() => {
|
|
23669
|
+
const repoRoot = repoRootRef.current;
|
|
23670
|
+
if (!repoRoot)
|
|
23671
|
+
return;
|
|
23672
|
+
saveDiffViewMode(repoRoot, state.diffViewMode);
|
|
23673
|
+
}, [state.diffViewMode]);
|
|
22072
23674
|
// P-stash-explorer: load `git stash show -p <ref>` once the diff view
|
|
22073
23675
|
// becomes active with diffSource='stash'. Best-effort — empty stashes
|
|
22074
23676
|
// or read errors fall through to a "no diff" hint at the render site.
|
|
@@ -22361,6 +23963,27 @@ function LogInkApp(deps) {
|
|
|
22361
23963
|
// disappears. Called from the y-confirm path for delete-branch / delete-
|
|
22362
23964
|
// tag / drop-stash / remove-worktree / abort-operation.
|
|
22363
23965
|
const runWorkflowAction = React.useCallback(async (id, payload) => {
|
|
23966
|
+
// Hunk-apply payload format: `<target>\n<patchText>` — the input
|
|
23967
|
+
// handler synthesizes both pieces (target from the keystroke,
|
|
23968
|
+
// patch text from extractDiffHunk against the live diff lines)
|
|
23969
|
+
// and packs them into the single `payload` field. Splitting on
|
|
23970
|
+
// the first newline keeps the patch body intact.
|
|
23971
|
+
const runApplyHunk = (expectedTarget, raw) => {
|
|
23972
|
+
if (!raw) {
|
|
23973
|
+
return Promise.resolve({ ok: false, message: 'No hunk under cursor to apply.' });
|
|
23974
|
+
}
|
|
23975
|
+
const newlineIndex = raw.indexOf('\n');
|
|
23976
|
+
if (newlineIndex < 0) {
|
|
23977
|
+
return Promise.resolve({ ok: false, message: 'Malformed hunk-apply payload.' });
|
|
23978
|
+
}
|
|
23979
|
+
const target = raw.slice(0, newlineIndex) === 'index' ? 'index' : 'worktree';
|
|
23980
|
+
const patchText = raw.slice(newlineIndex + 1);
|
|
23981
|
+
// The input handler is the source of truth for target — but if a
|
|
23982
|
+
// palette-injected payload mismatches the workflow id, prefer
|
|
23983
|
+
// the workflow id so the user sees the action they asked for.
|
|
23984
|
+
const effectiveTarget = expectedTarget || target;
|
|
23985
|
+
return applyHunkPatch(git, patchText, { target: effectiveTarget });
|
|
23986
|
+
};
|
|
22364
23987
|
const handlers = {
|
|
22365
23988
|
'create-branch': async () => {
|
|
22366
23989
|
const name = payload?.trim();
|
|
@@ -22466,6 +24089,44 @@ function LogInkApp(deps) {
|
|
|
22466
24089
|
message: commit.message,
|
|
22467
24090
|
});
|
|
22468
24091
|
},
|
|
24092
|
+
'revert-commit': async () => {
|
|
24093
|
+
const commit = getSelectedInkCommit(state);
|
|
24094
|
+
if (!commit)
|
|
24095
|
+
return { ok: false, message: 'No commit selected' };
|
|
24096
|
+
return revertCommit(git, {
|
|
24097
|
+
hash: commit.hash,
|
|
24098
|
+
shortHash: commit.shortHash,
|
|
24099
|
+
message: commit.message,
|
|
24100
|
+
});
|
|
24101
|
+
},
|
|
24102
|
+
'reset-to-commit': async () => {
|
|
24103
|
+
const commit = getSelectedInkCommit(state);
|
|
24104
|
+
if (!commit)
|
|
24105
|
+
return { ok: false, message: 'No commit selected' };
|
|
24106
|
+
// Mode arrives via the action's `payload` field — the input
|
|
24107
|
+
// handler runs the reset-mode prompt (kind: 'reset-mode') and
|
|
24108
|
+
// routes the typed value here. Default to `mixed` (git's own
|
|
24109
|
+
// default) when the user submitted an empty value.
|
|
24110
|
+
const raw = payload?.trim().toLowerCase() || 'mixed';
|
|
24111
|
+
if (!isResetMode(raw)) {
|
|
24112
|
+
return { ok: false, message: `Unknown reset mode: ${raw}. Use soft, mixed, or hard.` };
|
|
24113
|
+
}
|
|
24114
|
+
return resetToCommit(git, {
|
|
24115
|
+
hash: commit.hash,
|
|
24116
|
+
shortHash: commit.shortHash,
|
|
24117
|
+
message: commit.message,
|
|
24118
|
+
}, raw);
|
|
24119
|
+
},
|
|
24120
|
+
'interactive-rebase': async () => {
|
|
24121
|
+
const commit = getSelectedInkCommit(state);
|
|
24122
|
+
if (!commit)
|
|
24123
|
+
return { ok: false, message: 'No commit selected' };
|
|
24124
|
+
return startInteractiveRebase(git, {
|
|
24125
|
+
hash: commit.hash,
|
|
24126
|
+
shortHash: commit.shortHash,
|
|
24127
|
+
message: commit.message,
|
|
24128
|
+
});
|
|
24129
|
+
},
|
|
22469
24130
|
'checkout-file-from-commit': async () => {
|
|
22470
24131
|
// payload is "<sha> <path>" so we pass both through a single
|
|
22471
24132
|
// string field on the action.
|
|
@@ -22481,6 +24142,8 @@ function LogInkApp(deps) {
|
|
|
22481
24142
|
return { ok: false, message: 'No commit file under cursor' };
|
|
22482
24143
|
return checkoutFileFromCommit(git, sha, path);
|
|
22483
24144
|
},
|
|
24145
|
+
'apply-hunk-worktree': async () => runApplyHunk('worktree', payload),
|
|
24146
|
+
'apply-hunk-index': async () => runApplyHunk('index', payload),
|
|
22484
24147
|
'remove-worktree': async () => {
|
|
22485
24148
|
const all = context.worktreeList?.worktrees || [];
|
|
22486
24149
|
// Resolve the target from the visible (filtered) list so a
|
|
@@ -22569,6 +24232,32 @@ function LogInkApp(deps) {
|
|
|
22569
24232
|
return { ok: false, message: 'Stash message required' };
|
|
22570
24233
|
return createStash(git, message);
|
|
22571
24234
|
},
|
|
24235
|
+
// #783 — full PR action panel handlers. Each wraps the matching
|
|
24236
|
+
// pullRequestActions verb. Strategy / body arrives via `payload`
|
|
24237
|
+
// — input prompts validate before they reach here, but the
|
|
24238
|
+
// strategy guard stays as a defensive belt-and-suspenders since
|
|
24239
|
+
// a future palette path could call us with a raw value.
|
|
24240
|
+
'merge-pr': async () => {
|
|
24241
|
+
const strategy = (payload || 'merge').toLowerCase();
|
|
24242
|
+
if (!isPullRequestMergeStrategy(strategy)) {
|
|
24243
|
+
return { ok: false, message: `Unknown merge strategy: ${strategy}. Use merge, squash, or rebase.` };
|
|
24244
|
+
}
|
|
24245
|
+
return mergePullRequest(strategy);
|
|
24246
|
+
},
|
|
24247
|
+
'close-pr': async () => closePullRequest(),
|
|
24248
|
+
'approve-pr': async () => approvePullRequest(),
|
|
24249
|
+
'request-changes-pr': async () => {
|
|
24250
|
+
const body = payload?.trim();
|
|
24251
|
+
if (!body)
|
|
24252
|
+
return { ok: false, message: 'Review body required for change-request' };
|
|
24253
|
+
return requestChangesPullRequest(body);
|
|
24254
|
+
},
|
|
24255
|
+
'comment-pr': async () => {
|
|
24256
|
+
const body = payload?.trim();
|
|
24257
|
+
if (!body)
|
|
24258
|
+
return { ok: false, message: 'Comment body required' };
|
|
24259
|
+
return commentPullRequest(body);
|
|
24260
|
+
},
|
|
22572
24261
|
};
|
|
22573
24262
|
const handler = handlers[id];
|
|
22574
24263
|
if (!handler) {
|
|
@@ -22859,6 +24548,51 @@ function LogInkApp(deps) {
|
|
|
22859
24548
|
});
|
|
22860
24549
|
})();
|
|
22861
24550
|
}, [dispatch, git, logArgv, state.historyFetchArgs]);
|
|
24551
|
+
// Graph mode toggle (`g` key, #791 follow-up). The header label flips
|
|
24552
|
+
// between "compact graph" and "full graph", but unless we re-fetch with
|
|
24553
|
+
// the right `view`, the underlying rows still come from the user's
|
|
24554
|
+
// initial argv (default `--first-parent --no-merges`) and the renderer
|
|
24555
|
+
// has no topology to draw — defeating the per-lane / junction work.
|
|
24556
|
+
// Mirrors the historyFetchArgs effect: skip first run, request-id ref
|
|
24557
|
+
// for stale-completion guard, swap rows in place via replaceRows.
|
|
24558
|
+
const toggleGraphEffectInitialized = React.useRef(false);
|
|
24559
|
+
const toggleGraphRequestRef = React.useRef(0);
|
|
24560
|
+
React.useEffect(() => {
|
|
24561
|
+
if (!logArgv)
|
|
24562
|
+
return;
|
|
24563
|
+
if (!toggleGraphEffectInitialized.current) {
|
|
24564
|
+
toggleGraphEffectInitialized.current = true;
|
|
24565
|
+
return;
|
|
24566
|
+
}
|
|
24567
|
+
const requestId = toggleGraphRequestRef.current + 1;
|
|
24568
|
+
toggleGraphRequestRef.current = requestId;
|
|
24569
|
+
const merged = buildToggleGraphArgs(logArgv, state.fullGraph);
|
|
24570
|
+
dispatch({
|
|
24571
|
+
type: 'setStatus',
|
|
24572
|
+
value: state.fullGraph
|
|
24573
|
+
? 'Loading full topology…'
|
|
24574
|
+
: 'Loading compact history…',
|
|
24575
|
+
});
|
|
24576
|
+
void (async () => {
|
|
24577
|
+
const nextRows = await safe(getLogRows(git, merged, { limit: LOG_INTERACTIVE_DEFAULT_LIMIT }));
|
|
24578
|
+
if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
|
|
24579
|
+
return;
|
|
24580
|
+
}
|
|
24581
|
+
if (!nextRows) {
|
|
24582
|
+
dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows' });
|
|
24583
|
+
return;
|
|
24584
|
+
}
|
|
24585
|
+
dispatch({ type: 'replaceRows', rows: nextRows });
|
|
24586
|
+
const matched = getCommitRows(nextRows).length;
|
|
24587
|
+
setHasMoreCommits(matched >= LOG_INTERACTIVE_DEFAULT_LIMIT);
|
|
24588
|
+
dispatch({
|
|
24589
|
+
type: 'setStatus',
|
|
24590
|
+
value: state.fullGraph
|
|
24591
|
+
? `Showing ${matched} commits across all branches`
|
|
24592
|
+
: `Showing ${matched} commits (compact)`,
|
|
24593
|
+
});
|
|
24594
|
+
})();
|
|
24595
|
+
}, [dispatch, git, logArgv, state.fullGraph]);
|
|
22862
24596
|
const commitDiffHunkOffsets = React.useMemo(() => (filePreview?.hunks
|
|
22863
24597
|
.map((line, index) => (line.startsWith('@@') ? index : -1))
|
|
22864
24598
|
.filter((index) => index >= 0)), [filePreview]);
|
|
@@ -22953,6 +24687,17 @@ function LogInkApp(deps) {
|
|
|
22953
24687
|
? selected?.hash
|
|
22954
24688
|
: undefined,
|
|
22955
24689
|
worktreeDirty,
|
|
24690
|
+
// H / gH need the actual diff text (not just hunk offsets) to
|
|
24691
|
+
// slice the cursored hunk into a `git apply` patch. Stash uses
|
|
24692
|
+
// the full `git stash show -p` output; commit-diff uses the
|
|
24693
|
+
// per-file `filePreview.hunks` array. Either way, extractDiffHunk
|
|
24694
|
+
// walks `@@` headers and synthesizes a fresh diff --git / --- /
|
|
24695
|
+
// +++ header set using the path the caller already resolved.
|
|
24696
|
+
diffLinesForHunkApply: state.diffSource === 'stash'
|
|
24697
|
+
? stashDiffLines
|
|
24698
|
+
: state.diffSource === 'commit'
|
|
24699
|
+
? filePreview?.hunks
|
|
24700
|
+
: undefined,
|
|
22956
24701
|
}).forEach((event) => {
|
|
22957
24702
|
if (event.type === 'exit') {
|
|
22958
24703
|
exit();
|
|
@@ -23019,7 +24764,7 @@ function LogInkApp(deps) {
|
|
|
23019
24764
|
if (showOnboarding) {
|
|
23020
24765
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
23021
24766
|
}
|
|
23022
|
-
return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, theme)), renderFooter(h, { Box, Text }, state, theme, idleTip));
|
|
24767
|
+
return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
|
|
23023
24768
|
}
|
|
23024
24769
|
function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
|
|
23025
24770
|
const { Box, Text } = components;
|
|
@@ -23119,11 +24864,101 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
23119
24864
|
if (tab === 'status') {
|
|
23120
24865
|
return renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme);
|
|
23121
24866
|
}
|
|
23122
|
-
|
|
23123
|
-
|
|
23124
|
-
|
|
23125
|
-
|
|
23126
|
-
|
|
24867
|
+
// Branches / tags / stashes / worktrees: render selectable rows so
|
|
24868
|
+
// ↑/↓ navigates within the sidebar list and Enter / per-entity keys
|
|
24869
|
+
// act on the cursored item without needing to drill into the
|
|
24870
|
+
// dedicated view (#791 follow-up — in-sidebar selection).
|
|
24871
|
+
const focused = state.focus === 'sidebar' && state.sidebarTab === tab;
|
|
24872
|
+
if (tab === 'branches') {
|
|
24873
|
+
if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
|
|
24874
|
+
return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
|
|
24875
|
+
}
|
|
24876
|
+
const branches = context.branches;
|
|
24877
|
+
if (!branches) {
|
|
24878
|
+
return [h(Text, { key: 'tab-branches-empty', dimColor: true }, ' Branches unavailable')];
|
|
24879
|
+
}
|
|
24880
|
+
const sortedBranches = sortBranches(branches.localBranches, state.branchSort);
|
|
24881
|
+
const headerRows = [
|
|
24882
|
+
h(Text, { key: 'tab-branches-current', dimColor: true }, truncate$1(` Current: ${branches.currentBranch || '<detached>'}`, width - 4)),
|
|
24883
|
+
h(Text, { key: 'tab-branches-state', dimColor: true }, ` Worktree: ${branches.dirty ? 'dirty' : 'clean'}`),
|
|
24884
|
+
h(Text, { key: 'tab-branches-spacer' }, ''),
|
|
24885
|
+
];
|
|
24886
|
+
return [
|
|
24887
|
+
...headerRows,
|
|
24888
|
+
...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches'),
|
|
24889
|
+
];
|
|
24890
|
+
}
|
|
24891
|
+
if (tab === 'tags') {
|
|
24892
|
+
if (isLogInkContextKeyLoading(contextStatus, 'tags')) {
|
|
24893
|
+
return [h(Text, { key: 'tab-tags-loading', dimColor: true }, ' Loading tags…')];
|
|
24894
|
+
}
|
|
24895
|
+
const tags = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24896
|
+
if (tags.length === 0) {
|
|
24897
|
+
return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
|
|
24898
|
+
}
|
|
24899
|
+
return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags');
|
|
24900
|
+
}
|
|
24901
|
+
if (tab === 'stashes') {
|
|
24902
|
+
if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
|
|
24903
|
+
return [h(Text, { key: 'tab-stashes-loading', dimColor: true }, ' Loading stashes…')];
|
|
24904
|
+
}
|
|
24905
|
+
const stashes = context.stashes?.stashes || [];
|
|
24906
|
+
if (stashes.length === 0) {
|
|
24907
|
+
return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
|
|
24908
|
+
}
|
|
24909
|
+
return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes');
|
|
24910
|
+
}
|
|
24911
|
+
// worktrees
|
|
24912
|
+
if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
|
|
24913
|
+
return [h(Text, { key: 'tab-worktrees-loading', dimColor: true }, ' Loading worktrees…')];
|
|
24914
|
+
}
|
|
24915
|
+
const worktrees = context.worktreeList?.worktrees || [];
|
|
24916
|
+
if (worktrees.length === 0) {
|
|
24917
|
+
return [h(Text, { key: 'tab-worktrees-empty', dimColor: true }, ' No linked worktrees')];
|
|
24918
|
+
}
|
|
24919
|
+
return renderSelectableSidebarRows(h, Text, worktrees, state.selectedWorktreeListIndex, focused, width, theme, (worktree) => {
|
|
24920
|
+
const marker = worktree.current ? '*' : ' ';
|
|
24921
|
+
const wstate = worktree.dirty ? 'dirty' : 'clean';
|
|
24922
|
+
return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
|
|
24923
|
+
}, 'tab-worktrees');
|
|
24924
|
+
}
|
|
24925
|
+
/**
|
|
24926
|
+
* Render a sliding-window list of selectable sidebar rows. The cursor
|
|
24927
|
+
* highlights the row at `selectedIndex` only when `focused` is true so
|
|
24928
|
+
* an unfocused sidebar doesn't compete visually with the active panel.
|
|
24929
|
+
* Sliding window keeps the cursor in view as the user navigates a long
|
|
24930
|
+
* list; truncation hints surface the count of hidden rows.
|
|
24931
|
+
*/
|
|
24932
|
+
function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix) {
|
|
24933
|
+
if (items.length === 0)
|
|
24934
|
+
return [];
|
|
24935
|
+
const window = getSidebarVisibleWindow(items.length, selectedIndex);
|
|
24936
|
+
const elements = [];
|
|
24937
|
+
if (window.truncatedAbove > 0) {
|
|
24938
|
+
elements.push(h(Text, {
|
|
24939
|
+
key: `${keyPrefix}-trunc-above`,
|
|
24940
|
+
dimColor: true,
|
|
24941
|
+
}, truncate$1(` … ${window.truncatedAbove} more above`, width - 4)));
|
|
24942
|
+
}
|
|
24943
|
+
for (let offset = 0; offset < window.size; offset += 1) {
|
|
24944
|
+
const index = window.start + offset;
|
|
24945
|
+
if (index >= items.length)
|
|
24946
|
+
break;
|
|
24947
|
+
const isSelected = focused && index === selectedIndex;
|
|
24948
|
+
const text = toRowText(items[index], index);
|
|
24949
|
+
elements.push(h(Text, {
|
|
24950
|
+
key: `${keyPrefix}-row-${index}`,
|
|
24951
|
+
backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
24952
|
+
inverse: isSelected,
|
|
24953
|
+
}, truncate$1(` ${text}`, width - 4)));
|
|
24954
|
+
}
|
|
24955
|
+
if (window.truncatedBelow > 0) {
|
|
24956
|
+
elements.push(h(Text, {
|
|
24957
|
+
key: `${keyPrefix}-trunc-below`,
|
|
24958
|
+
dimColor: true,
|
|
24959
|
+
}, truncate$1(` … ${window.truncatedBelow} more below`, width - 4)));
|
|
24960
|
+
}
|
|
24961
|
+
return elements;
|
|
23127
24962
|
}
|
|
23128
24963
|
function renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme) {
|
|
23129
24964
|
if (isLogInkContextKeyLoading(contextStatus, 'worktree')) {
|
|
@@ -23181,6 +25016,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
23181
25016
|
if (state.activeView === 'worktrees') {
|
|
23182
25017
|
return renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
23183
25018
|
}
|
|
25019
|
+
if (state.activeView === 'pull-request') {
|
|
25020
|
+
return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
25021
|
+
}
|
|
23184
25022
|
return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
|
|
23185
25023
|
}
|
|
23186
25024
|
function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
|
|
@@ -23230,15 +25068,46 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
23230
25068
|
}))
|
|
23231
25069
|
: visible.items.map((item, index) => {
|
|
23232
25070
|
if (item.type === 'graph') {
|
|
25071
|
+
if (item.laneSegments && !theme.ascii) {
|
|
25072
|
+
return h(Text, { key: `graph-${index}-${item.graph}` }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`));
|
|
25073
|
+
}
|
|
23233
25074
|
return h(Text, {
|
|
23234
25075
|
key: `graph-${index}-${item.graph}`,
|
|
23235
25076
|
color: theme.noColor ? undefined : theme.colors.muted,
|
|
23236
25077
|
dimColor: theme.noColor,
|
|
23237
25078
|
}, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), 140));
|
|
23238
25079
|
}
|
|
23239
|
-
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index);
|
|
25080
|
+
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, item.laneSegments);
|
|
23240
25081
|
}));
|
|
23241
25082
|
}
|
|
25083
|
+
/**
|
|
25084
|
+
* Render `LaneSegment[]` as a flat list of Text spans, one per lane
|
|
25085
|
+
* (#791 stage 2). Each segment paints in its lane's palette color so
|
|
25086
|
+
* the eye can follow a branch column-by-column; segments without a
|
|
25087
|
+
* lane id (spaces, padding, decorations) fall back to the muted graph
|
|
25088
|
+
* color so they visually recede.
|
|
25089
|
+
*
|
|
25090
|
+
* Final padding is appended as its own span so callers do not need to
|
|
25091
|
+
* pre-pad the graph string before computing lane segments.
|
|
25092
|
+
*/
|
|
25093
|
+
function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
25094
|
+
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25095
|
+
const elements = [];
|
|
25096
|
+
let totalLen = 0;
|
|
25097
|
+
segments.forEach((seg, idx) => {
|
|
25098
|
+
const laneColor = getLaneColor(seg.laneId, theme);
|
|
25099
|
+
elements.push(h(Text, {
|
|
25100
|
+
key: `${keyPrefix}-${idx}`,
|
|
25101
|
+
color: laneColor ?? muted,
|
|
25102
|
+
dimColor: theme.noColor && seg.laneId === undefined,
|
|
25103
|
+
}, seg.text));
|
|
25104
|
+
totalLen += seg.text.length;
|
|
25105
|
+
});
|
|
25106
|
+
if (padTo > totalLen) {
|
|
25107
|
+
elements.push(h(Text, { key: `${keyPrefix}-pad` }, ' '.repeat(padTo - totalLen)));
|
|
25108
|
+
}
|
|
25109
|
+
return elements;
|
|
25110
|
+
}
|
|
23242
25111
|
/**
|
|
23243
25112
|
* Render a single commit row with each segment in its own colored span.
|
|
23244
25113
|
* Graph chars render in `theme.colors.muted` so the topology visually
|
|
@@ -23251,8 +25120,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
23251
25120
|
* Truncation is per-segment so the variable-length message field gets
|
|
23252
25121
|
* the leftover budget after fixed segments are accounted for.
|
|
23253
25122
|
*/
|
|
23254
|
-
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index) {
|
|
23255
|
-
const renderedGraph = substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii });
|
|
25123
|
+
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
|
|
23256
25124
|
const refs = formatInkRefLabels(commit.refs);
|
|
23257
25125
|
const totalWidth = 140;
|
|
23258
25126
|
const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
|
|
@@ -23261,11 +25129,17 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
|
|
|
23261
25129
|
const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
|
|
23262
25130
|
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
23263
25131
|
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25132
|
+
// Lane-colored graph spans when full graph mode + non-ASCII rendering
|
|
25133
|
+
// is in play; otherwise fall back to the legacy single-muted span so
|
|
25134
|
+
// compact mode and legacy terminals stay visually unchanged.
|
|
25135
|
+
const graphChildren = laneSegments && !theme.ascii
|
|
25136
|
+
? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `c${index}`)
|
|
25137
|
+
: [h(Text, { color: muted, dimColor: theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
|
|
23264
25138
|
return h(Text, {
|
|
23265
25139
|
key: `${commit.hash}-${index}`,
|
|
23266
25140
|
backgroundColor: selectedBg,
|
|
23267
25141
|
inverse: selected,
|
|
23268
|
-
},
|
|
25142
|
+
}, ...graphChildren, ' ', h(Text, { color: accent, bold: selected }, commit.shortHash), ' ', h(Text, { dimColor: true }, commit.date), ' ', h(Text, undefined, message), refs ? h(Text, { color: accent }, refs) : null);
|
|
23269
25143
|
}
|
|
23270
25144
|
/**
|
|
23271
25145
|
* Render the synthetic "(+) new commit" affordance shown above the real
|
|
@@ -23498,11 +25372,35 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
23498
25372
|
const cursor = isSelected ? '>' : ' ';
|
|
23499
25373
|
const marker = branchRowMarker(branch, { ascii: theme.ascii });
|
|
23500
25374
|
const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
|
|
25375
|
+
const lastTouched = formatBranchLastTouched(branch.date, new Date());
|
|
25376
|
+
// Split the row into spans so the timestamp stays dim even on the
|
|
25377
|
+
// currently-selected (bold) row. The leading marker + name keep
|
|
25378
|
+
// their original column widths; the timestamp is right-padded so
|
|
25379
|
+
// the divergence column stays aligned across rows.
|
|
25380
|
+
const namePadded = branch.shortName.padEnd(28);
|
|
25381
|
+
const timestampPadded = lastTouched.padEnd(8);
|
|
25382
|
+
const lineDim = !isSelected && !branch.current;
|
|
25383
|
+
const head = `${cursor} ${marker} ${namePadded} `;
|
|
25384
|
+
const trailingDivergence = divergence ? ` ${divergence}` : '';
|
|
25385
|
+
// Truncate the assembled line cooperatively so we never overflow
|
|
25386
|
+
// the panel; the timestamp is short and the divergence is the
|
|
25387
|
+
// most expendable, but the existing 140 cap is ample.
|
|
25388
|
+
const fullText = `${head}${timestampPadded}${trailingDivergence}`;
|
|
25389
|
+
const truncated = truncate$1(fullText, 140);
|
|
25390
|
+
// If truncation chopped into the timestamp/divergence portion,
|
|
25391
|
+
// fall back to a single Text to keep the visible width honest.
|
|
25392
|
+
if (truncated !== fullText) {
|
|
25393
|
+
return h(Text, {
|
|
25394
|
+
key: `branch-${index}`,
|
|
25395
|
+
bold: isSelected,
|
|
25396
|
+
dimColor: lineDim,
|
|
25397
|
+
}, truncated);
|
|
25398
|
+
}
|
|
23501
25399
|
return h(Text, {
|
|
23502
25400
|
key: `branch-${index}`,
|
|
23503
25401
|
bold: isSelected,
|
|
23504
|
-
dimColor:
|
|
23505
|
-
},
|
|
25402
|
+
dimColor: lineDim,
|
|
25403
|
+
}, head, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
|
|
23506
25404
|
});
|
|
23507
25405
|
return h(Box, {
|
|
23508
25406
|
borderColor: focusBorderColor(theme, focused),
|
|
@@ -23655,6 +25553,98 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
23655
25553
|
width,
|
|
23656
25554
|
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Worktrees', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
|
|
23657
25555
|
}
|
|
25556
|
+
/**
|
|
25557
|
+
* Pull-request action panel (#783) — renders the current branch's PR
|
|
25558
|
+
* with header, checks table, reviews summary, and a body preview.
|
|
25559
|
+
* Action keys (m / x / a / R / c / O) are wired in inkInput.ts and
|
|
25560
|
+
* surfaced via the footer; this renderer is read-only.
|
|
25561
|
+
*
|
|
25562
|
+
* Three loading / fallback states matter:
|
|
25563
|
+
* - Provider data still loading → "Loading pull request..."
|
|
25564
|
+
* - GitHub remote present but no PR for the current branch → empty
|
|
25565
|
+
* state hint pointing the user at `C` to create one.
|
|
25566
|
+
* - GitHub CLI missing / unauthenticated → unavailable hint.
|
|
25567
|
+
*/
|
|
25568
|
+
function renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
25569
|
+
const { Box, Text } = components;
|
|
25570
|
+
const focused = state.focus === 'commits';
|
|
25571
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequest');
|
|
25572
|
+
const pullRequestOverview = context.pullRequest;
|
|
25573
|
+
// Use the dedicated `pullRequest` overview only — the `provider`
|
|
25574
|
+
// shape carries a slimmer ProviderPullRequestStatus that lacks
|
|
25575
|
+
// url / headRefName / body / mergeable / reviews. The dedicated
|
|
25576
|
+
// overview hits `gh pr view --json` with the full enriched field
|
|
25577
|
+
// list (PULL_REQUEST_VIEW_JSON_FIELDS) so the panel has everything.
|
|
25578
|
+
const pr = pullRequestOverview?.currentPullRequest;
|
|
25579
|
+
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25580
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
25581
|
+
const containerProps = {
|
|
25582
|
+
borderColor: focusBorderColor(theme, focused),
|
|
25583
|
+
borderStyle: theme.borderStyle,
|
|
25584
|
+
flexDirection: 'column',
|
|
25585
|
+
flexShrink: 0,
|
|
25586
|
+
paddingX: 1,
|
|
25587
|
+
width,
|
|
25588
|
+
};
|
|
25589
|
+
if (loading && !pr) {
|
|
25590
|
+
return h(Box, containerProps, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Pull request', focused)), h(Text, { dimColor: true }, 'loading')), h(Text, { dimColor: true }, formatLogInkLoading({ resource: 'pull request' })));
|
|
25591
|
+
}
|
|
25592
|
+
if (!pr) {
|
|
25593
|
+
const hint = pullRequestOverview?.message
|
|
25594
|
+
|| 'No pull request detected for this branch. Press `C` (or `:create-pr`) to create one.';
|
|
25595
|
+
return h(Box, containerProps, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Pull request', focused)), h(Text, { dimColor: true }, 'no PR')), h(Text, { dimColor: true }, truncate$1(hint, width - 4)));
|
|
25596
|
+
}
|
|
25597
|
+
const checks = summarizePullRequestChecks(pr.statusCheckRollup);
|
|
25598
|
+
const reviews = summarizePullRequestReviews(pr.reviews, pr.reviewDecision);
|
|
25599
|
+
const checkRows = buildPullRequestCheckRows(pr.statusCheckRollup, { ascii: theme.ascii });
|
|
25600
|
+
const checkColor = (s) => {
|
|
25601
|
+
if (theme.noColor)
|
|
25602
|
+
return undefined;
|
|
25603
|
+
if (s === 'success')
|
|
25604
|
+
return theme.colors.success;
|
|
25605
|
+
if (s === 'failure')
|
|
25606
|
+
return theme.colors.danger;
|
|
25607
|
+
if (s === 'pending')
|
|
25608
|
+
return theme.colors.warning;
|
|
25609
|
+
return theme.colors.muted;
|
|
25610
|
+
};
|
|
25611
|
+
// Reserve a few rows for the header/section labels; the rest go to
|
|
25612
|
+
// the checks table. Body preview gets the leftover rows so the
|
|
25613
|
+
// surface stays vertically balanced even on tall terminals.
|
|
25614
|
+
const checkBudget = Math.max(3, Math.min(checkRows.length, Math.floor(bodyRows / 2)));
|
|
25615
|
+
const visibleChecks = checkRows.slice(0, checkBudget);
|
|
25616
|
+
const truncatedChecks = checkRows.length - visibleChecks.length;
|
|
25617
|
+
const bodyPreviewBudget = Math.max(2, bodyRows - 8 - visibleChecks.length);
|
|
25618
|
+
const bodyLines = (pr.body || '').split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
25619
|
+
const visibleBodyLines = bodyLines.slice(0, bodyPreviewBudget);
|
|
25620
|
+
const truncatedBodyLines = bodyLines.length - visibleBodyLines.length;
|
|
25621
|
+
const headerRight = `#${pr.number} · ${pr.headRefName} → ${pr.baseRefName}`;
|
|
25622
|
+
const stateLine = formatPullRequestStateLine(pr);
|
|
25623
|
+
const author = pr.author ? `by @${pr.author}` : '';
|
|
25624
|
+
return h(Box, containerProps, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Pull request', focused)), h(Text, { dimColor: true }, headerRight)), h(Text, undefined, truncate$1(pr.title, width - 4)), h(Text, { dimColor: true }, truncate$1(`${stateLine}${author ? ` · ${author}` : ''}`, width - 4)), h(Text, undefined, ''),
|
|
25625
|
+
// Checks section
|
|
25626
|
+
h(Text, { bold: true, color: accent }, 'Checks'), h(Text, { dimColor: true }, truncate$1(` ${formatPullRequestChecksSummary(checks, { ascii: theme.ascii })}`, width - 4)), ...visibleChecks.map((row, index) => h(Text, {
|
|
25627
|
+
key: `pr-check-${index}`,
|
|
25628
|
+
color: checkColor(row.status),
|
|
25629
|
+
}, truncate$1(` ${row.glyph} ${row.name.padEnd(28)} ${row.detail}`, width - 4))), ...(truncatedChecks > 0
|
|
25630
|
+
? [h(Text, { key: 'pr-checks-trunc', dimColor: true }, truncate$1(` … ${truncatedChecks} more`, width - 4))]
|
|
25631
|
+
: []), h(Text, undefined, ''),
|
|
25632
|
+
// Reviews section
|
|
25633
|
+
h(Text, { bold: true, color: accent }, 'Reviews'), h(Text, { dimColor: true }, truncate$1(` ${formatPullRequestReviewsSummary(reviews)}`, width - 4)), h(Text, undefined, ''),
|
|
25634
|
+
// Body preview
|
|
25635
|
+
...(visibleBodyLines.length > 0
|
|
25636
|
+
? [
|
|
25637
|
+
h(Text, { key: 'pr-body-label', bold: true, color: accent }, 'Description'),
|
|
25638
|
+
...visibleBodyLines.map((line, index) => h(Text, {
|
|
25639
|
+
key: `pr-body-${index}`,
|
|
25640
|
+
color: muted,
|
|
25641
|
+
}, truncate$1(` ${line}`, width - 4))),
|
|
25642
|
+
...(truncatedBodyLines > 0
|
|
25643
|
+
? [h(Text, { key: 'pr-body-trunc', dimColor: true }, truncate$1(` … ${truncatedBodyLines} more lines`, width - 4))]
|
|
25644
|
+
: []),
|
|
25645
|
+
]
|
|
25646
|
+
: []));
|
|
25647
|
+
}
|
|
23658
25648
|
/**
|
|
23659
25649
|
* Filter input cursor for the promoted views (branches/tags/stash).
|
|
23660
25650
|
* History already shows the same `filter: foo_` affordance in its header
|
|
@@ -23685,6 +25675,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23685
25675
|
// cherry-picks the file at the cursor.
|
|
23686
25676
|
if (state.diffSource === 'stash') {
|
|
23687
25677
|
const lines = stashDiffLines || [];
|
|
25678
|
+
const splitActive = isSplitDiffViable(state, width);
|
|
25679
|
+
const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
|
|
23688
25680
|
const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
23689
25681
|
const stashFiles = parseStashDiffFiles(lines);
|
|
23690
25682
|
const fileCount = stashFiles.length;
|
|
@@ -23705,11 +25697,19 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23705
25697
|
const currentFileIndex = currentFile
|
|
23706
25698
|
? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
|
|
23707
25699
|
: -1;
|
|
23708
|
-
|
|
23709
|
-
|
|
25700
|
+
// Look up the active stash entry so the panel header can show a
|
|
25701
|
+
// human-identifier instead of the raw `stash@{<iso-date>}` ref.
|
|
25702
|
+
// The git ref is the timestamp form (we fetch with --date=iso for
|
|
25703
|
+
// stable parsing) which reads as noise in the title bar; the
|
|
25704
|
+
// message + branch + index combination is what the user wrote down
|
|
25705
|
+
// when they ran `git stash`. Body still shows the full ref so it
|
|
25706
|
+
// stays unambiguous.
|
|
25707
|
+
const stashIdentity = formatStashHeaderIdentity(state.stashDiffRef, context.stashes?.stashes);
|
|
25708
|
+
const baseHeaderLines = stashDiffLoading
|
|
25709
|
+
? [`Loading diff for ${stashIdentity.subtitle}...`]
|
|
23710
25710
|
: lines.length
|
|
23711
25711
|
? [
|
|
23712
|
-
|
|
25712
|
+
stashIdentity.bodyLine,
|
|
23713
25713
|
fileCount > 0 && currentFile
|
|
23714
25714
|
? `File ${currentFileIndex + 1}/${fileCount}: ${currentFile.path}`
|
|
23715
25715
|
: 'No files in this stash.',
|
|
@@ -23717,6 +25717,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23717
25717
|
'',
|
|
23718
25718
|
]
|
|
23719
25719
|
: ['No diff to display for this stash.'];
|
|
25720
|
+
const headerLines = splitRequestedButTooNarrow
|
|
25721
|
+
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
25722
|
+
: baseHeaderLines;
|
|
25723
|
+
const stashBodyNodes = stashDiffLoading || !lines.length
|
|
25724
|
+
? []
|
|
25725
|
+
: splitActive
|
|
25726
|
+
? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
|
|
25727
|
+
: visibleLines.map((line, index) => h(Text, {
|
|
25728
|
+
key: `stash-diff-line-${state.diffPreviewOffset + index}`,
|
|
25729
|
+
...diffLineProps(line, theme),
|
|
25730
|
+
}, truncate$1(line, width - 4)));
|
|
23720
25731
|
return h(Box, {
|
|
23721
25732
|
borderColor: focusBorderColor(theme, focused),
|
|
23722
25733
|
borderStyle: theme.borderStyle,
|
|
@@ -23724,15 +25735,10 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23724
25735
|
flexShrink: 0,
|
|
23725
25736
|
paddingX: 1,
|
|
23726
25737
|
width,
|
|
23727
|
-
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash diff', focused)), h(Text, { dimColor: true },
|
|
25738
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle(splitActive ? 'Stash diff (split)' : 'Stash diff', focused)), h(Text, { dimColor: true }, stashIdentity.subtitle)), ...headerLines.map((line, index) => h(Text, {
|
|
23728
25739
|
key: `stash-diff-header-${index}`,
|
|
23729
25740
|
dimColor: index > 0,
|
|
23730
|
-
}, truncate$1(line, width - 4))), ...
|
|
23731
|
-
? []
|
|
23732
|
-
: visibleLines.map((line, index) => h(Text, {
|
|
23733
|
-
key: `stash-diff-line-${state.diffPreviewOffset + index}`,
|
|
23734
|
-
...diffLineProps(line, theme),
|
|
23735
|
-
}, truncate$1(line, width - 4)))));
|
|
25741
|
+
}, truncate$1(line, width - 4))), ...stashBodyNodes);
|
|
23736
25742
|
}
|
|
23737
25743
|
// diffSource disambiguates: 'commit' was set when the user opened the
|
|
23738
25744
|
// diff via history → Enter (read-only commit-diff explore), 'worktree'
|
|
@@ -23743,6 +25749,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23743
25749
|
(state.diffSource === undefined && !worktreeFile && Boolean(selectedDetailFile));
|
|
23744
25750
|
if (useCommitDiff) {
|
|
23745
25751
|
const previewHunks = filePreview?.hunks || [];
|
|
25752
|
+
const splitActive = isSplitDiffViable(state, width);
|
|
25753
|
+
const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
|
|
23746
25754
|
const visiblePreviewHunks = previewHunks.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
23747
25755
|
const hunkCount = commitDiffHunkOffsets?.length || 0;
|
|
23748
25756
|
const currentHunkIndex = hunkCount > 0
|
|
@@ -23753,7 +25761,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23753
25761
|
const currentHunkLabel = hunkCount > 0
|
|
23754
25762
|
? `Hunk ${Math.min(hunkCount - currentHunkIndex, hunkCount)}/${hunkCount}`
|
|
23755
25763
|
: 'No hunks for this file.';
|
|
23756
|
-
const
|
|
25764
|
+
const baseHeaderLines = filePreviewLoading
|
|
23757
25765
|
? [`Loading diff for ${selectedDetailFile?.path || 'selected file'}...`]
|
|
23758
25766
|
: previewHunks.length
|
|
23759
25767
|
? [
|
|
@@ -23763,6 +25771,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23763
25771
|
'',
|
|
23764
25772
|
]
|
|
23765
25773
|
: ['No diff preview available for this file.'];
|
|
25774
|
+
const headerLines = splitRequestedButTooNarrow
|
|
25775
|
+
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
25776
|
+
: baseHeaderLines;
|
|
25777
|
+
const commitBodyNodes = filePreviewLoading || !previewHunks.length
|
|
25778
|
+
? []
|
|
25779
|
+
: splitActive
|
|
25780
|
+
? renderSplitDiffBody(h, components, visiblePreviewHunks, state.diffPreviewOffset, width, theme, 'commit-diff-split')
|
|
25781
|
+
: visiblePreviewHunks.map((line, index) => h(Text, {
|
|
25782
|
+
key: `diff-surface-line-${state.diffPreviewOffset + index}`,
|
|
25783
|
+
...diffLineProps(line, theme),
|
|
25784
|
+
}, truncate$1(line, 140)));
|
|
23766
25785
|
return h(Box, {
|
|
23767
25786
|
borderColor: focusBorderColor(theme, focused),
|
|
23768
25787
|
borderStyle: theme.borderStyle,
|
|
@@ -23770,15 +25789,10 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23770
25789
|
flexShrink: 0,
|
|
23771
25790
|
paddingX: 1,
|
|
23772
25791
|
width,
|
|
23773
|
-
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)), h(Text, { dimColor: true }, selectedDetailFile?.path || 'no file')), ...headerLines.map((line, index) => h(Text, {
|
|
25792
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle(splitActive ? 'Diff (split)' : 'Diff', focused)), h(Text, { dimColor: true }, selectedDetailFile?.path || 'no file')), ...headerLines.map((line, index) => h(Text, {
|
|
23774
25793
|
key: `diff-surface-header-${index}`,
|
|
23775
25794
|
dimColor: index > 0,
|
|
23776
|
-
}, truncate$1(line, 140))), ...
|
|
23777
|
-
? []
|
|
23778
|
-
: visiblePreviewHunks.map((line, index) => h(Text, {
|
|
23779
|
-
key: `diff-surface-line-${state.diffPreviewOffset + index}`,
|
|
23780
|
-
...diffLineProps(line, theme),
|
|
23781
|
-
}, truncate$1(line, 140)))));
|
|
25795
|
+
}, truncate$1(line, 140))), ...commitBodyNodes);
|
|
23782
25796
|
}
|
|
23783
25797
|
const diffLines = worktreeDiff?.lines || [];
|
|
23784
25798
|
const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
|
|
@@ -24253,16 +26267,34 @@ function renderInputPromptPanel(h, components, state, width, theme, focused) {
|
|
|
24253
26267
|
if (!prompt) {
|
|
24254
26268
|
return h(Box, { width });
|
|
24255
26269
|
}
|
|
26270
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
26271
|
+
// Multi-line prompts (#806) split on newline and render one Text
|
|
26272
|
+
// row per buffer line — the cursor sits at the end of the last
|
|
26273
|
+
// line via the trailing `_`. Single-line prompts collapse to the
|
|
26274
|
+
// original one-row layout for muscle-memory continuity.
|
|
26275
|
+
const promptLines = prompt.multiline ? prompt.value.split('\n') : [prompt.value];
|
|
26276
|
+
if (promptLines.length === 0) {
|
|
26277
|
+
promptLines.push('');
|
|
26278
|
+
}
|
|
26279
|
+
const valueRows = promptLines.map((line, index) => {
|
|
26280
|
+
const isLast = index === promptLines.length - 1;
|
|
26281
|
+
const display = isLast ? `${line}_` : line;
|
|
26282
|
+
return h(Text, {
|
|
26283
|
+
key: `prompt-line-${index}`,
|
|
26284
|
+
bold: true,
|
|
26285
|
+
color: accent,
|
|
26286
|
+
}, truncate$1(display, width - 4));
|
|
26287
|
+
});
|
|
26288
|
+
const hint = prompt.multiline
|
|
26289
|
+
? 'Enter newline · Ctrl+d submit · Esc cancel · Ctrl+u clear'
|
|
26290
|
+
: 'Enter submit · Esc cancel · Ctrl+u clear';
|
|
24256
26291
|
return h(Box, {
|
|
24257
26292
|
borderColor: focusBorderColor(theme, focused),
|
|
24258
26293
|
borderStyle: theme.borderStyle,
|
|
24259
26294
|
flexDirection: 'column',
|
|
24260
26295
|
width,
|
|
24261
26296
|
paddingX: 1,
|
|
24262
|
-
}, h(Text, { bold: true }, panelTitle('Prompt', focused)), h(Text, { dimColor: true }, truncate$1(prompt.label, width - 4)), h(Text, undefined, ''), h(Text, {
|
|
24263
|
-
bold: true,
|
|
24264
|
-
color: theme.noColor ? undefined : theme.colors.accent,
|
|
24265
|
-
}, truncate$1(`${prompt.value}_`, width - 4)), h(Text, undefined, ''), h(Text, { dimColor: true }, 'Enter to submit · Esc to cancel · Ctrl+u to clear'));
|
|
26297
|
+
}, h(Text, { bold: true }, panelTitle('Prompt', focused)), h(Text, { dimColor: true }, truncate$1(prompt.label, width - 4)), h(Text, undefined, ''), ...valueRows, h(Text, undefined, ''), h(Text, { dimColor: true }, hint));
|
|
24266
26298
|
}
|
|
24267
26299
|
function renderConfirmationPanel(h, components, state, width, theme, focused) {
|
|
24268
26300
|
const { Box, Text } = components;
|
|
@@ -24436,16 +26468,31 @@ function renderCommandPalette(h, components, state, width, theme, focused) {
|
|
|
24436
26468
|
? [h(Text, { key: 'palette-recent-hint', dimColor: true }, '· marks recently-used')]
|
|
24437
26469
|
: []), ...itemLines);
|
|
24438
26470
|
}
|
|
24439
|
-
function renderFooter(h, components, state, theme, idleTip) {
|
|
26471
|
+
function renderFooter(h, components, state, context, theme, idleTip) {
|
|
24440
26472
|
const { Box, Text } = components;
|
|
26473
|
+
// Sidebar item count drives the per-tab footer hints — when items are
|
|
26474
|
+
// present the footer surfaces in-sidebar ops (checkout / apply / pop /
|
|
26475
|
+
// drop), otherwise it falls back to the generic "enter open" hint.
|
|
26476
|
+
const sidebarItemCount = (() => {
|
|
26477
|
+
switch (state.sidebarTab) {
|
|
26478
|
+
case 'branches': return context.branches?.localBranches.length;
|
|
26479
|
+
case 'tags': return context.tags?.tags.length;
|
|
26480
|
+
case 'stashes': return context.stashes?.stashes.length;
|
|
26481
|
+
case 'worktrees': return context.worktreeList?.worktrees.length;
|
|
26482
|
+
default: return undefined;
|
|
26483
|
+
}
|
|
26484
|
+
})();
|
|
24441
26485
|
const hints = getLogInkFooterHints({
|
|
24442
26486
|
activeView: state.activeView,
|
|
24443
26487
|
diffSource: state.diffSource,
|
|
26488
|
+
diffViewMode: state.diffViewMode,
|
|
24444
26489
|
filterMode: state.filterMode,
|
|
24445
26490
|
focus: state.focus,
|
|
24446
26491
|
pendingKey: state.pendingKey,
|
|
24447
26492
|
showCommandPalette: state.showCommandPalette,
|
|
24448
26493
|
showHelp: state.showHelp,
|
|
26494
|
+
sidebarTab: state.sidebarTab,
|
|
26495
|
+
sidebarItemCount,
|
|
24449
26496
|
});
|
|
24450
26497
|
// Real status messages always win; idle tips only fill the slot when it
|
|
24451
26498
|
// would otherwise be empty.
|