git-coco 0.37.0 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.esm.mjs +2848 -354
- package/dist/index.js +2847 -353
- 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.39.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
|
});
|
|
@@ -14464,84 +14772,6 @@ function formatInkRefLabels(refs) {
|
|
|
14464
14772
|
return refs.length ? ` ${refs.map((ref) => `[${ref}]`).join(' ')}` : '';
|
|
14465
14773
|
}
|
|
14466
14774
|
|
|
14467
|
-
function countLabel(count, singular, plural = `${singular}s`) {
|
|
14468
|
-
return `${count} ${count === 1 ? singular : plural}`;
|
|
14469
|
-
}
|
|
14470
|
-
function getLogInkWorkflowSections(context) {
|
|
14471
|
-
const currentBranch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
|
|
14472
|
-
const dirty = context.branches?.dirty ? 'dirty worktree' : 'clean worktree';
|
|
14473
|
-
const loading = context.contextLoading;
|
|
14474
|
-
const currentPullRequest = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
|
|
14475
|
-
const repository = context.provider?.repository;
|
|
14476
|
-
const repoName = repository?.owner && repository.name
|
|
14477
|
-
? `${repository.owner}/${repository.name}`
|
|
14478
|
-
: repository?.message || 'local repository';
|
|
14479
|
-
const operation = context.operation;
|
|
14480
|
-
const worktree = context.worktree;
|
|
14481
|
-
return [
|
|
14482
|
-
{
|
|
14483
|
-
title: 'Branch',
|
|
14484
|
-
lines: [
|
|
14485
|
-
`Current: ${currentBranch}`,
|
|
14486
|
-
`State: ${dirty}`,
|
|
14487
|
-
loading && !context.branches
|
|
14488
|
-
? 'Branch data loading'
|
|
14489
|
-
: context.branches
|
|
14490
|
-
? `${countLabel(context.branches.localBranches.length, 'local branch', 'local branches')} | ${countLabel(context.branches.remoteBranches.length, 'remote branch', 'remote branches')}`
|
|
14491
|
-
: 'Branch data unavailable',
|
|
14492
|
-
],
|
|
14493
|
-
},
|
|
14494
|
-
{
|
|
14495
|
-
title: 'Provider / PR',
|
|
14496
|
-
lines: [
|
|
14497
|
-
`Repository: ${repoName}`,
|
|
14498
|
-
loading && !context.provider && !context.pullRequest
|
|
14499
|
-
? 'Provider and pull request data loading'
|
|
14500
|
-
: currentPullRequest
|
|
14501
|
-
? `PR #${currentPullRequest.number} ${currentPullRequest.state}${currentPullRequest.isDraft ? ' draft' : ''}`
|
|
14502
|
-
: 'No pull request detected for current branch',
|
|
14503
|
-
context.provider?.authenticated === false ? 'Provider auth: offline' : 'Provider auth: available',
|
|
14504
|
-
],
|
|
14505
|
-
},
|
|
14506
|
-
{
|
|
14507
|
-
title: 'Status',
|
|
14508
|
-
lines: loading && !worktree
|
|
14509
|
-
? ['Status data loading']
|
|
14510
|
-
: worktree
|
|
14511
|
-
? [
|
|
14512
|
-
`${countLabel(worktree.stagedCount, 'staged file')}`,
|
|
14513
|
-
`${countLabel(worktree.unstagedCount, 'unstaged file')}`,
|
|
14514
|
-
`${countLabel(worktree.untrackedCount, 'untracked file')}`,
|
|
14515
|
-
]
|
|
14516
|
-
: ['Status data unavailable'],
|
|
14517
|
-
},
|
|
14518
|
-
{
|
|
14519
|
-
title: 'Tags / Stashes / Worktrees',
|
|
14520
|
-
lines: [
|
|
14521
|
-
loading && !context.tags ? 'Tags loading' : context.tags ? countLabel(context.tags.tags.length, 'tag') : 'Tags unavailable',
|
|
14522
|
-
loading && !context.stashes ? 'Stashes loading' : context.stashes ? countLabel(context.stashes.stashes.length, 'stash', 'stashes') : 'Stashes unavailable',
|
|
14523
|
-
context.worktreeList
|
|
14524
|
-
? countLabel(context.worktreeList.worktrees.length, 'worktree')
|
|
14525
|
-
: loading
|
|
14526
|
-
? 'Worktrees loading'
|
|
14527
|
-
: 'Worktrees unavailable',
|
|
14528
|
-
],
|
|
14529
|
-
},
|
|
14530
|
-
{
|
|
14531
|
-
title: 'Operation / AI',
|
|
14532
|
-
lines: [
|
|
14533
|
-
loading && !operation
|
|
14534
|
-
? 'Operation data loading'
|
|
14535
|
-
: operation?.operation
|
|
14536
|
-
? `${operation.operation} in progress with ${countLabel(operation.conflictedFiles.length, 'conflict')}`
|
|
14537
|
-
: 'No merge, rebase, cherry-pick, or revert in progress',
|
|
14538
|
-
context.selectedCommit
|
|
14539
|
-
? `AI actions target ${context.selectedCommit.shortHash}; estimates shown before execution`
|
|
14540
|
-
: 'AI actions require a selected commit',
|
|
14541
|
-
],
|
|
14542
|
-
},
|
|
14543
|
-
];
|
|
14544
|
-
}
|
|
14545
14775
|
function getLogInkWorkflowActions() {
|
|
14546
14776
|
return [
|
|
14547
14777
|
{
|
|
@@ -14595,6 +14825,34 @@ function getLogInkWorkflowActions() {
|
|
|
14595
14825
|
kind: 'destructive',
|
|
14596
14826
|
requiresConfirmation: true,
|
|
14597
14827
|
},
|
|
14828
|
+
{
|
|
14829
|
+
// Per-view-only: scoped to commit-diff and stash-diff explores in
|
|
14830
|
+
// inkInput (key: H). The action is non-destructive in the sense
|
|
14831
|
+
// that `git apply` won't lose any data — `git apply -R` undoes
|
|
14832
|
+
// it cleanly — so it bypasses the y-confirm path. The patch text
|
|
14833
|
+
// travels via the action's `payload` field. Empty key keeps the
|
|
14834
|
+
// workflow palette-discoverable without registering a global
|
|
14835
|
+
// hotkey (the palette path can't synthesize the patch text and
|
|
14836
|
+
// surfaces a hint instead — actual dispatch is from H in diff
|
|
14837
|
+
// view).
|
|
14838
|
+
id: 'apply-hunk-worktree',
|
|
14839
|
+
key: '',
|
|
14840
|
+
label: 'Apply hunk to worktree',
|
|
14841
|
+
description: 'Extract the hunk under the cursor and apply it to the working tree via `git apply`.',
|
|
14842
|
+
kind: 'normal',
|
|
14843
|
+
requiresConfirmation: false,
|
|
14844
|
+
},
|
|
14845
|
+
{
|
|
14846
|
+
// Sibling of `apply-hunk-worktree` — same extraction path, but
|
|
14847
|
+
// `git apply --cached` so the patch lands in the index without
|
|
14848
|
+
// touching the worktree. Bound to the `gH` chord in inkInput.
|
|
14849
|
+
id: 'apply-hunk-index',
|
|
14850
|
+
key: '',
|
|
14851
|
+
label: 'Apply hunk to index',
|
|
14852
|
+
description: 'Extract the hunk under the cursor and apply it to the index via `git apply --cached`.',
|
|
14853
|
+
kind: 'normal',
|
|
14854
|
+
requiresConfirmation: false,
|
|
14855
|
+
},
|
|
14598
14856
|
{
|
|
14599
14857
|
id: 'open-pr',
|
|
14600
14858
|
key: 'O',
|
|
@@ -14687,6 +14945,152 @@ function getLogInkWorkflowActions() {
|
|
|
14687
14945
|
kind: 'destructive',
|
|
14688
14946
|
requiresConfirmation: true,
|
|
14689
14947
|
},
|
|
14948
|
+
// #783 — full PR action panel. All five entries are palette-only
|
|
14949
|
+
// (`key: ''`) — actual dispatch is per-view scoped in inkInput so
|
|
14950
|
+
// the keys stay free outside the pull-request view. Merge / close /
|
|
14951
|
+
// approve / request-changes route through the y-confirm path
|
|
14952
|
+
// because each is irreversible (or near-irreversible) once gh
|
|
14953
|
+
// publishes it; comment is a free-form prompt with no extra
|
|
14954
|
+
// confirmation since the body itself is the affirmative action.
|
|
14955
|
+
{
|
|
14956
|
+
id: 'merge-pr',
|
|
14957
|
+
key: '',
|
|
14958
|
+
label: 'Merge pull request',
|
|
14959
|
+
description: 'Merge the current branch\'s pull request (prompts for merge / squash / rebase, then confirms).',
|
|
14960
|
+
kind: 'destructive',
|
|
14961
|
+
requiresConfirmation: true,
|
|
14962
|
+
},
|
|
14963
|
+
{
|
|
14964
|
+
id: 'close-pr',
|
|
14965
|
+
key: '',
|
|
14966
|
+
label: 'Close pull request',
|
|
14967
|
+
description: 'Close the current pull request without merging.',
|
|
14968
|
+
kind: 'destructive',
|
|
14969
|
+
requiresConfirmation: true,
|
|
14970
|
+
},
|
|
14971
|
+
{
|
|
14972
|
+
id: 'approve-pr',
|
|
14973
|
+
key: '',
|
|
14974
|
+
label: 'Approve pull request',
|
|
14975
|
+
description: 'Submit an approving review on the current pull request.',
|
|
14976
|
+
kind: 'normal',
|
|
14977
|
+
requiresConfirmation: true,
|
|
14978
|
+
},
|
|
14979
|
+
{
|
|
14980
|
+
id: 'request-changes-pr',
|
|
14981
|
+
key: '',
|
|
14982
|
+
label: 'Request changes on pull request',
|
|
14983
|
+
description: 'Submit a change-request review (prompts for the review body, then confirms).',
|
|
14984
|
+
kind: 'normal',
|
|
14985
|
+
requiresConfirmation: true,
|
|
14986
|
+
},
|
|
14987
|
+
{
|
|
14988
|
+
id: 'comment-pr',
|
|
14989
|
+
key: '',
|
|
14990
|
+
label: 'Comment on pull request',
|
|
14991
|
+
description: 'Add a comment to the current pull request (prompts for body).',
|
|
14992
|
+
kind: 'normal',
|
|
14993
|
+
requiresConfirmation: false,
|
|
14994
|
+
},
|
|
14995
|
+
{
|
|
14996
|
+
// Per-view-only: scoped to the history view in inkInput so `R`
|
|
14997
|
+
// doesn't fire elsewhere (it's also `R` for rename in branches
|
|
14998
|
+
// and delete-remote-tag in tags). Empty key keeps it
|
|
14999
|
+
// palette-discoverable without registering a global hotkey.
|
|
15000
|
+
id: 'revert-commit',
|
|
15001
|
+
key: '',
|
|
15002
|
+
label: 'Revert commit',
|
|
15003
|
+
description: 'Revert the cursored commit by adding an inverse commit on top of HEAD.',
|
|
15004
|
+
kind: 'destructive',
|
|
15005
|
+
requiresConfirmation: true,
|
|
15006
|
+
},
|
|
15007
|
+
{
|
|
15008
|
+
// Per-view-only: scoped to the history view in inkInput. Triggers
|
|
15009
|
+
// a mode prompt (soft / mixed / hard) before the reset runs so
|
|
15010
|
+
// `Z` alone never silently rewrites history.
|
|
15011
|
+
id: 'reset-to-commit',
|
|
15012
|
+
key: '',
|
|
15013
|
+
label: 'Reset to commit',
|
|
15014
|
+
description: 'Move the current branch tip to the cursored commit (prompts for soft / mixed / hard).',
|
|
15015
|
+
kind: 'destructive',
|
|
15016
|
+
requiresConfirmation: true,
|
|
15017
|
+
},
|
|
15018
|
+
{
|
|
15019
|
+
// Per-view-only: scoped to the history view in inkInput (key `B`).
|
|
15020
|
+
// The prompt itself is the affirmative gate — the user has to
|
|
15021
|
+
// type a branch name before anything happens — so this skips the
|
|
15022
|
+
// y-confirm path. Empty key keeps it palette-discoverable; the
|
|
15023
|
+
// palette path can't synthesize a branch name and surfaces a
|
|
15024
|
+
// hint instead.
|
|
15025
|
+
//
|
|
15026
|
+
// Distinct from `create-branch` (palette / `+` on branches view),
|
|
15027
|
+
// which uses `git switch -c` and switches onto the new branch.
|
|
15028
|
+
// This workflow uses `git branch <name> <sha>` and stays put —
|
|
15029
|
+
// GitKraken's "create branch here" semantic.
|
|
15030
|
+
id: 'create-branch-here',
|
|
15031
|
+
key: '',
|
|
15032
|
+
label: 'Create branch from commit',
|
|
15033
|
+
description: 'Create a branch pointed at the cursored commit (does not switch).',
|
|
15034
|
+
kind: 'normal',
|
|
15035
|
+
requiresConfirmation: false,
|
|
15036
|
+
},
|
|
15037
|
+
{
|
|
15038
|
+
// Per-view-only: scoped to the history view in inkInput via the
|
|
15039
|
+
// `gT` chord (bare `T` is taken by delete-tag on the tags view).
|
|
15040
|
+
// Same prompt-as-confirmation pattern as create-branch-here.
|
|
15041
|
+
// Lightweight tag — annotated tags remain available through the
|
|
15042
|
+
// existing `+` flow on the tags view.
|
|
15043
|
+
id: 'create-tag-here',
|
|
15044
|
+
key: '',
|
|
15045
|
+
label: 'Create tag at commit',
|
|
15046
|
+
description: 'Create a lightweight tag at the cursored commit.',
|
|
15047
|
+
kind: 'normal',
|
|
15048
|
+
requiresConfirmation: false,
|
|
15049
|
+
},
|
|
15050
|
+
{
|
|
15051
|
+
// Per-view-only: scoped to the history view in inkInput. `i`
|
|
15052
|
+
// (lowercase) is used instead of `I` so the existing `I`
|
|
15053
|
+
// ai-commit-summary workflow stays reachable on the history
|
|
15054
|
+
// view — `i` matches the `git rebase -i` flag mnemonic anyway.
|
|
15055
|
+
id: 'interactive-rebase',
|
|
15056
|
+
key: '',
|
|
15057
|
+
label: 'Interactive rebase',
|
|
15058
|
+
description: 'Start an interactive rebase from the cursored commit (opens $GIT_EDITOR for the todo list).',
|
|
15059
|
+
kind: 'destructive',
|
|
15060
|
+
requiresConfirmation: true,
|
|
15061
|
+
},
|
|
15062
|
+
{
|
|
15063
|
+
// Per-view-only: scoped to the history view in inkInput (key `B`).
|
|
15064
|
+
// The prompt itself is the affirmative gate — the user has to
|
|
15065
|
+
// type a branch name before anything happens — so this skips the
|
|
15066
|
+
// y-confirm path. Empty key keeps it palette-discoverable; the
|
|
15067
|
+
// palette path can't synthesize a branch name and surfaces a
|
|
15068
|
+
// hint instead.
|
|
15069
|
+
//
|
|
15070
|
+
// Distinct from `create-branch` (palette / `+` on branches view),
|
|
15071
|
+
// which uses `git switch -c` and switches onto the new branch.
|
|
15072
|
+
// This workflow uses `git branch <name> <sha>` and stays put —
|
|
15073
|
+
// GitKraken's "create branch here" semantic.
|
|
15074
|
+
id: 'create-branch-here',
|
|
15075
|
+
key: '',
|
|
15076
|
+
label: 'Create branch from commit',
|
|
15077
|
+
description: 'Create a branch pointed at the cursored commit (does not switch).',
|
|
15078
|
+
kind: 'normal',
|
|
15079
|
+
requiresConfirmation: false,
|
|
15080
|
+
},
|
|
15081
|
+
{
|
|
15082
|
+
// Per-view-only: scoped to the history view in inkInput via the
|
|
15083
|
+
// `gT` chord (bare `T` is taken by delete-tag on the tags view).
|
|
15084
|
+
// Same prompt-as-confirmation pattern as create-branch-here.
|
|
15085
|
+
// Lightweight tag — annotated tags remain available through the
|
|
15086
|
+
// existing `+` flow on the tags view.
|
|
15087
|
+
id: 'create-tag-here',
|
|
15088
|
+
key: '',
|
|
15089
|
+
label: 'Create tag at commit',
|
|
15090
|
+
description: 'Create a lightweight tag at the cursored commit.',
|
|
15091
|
+
kind: 'normal',
|
|
15092
|
+
requiresConfirmation: false,
|
|
15093
|
+
},
|
|
14690
15094
|
{
|
|
14691
15095
|
id: 'ai-commit-summary',
|
|
14692
15096
|
key: 'I',
|
|
@@ -14708,6 +15112,15 @@ function getLogInkWorkflowActions() {
|
|
|
14708
15112
|
];
|
|
14709
15113
|
}
|
|
14710
15114
|
function getLogInkWorkflowActionByKey(inputValue) {
|
|
15115
|
+
// Workflow actions with an empty `key` are palette-only — they
|
|
15116
|
+
// exist so the command palette can surface them but should never
|
|
15117
|
+
// match a raw keystroke. Without this guard, any unbound key
|
|
15118
|
+
// (left/right arrow, function keys) that arrives with an empty
|
|
15119
|
+
// inputValue would `find()` the first empty-key entry —
|
|
15120
|
+
// `cherry-pick-commit` — and pop its confirmation dialog.
|
|
15121
|
+
if (!inputValue) {
|
|
15122
|
+
return undefined;
|
|
15123
|
+
}
|
|
14711
15124
|
return getLogInkWorkflowActions().find((action) => action.key === inputValue);
|
|
14712
15125
|
}
|
|
14713
15126
|
function getLogInkWorkflowActionById(id) {
|
|
@@ -14837,6 +15250,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
14837
15250
|
description: 'Toggle compact and full graph display.',
|
|
14838
15251
|
contexts: ['normal', 'commits'],
|
|
14839
15252
|
},
|
|
15253
|
+
{
|
|
15254
|
+
id: 'toggleDiffViewMode',
|
|
15255
|
+
keys: ['d'],
|
|
15256
|
+
label: 'split/unified',
|
|
15257
|
+
description: 'Toggle the diff view between unified and side-by-side split rendering. Falls back to unified on narrow terminals.',
|
|
15258
|
+
contexts: ['commits'],
|
|
15259
|
+
},
|
|
14840
15260
|
{
|
|
14841
15261
|
id: 'navigateHome',
|
|
14842
15262
|
keys: ['gh'],
|
|
@@ -14893,6 +15313,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
14893
15313
|
description: 'Push the linked worktrees view.',
|
|
14894
15314
|
contexts: ['normal'],
|
|
14895
15315
|
},
|
|
15316
|
+
{
|
|
15317
|
+
id: 'navigatePullRequest',
|
|
15318
|
+
keys: ['gp'],
|
|
15319
|
+
label: 'pull request',
|
|
15320
|
+
description: 'Push the dedicated pull-request action panel for the current branch.',
|
|
15321
|
+
contexts: ['normal'],
|
|
15322
|
+
},
|
|
14896
15323
|
{
|
|
14897
15324
|
id: 'navigateBack',
|
|
14898
15325
|
keys: ['<', 'esc'],
|
|
@@ -15021,6 +15448,7 @@ const GLOBAL_BINDING_IDS = [
|
|
|
15021
15448
|
'navigateTags',
|
|
15022
15449
|
'navigateStash',
|
|
15023
15450
|
'navigateWorktrees',
|
|
15451
|
+
'navigatePullRequest',
|
|
15024
15452
|
'navigateBack',
|
|
15025
15453
|
];
|
|
15026
15454
|
const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
|
|
@@ -15081,8 +15509,37 @@ function getLogInkFooterHints(options) {
|
|
|
15081
15509
|
};
|
|
15082
15510
|
}
|
|
15083
15511
|
if (options.focus === 'sidebar') {
|
|
15512
|
+
// Per-tab hints when the active tab has selectable items — the user
|
|
15513
|
+
// can act on the cursored entity without leaving the workstation
|
|
15514
|
+
// view. Status tab + empty content tabs fall back to the generic
|
|
15515
|
+
// "enter open" hint that drills into the dedicated view.
|
|
15516
|
+
const itemsPresent = (options.sidebarItemCount ?? 0) > 0;
|
|
15517
|
+
if (itemsPresent && options.sidebarTab === 'branches') {
|
|
15518
|
+
return {
|
|
15519
|
+
contextual: ['↑/↓ branches', '←/→ tab', 'enter checkout', 'D delete', 'R rename', 'u upstream'],
|
|
15520
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15521
|
+
};
|
|
15522
|
+
}
|
|
15523
|
+
if (itemsPresent && options.sidebarTab === 'stashes') {
|
|
15524
|
+
return {
|
|
15525
|
+
contextual: ['↑/↓ stashes', '←/→ tab', 'enter diff', 'a apply', 'p pop', 'X drop'],
|
|
15526
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15527
|
+
};
|
|
15528
|
+
}
|
|
15529
|
+
if (itemsPresent && options.sidebarTab === 'tags') {
|
|
15530
|
+
return {
|
|
15531
|
+
contextual: ['↑/↓ tags', '←/→ tab', '+ new', 'P push', 'T delete'],
|
|
15532
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15533
|
+
};
|
|
15534
|
+
}
|
|
15535
|
+
if (itemsPresent && options.sidebarTab === 'worktrees') {
|
|
15536
|
+
return {
|
|
15537
|
+
contextual: ['↑/↓ worktrees', '←/→ tab', 'W remove'],
|
|
15538
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15539
|
+
};
|
|
15540
|
+
}
|
|
15084
15541
|
return {
|
|
15085
|
-
contextual: ['
|
|
15542
|
+
contextual: ['←/→ tab', '1-5 jump', 'enter open', 'tab focus'],
|
|
15086
15543
|
global: NORMAL_GLOBAL_HINTS,
|
|
15087
15544
|
};
|
|
15088
15545
|
}
|
|
@@ -15099,17 +15556,23 @@ function getLogInkFooterHints(options) {
|
|
|
15099
15556
|
};
|
|
15100
15557
|
}
|
|
15101
15558
|
if (options.activeView === 'diff') {
|
|
15559
|
+
// Surface what `d` will switch *to* — labels the next mode rather
|
|
15560
|
+
// than the current one so the hint reads as a verb. The split-mode
|
|
15561
|
+
// hint is only shown for the read-only diff sources (commit/stash);
|
|
15562
|
+
// the worktree diff stays unified-only for now.
|
|
15563
|
+
const splitToggleHint = options.diffViewMode === 'split' ? 'd unified' : 'd split';
|
|
15102
15564
|
if (options.diffSource === 'stash') {
|
|
15103
15565
|
return {
|
|
15104
|
-
contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'o edit', 'y yank', 'esc back'],
|
|
15566
|
+
contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', 'o edit', splitToggleHint, 'y yank', 'esc back'],
|
|
15105
15567
|
global: NORMAL_GLOBAL_HINTS,
|
|
15106
15568
|
};
|
|
15107
15569
|
}
|
|
15108
15570
|
if (options.diffSource === 'commit') {
|
|
15109
15571
|
// Commit-diff explore: read-only diff, but `c` cherry-picks the
|
|
15110
|
-
// cursored file from the commit into the worktree
|
|
15572
|
+
// cursored file from the commit into the worktree, and `H`
|
|
15573
|
+
// (or `gH` for index) applies just the cursored hunk.
|
|
15111
15574
|
return {
|
|
15112
|
-
contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'y/Y yank', 'esc back'],
|
|
15575
|
+
contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'y/Y yank', 'esc back'],
|
|
15113
15576
|
global: NORMAL_GLOBAL_HINTS,
|
|
15114
15577
|
};
|
|
15115
15578
|
}
|
|
@@ -15148,8 +15611,26 @@ function getLogInkFooterHints(options) {
|
|
|
15148
15611
|
global: NORMAL_GLOBAL_HINTS,
|
|
15149
15612
|
};
|
|
15150
15613
|
}
|
|
15614
|
+
if (options.activeView === 'pull-request') {
|
|
15615
|
+
return {
|
|
15616
|
+
// #783 — full PR action panel. Five mutating ops scoped to this
|
|
15617
|
+
// view: m / x / a / R / c, plus O for open-in-browser (already
|
|
15618
|
+
// a global). Each routes through y-confirm or an input prompt;
|
|
15619
|
+
// none fire silently.
|
|
15620
|
+
contextual: ['m merge', 'x close', 'a approve', 'R changes', 'c comment', 'O open', 'esc back'],
|
|
15621
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15622
|
+
};
|
|
15623
|
+
}
|
|
15151
15624
|
return {
|
|
15152
|
-
|
|
15625
|
+
// History view default hints. Mutating ops (`c` cherry-pick, `R`
|
|
15626
|
+
// revert, `Z` reset, `i` interactive-rebase) all route through a
|
|
15627
|
+
// y-confirm or mode prompt — none fire silently from the keystroke.
|
|
15628
|
+
// `B` create-branch-here and `gT` create-tag-here use a prompt as
|
|
15629
|
+
// the affirmative gate (typing the name is the confirmation).
|
|
15630
|
+
// Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
|
|
15631
|
+
// the footer stays scannable; full descriptions live in `?` help
|
|
15632
|
+
// and the palette.
|
|
15633
|
+
contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'y/Y yank', '/ search', 'gg/G top/bottom'],
|
|
15153
15634
|
global: NORMAL_GLOBAL_HINTS,
|
|
15154
15635
|
};
|
|
15155
15636
|
}
|
|
@@ -15292,39 +15773,6 @@ function filterLogInkPaletteCommands(commands, filter, recent) {
|
|
|
15292
15773
|
.map((entry) => entry.command);
|
|
15293
15774
|
}
|
|
15294
15775
|
|
|
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
15776
|
/**
|
|
15329
15777
|
* OSC 8 hyperlink helpers for the Ink TUI (P5.1).
|
|
15330
15778
|
*
|
|
@@ -15396,6 +15844,84 @@ function formatHyperlink(text, url, env = process.env) {
|
|
|
15396
15844
|
return `${OSC_PREFIX}${url}${ST}${text}${OSC_PREFIX}${ST}`;
|
|
15397
15845
|
}
|
|
15398
15846
|
|
|
15847
|
+
/**
|
|
15848
|
+
* Extract a single hunk from a unified-patch diff so it can be fed to
|
|
15849
|
+
* `git apply` (or `git apply --cached`) for a hunk-level cherry-pick.
|
|
15850
|
+
*
|
|
15851
|
+
* The TUI's diff explore views render two flavors of patch text:
|
|
15852
|
+
*
|
|
15853
|
+
* - stash-diff: full `git stash show -p` output, which includes
|
|
15854
|
+
* `diff --git`, `---`, `+++`, and one or more `@@ ... @@` hunks
|
|
15855
|
+
* per file.
|
|
15856
|
+
* - commit-diff: the per-file `filePreview.hunks` array, which is
|
|
15857
|
+
* hunks-only (no `diff --git` / `---` / `+++` headers).
|
|
15858
|
+
*
|
|
15859
|
+
* Either way, this helper walks `lines` from `cursorOffset` backwards
|
|
15860
|
+
* to find the most recent `@@` header, walks forward to the end of
|
|
15861
|
+
* that hunk's body, and synthesizes a fresh `diff --git` /
|
|
15862
|
+
* `---` / `+++` set using the caller-provided path. The output is a
|
|
15863
|
+
* complete, self-contained patch suitable for `git apply` without
|
|
15864
|
+
* having to preserve original headers from `lines`.
|
|
15865
|
+
*/
|
|
15866
|
+
const HUNK_HEADER_PREFIX = '@@';
|
|
15867
|
+
const DIFF_GIT_PREFIX = 'diff --git ';
|
|
15868
|
+
/**
|
|
15869
|
+
* Find the index of the `@@` hunk header at or before `cursorOffset`.
|
|
15870
|
+
* Returns -1 when the cursor sits before the first hunk in the patch
|
|
15871
|
+
* (i.e. on a `diff --git` / `---` / `+++` header line) — caller treats
|
|
15872
|
+
* that as "no hunk at cursor" and surfaces a status message.
|
|
15873
|
+
*/
|
|
15874
|
+
function findHunkHeaderAtOrBefore(lines, cursorOffset) {
|
|
15875
|
+
const start = Math.min(cursorOffset, lines.length - 1);
|
|
15876
|
+
for (let i = start; i >= 0; i -= 1) {
|
|
15877
|
+
if (lines[i]?.startsWith(HUNK_HEADER_PREFIX)) {
|
|
15878
|
+
return i;
|
|
15879
|
+
}
|
|
15880
|
+
}
|
|
15881
|
+
return -1;
|
|
15882
|
+
}
|
|
15883
|
+
/**
|
|
15884
|
+
* Walk forward from a hunk header to either the next `@@` header or
|
|
15885
|
+
* the next `diff --git` line — that's where this hunk's body ends.
|
|
15886
|
+
* The end index is exclusive (the line at `endIndex` is NOT part of
|
|
15887
|
+
* this hunk).
|
|
15888
|
+
*/
|
|
15889
|
+
function findHunkBodyEnd(lines, headerIndex) {
|
|
15890
|
+
for (let i = headerIndex + 1; i < lines.length; i += 1) {
|
|
15891
|
+
const line = lines[i];
|
|
15892
|
+
if (line?.startsWith(HUNK_HEADER_PREFIX) || line?.startsWith(DIFF_GIT_PREFIX)) {
|
|
15893
|
+
return i;
|
|
15894
|
+
}
|
|
15895
|
+
}
|
|
15896
|
+
return lines.length;
|
|
15897
|
+
}
|
|
15898
|
+
function extractDiffHunk(input) {
|
|
15899
|
+
const { lines, cursorOffset, path } = input;
|
|
15900
|
+
if (!lines.length || !path) {
|
|
15901
|
+
return null;
|
|
15902
|
+
}
|
|
15903
|
+
const headerIndex = findHunkHeaderAtOrBefore(lines, cursorOffset);
|
|
15904
|
+
if (headerIndex < 0) {
|
|
15905
|
+
return null;
|
|
15906
|
+
}
|
|
15907
|
+
const bodyEnd = findHunkBodyEnd(lines, headerIndex);
|
|
15908
|
+
// Header itself + at least one body line. An empty hunk body would
|
|
15909
|
+
// mean the patch is malformed and `git apply` would reject it; bail
|
|
15910
|
+
// out early so the caller can surface a clear status message.
|
|
15911
|
+
if (bodyEnd <= headerIndex + 1) {
|
|
15912
|
+
return null;
|
|
15913
|
+
}
|
|
15914
|
+
const hunkLines = lines.slice(headerIndex, bodyEnd);
|
|
15915
|
+
const patchText = [
|
|
15916
|
+
`diff --git a/${path} b/${path}`,
|
|
15917
|
+
`--- a/${path}`,
|
|
15918
|
+
`+++ b/${path}`,
|
|
15919
|
+
...hunkLines,
|
|
15920
|
+
'',
|
|
15921
|
+
].join('\n');
|
|
15922
|
+
return { patchText };
|
|
15923
|
+
}
|
|
15924
|
+
|
|
15399
15925
|
/**
|
|
15400
15926
|
* Sort modes for the promoted views (P4.2).
|
|
15401
15927
|
*
|
|
@@ -15415,23 +15941,31 @@ function cycleBranchSort(mode) {
|
|
|
15415
15941
|
return BRANCH_SORT_MODES[(index + 1) % BRANCH_SORT_MODES.length];
|
|
15416
15942
|
}
|
|
15417
15943
|
function sortBranches(branches, mode) {
|
|
15418
|
-
|
|
15944
|
+
// Pin the current branch at index 0 regardless of sort mode (#806
|
|
15945
|
+
// follow-up). Lands the user's cursor on the active branch by
|
|
15946
|
+
// default and keeps the most-relevant row glued to the top of the
|
|
15947
|
+
// list as they cycle sorts.
|
|
15948
|
+
const current = branches.find((entry) => entry.current);
|
|
15949
|
+
const rest = branches.filter((entry) => !entry.current);
|
|
15950
|
+
const sortedRest = rest.slice();
|
|
15419
15951
|
switch (mode) {
|
|
15420
15952
|
case 'name':
|
|
15421
|
-
|
|
15953
|
+
sortedRest.sort((a, b) => a.shortName.localeCompare(b.shortName));
|
|
15954
|
+
break;
|
|
15422
15955
|
case 'recent':
|
|
15423
15956
|
// ISO-shaped dates compare byte-for-byte; descending so the freshest
|
|
15424
15957
|
// branch sits at the top.
|
|
15425
|
-
|
|
15958
|
+
sortedRest.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
|
|
15426
15959
|
a.shortName.localeCompare(b.shortName));
|
|
15960
|
+
break;
|
|
15427
15961
|
case 'ahead':
|
|
15428
15962
|
// ahead-first; ties broken by behind, then by name. Keeps "this branch
|
|
15429
15963
|
// has unmerged work" in the user's first scroll.
|
|
15430
|
-
|
|
15964
|
+
sortedRest.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
|
|
15431
15965
|
a.shortName.localeCompare(b.shortName));
|
|
15432
|
-
|
|
15433
|
-
return copy;
|
|
15966
|
+
break;
|
|
15434
15967
|
}
|
|
15968
|
+
return current ? [current, ...sortedRest] : sortedRest;
|
|
15435
15969
|
}
|
|
15436
15970
|
const TAG_SORT_MODES = ['recent', 'name'];
|
|
15437
15971
|
const DEFAULT_TAG_SORT_MODE = 'recent';
|
|
@@ -15765,6 +16299,8 @@ function createLogInkState(rows, options = {}) {
|
|
|
15765
16299
|
sidebarTab: 'status',
|
|
15766
16300
|
userSidebarTab: 'status',
|
|
15767
16301
|
statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
|
|
16302
|
+
diffViewMode: 'unified',
|
|
16303
|
+
inspectorTab: 'inspector',
|
|
15768
16304
|
};
|
|
15769
16305
|
}
|
|
15770
16306
|
function getSelectedInkCommit(state) {
|
|
@@ -15822,6 +16358,28 @@ function applyLogInkAction(state, action) {
|
|
|
15822
16358
|
pendingCommitFocused: false,
|
|
15823
16359
|
pendingKey: undefined,
|
|
15824
16360
|
};
|
|
16361
|
+
case 'selectCommitByHash': {
|
|
16362
|
+
// Locates a commit by its full or short hash within the active
|
|
16363
|
+
// filtered list and snaps the cursor to it. Used by the
|
|
16364
|
+
// branch/tag auto-jump effect (#806 follow-up): cursoring a
|
|
16365
|
+
// branch in the sidebar tracks the history view to that
|
|
16366
|
+
// branch's tip without the user manually scrolling. No-op when
|
|
16367
|
+
// the hash isn't in the loaded list (the runtime surfaces a
|
|
16368
|
+
// status hint in that case).
|
|
16369
|
+
const target = action.hash;
|
|
16370
|
+
const index = state.filteredCommits.findIndex((commit) => commit.hash === target || commit.shortHash === target);
|
|
16371
|
+
if (index < 0) {
|
|
16372
|
+
return state;
|
|
16373
|
+
}
|
|
16374
|
+
return {
|
|
16375
|
+
...state,
|
|
16376
|
+
selectedIndex: index,
|
|
16377
|
+
selectedFileIndex: 0,
|
|
16378
|
+
diffPreviewOffset: 0,
|
|
16379
|
+
pendingCommitFocused: false,
|
|
16380
|
+
pendingKey: undefined,
|
|
16381
|
+
};
|
|
16382
|
+
}
|
|
15825
16383
|
case 'focusPendingCommit':
|
|
15826
16384
|
return {
|
|
15827
16385
|
...state,
|
|
@@ -15861,6 +16419,34 @@ function applyLogInkAction(state, action) {
|
|
|
15861
16419
|
selectedBranchIndex: clampIndex$1(state.selectedBranchIndex + action.delta, action.count),
|
|
15862
16420
|
pendingKey: undefined,
|
|
15863
16421
|
};
|
|
16422
|
+
case 'resetBranchSelection':
|
|
16423
|
+
// Snap the branches sidebar / view cursor back to position 0.
|
|
16424
|
+
// Used after a successful checkout (#806 follow-up): combined
|
|
16425
|
+
// with the "current branch pinned at top" rule from #809, this
|
|
16426
|
+
// lands the user's cursor on the just-checked-out branch.
|
|
16427
|
+
return {
|
|
16428
|
+
...state,
|
|
16429
|
+
selectedBranchIndex: 0,
|
|
16430
|
+
pendingKey: undefined,
|
|
16431
|
+
};
|
|
16432
|
+
case 'setInspectorTab':
|
|
16433
|
+
return {
|
|
16434
|
+
...state,
|
|
16435
|
+
inspectorTab: action.value,
|
|
16436
|
+
pendingKey: undefined,
|
|
16437
|
+
};
|
|
16438
|
+
case 'cycleInspectorTab': {
|
|
16439
|
+
// Two-tab toggle — `delta` is symmetrical so direction does not
|
|
16440
|
+
// matter, but we keep the action shape consistent with the
|
|
16441
|
+
// sidebar's `nextSidebarTab` / `previousSidebarTab` so callers
|
|
16442
|
+
// can mirror the sidebar pattern verbatim.
|
|
16443
|
+
const next = state.inspectorTab === 'inspector' ? 'actions' : 'inspector';
|
|
16444
|
+
return {
|
|
16445
|
+
...state,
|
|
16446
|
+
inspectorTab: next,
|
|
16447
|
+
pendingKey: undefined,
|
|
16448
|
+
};
|
|
16449
|
+
}
|
|
15864
16450
|
case 'moveTag':
|
|
15865
16451
|
return {
|
|
15866
16452
|
...state,
|
|
@@ -15902,6 +16488,7 @@ function applyLogInkAction(state, action) {
|
|
|
15902
16488
|
kind: action.kind,
|
|
15903
16489
|
label: action.label,
|
|
15904
16490
|
value: action.initial || '',
|
|
16491
|
+
multiline: action.multiline,
|
|
15905
16492
|
},
|
|
15906
16493
|
pendingKey: undefined,
|
|
15907
16494
|
};
|
|
@@ -15934,6 +16521,27 @@ function applyLogInkAction(state, action) {
|
|
|
15934
16521
|
}
|
|
15935
16522
|
case 'setHistoryFetchArgs':
|
|
15936
16523
|
return { ...state, historyFetchArgs: action.value, pendingKey: undefined };
|
|
16524
|
+
case 'toggleDiffViewMode':
|
|
16525
|
+
// Reset the scroll offsets so the new mode opens at the top — long
|
|
16526
|
+
// lines wrap differently in split mode (the renderer truncates per
|
|
16527
|
+
// column instead of per row), so the saved offset can land on a
|
|
16528
|
+
// different visual line. Snap to the top is simpler than mapping
|
|
16529
|
+
// unified offsets to split offsets.
|
|
16530
|
+
return {
|
|
16531
|
+
...state,
|
|
16532
|
+
diffViewMode: state.diffViewMode === 'unified' ? 'split' : 'unified',
|
|
16533
|
+
diffPreviewOffset: 0,
|
|
16534
|
+
worktreeDiffOffset: 0,
|
|
16535
|
+
pendingKey: undefined,
|
|
16536
|
+
};
|
|
16537
|
+
case 'setDiffViewMode':
|
|
16538
|
+
return {
|
|
16539
|
+
...state,
|
|
16540
|
+
diffViewMode: action.value,
|
|
16541
|
+
diffPreviewOffset: 0,
|
|
16542
|
+
worktreeDiffOffset: 0,
|
|
16543
|
+
pendingKey: undefined,
|
|
16544
|
+
};
|
|
15937
16545
|
case 'moveToBottom':
|
|
15938
16546
|
return {
|
|
15939
16547
|
...state,
|
|
@@ -16212,12 +16820,151 @@ function applyLogInkAction(state, action) {
|
|
|
16212
16820
|
}
|
|
16213
16821
|
}
|
|
16214
16822
|
|
|
16823
|
+
/**
|
|
16824
|
+
* In-sidebar selection helpers (#791 follow-up — sidebar entity ops).
|
|
16825
|
+
*
|
|
16826
|
+
* The workstation sidebar's branches / tags / stashes / worktrees tabs
|
|
16827
|
+
* used to be read-only previews — to act on an entity the user had to
|
|
16828
|
+
* drill into the dedicated promoted view. With the per-entity ops
|
|
16829
|
+
* gated to also fire on `state.focus === 'sidebar'` plus a matching
|
|
16830
|
+
* `sidebarTab`, j/k navigates the visible list inside the sidebar
|
|
16831
|
+
* itself, Enter performs the primary action (checkout / open diff),
|
|
16832
|
+
* and the existing per-view secondary keys (a/p/X/D/R/u/+P) are now
|
|
16833
|
+
* reachable without leaving the workstation view.
|
|
16834
|
+
*
|
|
16835
|
+
* The sidebar accordion is short — the visible window for an active
|
|
16836
|
+
* tab is capped (defaults below) so a long branch list doesn't
|
|
16837
|
+
* collapse the rest of the chrome. When the cursor scrolls past the
|
|
16838
|
+
* visible window, this module produces a sliding window that keeps it
|
|
16839
|
+
* in view; the dedicated view stays the right home for "show me all
|
|
16840
|
+
* 80 branches at once."
|
|
16841
|
+
*/
|
|
16842
|
+
const DEFAULT_SIDEBAR_VISIBLE = 8;
|
|
16843
|
+
/**
|
|
16844
|
+
* Compute the sliding window so that `selected` stays inside it while
|
|
16845
|
+
* the window remains anchored at the top whenever possible (so short
|
|
16846
|
+
* lists don't scroll for no reason). When the cursor moves past the
|
|
16847
|
+
* window, the window slides just enough to keep the cursor in view —
|
|
16848
|
+
* matching the commit history's `clampWindowStart` behaviour for
|
|
16849
|
+
* familiarity.
|
|
16850
|
+
*/
|
|
16851
|
+
function getSidebarVisibleWindow(total, selected, visible = DEFAULT_SIDEBAR_VISIBLE) {
|
|
16852
|
+
const size = Math.max(1, Math.min(visible, total));
|
|
16853
|
+
if (total <= visible) {
|
|
16854
|
+
return { start: 0, size, truncatedAbove: 0, truncatedBelow: 0 };
|
|
16855
|
+
}
|
|
16856
|
+
const half = Math.floor(size / 2);
|
|
16857
|
+
const idealStart = selected - half;
|
|
16858
|
+
const maxStart = total - size;
|
|
16859
|
+
const start = Math.max(0, Math.min(idealStart, maxStart));
|
|
16860
|
+
return {
|
|
16861
|
+
start,
|
|
16862
|
+
size,
|
|
16863
|
+
truncatedAbove: start,
|
|
16864
|
+
truncatedBelow: total - (start + size),
|
|
16865
|
+
};
|
|
16866
|
+
}
|
|
16867
|
+
/**
|
|
16868
|
+
* True when an in-sidebar action (j/k move, Enter checkout, etc.)
|
|
16869
|
+
* should fire instead of the generic drill-in / tab-cycle behaviour.
|
|
16870
|
+
*
|
|
16871
|
+
* Status tab is excluded because its preview shows worktree files —
|
|
16872
|
+
* those have their own selection model in the dedicated status view
|
|
16873
|
+
* and the sidebar doesn't surface them as selectable rows.
|
|
16874
|
+
*/
|
|
16875
|
+
function sidebarTabHasSelectableItems(sidebarTab, itemCount) {
|
|
16876
|
+
if (!itemCount || itemCount <= 0)
|
|
16877
|
+
return false;
|
|
16878
|
+
return sidebarTab === 'branches' ||
|
|
16879
|
+
sidebarTab === 'tags' ||
|
|
16880
|
+
sidebarTab === 'stashes' ||
|
|
16881
|
+
sidebarTab === 'worktrees';
|
|
16882
|
+
}
|
|
16883
|
+
|
|
16215
16884
|
function action(actionValue) {
|
|
16216
16885
|
return {
|
|
16217
16886
|
type: 'action',
|
|
16218
16887
|
action: actionValue,
|
|
16219
16888
|
};
|
|
16220
16889
|
}
|
|
16890
|
+
/**
|
|
16891
|
+
* Build the events needed to apply the hunk under the diff cursor. The
|
|
16892
|
+
* runtime workflow handler expects payload format `<target>\n<patch>`
|
|
16893
|
+
* — splitting on the first newline keeps the patch body intact for
|
|
16894
|
+
* targets like `worktree` and `index` (no newlines in the prefix).
|
|
16895
|
+
*
|
|
16896
|
+
* Returns [] when the user isn't on a commit-diff / stash-diff explore,
|
|
16897
|
+
* or when no hunk can be extracted at the current cursor offset
|
|
16898
|
+
* (e.g. cursor sits on a `diff --git` header before the first `@@`).
|
|
16899
|
+
* Callers fall back to a contextual status message when this returns [].
|
|
16900
|
+
*/
|
|
16901
|
+
function buildApplyHunkEvents(state, context, target) {
|
|
16902
|
+
if (state.activeView !== 'diff')
|
|
16903
|
+
return [];
|
|
16904
|
+
if (state.diffSource !== 'commit' && state.diffSource !== 'stash')
|
|
16905
|
+
return [];
|
|
16906
|
+
const lines = context.diffLinesForHunkApply;
|
|
16907
|
+
if (!lines || lines.length === 0)
|
|
16908
|
+
return [];
|
|
16909
|
+
const path = state.diffSource === 'stash'
|
|
16910
|
+
? context.stashDiffSelectedPath
|
|
16911
|
+
: context.commitDiffSelectedPath;
|
|
16912
|
+
if (!path)
|
|
16913
|
+
return [];
|
|
16914
|
+
const extracted = extractDiffHunk({
|
|
16915
|
+
lines,
|
|
16916
|
+
cursorOffset: state.diffPreviewOffset,
|
|
16917
|
+
path,
|
|
16918
|
+
});
|
|
16919
|
+
if (!extracted)
|
|
16920
|
+
return [];
|
|
16921
|
+
const id = target === 'index' ? 'apply-hunk-index' : 'apply-hunk-worktree';
|
|
16922
|
+
return [{
|
|
16923
|
+
type: 'runWorkflowAction',
|
|
16924
|
+
id,
|
|
16925
|
+
payload: `${target}\n${extracted.patchText}`,
|
|
16926
|
+
}];
|
|
16927
|
+
}
|
|
16928
|
+
/**
|
|
16929
|
+
* Per-entity action-target predicates. The promoted views (`branches`,
|
|
16930
|
+
* `tags`, `stash`, `worktrees`) each scope a set of ops to their
|
|
16931
|
+
* dedicated surface. The same ops also fire when the user has the
|
|
16932
|
+
* sidebar focused on the matching tab — that's how in-sidebar
|
|
16933
|
+
* selection (#791 follow-up) lets the user checkout / apply / drop
|
|
16934
|
+
* without leaving the workstation view.
|
|
16935
|
+
*/
|
|
16936
|
+
function isBranchActionTarget(state) {
|
|
16937
|
+
return (state.activeView === 'branches' && state.focus === 'commits') ||
|
|
16938
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
16939
|
+
}
|
|
16940
|
+
function isTagActionTarget(state) {
|
|
16941
|
+
return (state.activeView === 'tags' && state.focus === 'commits') ||
|
|
16942
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
16943
|
+
}
|
|
16944
|
+
function isStashActionTarget(state) {
|
|
16945
|
+
return (state.activeView === 'stash' && state.focus === 'commits') ||
|
|
16946
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'stashes');
|
|
16947
|
+
}
|
|
16948
|
+
function isWorktreeActionTarget(state) {
|
|
16949
|
+
return (state.activeView === 'worktrees' && state.focus === 'commits') ||
|
|
16950
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
|
|
16951
|
+
}
|
|
16952
|
+
/**
|
|
16953
|
+
* Item count for the active sidebar tab — used by the generic
|
|
16954
|
+
* sidebar-Enter handler to decide whether to defer to the per-entity
|
|
16955
|
+
* Enter (when items are present and the user is cursoring through
|
|
16956
|
+
* them) or to drill into the dedicated view (when the tab is empty
|
|
16957
|
+
* or has no per-entity Enter handler defined).
|
|
16958
|
+
*/
|
|
16959
|
+
function getSidebarItemCount(sidebarTab, context) {
|
|
16960
|
+
switch (sidebarTab) {
|
|
16961
|
+
case 'branches': return context.branchCount;
|
|
16962
|
+
case 'tags': return context.tagCount;
|
|
16963
|
+
case 'stashes': return context.stashCount;
|
|
16964
|
+
case 'worktrees': return context.worktreeListCount;
|
|
16965
|
+
default: return undefined;
|
|
16966
|
+
}
|
|
16967
|
+
}
|
|
16221
16968
|
/**
|
|
16222
16969
|
* Translate a palette command into the same events its keystroke would have
|
|
16223
16970
|
* produced. Phase 6 makes `:` a real launcher: this is the single mapping
|
|
@@ -16297,6 +17044,8 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
16297
17044
|
return [action({ type: 'pushView', value: 'stash' })];
|
|
16298
17045
|
case 'navigateWorktrees':
|
|
16299
17046
|
return [action({ type: 'pushView', value: 'worktrees' })];
|
|
17047
|
+
case 'navigatePullRequest':
|
|
17048
|
+
return [action({ type: 'pushView', value: 'pull-request' })];
|
|
16300
17049
|
case 'navigateBack':
|
|
16301
17050
|
return [action({ type: 'popView' })];
|
|
16302
17051
|
case 'openSelected': {
|
|
@@ -16354,10 +17103,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
16354
17103
|
case 'clearSearch':
|
|
16355
17104
|
return [action({ type: 'clearFilter' })];
|
|
16356
17105
|
case 'cycleSort':
|
|
16357
|
-
if (state
|
|
17106
|
+
if (isBranchActionTarget(state)) {
|
|
16358
17107
|
return [action({ type: 'cycleBranchSort' })];
|
|
16359
17108
|
}
|
|
16360
|
-
if (state
|
|
17109
|
+
if (isTagActionTarget(state)) {
|
|
16361
17110
|
return [action({ type: 'cycleTagSort' })];
|
|
16362
17111
|
}
|
|
16363
17112
|
return [action({
|
|
@@ -16394,6 +17143,77 @@ function hasUnsavedComposeDraft(state) {
|
|
|
16394
17143
|
}
|
|
16395
17144
|
return Boolean(compose.summary.trim() || compose.body.trim());
|
|
16396
17145
|
}
|
|
17146
|
+
/**
|
|
17147
|
+
* Submit the active input prompt — used by Enter on single-line
|
|
17148
|
+
* prompts and by Ctrl+D on multi-line prompts (#806). Most prompt
|
|
17149
|
+
* kinds dispatch a workflow whose id matches the kind
|
|
17150
|
+
* (`create-branch`, `rename-branch`, etc.). A few are exceptions:
|
|
17151
|
+
* - `reset-mode` (#777) collects soft/mixed/hard and forwards the
|
|
17152
|
+
* mode as the payload to `reset-to-commit`.
|
|
17153
|
+
* - `pr-merge-strategy` (#783) validates the strategy and routes to
|
|
17154
|
+
* `merge-pr` via the y-confirm path.
|
|
17155
|
+
* - `pr-comment` dispatches `comment-pr` directly — the body itself
|
|
17156
|
+
* is the affirmative action.
|
|
17157
|
+
* - `pr-request-changes` routes to `request-changes-pr` via
|
|
17158
|
+
* y-confirm because the review is publicly visible.
|
|
17159
|
+
* Each exception validates here so a typo doesn't surface as a
|
|
17160
|
+
* "workflow not yet wired" status downstream.
|
|
17161
|
+
*
|
|
17162
|
+
* Empty values yield a hint instead of a no-op so the user knows what
|
|
17163
|
+
* to do — the same UX whether they pressed Enter (single-line) or
|
|
17164
|
+
* Ctrl+D (multi-line).
|
|
17165
|
+
*/
|
|
17166
|
+
function submitInputPrompt(state) {
|
|
17167
|
+
if (!state.inputPrompt)
|
|
17168
|
+
return [];
|
|
17169
|
+
const value = state.inputPrompt.value.trim();
|
|
17170
|
+
if (!value) {
|
|
17171
|
+
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
|
|
17172
|
+
}
|
|
17173
|
+
if (state.inputPrompt.kind === 'reset-mode') {
|
|
17174
|
+
const mode = value.toLowerCase();
|
|
17175
|
+
if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
|
|
17176
|
+
return [action({
|
|
17177
|
+
type: 'setStatus',
|
|
17178
|
+
value: `Unknown reset mode: ${value}. Use soft, mixed, or hard.`,
|
|
17179
|
+
})];
|
|
17180
|
+
}
|
|
17181
|
+
return [
|
|
17182
|
+
{ type: 'runWorkflowAction', id: 'reset-to-commit', payload: mode },
|
|
17183
|
+
action({ type: 'closeInputPrompt' }),
|
|
17184
|
+
];
|
|
17185
|
+
}
|
|
17186
|
+
if (state.inputPrompt.kind === 'pr-merge-strategy') {
|
|
17187
|
+
const strategy = value.toLowerCase();
|
|
17188
|
+
if (strategy !== 'merge' && strategy !== 'squash' && strategy !== 'rebase') {
|
|
17189
|
+
return [action({
|
|
17190
|
+
type: 'setStatus',
|
|
17191
|
+
value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
|
|
17192
|
+
})];
|
|
17193
|
+
}
|
|
17194
|
+
return [
|
|
17195
|
+
action({ type: 'setPendingConfirmation', value: 'merge-pr', payload: strategy }),
|
|
17196
|
+
action({ type: 'closeInputPrompt' }),
|
|
17197
|
+
];
|
|
17198
|
+
}
|
|
17199
|
+
if (state.inputPrompt.kind === 'pr-comment') {
|
|
17200
|
+
return [
|
|
17201
|
+
{ type: 'runWorkflowAction', id: 'comment-pr', payload: value },
|
|
17202
|
+
action({ type: 'closeInputPrompt' }),
|
|
17203
|
+
];
|
|
17204
|
+
}
|
|
17205
|
+
if (state.inputPrompt.kind === 'pr-request-changes') {
|
|
17206
|
+
return [
|
|
17207
|
+
action({ type: 'setPendingConfirmation', value: 'request-changes-pr', payload: value }),
|
|
17208
|
+
action({ type: 'closeInputPrompt' }),
|
|
17209
|
+
];
|
|
17210
|
+
}
|
|
17211
|
+
const id = state.inputPrompt.kind;
|
|
17212
|
+
return [
|
|
17213
|
+
{ type: 'runWorkflowAction', id, payload: value },
|
|
17214
|
+
action({ type: 'closeInputPrompt' }),
|
|
17215
|
+
];
|
|
17216
|
+
}
|
|
16397
17217
|
function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
16398
17218
|
if (key.ctrl && inputValue === 'c') {
|
|
16399
17219
|
if (hasUnsavedComposeDraft(state) && !state.pendingMutationConfirmation) {
|
|
@@ -16406,22 +17226,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16406
17226
|
// filter/confirmation/compose handlers so a prompt opened from inside
|
|
16407
17227
|
// any of those still captures focus cleanly.
|
|
16408
17228
|
if (state.inputPrompt) {
|
|
17229
|
+
const isMultiline = Boolean(state.inputPrompt.multiline);
|
|
16409
17230
|
if (key.escape) {
|
|
16410
17231
|
return [
|
|
16411
17232
|
action({ type: 'closeInputPrompt' }),
|
|
16412
17233
|
action({ type: 'setStatus', value: 'cancelled' }),
|
|
16413
17234
|
];
|
|
16414
17235
|
}
|
|
17236
|
+
// Multi-line prompts (#806): Ctrl+D submits (Unix EOF convention,
|
|
17237
|
+
// mirrors `git commit -m -` and HEREDOC patterns). Plain Enter
|
|
17238
|
+
// inserts a newline so the user can compose review bodies / PR
|
|
17239
|
+
// comments naturally without opening $EDITOR.
|
|
17240
|
+
if (isMultiline && key.ctrl && inputValue === 'd') {
|
|
17241
|
+
return submitInputPrompt(state);
|
|
17242
|
+
}
|
|
17243
|
+
if (isMultiline && key.return) {
|
|
17244
|
+
return [action({ type: 'appendInputPrompt', value: '\n' })];
|
|
17245
|
+
}
|
|
16415
17246
|
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
|
-
];
|
|
17247
|
+
return submitInputPrompt(state);
|
|
16425
17248
|
}
|
|
16426
17249
|
if (key.backspace || key.delete) {
|
|
16427
17250
|
return [action({ type: 'backspaceInputPrompt' })];
|
|
@@ -16683,6 +17506,56 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16683
17506
|
action({ type: 'setStatus', value: 'jumped to worktrees' }),
|
|
16684
17507
|
];
|
|
16685
17508
|
}
|
|
17509
|
+
// `gp` jumps to the dedicated pull-request action panel (#783).
|
|
17510
|
+
// Lowercase `p` matches the pattern of other navigation chords
|
|
17511
|
+
// (gh / gs / gd / gc / gb / gt / gz / gw). The panel renders the
|
|
17512
|
+
// current branch's PR via `gh pr view --json` enriched fields and
|
|
17513
|
+
// exposes m / x / a / R / c action keys scoped to the view.
|
|
17514
|
+
if (state.pendingKey === 'g' && inputValue === 'p') {
|
|
17515
|
+
return [
|
|
17516
|
+
action({ type: 'pushView', value: 'pull-request' }),
|
|
17517
|
+
action({ type: 'setStatus', value: 'jumped to pull request' }),
|
|
17518
|
+
];
|
|
17519
|
+
}
|
|
17520
|
+
// `gH` chord: apply the cursored hunk to the index (`git apply
|
|
17521
|
+
// --cached`). Sibling of bare `H` which targets the worktree.
|
|
17522
|
+
// Discoverable via the footer hint on diff views and the help
|
|
17523
|
+
// overlay; the explicit chord keeps `H` (single keystroke) for
|
|
17524
|
+
// the more common worktree case.
|
|
17525
|
+
if (state.pendingKey === 'g' && inputValue === 'H') {
|
|
17526
|
+
const events = buildApplyHunkEvents(state, context, 'index');
|
|
17527
|
+
if (events.length) {
|
|
17528
|
+
return [action({ type: 'setPendingKey', value: undefined }), ...events];
|
|
17529
|
+
}
|
|
17530
|
+
return [
|
|
17531
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
17532
|
+
action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
|
|
17533
|
+
];
|
|
17534
|
+
}
|
|
17535
|
+
// `gT` chord: create a lightweight tag at the cursored commit on the
|
|
17536
|
+
// history view. Bare `T` is taken (delete-tag on the tags view) so we
|
|
17537
|
+
// use the chord. Mirrors `gH` exactly — uppercase letter after the
|
|
17538
|
+
// `g` chord prefix, distinct from the lowercase `gt` chord which
|
|
17539
|
+
// jumps to the tags view. The prompt is the affirmative gate.
|
|
17540
|
+
if (state.pendingKey === 'g' && inputValue === 'T') {
|
|
17541
|
+
if (state.activeView === 'history' &&
|
|
17542
|
+
state.focus === 'commits' &&
|
|
17543
|
+
state.filteredCommits.length > 0 &&
|
|
17544
|
+
!state.pendingCommitFocused) {
|
|
17545
|
+
return [
|
|
17546
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
17547
|
+
action({
|
|
17548
|
+
type: 'openInputPrompt',
|
|
17549
|
+
kind: 'create-tag-here',
|
|
17550
|
+
label: 'New tag name (at cursored commit)',
|
|
17551
|
+
}),
|
|
17552
|
+
];
|
|
17553
|
+
}
|
|
17554
|
+
return [
|
|
17555
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
17556
|
+
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
|
|
17557
|
+
];
|
|
17558
|
+
}
|
|
16686
17559
|
if (inputValue === 'g') {
|
|
16687
17560
|
if (state.pendingKey === 'g') {
|
|
16688
17561
|
return [
|
|
@@ -16692,6 +17565,22 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16692
17565
|
}
|
|
16693
17566
|
return [action({ type: 'setPendingKey', value: 'g' })];
|
|
16694
17567
|
}
|
|
17568
|
+
// `d` on the diff view toggles between unified and side-by-side split
|
|
17569
|
+
// rendering (#785). Scoped to the diff view so the letter stays free
|
|
17570
|
+
// for other surfaces. The chord branch above already claimed `gd`,
|
|
17571
|
+
// so by the time we get here `pendingKey` is not `g`.
|
|
17572
|
+
if (inputValue === 'd' && state.activeView === 'diff') {
|
|
17573
|
+
const next = state.diffViewMode === 'unified' ? 'split' : 'unified';
|
|
17574
|
+
return [
|
|
17575
|
+
action({ type: 'toggleDiffViewMode' }),
|
|
17576
|
+
action({
|
|
17577
|
+
type: 'setStatus',
|
|
17578
|
+
value: next === 'split'
|
|
17579
|
+
? 'Switched to side-by-side diff'
|
|
17580
|
+
: 'Switched to unified diff',
|
|
17581
|
+
}),
|
|
17582
|
+
];
|
|
17583
|
+
}
|
|
16695
17584
|
if (inputValue === '\\') {
|
|
16696
17585
|
return [action({ type: 'toggleGraph' })];
|
|
16697
17586
|
}
|
|
@@ -16714,10 +17603,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16714
17603
|
return [{ type: 'refreshContext' }];
|
|
16715
17604
|
}
|
|
16716
17605
|
if (inputValue === 's') {
|
|
16717
|
-
if (state
|
|
17606
|
+
if (isBranchActionTarget(state)) {
|
|
16718
17607
|
return [action({ type: 'cycleBranchSort' })];
|
|
16719
17608
|
}
|
|
16720
|
-
if (state
|
|
17609
|
+
if (isTagActionTarget(state)) {
|
|
16721
17610
|
return [action({ type: 'cycleTagSort' })];
|
|
16722
17611
|
}
|
|
16723
17612
|
// Falls through so other views (history/status/diff/compose/stash) still
|
|
@@ -16748,6 +17637,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16748
17637
|
hunkOffsets: context.commitDiffHunkOffsets,
|
|
16749
17638
|
})];
|
|
16750
17639
|
}
|
|
17640
|
+
// Inspector focused: cycle the inspector tab. The renderer only
|
|
17641
|
+
// honors the tab field on short terminals (where the inspector
|
|
17642
|
+
// collapses into a tabbed layout), but we let the user pre-set
|
|
17643
|
+
// their preference on tall terminals too.
|
|
17644
|
+
if (state.focus === 'detail') {
|
|
17645
|
+
return [action({ type: 'cycleInspectorTab', delta: -1 })];
|
|
17646
|
+
}
|
|
16751
17647
|
return [action({ type: 'previousSidebarTab' })];
|
|
16752
17648
|
}
|
|
16753
17649
|
if (inputValue === ']') {
|
|
@@ -16772,6 +17668,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16772
17668
|
hunkOffsets: context.commitDiffHunkOffsets,
|
|
16773
17669
|
})];
|
|
16774
17670
|
}
|
|
17671
|
+
if (state.focus === 'detail') {
|
|
17672
|
+
return [action({ type: 'cycleInspectorTab', delta: 1 })];
|
|
17673
|
+
}
|
|
16775
17674
|
return [action({ type: 'nextSidebarTab' })];
|
|
16776
17675
|
}
|
|
16777
17676
|
// Status surface intercepts 1/2/3 before the sidebar-tab numeric
|
|
@@ -16788,6 +17687,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16788
17687
|
if (key.tab) {
|
|
16789
17688
|
return [action({ type: key.shift ? 'focusPrevious' : 'focusNext' })];
|
|
16790
17689
|
}
|
|
17690
|
+
// ←/→ on the sidebar switch tabs (Status ↔ Branches ↔ Tags ↔
|
|
17691
|
+
// Stashes ↔ Worktrees) — the horizontal axis is "between tabs", the
|
|
17692
|
+
// vertical axis (↑/↓ below) is "within the active tab's items".
|
|
17693
|
+
// [/] still works as a keyboard alternative for users who prefer
|
|
17694
|
+
// non-arrow keys.
|
|
17695
|
+
if (key.leftArrow && state.focus === 'sidebar') {
|
|
17696
|
+
return [action({ type: 'previousSidebarTab' })];
|
|
17697
|
+
}
|
|
17698
|
+
if (key.rightArrow && state.focus === 'sidebar') {
|
|
17699
|
+
return [action({ type: 'nextSidebarTab' })];
|
|
17700
|
+
}
|
|
16791
17701
|
if (key.upArrow || inputValue === 'k') {
|
|
16792
17702
|
if (state.focus === 'detail' && context.detailFileCount) {
|
|
16793
17703
|
return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
|
|
@@ -16817,16 +17727,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16817
17727
|
previewLineCount: context.previewLineCount,
|
|
16818
17728
|
})];
|
|
16819
17729
|
}
|
|
16820
|
-
if (state
|
|
17730
|
+
if (isBranchActionTarget(state) && context.branchCount) {
|
|
16821
17731
|
return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
|
|
16822
17732
|
}
|
|
16823
|
-
if (state
|
|
17733
|
+
if (isTagActionTarget(state) && context.tagCount) {
|
|
16824
17734
|
return [action({ type: 'moveTag', delta: -1, count: context.tagCount })];
|
|
16825
17735
|
}
|
|
16826
|
-
if (state
|
|
17736
|
+
if (isStashActionTarget(state) && context.stashCount) {
|
|
16827
17737
|
return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
|
|
16828
17738
|
}
|
|
16829
|
-
if (state
|
|
17739
|
+
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
16830
17740
|
return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
|
|
16831
17741
|
}
|
|
16832
17742
|
if (state.activeView === 'history' &&
|
|
@@ -16836,6 +17746,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16836
17746
|
context.worktreeDirty) {
|
|
16837
17747
|
return [action({ type: 'focusPendingCommit' })];
|
|
16838
17748
|
}
|
|
17749
|
+
// Sidebar fallback: when no entity claim above succeeds (status
|
|
17750
|
+
// tab or empty content tab), ↑ falls through to cycling sidebar
|
|
17751
|
+
// tabs so the user always has a way to navigate. With ←/→ above
|
|
17752
|
+
// already handling tab switching, this is mostly a vim-style
|
|
17753
|
+
// safety net for `k`.
|
|
16839
17754
|
return [
|
|
16840
17755
|
action(state.focus === 'sidebar'
|
|
16841
17756
|
? { type: 'previousSidebarTab' }
|
|
@@ -16870,16 +17785,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16870
17785
|
previewLineCount: context.previewLineCount,
|
|
16871
17786
|
})];
|
|
16872
17787
|
}
|
|
16873
|
-
if (state
|
|
17788
|
+
if (isBranchActionTarget(state) && context.branchCount) {
|
|
16874
17789
|
return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
|
|
16875
17790
|
}
|
|
16876
|
-
if (state
|
|
17791
|
+
if (isTagActionTarget(state) && context.tagCount) {
|
|
16877
17792
|
return [action({ type: 'moveTag', delta: 1, count: context.tagCount })];
|
|
16878
17793
|
}
|
|
16879
|
-
if (state
|
|
17794
|
+
if (isStashActionTarget(state) && context.stashCount) {
|
|
16880
17795
|
return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
|
|
16881
17796
|
}
|
|
16882
|
-
if (state
|
|
17797
|
+
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
16883
17798
|
return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
|
|
16884
17799
|
}
|
|
16885
17800
|
return [
|
|
@@ -16983,30 +17898,42 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16983
17898
|
}
|
|
16984
17899
|
}
|
|
16985
17900
|
// Enter on a sidebar tab drills into the corresponding promoted view
|
|
16986
|
-
// (status / branches / tags / stash)
|
|
16987
|
-
//
|
|
16988
|
-
//
|
|
16989
|
-
// the
|
|
17901
|
+
// (status / branches / tags / stash) — but only when the sidebar tab
|
|
17902
|
+
// either has no per-entity Enter handler defined (status, tags,
|
|
17903
|
+
// worktrees) or has zero items (so the dedicated view's empty-state
|
|
17904
|
+
// tells the user what to do next).
|
|
16990
17905
|
//
|
|
16991
|
-
//
|
|
16992
|
-
//
|
|
16993
|
-
//
|
|
17906
|
+
// When the sidebar IS focused on a content tab WITH items, this
|
|
17907
|
+
// handler defers to the per-entity Enter below (checkout-branch for
|
|
17908
|
+
// branches, navigateOpenDiffForStash for stashes) so the user can
|
|
17909
|
+
// act on the cursored item without leaving the workstation view —
|
|
17910
|
+
// the in-sidebar selection win from #791 follow-up.
|
|
17911
|
+
//
|
|
17912
|
+
// The drill-in moves focus out of the sidebar into the newly opened
|
|
17913
|
+
// list — otherwise ↑/↓ keep navigating the sidebar instead of the
|
|
17914
|
+
// just-opened view, which made the drill-in feel half-done.
|
|
16994
17915
|
if (key.return && state.focus === 'sidebar') {
|
|
16995
|
-
const
|
|
16996
|
-
|
|
16997
|
-
|
|
16998
|
-
|
|
16999
|
-
|
|
17000
|
-
|
|
17001
|
-
|
|
17002
|
-
|
|
17003
|
-
|
|
17004
|
-
|
|
17005
|
-
|
|
17006
|
-
|
|
17007
|
-
|
|
17916
|
+
const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
|
|
17917
|
+
const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
|
|
17918
|
+
sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
|
|
17919
|
+
if (!hasInSidebarPrimaryAction) {
|
|
17920
|
+
const tabToView = {
|
|
17921
|
+
status: 'status',
|
|
17922
|
+
branches: 'branches',
|
|
17923
|
+
tags: 'tags',
|
|
17924
|
+
stashes: 'stash',
|
|
17925
|
+
worktrees: 'worktrees',
|
|
17926
|
+
};
|
|
17927
|
+
const target = tabToView[state.sidebarTab];
|
|
17928
|
+
if (target) {
|
|
17929
|
+
return [
|
|
17930
|
+
action({ type: 'pushView', value: target }),
|
|
17931
|
+
action({ type: 'setFocus', value: 'commits' }),
|
|
17932
|
+
];
|
|
17933
|
+
}
|
|
17934
|
+
return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
|
|
17008
17935
|
}
|
|
17009
|
-
|
|
17936
|
+
// Fall through — per-entity Enter handler below claims the keystroke.
|
|
17010
17937
|
}
|
|
17011
17938
|
if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
|
|
17012
17939
|
return [action({
|
|
@@ -17015,8 +17942,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17015
17942
|
})];
|
|
17016
17943
|
}
|
|
17017
17944
|
// Enter on a branch row checks the branch out. Non-destructive workflow
|
|
17018
|
-
// action — no confirmation prompt.
|
|
17019
|
-
|
|
17945
|
+
// action — no confirmation prompt. Fires from either the dedicated
|
|
17946
|
+
// branches view or from the sidebar when the branches tab is focused
|
|
17947
|
+
// with items.
|
|
17948
|
+
if (key.return && isBranchActionTarget(state) && context.branchCount) {
|
|
17020
17949
|
return [{ type: 'runWorkflowAction', id: 'checkout-branch' }];
|
|
17021
17950
|
}
|
|
17022
17951
|
// `+` opens a create-branch / create-tag prompt depending on context.
|
|
@@ -17043,32 +17972,33 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17043
17972
|
}
|
|
17044
17973
|
// Per-view stash actions: `a` apply (keep the stash), `p` pop (apply
|
|
17045
17974
|
// 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
|
-
|
|
17975
|
+
// routes through the y-confirm path. Scoped to the stash target so
|
|
17976
|
+
// the letters stay free elsewhere — the target predicate also fires
|
|
17977
|
+
// when the sidebar's stashes tab is focused with items.
|
|
17978
|
+
if (inputValue === 'a' && isStashActionTarget(state) && context.stashCount) {
|
|
17049
17979
|
return [{ type: 'runWorkflowAction', id: 'apply-stash' }];
|
|
17050
17980
|
}
|
|
17051
|
-
if (inputValue === 'p' && state
|
|
17981
|
+
if (inputValue === 'p' && isStashActionTarget(state) && context.stashCount) {
|
|
17052
17982
|
return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
|
|
17053
17983
|
}
|
|
17054
17984
|
// Per-view tag action: `P` pushes the selected tag to origin. Letter
|
|
17055
|
-
// is scoped to the tags
|
|
17985
|
+
// is scoped to the tags target so it doesn't collide with `p` for
|
|
17056
17986
|
// pop-stash. Note: this also takes precedence over the global
|
|
17057
17987
|
// push-current-branch workflow's `P` key.
|
|
17058
|
-
if (inputValue === 'P' && state
|
|
17988
|
+
if (inputValue === 'P' && isTagActionTarget(state) && context.tagCount) {
|
|
17059
17989
|
return [{ type: 'runWorkflowAction', id: 'push-tag' }];
|
|
17060
17990
|
}
|
|
17061
17991
|
// Per-view branches actions: `R` renames the selected branch, `u`
|
|
17062
17992
|
// sets its upstream. Both open the input prompt so the user can type
|
|
17063
17993
|
// the new value. Pre-fills are handled by the prompt's `initial`.
|
|
17064
|
-
if (inputValue === 'R' && state
|
|
17994
|
+
if (inputValue === 'R' && isBranchActionTarget(state) && context.branchCount) {
|
|
17065
17995
|
return [action({
|
|
17066
17996
|
type: 'openInputPrompt',
|
|
17067
17997
|
kind: 'rename-branch',
|
|
17068
17998
|
label: 'Rename branch to',
|
|
17069
17999
|
})];
|
|
17070
18000
|
}
|
|
17071
|
-
if (inputValue === 'u' && state
|
|
18001
|
+
if (inputValue === 'u' && isBranchActionTarget(state) && context.branchCount) {
|
|
17072
18002
|
return [action({
|
|
17073
18003
|
type: 'openInputPrompt',
|
|
17074
18004
|
kind: 'set-upstream',
|
|
@@ -17076,11 +18006,48 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17076
18006
|
})];
|
|
17077
18007
|
}
|
|
17078
18008
|
// 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
|
|
18009
|
+
// confirmation). Scoped per-target so this letter is free elsewhere
|
|
18010
|
+
// (especially the `R` rename binding on the branches target).
|
|
18011
|
+
if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
|
|
17082
18012
|
return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
|
|
17083
18013
|
}
|
|
18014
|
+
// #783 — full PR action panel keys, scoped to the pull-request view.
|
|
18015
|
+
// All five wrap a `gh pr <verb>` invocation; merge / request-changes /
|
|
18016
|
+
// comment open prompts first, the rest route through the y-confirm
|
|
18017
|
+
// path because they're irreversible (or near-irreversible).
|
|
18018
|
+
if (inputValue === 'm' && state.activeView === 'pull-request') {
|
|
18019
|
+
return [action({
|
|
18020
|
+
type: 'openInputPrompt',
|
|
18021
|
+
kind: 'pr-merge-strategy',
|
|
18022
|
+
label: 'Merge strategy (merge / squash / rebase)',
|
|
18023
|
+
})];
|
|
18024
|
+
}
|
|
18025
|
+
if (inputValue === 'x' && state.activeView === 'pull-request') {
|
|
18026
|
+
return [action({ type: 'setPendingConfirmation', value: 'close-pr' })];
|
|
18027
|
+
}
|
|
18028
|
+
if (inputValue === 'a' && state.activeView === 'pull-request') {
|
|
18029
|
+
return [action({ type: 'setPendingConfirmation', value: 'approve-pr' })];
|
|
18030
|
+
}
|
|
18031
|
+
if (inputValue === 'R' && state.activeView === 'pull-request') {
|
|
18032
|
+
// Free-form review body — multi-line so the reviewer can structure
|
|
18033
|
+
// their feedback naturally without opening $EDITOR (#806).
|
|
18034
|
+
return [action({
|
|
18035
|
+
type: 'openInputPrompt',
|
|
18036
|
+
kind: 'pr-request-changes',
|
|
18037
|
+
label: 'Request changes — review body (Enter newline · Ctrl+D submit)',
|
|
18038
|
+
multiline: true,
|
|
18039
|
+
})];
|
|
18040
|
+
}
|
|
18041
|
+
if (inputValue === 'c' && state.activeView === 'pull-request') {
|
|
18042
|
+
// Free-form comment body — multi-line for the same reason as
|
|
18043
|
+
// pr-request-changes.
|
|
18044
|
+
return [action({
|
|
18045
|
+
type: 'openInputPrompt',
|
|
18046
|
+
kind: 'pr-comment',
|
|
18047
|
+
label: 'Comment body (Enter newline · Ctrl+D submit)',
|
|
18048
|
+
multiline: true,
|
|
18049
|
+
})];
|
|
18050
|
+
}
|
|
17084
18051
|
// Global stash hotkey: `S` opens a stash-message prompt and
|
|
17085
18052
|
// `createStash` runs once submitted. Available everywhere there's
|
|
17086
18053
|
// not a more modal handler in front of it.
|
|
@@ -17138,6 +18105,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17138
18105
|
payload: `${context.commitDiffSelectedSha} ${context.commitDiffSelectedPath}`,
|
|
17139
18106
|
})];
|
|
17140
18107
|
}
|
|
18108
|
+
// `H` on a commit-diff or stash-diff explore extracts the hunk under
|
|
18109
|
+
// the cursor and applies it to the working tree (`git apply`). The
|
|
18110
|
+
// sibling `gH` chord targets the index (`git apply --cached`). Both
|
|
18111
|
+
// bypass the y-confirm path because `git apply` is non-destructive
|
|
18112
|
+
// (it'll fail loudly on conflict and `git apply -R` undoes a clean
|
|
18113
|
+
// apply).
|
|
18114
|
+
if (inputValue === 'H') {
|
|
18115
|
+
const events = buildApplyHunkEvents(state, context, 'worktree');
|
|
18116
|
+
if (events.length) {
|
|
18117
|
+
return events;
|
|
18118
|
+
}
|
|
18119
|
+
if (state.activeView === 'diff' && (state.diffSource === 'commit' || state.diffSource === 'stash')) {
|
|
18120
|
+
return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first' })];
|
|
18121
|
+
}
|
|
18122
|
+
}
|
|
17141
18123
|
// `c` on the history view cherry-picks the full selected commit on
|
|
17142
18124
|
// top of the current branch. Routed through the y-confirm flow since
|
|
17143
18125
|
// it can produce conflicts and is a real working-tree mutation.
|
|
@@ -17148,6 +18130,62 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17148
18130
|
!state.pendingCommitFocused) {
|
|
17149
18131
|
return [action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' })];
|
|
17150
18132
|
}
|
|
18133
|
+
// `R` reverts the cursored commit by adding an inverse commit on top
|
|
18134
|
+
// of HEAD. Same y-confirm gate as cherry-pick — non-rewriting but
|
|
18135
|
+
// still a real mutation.
|
|
18136
|
+
if (inputValue === 'R' &&
|
|
18137
|
+
state.activeView === 'history' &&
|
|
18138
|
+
state.focus === 'commits' &&
|
|
18139
|
+
state.filteredCommits.length > 0 &&
|
|
18140
|
+
!state.pendingCommitFocused) {
|
|
18141
|
+
return [action({ type: 'setPendingConfirmation', value: 'revert-commit' })];
|
|
18142
|
+
}
|
|
18143
|
+
// `Z` resets the current branch tip to the cursored commit. Opens a
|
|
18144
|
+
// mode prompt (soft / mixed / hard) instead of jumping straight to
|
|
18145
|
+
// confirmation because the choice changes the destructiveness
|
|
18146
|
+
// dramatically — `--hard` discards working-tree changes. The prompt
|
|
18147
|
+
// submission special-cases `kind === 'reset-mode'` to forward the
|
|
18148
|
+
// mode through `reset-to-commit` (see prompt-submit handler above).
|
|
18149
|
+
// No `initial` value: existing prompts append to initial rather than
|
|
18150
|
+
// replacing it, which would surprise the user typing the mode.
|
|
18151
|
+
if (inputValue === 'Z' &&
|
|
18152
|
+
state.activeView === 'history' &&
|
|
18153
|
+
state.focus === 'commits' &&
|
|
18154
|
+
state.filteredCommits.length > 0 &&
|
|
18155
|
+
!state.pendingCommitFocused) {
|
|
18156
|
+
return [action({
|
|
18157
|
+
type: 'openInputPrompt',
|
|
18158
|
+
kind: 'reset-mode',
|
|
18159
|
+
label: 'Reset mode (soft / mixed / hard)',
|
|
18160
|
+
})];
|
|
18161
|
+
}
|
|
18162
|
+
// `i` (lowercase) starts an interactive rebase from the cursored
|
|
18163
|
+
// commit's parent. Lowercase keeps the existing global `I`
|
|
18164
|
+
// ai-commit-summary workflow reachable on the history view; `i`
|
|
18165
|
+
// also matches the `git rebase -i` flag mnemonic.
|
|
18166
|
+
if (inputValue === 'i' &&
|
|
18167
|
+
state.activeView === 'history' &&
|
|
18168
|
+
state.focus === 'commits' &&
|
|
18169
|
+
state.filteredCommits.length > 0 &&
|
|
18170
|
+
!state.pendingCommitFocused) {
|
|
18171
|
+
return [action({ type: 'setPendingConfirmation', value: 'interactive-rebase' })];
|
|
18172
|
+
}
|
|
18173
|
+
// `B` opens a create-branch prompt rooted at the cursored commit
|
|
18174
|
+
// (`git branch <name> <sha>` — does NOT switch to the new branch).
|
|
18175
|
+
// The prompt itself is the affirmative gate, so no separate y-confirm.
|
|
18176
|
+
// Bare uppercase `B` since the lowercase `b` is used by the `gb`
|
|
18177
|
+
// chord prefix and we want a single keystroke for this common op.
|
|
18178
|
+
if (inputValue === 'B' &&
|
|
18179
|
+
state.activeView === 'history' &&
|
|
18180
|
+
state.focus === 'commits' &&
|
|
18181
|
+
state.filteredCommits.length > 0 &&
|
|
18182
|
+
!state.pendingCommitFocused) {
|
|
18183
|
+
return [action({
|
|
18184
|
+
type: 'openInputPrompt',
|
|
18185
|
+
kind: 'create-branch-here',
|
|
18186
|
+
label: 'New branch name (at cursored commit)',
|
|
18187
|
+
})];
|
|
18188
|
+
}
|
|
17151
18189
|
// `y` / `Y` yank the contextually relevant identifier from the active
|
|
17152
18190
|
// view to the system clipboard:
|
|
17153
18191
|
// history → cursored commit hash (Y for short hash)
|
|
@@ -17163,13 +18201,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17163
18201
|
if (state.activeView === 'history' && state.filteredCommits.length > 0) {
|
|
17164
18202
|
return [{ type: 'yankFromActiveView', short }];
|
|
17165
18203
|
}
|
|
17166
|
-
if (state
|
|
18204
|
+
if (isBranchActionTarget(state) && context.branchCount) {
|
|
17167
18205
|
return [{ type: 'yankFromActiveView' }];
|
|
17168
18206
|
}
|
|
17169
|
-
if (state
|
|
18207
|
+
if (isTagActionTarget(state) && context.tagCount) {
|
|
17170
18208
|
return [{ type: 'yankFromActiveView' }];
|
|
17171
18209
|
}
|
|
17172
|
-
if (state
|
|
18210
|
+
if (isStashActionTarget(state) && context.stashCount && context.stashSelectedRef) {
|
|
17173
18211
|
return [{ type: 'yankFromActiveView' }];
|
|
17174
18212
|
}
|
|
17175
18213
|
if (state.activeView === 'status' && context.worktreeSelectedPath) {
|
|
@@ -17187,8 +18225,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17187
18225
|
// Enter on a stash row pushes the diff view scoped to that stash.
|
|
17188
18226
|
// The runtime loads `git stash show -p <ref>` once the view is
|
|
17189
18227
|
// active. The stash ref is passed via the action so we don't need a
|
|
17190
|
-
// context lookup here.
|
|
17191
|
-
|
|
18228
|
+
// context lookup here. Fires from either the dedicated stash view or
|
|
18229
|
+
// from the sidebar when the stashes tab is focused with items.
|
|
18230
|
+
if (key.return && isStashActionTarget(state) && context.stashCount && context.stashSelectedRef) {
|
|
17192
18231
|
return [action({
|
|
17193
18232
|
type: 'navigateOpenDiffForStash',
|
|
17194
18233
|
ref: context.stashSelectedRef,
|
|
@@ -17249,6 +18288,45 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17249
18288
|
* fall back to "already seen" so we never block startup.
|
|
17250
18289
|
*/
|
|
17251
18290
|
const MARKER_BASENAME = 'onboarding.seen';
|
|
18291
|
+
function resolveCacheDir$2() {
|
|
18292
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
18293
|
+
if (xdg && xdg.trim().length > 0) {
|
|
18294
|
+
return path__namespace$1.join(xdg, 'coco');
|
|
18295
|
+
}
|
|
18296
|
+
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
|
|
18297
|
+
}
|
|
18298
|
+
function getOnboardingMarkerPath() {
|
|
18299
|
+
return path__namespace$1.join(resolveCacheDir$2(), MARKER_BASENAME);
|
|
18300
|
+
}
|
|
18301
|
+
function hasSeenOnboarding() {
|
|
18302
|
+
try {
|
|
18303
|
+
return fs__namespace$1.existsSync(getOnboardingMarkerPath());
|
|
18304
|
+
}
|
|
18305
|
+
catch {
|
|
18306
|
+
// If we can't even stat the path (sandboxed env, etc.), treat the
|
|
18307
|
+
// user as "seen" so we don't keep showing a panel they can never
|
|
18308
|
+
// dismiss persistently.
|
|
18309
|
+
return true;
|
|
18310
|
+
}
|
|
18311
|
+
}
|
|
18312
|
+
function markOnboardingSeen() {
|
|
18313
|
+
const markerPath = getOnboardingMarkerPath();
|
|
18314
|
+
try {
|
|
18315
|
+
fs__namespace$1.mkdirSync(path__namespace$1.dirname(markerPath), { recursive: true });
|
|
18316
|
+
fs__namespace$1.writeFileSync(markerPath, '');
|
|
18317
|
+
}
|
|
18318
|
+
catch {
|
|
18319
|
+
// Best-effort persistence; swallow.
|
|
18320
|
+
}
|
|
18321
|
+
}
|
|
18322
|
+
|
|
18323
|
+
/**
|
|
18324
|
+
* Persist the user's preferred diff view mode (unified vs side-by-side
|
|
18325
|
+
* split — #785) per repo. Mirrors `inkSidebarPersistence.ts` so the
|
|
18326
|
+
* cache layout, error model, and key derivation stay consistent across
|
|
18327
|
+
* settings: best-effort, XDG-friendly, no PII in the cache filename.
|
|
18328
|
+
*/
|
|
18329
|
+
const VALID_MODES = ['unified', 'split'];
|
|
17252
18330
|
function resolveCacheDir$1() {
|
|
17253
18331
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
17254
18332
|
if (xdg && xdg.trim().length > 0) {
|
|
@@ -17256,25 +18334,32 @@ function resolveCacheDir$1() {
|
|
|
17256
18334
|
}
|
|
17257
18335
|
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
|
|
17258
18336
|
}
|
|
17259
|
-
function
|
|
17260
|
-
|
|
18337
|
+
function repoKey$1(repoPath) {
|
|
18338
|
+
// sha1 is used here as a non-security cache-key derivation — we just
|
|
18339
|
+
// need a deterministic short identifier for the marker filename. No
|
|
18340
|
+
// PII or auth context is hashed.
|
|
18341
|
+
// DevSkim: ignore DS126858
|
|
18342
|
+
return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
17261
18343
|
}
|
|
17262
|
-
function
|
|
18344
|
+
function getDiffViewModeMarkerPath(repoPath) {
|
|
18345
|
+
return path__namespace$1.join(resolveCacheDir$1(), `diff-view-mode.${repoKey$1(repoPath)}`);
|
|
18346
|
+
}
|
|
18347
|
+
function getSavedDiffViewMode(repoPath) {
|
|
17263
18348
|
try {
|
|
17264
|
-
|
|
18349
|
+
const raw = fs__namespace$1.readFileSync(getDiffViewModeMarkerPath(repoPath), 'utf8').trim();
|
|
18350
|
+
return VALID_MODES.includes(raw)
|
|
18351
|
+
? raw
|
|
18352
|
+
: undefined;
|
|
17265
18353
|
}
|
|
17266
18354
|
catch {
|
|
17267
|
-
|
|
17268
|
-
// user as "seen" so we don't keep showing a panel they can never
|
|
17269
|
-
// dismiss persistently.
|
|
17270
|
-
return true;
|
|
18355
|
+
return undefined;
|
|
17271
18356
|
}
|
|
17272
18357
|
}
|
|
17273
|
-
function
|
|
17274
|
-
const
|
|
18358
|
+
function saveDiffViewMode(repoPath, mode) {
|
|
18359
|
+
const marker = getDiffViewModeMarkerPath(repoPath);
|
|
17275
18360
|
try {
|
|
17276
|
-
fs__namespace$1.mkdirSync(path__namespace$1.dirname(
|
|
17277
|
-
fs__namespace$1.writeFileSync(
|
|
18361
|
+
fs__namespace$1.mkdirSync(path__namespace$1.dirname(marker), { recursive: true });
|
|
18362
|
+
fs__namespace$1.writeFileSync(marker, mode);
|
|
17278
18363
|
}
|
|
17279
18364
|
catch {
|
|
17280
18365
|
// Best-effort persistence; swallow.
|
|
@@ -17340,6 +18425,144 @@ function saveSidebarTab(repoPath, tab) {
|
|
|
17340
18425
|
}
|
|
17341
18426
|
}
|
|
17342
18427
|
|
|
18428
|
+
/**
|
|
18429
|
+
* Pair-alignment helper for the side-by-side diff view (#785).
|
|
18430
|
+
*
|
|
18431
|
+
* Takes the unified-diff line array that the renderer already paints (one
|
|
18432
|
+
* line per element, the leading character drives `+`/`-`/context coloring)
|
|
18433
|
+
* and re-shapes it into two-column rows the split renderer can lay out
|
|
18434
|
+
* without further parsing. Pure / synchronous so it can be exercised from
|
|
18435
|
+
* tests without spinning up Ink.
|
|
18436
|
+
*
|
|
18437
|
+
* Algorithm:
|
|
18438
|
+
* 1. Walk lines in order. `@@` headers seed a new hunk and reset the
|
|
18439
|
+
* `oldLineNo` / `newLineNo` cursors from the header range.
|
|
18440
|
+
* 2. Inside a hunk, group the consecutive runs of `-` and `+` lines that
|
|
18441
|
+
* follow each other. Each run of removals + the immediately-following
|
|
18442
|
+
* run of additions forms a "change block" that pairs up element-wise:
|
|
18443
|
+
* row[i] = { left: removals[i], right: additions[i] }. When one side
|
|
18444
|
+
* is shorter, pad with `kind: 'empty'` rows so the columns stay
|
|
18445
|
+
* aligned.
|
|
18446
|
+
* 3. Context lines emit as a paired row with the same text on both
|
|
18447
|
+
* sides and the synthesized line numbers from each cursor.
|
|
18448
|
+
* 4. Diff metadata (`diff `, `index `, `--- `, `+++ `, etc.) emit as
|
|
18449
|
+
* `kind: 'header'` rows so the split view still has a section break.
|
|
18450
|
+
* 5. A context line that interrupts a change block forces the in-flight
|
|
18451
|
+
* block to flush before the context row is emitted — pairs are never
|
|
18452
|
+
* drawn across context boundaries (matches lazygit / fugitive
|
|
18453
|
+
* behavior, and is what the issue specifies).
|
|
18454
|
+
*
|
|
18455
|
+
* Long lines are not wrapped here — the renderer truncates per column at
|
|
18456
|
+
* paint time so this helper stays pure and trivially testable.
|
|
18457
|
+
*/
|
|
18458
|
+
const EMPTY_LEFT = { text: '', kind: 'empty' };
|
|
18459
|
+
const EMPTY_RIGHT = { text: '', kind: 'empty' };
|
|
18460
|
+
/**
|
|
18461
|
+
* Parse the start line numbers out of an `@@ -A,B +C,D @@` header. Returns
|
|
18462
|
+
* `[oldStart, newStart]`; either falls back to 1 when the header is
|
|
18463
|
+
* malformed (which only happens with synthetic / hand-crafted patches).
|
|
18464
|
+
*/
|
|
18465
|
+
function parseHunkHeader(line) {
|
|
18466
|
+
const match = /@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/.exec(line);
|
|
18467
|
+
if (!match) {
|
|
18468
|
+
return [1, 1];
|
|
18469
|
+
}
|
|
18470
|
+
return [Number(match[1]) || 1, Number(match[2]) || 1];
|
|
18471
|
+
}
|
|
18472
|
+
function isDiffHeader(line) {
|
|
18473
|
+
return (line.startsWith('diff ') ||
|
|
18474
|
+
line.startsWith('index ') ||
|
|
18475
|
+
line.startsWith('--- ') ||
|
|
18476
|
+
line.startsWith('+++ ') ||
|
|
18477
|
+
line.startsWith('similarity ') ||
|
|
18478
|
+
line.startsWith('rename ') ||
|
|
18479
|
+
line.startsWith('copy ') ||
|
|
18480
|
+
line.startsWith('new file ') ||
|
|
18481
|
+
line.startsWith('deleted file ') ||
|
|
18482
|
+
line.startsWith('old mode ') ||
|
|
18483
|
+
line.startsWith('new mode ') ||
|
|
18484
|
+
line.startsWith('Binary files '));
|
|
18485
|
+
}
|
|
18486
|
+
/**
|
|
18487
|
+
* Flush a pending change block (removals + additions accumulated from a
|
|
18488
|
+
* contiguous `-`/`+` run) into paired rows. Pads the shorter side with
|
|
18489
|
+
* empty placeholders so columns stay aligned.
|
|
18490
|
+
*/
|
|
18491
|
+
function flushChangeBlock(removals, additions, rows) {
|
|
18492
|
+
const max = Math.max(removals.length, additions.length);
|
|
18493
|
+
for (let i = 0; i < max; i++) {
|
|
18494
|
+
const left = removals[i] || EMPTY_LEFT;
|
|
18495
|
+
const right = additions[i] || EMPTY_RIGHT;
|
|
18496
|
+
rows.push({ left, right });
|
|
18497
|
+
}
|
|
18498
|
+
removals.length = 0;
|
|
18499
|
+
additions.length = 0;
|
|
18500
|
+
}
|
|
18501
|
+
function buildSplitDiffRows(unifiedLines) {
|
|
18502
|
+
const rows = [];
|
|
18503
|
+
let oldLineNo = 0;
|
|
18504
|
+
let newLineNo = 0;
|
|
18505
|
+
let inHunk = false;
|
|
18506
|
+
const removals = [];
|
|
18507
|
+
const additions = [];
|
|
18508
|
+
const flushHeader = (text) => {
|
|
18509
|
+
flushChangeBlock(removals, additions, rows);
|
|
18510
|
+
rows.push({
|
|
18511
|
+
left: { text, kind: 'header' },
|
|
18512
|
+
right: { text, kind: 'header' },
|
|
18513
|
+
});
|
|
18514
|
+
};
|
|
18515
|
+
for (const raw of unifiedLines) {
|
|
18516
|
+
if (raw.startsWith('@@')) {
|
|
18517
|
+
flushChangeBlock(removals, additions, rows);
|
|
18518
|
+
const [oldStart, newStart] = parseHunkHeader(raw);
|
|
18519
|
+
oldLineNo = oldStart;
|
|
18520
|
+
newLineNo = newStart;
|
|
18521
|
+
inHunk = true;
|
|
18522
|
+
rows.push({
|
|
18523
|
+
left: { text: raw, kind: 'header' },
|
|
18524
|
+
right: { text: raw, kind: 'header' },
|
|
18525
|
+
});
|
|
18526
|
+
continue;
|
|
18527
|
+
}
|
|
18528
|
+
if (!inHunk || isDiffHeader(raw)) {
|
|
18529
|
+
flushHeader(raw);
|
|
18530
|
+
continue;
|
|
18531
|
+
}
|
|
18532
|
+
if (raw.startsWith('-')) {
|
|
18533
|
+
removals.push({
|
|
18534
|
+
text: raw.slice(1),
|
|
18535
|
+
lineNumber: oldLineNo,
|
|
18536
|
+
kind: 'remove',
|
|
18537
|
+
});
|
|
18538
|
+
oldLineNo += 1;
|
|
18539
|
+
continue;
|
|
18540
|
+
}
|
|
18541
|
+
if (raw.startsWith('+')) {
|
|
18542
|
+
additions.push({
|
|
18543
|
+
text: raw.slice(1),
|
|
18544
|
+
lineNumber: newLineNo,
|
|
18545
|
+
kind: 'add',
|
|
18546
|
+
});
|
|
18547
|
+
newLineNo += 1;
|
|
18548
|
+
continue;
|
|
18549
|
+
}
|
|
18550
|
+
// Context line (or `` marker, which we
|
|
18551
|
+
// treat like a context row so it lands on both sides — readers
|
|
18552
|
+
// expect to see it in either column).
|
|
18553
|
+
flushChangeBlock(removals, additions, rows);
|
|
18554
|
+
const text = raw.startsWith(' ') ? raw.slice(1) : raw;
|
|
18555
|
+
rows.push({
|
|
18556
|
+
left: { text, lineNumber: oldLineNo, kind: 'context' },
|
|
18557
|
+
right: { text, lineNumber: newLineNo, kind: 'context' },
|
|
18558
|
+
});
|
|
18559
|
+
oldLineNo += 1;
|
|
18560
|
+
newLineNo += 1;
|
|
18561
|
+
}
|
|
18562
|
+
flushChangeBlock(removals, additions, rows);
|
|
18563
|
+
return rows;
|
|
18564
|
+
}
|
|
18565
|
+
|
|
17343
18566
|
/**
|
|
17344
18567
|
* Promoted-view selection rectification on filter changes (P4.5).
|
|
17345
18568
|
*
|
|
@@ -17589,10 +18812,25 @@ const LOG_INK_MIN_COLUMNS = 80;
|
|
|
17589
18812
|
const LOG_INK_MIN_ROWS = 24;
|
|
17590
18813
|
const LOG_INK_DEFAULT_COLUMNS = 120;
|
|
17591
18814
|
const LOG_INK_DEFAULT_ROWS = 40;
|
|
18815
|
+
/**
|
|
18816
|
+
* Terminal-row threshold below which the inspector switches to a
|
|
18817
|
+
* tabbed layout (commit-detail vs actions). Picked empirically: at
|
|
18818
|
+
* 28 rows the inspector's full stack (~30 rows when fully populated)
|
|
18819
|
+
* starts clipping the actions section; below that, the tabbed mode
|
|
18820
|
+
* gives both views their own air.
|
|
18821
|
+
*/
|
|
18822
|
+
const INSPECTOR_TABBED_BELOW_ROWS = 28;
|
|
17592
18823
|
function getLogInkLayout(input) {
|
|
17593
18824
|
const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
|
|
17594
18825
|
const rows = input.rows || LOG_INK_DEFAULT_ROWS;
|
|
17595
|
-
|
|
18826
|
+
// Inspector width — at rest 20-32 cells (~22% of width), focused
|
|
18827
|
+
// 36-60 cells (~40% of width). Narrow rest state keeps the commit
|
|
18828
|
+
// graph dominant; focus expansion gives the inspector room for long
|
|
18829
|
+
// commit bodies / file lists / action labels. Mirrors the sidebar
|
|
18830
|
+
// pattern (sidebarFocused above): instant transition per render.
|
|
18831
|
+
const detailWidth = input.inspectorFocused
|
|
18832
|
+
? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
|
|
18833
|
+
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
17596
18834
|
// Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
|
|
17597
18835
|
// (~36% of width). The transition is instant per render — focus tab to
|
|
17598
18836
|
// expand, focus away to collapse.
|
|
@@ -17607,6 +18845,7 @@ function getLogInkLayout(input) {
|
|
|
17607
18845
|
rows,
|
|
17608
18846
|
sidebarWidth,
|
|
17609
18847
|
tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
|
|
18848
|
+
inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
|
|
17610
18849
|
};
|
|
17611
18850
|
}
|
|
17612
18851
|
|
|
@@ -17761,7 +19000,8 @@ function createLogInkTheme(options = {}) {
|
|
|
17761
19000
|
/**
|
|
17762
19001
|
* Format a branch's relationship to its upstream.
|
|
17763
19002
|
* - no upstream → "no upstream"
|
|
17764
|
-
* - even → "
|
|
19003
|
+
* - even → "" (the boring default — keep the row tight; the row
|
|
19004
|
+
* marker already encodes "synced")
|
|
17765
19005
|
* - divergent → "↑<ahead> ↓<behind> <upstream>" (only the non-zero side
|
|
17766
19006
|
* is rendered so the line stays tight). ASCII mode falls back to the
|
|
17767
19007
|
* legacy `+N/-N` form.
|
|
@@ -17771,7 +19011,7 @@ function formatBranchDivergence(branch, options = {}) {
|
|
|
17771
19011
|
return 'no upstream';
|
|
17772
19012
|
}
|
|
17773
19013
|
if (branch.ahead === 0 && branch.behind === 0) {
|
|
17774
|
-
return
|
|
19014
|
+
return '';
|
|
17775
19015
|
}
|
|
17776
19016
|
if (options.ascii) {
|
|
17777
19017
|
return `+${branch.ahead}/-${branch.behind} ${branch.upstream}`;
|
|
@@ -17785,14 +19025,76 @@ function formatBranchDivergence(branch, options = {}) {
|
|
|
17785
19025
|
}
|
|
17786
19026
|
/**
|
|
17787
19027
|
* Single-cell marker shown to the left of a branch name in lists.
|
|
17788
|
-
*
|
|
19028
|
+
*
|
|
19029
|
+
* - `*` — current branch (regardless of remote state)
|
|
19030
|
+
* - `◌` — no upstream
|
|
19031
|
+
* - `≡` — has upstream + synced (ahead === 0 && behind === 0)
|
|
19032
|
+
* - `↕` — has upstream + diverged (any non-zero ahead/behind)
|
|
19033
|
+
* - ` ` — fallback / no info
|
|
19034
|
+
*
|
|
19035
|
+
* ASCII fallbacks (legible without box-drawing/arrow glyphs):
|
|
19036
|
+
* - `?` for "no upstream", `=` for synced, `~` for diverged.
|
|
17789
19037
|
*/
|
|
17790
19038
|
function branchRowMarker(branch, options = {}) {
|
|
17791
19039
|
if (branch.current)
|
|
17792
19040
|
return '*';
|
|
17793
19041
|
if (!branch.upstream)
|
|
17794
19042
|
return options.ascii ? '?' : '◌';
|
|
17795
|
-
|
|
19043
|
+
const ahead = branch.ahead ?? 0;
|
|
19044
|
+
const behind = branch.behind ?? 0;
|
|
19045
|
+
if (ahead === 0 && behind === 0) {
|
|
19046
|
+
return options.ascii ? '=' : '≡';
|
|
19047
|
+
}
|
|
19048
|
+
return options.ascii ? '~' : '↕';
|
|
19049
|
+
}
|
|
19050
|
+
/**
|
|
19051
|
+
* Compact, human-friendly relative timestamp for the branch row.
|
|
19052
|
+
* Inputs:
|
|
19053
|
+
* - `iso` — committer-date in `YYYY-MM-DD` form (as produced by
|
|
19054
|
+
* `for-each-ref` with `committerdate:short`).
|
|
19055
|
+
* - `now` — reference instant; pass it explicitly so callers can pin it
|
|
19056
|
+
* for deterministic tests.
|
|
19057
|
+
*
|
|
19058
|
+
* Outputs (rounded toward the nearest unit):
|
|
19059
|
+
* - `today`, `1d ago`, `2d ago` … up to 13d
|
|
19060
|
+
* - `2w ago` … up to 8w
|
|
19061
|
+
* - `2mo ago` … up to 12mo
|
|
19062
|
+
* - `2y ago` for older
|
|
19063
|
+
* - `''` for malformed inputs (caller renders nothing).
|
|
19064
|
+
*
|
|
19065
|
+
* "in the future" inputs (clock skew, bad data) collapse to `today`.
|
|
19066
|
+
*/
|
|
19067
|
+
function formatBranchLastTouched(iso, now) {
|
|
19068
|
+
if (!iso)
|
|
19069
|
+
return '';
|
|
19070
|
+
// Tolerate either `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS…` ISO strings.
|
|
19071
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
|
19072
|
+
if (!match)
|
|
19073
|
+
return '';
|
|
19074
|
+
const year = Number.parseInt(match[1], 10);
|
|
19075
|
+
const month = Number.parseInt(match[2], 10);
|
|
19076
|
+
const day = Number.parseInt(match[3], 10);
|
|
19077
|
+
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day))
|
|
19078
|
+
return '';
|
|
19079
|
+
// Compare at day granularity in UTC so a branch touched "yesterday"
|
|
19080
|
+
// never reads "today" depending on the operator's timezone.
|
|
19081
|
+
const branchUtc = Date.UTC(year, month - 1, day);
|
|
19082
|
+
const nowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
19083
|
+
const diffMs = nowUtc - branchUtc;
|
|
19084
|
+
const oneDay = 24 * 60 * 60 * 1000;
|
|
19085
|
+
const days = Math.floor(diffMs / oneDay);
|
|
19086
|
+
if (days <= 0)
|
|
19087
|
+
return 'today';
|
|
19088
|
+
if (days < 14)
|
|
19089
|
+
return `${days}d ago`;
|
|
19090
|
+
const weeks = Math.floor(days / 7);
|
|
19091
|
+
if (weeks < 9)
|
|
19092
|
+
return `${weeks}w ago`;
|
|
19093
|
+
const months = Math.floor(days / 30);
|
|
19094
|
+
if (months < 12)
|
|
19095
|
+
return `${months}mo ago`;
|
|
19096
|
+
const years = Math.floor(days / 365);
|
|
19097
|
+
return `${years}y ago`;
|
|
17796
19098
|
}
|
|
17797
19099
|
/**
|
|
17798
19100
|
* Pick the glyph + color for a PR state badge.
|
|
@@ -18222,7 +19524,7 @@ function createChangelogArgv(input) {
|
|
|
18222
19524
|
...input,
|
|
18223
19525
|
};
|
|
18224
19526
|
}
|
|
18225
|
-
function compactOutputLines$
|
|
19527
|
+
function compactOutputLines$3(output) {
|
|
18226
19528
|
return output
|
|
18227
19529
|
.split('\n')
|
|
18228
19530
|
.map((line) => line.trim())
|
|
@@ -18246,7 +19548,7 @@ async function captureStdout(action) {
|
|
|
18246
19548
|
}
|
|
18247
19549
|
}
|
|
18248
19550
|
function formatCapturedAiOutput(output) {
|
|
18249
|
-
const lines = compactOutputLines$
|
|
19551
|
+
const lines = compactOutputLines$3(output);
|
|
18250
19552
|
const telemetry = lines.filter((line) => line.includes('[llm:summary]'));
|
|
18251
19553
|
const content = lines.filter((line) => !line.includes('[llm]') && !line.includes('[llm:summary]'));
|
|
18252
19554
|
const editable = content.join('\n');
|
|
@@ -18543,8 +19845,65 @@ function parsePullRequestInfo(output) {
|
|
|
18543
19845
|
if (!trimmed) {
|
|
18544
19846
|
return undefined;
|
|
18545
19847
|
}
|
|
18546
|
-
|
|
19848
|
+
const raw = JSON.parse(trimmed);
|
|
19849
|
+
const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
|
|
19850
|
+
? String(raw.author.login)
|
|
19851
|
+
: undefined;
|
|
19852
|
+
return {
|
|
19853
|
+
number: raw.number,
|
|
19854
|
+
title: raw.title,
|
|
19855
|
+
url: raw.url,
|
|
19856
|
+
state: raw.state,
|
|
19857
|
+
isDraft: raw.isDraft,
|
|
19858
|
+
headRefName: raw.headRefName,
|
|
19859
|
+
baseRefName: raw.baseRefName,
|
|
19860
|
+
body: typeof raw.body === 'string' ? raw.body : undefined,
|
|
19861
|
+
author,
|
|
19862
|
+
reviewDecision: typeof raw.reviewDecision === 'string' ? raw.reviewDecision : undefined,
|
|
19863
|
+
mergeable: typeof raw.mergeable === 'string' ? raw.mergeable : undefined,
|
|
19864
|
+
mergeStateStatus: typeof raw.mergeStateStatus === 'string' ? raw.mergeStateStatus : undefined,
|
|
19865
|
+
statusCheckRollup: Array.isArray(raw.statusCheckRollup)
|
|
19866
|
+
? raw.statusCheckRollup.map((entry) => ({
|
|
19867
|
+
name: String(entry.name || entry.context || 'check'),
|
|
19868
|
+
status: typeof entry.status === 'string' ? entry.status : undefined,
|
|
19869
|
+
conclusion: typeof entry.conclusion === 'string' ? entry.conclusion : undefined,
|
|
19870
|
+
}))
|
|
19871
|
+
: undefined,
|
|
19872
|
+
reviews: Array.isArray(raw.reviews)
|
|
19873
|
+
? raw.reviews.map((entry) => {
|
|
19874
|
+
const author = entry.author && typeof entry.author === 'object' && 'login' in entry.author
|
|
19875
|
+
? String(entry.author.login)
|
|
19876
|
+
: '';
|
|
19877
|
+
return {
|
|
19878
|
+
author,
|
|
19879
|
+
state: typeof entry.state === 'string' ? entry.state : '',
|
|
19880
|
+
};
|
|
19881
|
+
}).filter((review) => review.author)
|
|
19882
|
+
: undefined,
|
|
19883
|
+
};
|
|
18547
19884
|
}
|
|
19885
|
+
/**
|
|
19886
|
+
* `gh pr view --json` field list. Centralized so the data fetcher and
|
|
19887
|
+
* any future re-fetch (e.g., refresh after a merge action) request the
|
|
19888
|
+
* same shape — the parser depends on every field being present, even
|
|
19889
|
+
* if optional, so they're safe to deserialize.
|
|
19890
|
+
*/
|
|
19891
|
+
const PULL_REQUEST_VIEW_JSON_FIELDS = [
|
|
19892
|
+
'number',
|
|
19893
|
+
'title',
|
|
19894
|
+
'url',
|
|
19895
|
+
'state',
|
|
19896
|
+
'isDraft',
|
|
19897
|
+
'headRefName',
|
|
19898
|
+
'baseRefName',
|
|
19899
|
+
'body',
|
|
19900
|
+
'author',
|
|
19901
|
+
'reviewDecision',
|
|
19902
|
+
'mergeable',
|
|
19903
|
+
'mergeStateStatus',
|
|
19904
|
+
'statusCheckRollup',
|
|
19905
|
+
'reviews',
|
|
19906
|
+
].join(',');
|
|
18548
19907
|
async function getPullRequestOverview(git, runner = defaultGhRunner) {
|
|
18549
19908
|
const [repository, currentBranchOutput] = await Promise.all([
|
|
18550
19909
|
getGitHubRepository(git),
|
|
@@ -18576,7 +19935,7 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
|
|
|
18576
19935
|
'pr',
|
|
18577
19936
|
'view',
|
|
18578
19937
|
'--json',
|
|
18579
|
-
|
|
19938
|
+
PULL_REQUEST_VIEW_JSON_FIELDS,
|
|
18580
19939
|
]);
|
|
18581
19940
|
return {
|
|
18582
19941
|
available: true,
|
|
@@ -18743,7 +20102,7 @@ function providerBranchName(branch) {
|
|
|
18743
20102
|
return branch.shortName;
|
|
18744
20103
|
}
|
|
18745
20104
|
|
|
18746
|
-
function compactOutputLines$
|
|
20105
|
+
function compactOutputLines$2(output) {
|
|
18747
20106
|
return output
|
|
18748
20107
|
.split('\n')
|
|
18749
20108
|
.map((line) => line.trim())
|
|
@@ -18758,7 +20117,7 @@ async function runAction$5(action, successMessage) {
|
|
|
18758
20117
|
};
|
|
18759
20118
|
}
|
|
18760
20119
|
catch (error) {
|
|
18761
|
-
const lines = compactOutputLines$
|
|
20120
|
+
const lines = compactOutputLines$2(error.message);
|
|
18762
20121
|
return {
|
|
18763
20122
|
ok: false,
|
|
18764
20123
|
message: lines[0] || 'History action failed.',
|
|
@@ -18900,7 +20259,7 @@ async function compareCommits(git, from, to) {
|
|
|
18900
20259
|
}
|
|
18901
20260
|
try {
|
|
18902
20261
|
const output = await git.raw(['diff', '--stat', '--color=never', `${from.hash}..${to.hash}`]);
|
|
18903
|
-
const lines = compactOutputLines$
|
|
20262
|
+
const lines = compactOutputLines$2(output);
|
|
18904
20263
|
return {
|
|
18905
20264
|
ok: true,
|
|
18906
20265
|
message: `Compared ${from.shortHash}..${to.shortHash}`,
|
|
@@ -18982,6 +20341,63 @@ function resetToCommit(git, commit, mode) {
|
|
|
18982
20341
|
: result.details,
|
|
18983
20342
|
}));
|
|
18984
20343
|
}
|
|
20344
|
+
/**
|
|
20345
|
+
* Create a new local branch pointed at <commit>, without switching to it.
|
|
20346
|
+
*
|
|
20347
|
+
* This is the "create branch from cursored commit" history action — the
|
|
20348
|
+
* user types the new branch name into an input prompt and we run
|
|
20349
|
+
* `git branch <name> <sha>` (NOT `git switch -c`, which is what
|
|
20350
|
+
* `branchActions.createBranch` does for the create-branch-at-HEAD flow).
|
|
20351
|
+
* The split exists because GitKraken-style "create branch here" is
|
|
20352
|
+
* specifically about marking a historical commit, not about switching
|
|
20353
|
+
* onto a new working branch.
|
|
20354
|
+
*
|
|
20355
|
+
* Note for the inspector follow-up: workflow surfacing is driven by the
|
|
20356
|
+
* registry in `inkWorkflows.ts`, not a hardcoded action list — adding
|
|
20357
|
+
* `create-branch-here` there is enough for the inspector / palette to
|
|
20358
|
+
* pick this up.
|
|
20359
|
+
*/
|
|
20360
|
+
function createBranchFromCommit(git, name, commit) {
|
|
20361
|
+
const trimmedName = name.trim();
|
|
20362
|
+
if (!commit) {
|
|
20363
|
+
return Promise.resolve({
|
|
20364
|
+
ok: false,
|
|
20365
|
+
message: 'No commit selected.',
|
|
20366
|
+
});
|
|
20367
|
+
}
|
|
20368
|
+
if (!trimmedName) {
|
|
20369
|
+
return Promise.resolve({
|
|
20370
|
+
ok: false,
|
|
20371
|
+
message: 'Branch name required.',
|
|
20372
|
+
});
|
|
20373
|
+
}
|
|
20374
|
+
return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['branch', trimmedName, commit.hash]), `Created branch ${trimmedName} at ${commit.shortHash}`)));
|
|
20375
|
+
}
|
|
20376
|
+
/**
|
|
20377
|
+
* Create a lightweight tag pointed at <commit>.
|
|
20378
|
+
*
|
|
20379
|
+
* Mirrors `createBranchFromCommit` for the tag side: the user types a
|
|
20380
|
+
* tag name into an input prompt and we run `git tag <name> <sha>`
|
|
20381
|
+
* (lightweight, no `-a`/`-m`). Annotated tags remain available through
|
|
20382
|
+
* the existing `+` flow on the tags view; this is the per-commit
|
|
20383
|
+
* shortcut.
|
|
20384
|
+
*/
|
|
20385
|
+
function createTagAtCommit(git, name, commit) {
|
|
20386
|
+
const trimmedName = name.trim();
|
|
20387
|
+
if (!commit) {
|
|
20388
|
+
return Promise.resolve({
|
|
20389
|
+
ok: false,
|
|
20390
|
+
message: 'No commit selected.',
|
|
20391
|
+
});
|
|
20392
|
+
}
|
|
20393
|
+
if (!trimmedName) {
|
|
20394
|
+
return Promise.resolve({
|
|
20395
|
+
ok: false,
|
|
20396
|
+
message: 'Tag name required.',
|
|
20397
|
+
});
|
|
20398
|
+
}
|
|
20399
|
+
return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['tag', trimmedName, commit.hash]), `Created tag ${trimmedName} at ${commit.shortHash}`)));
|
|
20400
|
+
}
|
|
18985
20401
|
function startInteractiveRebase(git, commit) {
|
|
18986
20402
|
if (!commit) {
|
|
18987
20403
|
return Promise.resolve({
|
|
@@ -19067,6 +20483,61 @@ function createPullRequest(input, runner = defaultGhRunner) {
|
|
|
19067
20483
|
};
|
|
19068
20484
|
});
|
|
19069
20485
|
}
|
|
20486
|
+
function isPullRequestMergeStrategy(value) {
|
|
20487
|
+
return value === 'merge' || value === 'squash' || value === 'rebase';
|
|
20488
|
+
}
|
|
20489
|
+
function buildMergePullRequestArgs(strategy) {
|
|
20490
|
+
// `--auto` and `--admin` are intentionally omitted — they're rarely
|
|
20491
|
+
// what a user wants from a TUI and require explicit gh auth scopes.
|
|
20492
|
+
// `--delete-branch` is opt-in via a future flag; default leaves the
|
|
20493
|
+
// branch in place so the user can verify before cleanup.
|
|
20494
|
+
return ['pr', 'merge', `--${strategy}`];
|
|
20495
|
+
}
|
|
20496
|
+
function mergePullRequest(strategy, runner = defaultGhRunner) {
|
|
20497
|
+
return runGhAction(runner, buildMergePullRequestArgs(strategy), (output) => ({
|
|
20498
|
+
ok: true,
|
|
20499
|
+
message: output.trim() || `Merged pull request with ${strategy}`,
|
|
20500
|
+
}));
|
|
20501
|
+
}
|
|
20502
|
+
function closePullRequest(runner = defaultGhRunner) {
|
|
20503
|
+
return runGhAction(runner, ['pr', 'close'], (output) => ({
|
|
20504
|
+
ok: true,
|
|
20505
|
+
message: output.trim() || 'Closed pull request',
|
|
20506
|
+
}));
|
|
20507
|
+
}
|
|
20508
|
+
/**
|
|
20509
|
+
* `gh pr review --approve` requires the user's gh auth to have scope
|
|
20510
|
+
* to write reviews — same scope that the in-browser approve button
|
|
20511
|
+
* uses. The runner surfaces auth failures via the standard error path.
|
|
20512
|
+
*/
|
|
20513
|
+
function approvePullRequest(runner = defaultGhRunner) {
|
|
20514
|
+
return runGhAction(runner, ['pr', 'review', '--approve'], (output) => ({
|
|
20515
|
+
ok: true,
|
|
20516
|
+
message: output.trim() || 'Approved pull request',
|
|
20517
|
+
}));
|
|
20518
|
+
}
|
|
20519
|
+
/**
|
|
20520
|
+
* Request changes — `gh pr review` requires a body with this verb so
|
|
20521
|
+
* the empty-body case is rejected upstream by the input prompt.
|
|
20522
|
+
*/
|
|
20523
|
+
function requestChangesPullRequest(body, runner = defaultGhRunner) {
|
|
20524
|
+
if (!body.trim()) {
|
|
20525
|
+
return Promise.resolve({ ok: false, message: 'Review body required for change-request' });
|
|
20526
|
+
}
|
|
20527
|
+
return runGhAction(runner, ['pr', 'review', '--request-changes', '--body', body], (output) => ({
|
|
20528
|
+
ok: true,
|
|
20529
|
+
message: output.trim() || 'Requested changes',
|
|
20530
|
+
}));
|
|
20531
|
+
}
|
|
20532
|
+
function commentPullRequest(body, runner = defaultGhRunner) {
|
|
20533
|
+
if (!body.trim()) {
|
|
20534
|
+
return Promise.resolve({ ok: false, message: 'Comment body required' });
|
|
20535
|
+
}
|
|
20536
|
+
return runGhAction(runner, ['pr', 'comment', '--body', body], (output) => ({
|
|
20537
|
+
ok: true,
|
|
20538
|
+
message: output.trim() || 'Comment added',
|
|
20539
|
+
}));
|
|
20540
|
+
}
|
|
19070
20541
|
|
|
19071
20542
|
async function runAction$4(action, successMessage) {
|
|
19072
20543
|
try {
|
|
@@ -19691,7 +21162,7 @@ function applyLogTuiAction(state, action) {
|
|
|
19691
21162
|
}
|
|
19692
21163
|
}
|
|
19693
21164
|
|
|
19694
|
-
function compactOutputLines(output) {
|
|
21165
|
+
function compactOutputLines$1(output) {
|
|
19695
21166
|
return output
|
|
19696
21167
|
.split('\n')
|
|
19697
21168
|
.map((line) => line.trim())
|
|
@@ -19706,7 +21177,7 @@ async function runAction(action, successMessage) {
|
|
|
19706
21177
|
};
|
|
19707
21178
|
}
|
|
19708
21179
|
catch (error) {
|
|
19709
|
-
const details = compactOutputLines(error.message);
|
|
21180
|
+
const details = compactOutputLines$1(error.message);
|
|
19710
21181
|
return {
|
|
19711
21182
|
ok: false,
|
|
19712
21183
|
message: details[0] || 'Git operation action failed.',
|
|
@@ -21385,6 +22856,356 @@ async function startInteractiveLog(git, rows, streams = {}) {
|
|
|
21385
22856
|
});
|
|
21386
22857
|
}
|
|
21387
22858
|
|
|
22859
|
+
function compactOutputLines(output) {
|
|
22860
|
+
return output
|
|
22861
|
+
.split('\n')
|
|
22862
|
+
.map((line) => line.trim())
|
|
22863
|
+
.filter(Boolean);
|
|
22864
|
+
}
|
|
22865
|
+
async function safeUnlink(path) {
|
|
22866
|
+
try {
|
|
22867
|
+
await fs.promises.unlink(path);
|
|
22868
|
+
}
|
|
22869
|
+
catch (error) {
|
|
22870
|
+
// ENOENT is fine — the temp file was never created or already
|
|
22871
|
+
// cleaned up. Anything else we silently swallow because the
|
|
22872
|
+
// worst-case impact is a single ~1KB file in $TMPDIR.
|
|
22873
|
+
error.code;
|
|
22874
|
+
}
|
|
22875
|
+
}
|
|
22876
|
+
/**
|
|
22877
|
+
* Write a unified-diff patch to a temp file and feed it to
|
|
22878
|
+
* `git apply` (or `git apply --cached` when target === 'index').
|
|
22879
|
+
*
|
|
22880
|
+
* This is the runner behind the `apply-hunk-worktree` /
|
|
22881
|
+
* `apply-hunk-index` workflow actions — the input handler builds
|
|
22882
|
+
* `patchText` from the cursored hunk via `extractDiffHunk` and the
|
|
22883
|
+
* runtime hands it here.
|
|
22884
|
+
*
|
|
22885
|
+
* `--whitespace=nowarn` keeps `git apply` quiet about trailing
|
|
22886
|
+
* whitespace differences (the most common false positive when the
|
|
22887
|
+
* patch comes from a stash made on a different platform). Real
|
|
22888
|
+
* conflicts still surface via the non-zero exit code.
|
|
22889
|
+
*
|
|
22890
|
+
* The patch is written to a temp file rather than piped on stdin
|
|
22891
|
+
* because some `simple-git` adapters don't expose a clean stdin
|
|
22892
|
+
* channel for `git.raw`; the tempfile path keeps the runner
|
|
22893
|
+
* portable across environments.
|
|
22894
|
+
*/
|
|
22895
|
+
async function applyHunkPatch(git, patchText, options) {
|
|
22896
|
+
if (!patchText.trim()) {
|
|
22897
|
+
return {
|
|
22898
|
+
ok: false,
|
|
22899
|
+
message: 'No hunk under cursor to apply.',
|
|
22900
|
+
};
|
|
22901
|
+
}
|
|
22902
|
+
const targetLabel = options.target === 'index' ? 'index' : 'worktree';
|
|
22903
|
+
const tempPath = path.join(os.tmpdir(), `coco-hunk-${crypto$1.randomUUID()}.patch`);
|
|
22904
|
+
try {
|
|
22905
|
+
await fs.promises.writeFile(tempPath, patchText, 'utf8');
|
|
22906
|
+
const args = ['apply'];
|
|
22907
|
+
if (options.target === 'index') {
|
|
22908
|
+
args.push('--cached');
|
|
22909
|
+
}
|
|
22910
|
+
args.push('--whitespace=nowarn');
|
|
22911
|
+
args.push(tempPath);
|
|
22912
|
+
try {
|
|
22913
|
+
await git.raw(args);
|
|
22914
|
+
return {
|
|
22915
|
+
ok: true,
|
|
22916
|
+
message: `Applied hunk to ${targetLabel}`,
|
|
22917
|
+
};
|
|
22918
|
+
}
|
|
22919
|
+
catch (error) {
|
|
22920
|
+
const lines = compactOutputLines(error.message);
|
|
22921
|
+
return {
|
|
22922
|
+
ok: false,
|
|
22923
|
+
message: lines[0] || `Failed to apply hunk to ${targetLabel}`,
|
|
22924
|
+
details: lines.slice(1, 6),
|
|
22925
|
+
};
|
|
22926
|
+
}
|
|
22927
|
+
}
|
|
22928
|
+
catch (error) {
|
|
22929
|
+
return {
|
|
22930
|
+
ok: false,
|
|
22931
|
+
message: `Could not stage hunk for apply: ${error.message}`,
|
|
22932
|
+
};
|
|
22933
|
+
}
|
|
22934
|
+
finally {
|
|
22935
|
+
await safeUnlink(tempPath);
|
|
22936
|
+
}
|
|
22937
|
+
}
|
|
22938
|
+
|
|
22939
|
+
function formatStashHeaderIdentity(ref, stashes) {
|
|
22940
|
+
if (!ref) {
|
|
22941
|
+
return { subtitle: 'no stash', bodyLine: 'Stash:' };
|
|
22942
|
+
}
|
|
22943
|
+
const index = stashes?.findIndex((entry) => entry.ref === ref) ?? -1;
|
|
22944
|
+
const entry = index >= 0 ? stashes[index] : undefined;
|
|
22945
|
+
if (!entry) {
|
|
22946
|
+
return {
|
|
22947
|
+
subtitle: ref,
|
|
22948
|
+
bodyLine: `Stash: ${ref}`,
|
|
22949
|
+
};
|
|
22950
|
+
}
|
|
22951
|
+
const onBranch = entry.branch && entry.branch !== '<unknown>' ? ` on ${entry.branch}` : '';
|
|
22952
|
+
const message = entry.message?.trim() || '(no message)';
|
|
22953
|
+
return {
|
|
22954
|
+
subtitle: `@{${index}} ${message}${onBranch}`,
|
|
22955
|
+
bodyLine: `Stash: ${ref}${onBranch} — ${message}`,
|
|
22956
|
+
};
|
|
22957
|
+
}
|
|
22958
|
+
|
|
22959
|
+
/**
|
|
22960
|
+
* Normalize gh's two parallel signals (`status` for in-flight check
|
|
22961
|
+
* runs, `conclusion` for completed runs and status contexts) into a
|
|
22962
|
+
* single status enum the renderer can map to a glyph + color.
|
|
22963
|
+
*/
|
|
22964
|
+
function normalizePullRequestCheckStatus(check) {
|
|
22965
|
+
const status = (check.status || '').toUpperCase();
|
|
22966
|
+
const conclusion = (check.conclusion || '').toUpperCase();
|
|
22967
|
+
// In-flight check runs: gh emits `status: IN_PROGRESS|QUEUED` with
|
|
22968
|
+
// no conclusion yet. `PENDING` covers status-context runs that are
|
|
22969
|
+
// still waiting on a reporter.
|
|
22970
|
+
if (!conclusion && (status === 'IN_PROGRESS' || status === 'QUEUED' || status === 'PENDING')) {
|
|
22971
|
+
return 'pending';
|
|
22972
|
+
}
|
|
22973
|
+
switch (conclusion || status) {
|
|
22974
|
+
case 'SUCCESS':
|
|
22975
|
+
return 'success';
|
|
22976
|
+
case 'FAILURE':
|
|
22977
|
+
case 'ERROR':
|
|
22978
|
+
case 'TIMED_OUT':
|
|
22979
|
+
case 'ACTION_REQUIRED':
|
|
22980
|
+
return 'failure';
|
|
22981
|
+
case 'NEUTRAL':
|
|
22982
|
+
return 'neutral';
|
|
22983
|
+
case 'SKIPPED':
|
|
22984
|
+
case 'CANCELLED':
|
|
22985
|
+
return 'skipped';
|
|
22986
|
+
default:
|
|
22987
|
+
return 'pending';
|
|
22988
|
+
}
|
|
22989
|
+
}
|
|
22990
|
+
/**
|
|
22991
|
+
* Glyph for a normalized check status. ASCII fallbacks keep the panel
|
|
22992
|
+
* usable on legacy terminals where the geometric shapes block isn't
|
|
22993
|
+
* rendered.
|
|
22994
|
+
*/
|
|
22995
|
+
function pullRequestCheckGlyph(status, options = {}) {
|
|
22996
|
+
if (options.ascii) {
|
|
22997
|
+
switch (status) {
|
|
22998
|
+
case 'success': return '+';
|
|
22999
|
+
case 'failure': return 'x';
|
|
23000
|
+
case 'pending': return '.';
|
|
23001
|
+
case 'neutral': return '-';
|
|
23002
|
+
case 'skipped': return '/';
|
|
23003
|
+
}
|
|
23004
|
+
}
|
|
23005
|
+
switch (status) {
|
|
23006
|
+
case 'success': return '✓';
|
|
23007
|
+
case 'failure': return '✗';
|
|
23008
|
+
case 'pending': return '◌';
|
|
23009
|
+
case 'neutral': return '○';
|
|
23010
|
+
case 'skipped': return '∼';
|
|
23011
|
+
}
|
|
23012
|
+
}
|
|
23013
|
+
function summarizePullRequestChecks(checks) {
|
|
23014
|
+
const summary = {
|
|
23015
|
+
total: 0, success: 0, failure: 0, pending: 0, neutral: 0, skipped: 0,
|
|
23016
|
+
};
|
|
23017
|
+
if (!checks)
|
|
23018
|
+
return summary;
|
|
23019
|
+
for (const check of checks) {
|
|
23020
|
+
summary.total += 1;
|
|
23021
|
+
summary[normalizePullRequestCheckStatus(check)] += 1;
|
|
23022
|
+
}
|
|
23023
|
+
return summary;
|
|
23024
|
+
}
|
|
23025
|
+
/**
|
|
23026
|
+
* One-line summary like `5 checks · 4 ✓ · 1 ◌` for the panel header.
|
|
23027
|
+
* Hides zero-count categories so the line stays scannable.
|
|
23028
|
+
*/
|
|
23029
|
+
function formatPullRequestChecksSummary(summary, options = {}) {
|
|
23030
|
+
if (summary.total === 0) {
|
|
23031
|
+
return 'No status checks reported';
|
|
23032
|
+
}
|
|
23033
|
+
const parts = [`${summary.total} ${summary.total === 1 ? 'check' : 'checks'}`];
|
|
23034
|
+
const push = (count, status) => {
|
|
23035
|
+
if (count > 0)
|
|
23036
|
+
parts.push(`${count} ${pullRequestCheckGlyph(status, options)}`);
|
|
23037
|
+
};
|
|
23038
|
+
push(summary.success, 'success');
|
|
23039
|
+
push(summary.failure, 'failure');
|
|
23040
|
+
push(summary.pending, 'pending');
|
|
23041
|
+
push(summary.neutral, 'neutral');
|
|
23042
|
+
push(summary.skipped, 'skipped');
|
|
23043
|
+
return parts.join(' · ');
|
|
23044
|
+
}
|
|
23045
|
+
function buildPullRequestCheckRows(checks, options = {}) {
|
|
23046
|
+
if (!checks)
|
|
23047
|
+
return [];
|
|
23048
|
+
return checks.map((check) => {
|
|
23049
|
+
const status = normalizePullRequestCheckStatus(check);
|
|
23050
|
+
return {
|
|
23051
|
+
glyph: pullRequestCheckGlyph(status, options),
|
|
23052
|
+
name: check.name,
|
|
23053
|
+
status,
|
|
23054
|
+
detail: (check.conclusion || check.status || '').toLowerCase(),
|
|
23055
|
+
};
|
|
23056
|
+
});
|
|
23057
|
+
}
|
|
23058
|
+
function summarizePullRequestReviews(reviews, reviewDecision) {
|
|
23059
|
+
const summary = {
|
|
23060
|
+
total: 0, approved: 0, changesRequested: 0, commented: 0, dismissed: 0, pending: 0,
|
|
23061
|
+
decisionLabel: reviewDecision || undefined,
|
|
23062
|
+
};
|
|
23063
|
+
if (!reviews)
|
|
23064
|
+
return summary;
|
|
23065
|
+
for (const review of reviews) {
|
|
23066
|
+
summary.total += 1;
|
|
23067
|
+
switch (review.state.toUpperCase()) {
|
|
23068
|
+
case 'APPROVED':
|
|
23069
|
+
summary.approved += 1;
|
|
23070
|
+
break;
|
|
23071
|
+
case 'CHANGES_REQUESTED':
|
|
23072
|
+
summary.changesRequested += 1;
|
|
23073
|
+
break;
|
|
23074
|
+
case 'COMMENTED':
|
|
23075
|
+
summary.commented += 1;
|
|
23076
|
+
break;
|
|
23077
|
+
case 'DISMISSED':
|
|
23078
|
+
summary.dismissed += 1;
|
|
23079
|
+
break;
|
|
23080
|
+
case 'PENDING':
|
|
23081
|
+
summary.pending += 1;
|
|
23082
|
+
break;
|
|
23083
|
+
}
|
|
23084
|
+
}
|
|
23085
|
+
return summary;
|
|
23086
|
+
}
|
|
23087
|
+
function formatPullRequestReviewsSummary(summary) {
|
|
23088
|
+
const decision = summary.decisionLabel
|
|
23089
|
+
? summary.decisionLabel.replace(/_/g, ' ').toLowerCase()
|
|
23090
|
+
: undefined;
|
|
23091
|
+
if (summary.total === 0) {
|
|
23092
|
+
return decision ? `No reviews · ${decision}` : 'No reviews submitted';
|
|
23093
|
+
}
|
|
23094
|
+
const parts = [`${summary.total} ${summary.total === 1 ? 'review' : 'reviews'}`];
|
|
23095
|
+
if (summary.approved > 0)
|
|
23096
|
+
parts.push(`${summary.approved} approved`);
|
|
23097
|
+
if (summary.changesRequested > 0)
|
|
23098
|
+
parts.push(`${summary.changesRequested} changes requested`);
|
|
23099
|
+
if (summary.commented > 0)
|
|
23100
|
+
parts.push(`${summary.commented} commented`);
|
|
23101
|
+
if (summary.pending > 0)
|
|
23102
|
+
parts.push(`${summary.pending} pending`);
|
|
23103
|
+
if (summary.dismissed > 0)
|
|
23104
|
+
parts.push(`${summary.dismissed} dismissed`);
|
|
23105
|
+
if (decision)
|
|
23106
|
+
parts.push(`decision: ${decision}`);
|
|
23107
|
+
return parts.join(' · ');
|
|
23108
|
+
}
|
|
23109
|
+
/**
|
|
23110
|
+
* One-line state badge for the header, e.g. `OPEN · draft` or `MERGED`.
|
|
23111
|
+
* Mergeable / merge-state is appended as a secondary chip when the PR
|
|
23112
|
+
* is open so the user sees `MERGEABLE` / `CONFLICTING` at a glance.
|
|
23113
|
+
*/
|
|
23114
|
+
function formatPullRequestStateLine(pr) {
|
|
23115
|
+
const parts = [pr.state];
|
|
23116
|
+
if (pr.isDraft)
|
|
23117
|
+
parts.push('draft');
|
|
23118
|
+
if (pr.state === 'OPEN' && pr.mergeable) {
|
|
23119
|
+
parts.push(pr.mergeable.toLowerCase());
|
|
23120
|
+
}
|
|
23121
|
+
if (pr.state === 'OPEN' && pr.mergeStateStatus && pr.mergeStateStatus !== 'CLEAN') {
|
|
23122
|
+
parts.push(pr.mergeStateStatus.toLowerCase());
|
|
23123
|
+
}
|
|
23124
|
+
return parts.join(' · ');
|
|
23125
|
+
}
|
|
23126
|
+
|
|
23127
|
+
/**
|
|
23128
|
+
* Hardcoded per-entity action lists surfaced inside the right-hand
|
|
23129
|
+
* inspector panel. The inspector used to repeat the repo / branch /
|
|
23130
|
+
* status content the top header and left sidebar already show; we drop
|
|
23131
|
+
* that trailer in favor of an actionable cheat-sheet so the user knows
|
|
23132
|
+
* exactly which keystrokes apply to whatever they have under the cursor.
|
|
23133
|
+
*
|
|
23134
|
+
* Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
|
|
23135
|
+
* - Most per-entity actions live in `inkInput.ts` as direct keystroke
|
|
23136
|
+
* handlers (e.g. `c` cherry-pick, `R` revert) rather than as
|
|
23137
|
+
* globally-registered bindings, so the registry would be a partial
|
|
23138
|
+
* view at best.
|
|
23139
|
+
* - The bindings registry's `contexts` model (normal / search / focus
|
|
23140
|
+
* name) does not cleanly map to inspector entity types like "branch"
|
|
23141
|
+
* or "tag". Filtering it would mean replicating the same per-view
|
|
23142
|
+
* scoping logic the input dispatcher already encodes.
|
|
23143
|
+
* - New per-entity actions are added infrequently — the maintenance
|
|
23144
|
+
* cost of mirroring them here is low and keeps this file the single
|
|
23145
|
+
* source of truth for "what shows in the inspector".
|
|
23146
|
+
*
|
|
23147
|
+
* If you wire up a new per-entity keystroke in `inkInput.ts` — for
|
|
23148
|
+
* example a "create branch from this commit" or "create tag from this
|
|
23149
|
+
* commit" action — add the matching row to the relevant array below so
|
|
23150
|
+
* it shows up in the inspector automatically.
|
|
23151
|
+
*/
|
|
23152
|
+
const HISTORY_COMMIT_ACTIONS = [
|
|
23153
|
+
{ key: 'enter', label: 'Open diff' },
|
|
23154
|
+
{ key: 'c', label: 'Cherry-pick' },
|
|
23155
|
+
{ key: 'R', label: 'Revert', destructive: true },
|
|
23156
|
+
{ key: 'Z', label: 'Reset to commit', destructive: true },
|
|
23157
|
+
{ key: 'i', label: 'Interactive rebase', destructive: true },
|
|
23158
|
+
{ key: 'y', label: 'Yank hash' },
|
|
23159
|
+
{ key: 'Y', label: 'Yank short hash' },
|
|
23160
|
+
{ key: 'O', label: 'Open in browser' },
|
|
23161
|
+
];
|
|
23162
|
+
const BRANCH_ACTIONS = [
|
|
23163
|
+
{ key: 'enter', label: 'Checkout' },
|
|
23164
|
+
{ key: '+', label: 'New branch' },
|
|
23165
|
+
{ key: 'R', label: 'Rename' },
|
|
23166
|
+
{ key: 'u', label: 'Set upstream' },
|
|
23167
|
+
{ key: 'D', label: 'Delete', destructive: true },
|
|
23168
|
+
{ key: 'P', label: 'Push current' },
|
|
23169
|
+
{ key: 'F', label: 'Fetch all' },
|
|
23170
|
+
{ key: 'y', label: 'Yank name' },
|
|
23171
|
+
];
|
|
23172
|
+
const TAG_ACTIONS = [
|
|
23173
|
+
{ key: '+', label: 'New tag' },
|
|
23174
|
+
{ key: 'P', label: 'Push tag' },
|
|
23175
|
+
{ key: 'T', label: 'Delete', destructive: true },
|
|
23176
|
+
{ key: 'R', label: 'Delete remote', destructive: true },
|
|
23177
|
+
{ key: 'y', label: 'Yank name' },
|
|
23178
|
+
];
|
|
23179
|
+
const STASH_ACTIONS = [
|
|
23180
|
+
{ key: 'enter', label: 'Open diff' },
|
|
23181
|
+
{ key: 'a', label: 'Apply' },
|
|
23182
|
+
{ key: 'p', label: 'Pop' },
|
|
23183
|
+
{ key: 'X', label: 'Drop', destructive: true },
|
|
23184
|
+
{ key: 'y', label: 'Yank ref' },
|
|
23185
|
+
];
|
|
23186
|
+
const WORKTREE_ACTIONS = [
|
|
23187
|
+
{ key: 'W', label: 'Remove', destructive: true },
|
|
23188
|
+
{ key: 'y', label: 'Yank path' },
|
|
23189
|
+
];
|
|
23190
|
+
function getInspectorActions(context) {
|
|
23191
|
+
switch (context) {
|
|
23192
|
+
case 'history-commit':
|
|
23193
|
+
return HISTORY_COMMIT_ACTIONS;
|
|
23194
|
+
case 'branch':
|
|
23195
|
+
return BRANCH_ACTIONS;
|
|
23196
|
+
case 'tag':
|
|
23197
|
+
return TAG_ACTIONS;
|
|
23198
|
+
case 'stash':
|
|
23199
|
+
return STASH_ACTIONS;
|
|
23200
|
+
case 'worktree':
|
|
23201
|
+
return WORKTREE_ACTIONS;
|
|
23202
|
+
default: {
|
|
23203
|
+
const exhaustive = context;
|
|
23204
|
+
throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
|
|
23205
|
+
}
|
|
23206
|
+
}
|
|
23207
|
+
}
|
|
23208
|
+
|
|
21388
23209
|
function sectionLines(title, diff) {
|
|
21389
23210
|
const lines = diff.split('\n').map((line) => line.trimEnd());
|
|
21390
23211
|
return [
|
|
@@ -21600,6 +23421,89 @@ function diffLineProps(line, theme) {
|
|
|
21600
23421
|
}
|
|
21601
23422
|
return {};
|
|
21602
23423
|
}
|
|
23424
|
+
/**
|
|
23425
|
+
* Minimum terminal width below which the split diff falls back to
|
|
23426
|
+
* unified rendering (#785). Each column needs ~50 columns for code to
|
|
23427
|
+
* read comfortably plus border + padding overhead, so anything narrower
|
|
23428
|
+
* than ~120 columns gets the unified view regardless of the user's
|
|
23429
|
+
* preference. The preference is preserved — switching back to a wide
|
|
23430
|
+
* terminal restores split mode automatically.
|
|
23431
|
+
*/
|
|
23432
|
+
const MIN_SPLIT_DIFF_WIDTH = 120;
|
|
23433
|
+
function isSplitDiffViable(state, width) {
|
|
23434
|
+
return state.diffViewMode === 'split' && width >= MIN_SPLIT_DIFF_WIDTH;
|
|
23435
|
+
}
|
|
23436
|
+
/**
|
|
23437
|
+
* Style props for one side of a split-diff row, derived from the row's
|
|
23438
|
+
* `kind` rather than the leading character (because the helper has
|
|
23439
|
+
* already stripped the leading +/-/space). Keeps the colors aligned with
|
|
23440
|
+
* `diffLineProps`.
|
|
23441
|
+
*/
|
|
23442
|
+
function splitDiffSideProps(kind, theme) {
|
|
23443
|
+
if (kind === 'header') {
|
|
23444
|
+
if (theme.noColor)
|
|
23445
|
+
return { dimColor: true };
|
|
23446
|
+
return { color: theme.colors.accent };
|
|
23447
|
+
}
|
|
23448
|
+
if (kind === 'empty') {
|
|
23449
|
+
return { dimColor: true };
|
|
23450
|
+
}
|
|
23451
|
+
if (theme.noColor) {
|
|
23452
|
+
return { dimColor: kind === 'context' };
|
|
23453
|
+
}
|
|
23454
|
+
if (kind === 'add')
|
|
23455
|
+
return { color: theme.colors.gitAdded };
|
|
23456
|
+
if (kind === 'remove')
|
|
23457
|
+
return { color: theme.colors.gitDeleted };
|
|
23458
|
+
return {};
|
|
23459
|
+
}
|
|
23460
|
+
/**
|
|
23461
|
+
* Format one column of a split-diff row: an optional 4-digit line
|
|
23462
|
+
* number prefix + the line text, padded/truncated to the column width.
|
|
23463
|
+
* Empty rows render a faint `·` placeholder so the alignment gap is
|
|
23464
|
+
* visible at a glance.
|
|
23465
|
+
*/
|
|
23466
|
+
function formatSplitDiffCell(side, columnWidth) {
|
|
23467
|
+
if (side.kind === 'empty') {
|
|
23468
|
+
const placeholder = ' · ';
|
|
23469
|
+
return placeholder.padEnd(columnWidth);
|
|
23470
|
+
}
|
|
23471
|
+
if (side.kind === 'header') {
|
|
23472
|
+
return truncate$1(side.text, columnWidth).padEnd(columnWidth);
|
|
23473
|
+
}
|
|
23474
|
+
const lineNo = side.lineNumber !== undefined ? String(side.lineNumber).padStart(4) : ' ';
|
|
23475
|
+
// Strip the trailing newline that some diffs include. Keeps column
|
|
23476
|
+
// widths predictable.
|
|
23477
|
+
const text = side.text.replace(/\n$/, '');
|
|
23478
|
+
// 4 digits + 1 space gutter = 5 chars; reserve that off the column
|
|
23479
|
+
// before truncating the text.
|
|
23480
|
+
const textRoom = Math.max(1, columnWidth - 5);
|
|
23481
|
+
return `${lineNo} ${truncate$1(text, textRoom)}`.padEnd(columnWidth);
|
|
23482
|
+
}
|
|
23483
|
+
/**
|
|
23484
|
+
* Render the split-diff body as a list of two-column rows. The caller
|
|
23485
|
+
* is responsible for slicing the unified-line array to the visible
|
|
23486
|
+
* window — the helper just transforms that slice into Ink nodes.
|
|
23487
|
+
*/
|
|
23488
|
+
function renderSplitDiffBody(h, components, unifiedSlice, startOffset, width, theme, keyPrefix) {
|
|
23489
|
+
const { Box, Text } = components;
|
|
23490
|
+
const rows = buildSplitDiffRows(unifiedSlice);
|
|
23491
|
+
// Reserve 3 columns of gutter (1 left padding from the Box + 1 column
|
|
23492
|
+
// separator + 1 right padding) so neither side touches the border.
|
|
23493
|
+
const usable = Math.max(20, width - 4);
|
|
23494
|
+
const gutter = 1;
|
|
23495
|
+
const half = Math.max(10, Math.floor((usable - gutter) / 2));
|
|
23496
|
+
return rows.map((row, index) => {
|
|
23497
|
+
const leftProps = splitDiffSideProps(row.left.kind, theme);
|
|
23498
|
+
const rightProps = splitDiffSideProps(row.right.kind, theme);
|
|
23499
|
+
const leftText = formatSplitDiffCell(row.left, half);
|
|
23500
|
+
const rightText = formatSplitDiffCell(row.right, half);
|
|
23501
|
+
return h(Box, {
|
|
23502
|
+
key: `${keyPrefix}-${startOffset + index}`,
|
|
23503
|
+
flexDirection: 'row',
|
|
23504
|
+
}, 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)));
|
|
23505
|
+
});
|
|
23506
|
+
}
|
|
21603
23507
|
/**
|
|
21604
23508
|
* Pick a theme color for a single name-status code (`A`, `M`, `D`,
|
|
21605
23509
|
* `R100`, etc.) so the inspector and commit-diff file list render with
|
|
@@ -21653,68 +23557,6 @@ function sidebarTabLabel(tab) {
|
|
|
21653
23557
|
return tab;
|
|
21654
23558
|
}
|
|
21655
23559
|
}
|
|
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
23560
|
async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
21719
23561
|
const input = streams.input || process.stdin;
|
|
21720
23562
|
const output = streams.output || process.stdout;
|
|
@@ -22056,6 +23898,13 @@ function LogInkApp(deps) {
|
|
|
22056
23898
|
if (saved && saved !== state.userSidebarTab) {
|
|
22057
23899
|
dispatch({ type: 'restoreSidebarTab', value: saved });
|
|
22058
23900
|
}
|
|
23901
|
+
// Diff view mode persistence (#785). Same per-repo cache pattern
|
|
23902
|
+
// as the sidebar tab — restore the user's last preference if
|
|
23903
|
+
// they had one. New repos / fresh installs default to unified.
|
|
23904
|
+
const savedDiffMode = getSavedDiffViewMode(repoRoot);
|
|
23905
|
+
if (savedDiffMode && savedDiffMode !== state.diffViewMode) {
|
|
23906
|
+
dispatch({ type: 'setDiffViewMode', value: savedDiffMode });
|
|
23907
|
+
}
|
|
22059
23908
|
}
|
|
22060
23909
|
catch {
|
|
22061
23910
|
// Not in a worktree, or revparse failed; nothing to restore.
|
|
@@ -22069,6 +23918,12 @@ function LogInkApp(deps) {
|
|
|
22069
23918
|
return;
|
|
22070
23919
|
saveSidebarTab(repoRoot, state.userSidebarTab);
|
|
22071
23920
|
}, [state.userSidebarTab]);
|
|
23921
|
+
React.useEffect(() => {
|
|
23922
|
+
const repoRoot = repoRootRef.current;
|
|
23923
|
+
if (!repoRoot)
|
|
23924
|
+
return;
|
|
23925
|
+
saveDiffViewMode(repoRoot, state.diffViewMode);
|
|
23926
|
+
}, [state.diffViewMode]);
|
|
22072
23927
|
// P-stash-explorer: load `git stash show -p <ref>` once the diff view
|
|
22073
23928
|
// becomes active with diffSource='stash'. Best-effort — empty stashes
|
|
22074
23929
|
// or read errors fall through to a "no diff" hint at the render site.
|
|
@@ -22150,6 +24005,80 @@ function LogInkApp(deps) {
|
|
|
22150
24005
|
active = false;
|
|
22151
24006
|
};
|
|
22152
24007
|
}, [git, selected?.hash]);
|
|
24008
|
+
// #806 follow-up — auto-jump the history view to whichever branch /
|
|
24009
|
+
// tag the user is currently cursoring in the sidebar (or the
|
|
24010
|
+
// dedicated branches / tags view). Debounced so cursor-scrolling
|
|
24011
|
+
// through a long branch list doesn't dispatch on every keystroke.
|
|
24012
|
+
// No-op when the cursored ref's tip isn't in the loaded commit
|
|
24013
|
+
// window (under compact mode the cursored branch's tip may not be
|
|
24014
|
+
// fetched yet); a status hint surfaces in that case so the user
|
|
24015
|
+
// knows to toggle full graph or load older commits.
|
|
24016
|
+
React.useEffect(() => {
|
|
24017
|
+
const onBranchTab = state.activeView === 'branches' ||
|
|
24018
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
24019
|
+
const onTagTab = state.activeView === 'tags' ||
|
|
24020
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
24021
|
+
if (!onBranchTab && !onTagTab)
|
|
24022
|
+
return;
|
|
24023
|
+
let cancelled = false;
|
|
24024
|
+
const timer = setTimeout(() => {
|
|
24025
|
+
if (cancelled)
|
|
24026
|
+
return;
|
|
24027
|
+
let targetHash;
|
|
24028
|
+
let targetLabel;
|
|
24029
|
+
if (onBranchTab) {
|
|
24030
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
24031
|
+
const visible = state.filter
|
|
24032
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
|
|
24033
|
+
: all;
|
|
24034
|
+
const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
|
|
24035
|
+
if (branch) {
|
|
24036
|
+
targetHash = branch.hash;
|
|
24037
|
+
targetLabel = `branch ${branch.shortName}`;
|
|
24038
|
+
}
|
|
24039
|
+
}
|
|
24040
|
+
else if (onTagTab) {
|
|
24041
|
+
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24042
|
+
const visible = state.filter
|
|
24043
|
+
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
|
|
24044
|
+
: all;
|
|
24045
|
+
const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
|
|
24046
|
+
if (tag) {
|
|
24047
|
+
targetHash = tag.hash;
|
|
24048
|
+
targetLabel = `tag ${tag.name}`;
|
|
24049
|
+
}
|
|
24050
|
+
}
|
|
24051
|
+
if (!targetHash)
|
|
24052
|
+
return;
|
|
24053
|
+
const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
|
|
24054
|
+
if (loaded) {
|
|
24055
|
+
dispatch({ type: 'selectCommitByHash', hash: targetHash });
|
|
24056
|
+
// Confirmation status message so the user gets feedback even
|
|
24057
|
+
// when the dedicated branches / tags view is occupying the
|
|
24058
|
+
// main panel and the history cursor moves invisibly behind it.
|
|
24059
|
+
dispatch({
|
|
24060
|
+
type: 'setStatus',
|
|
24061
|
+
value: `Synced history to ${targetLabel} tip`,
|
|
24062
|
+
});
|
|
24063
|
+
}
|
|
24064
|
+
else {
|
|
24065
|
+
dispatch({
|
|
24066
|
+
type: 'setStatus',
|
|
24067
|
+
value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
|
|
24068
|
+
});
|
|
24069
|
+
}
|
|
24070
|
+
}, 150);
|
|
24071
|
+
return () => {
|
|
24072
|
+
cancelled = true;
|
|
24073
|
+
clearTimeout(timer);
|
|
24074
|
+
};
|
|
24075
|
+
}, [
|
|
24076
|
+
dispatch, context.branches, context.tags,
|
|
24077
|
+
state.activeView, state.focus, state.sidebarTab,
|
|
24078
|
+
state.selectedBranchIndex, state.selectedTagIndex,
|
|
24079
|
+
state.branchSort, state.tagSort, state.filter,
|
|
24080
|
+
state.filteredCommits,
|
|
24081
|
+
]);
|
|
22153
24082
|
React.useEffect(() => {
|
|
22154
24083
|
let active = true;
|
|
22155
24084
|
async function loadWorktreeDiff() {
|
|
@@ -22361,6 +24290,27 @@ function LogInkApp(deps) {
|
|
|
22361
24290
|
// disappears. Called from the y-confirm path for delete-branch / delete-
|
|
22362
24291
|
// tag / drop-stash / remove-worktree / abort-operation.
|
|
22363
24292
|
const runWorkflowAction = React.useCallback(async (id, payload) => {
|
|
24293
|
+
// Hunk-apply payload format: `<target>\n<patchText>` — the input
|
|
24294
|
+
// handler synthesizes both pieces (target from the keystroke,
|
|
24295
|
+
// patch text from extractDiffHunk against the live diff lines)
|
|
24296
|
+
// and packs them into the single `payload` field. Splitting on
|
|
24297
|
+
// the first newline keeps the patch body intact.
|
|
24298
|
+
const runApplyHunk = (expectedTarget, raw) => {
|
|
24299
|
+
if (!raw) {
|
|
24300
|
+
return Promise.resolve({ ok: false, message: 'No hunk under cursor to apply.' });
|
|
24301
|
+
}
|
|
24302
|
+
const newlineIndex = raw.indexOf('\n');
|
|
24303
|
+
if (newlineIndex < 0) {
|
|
24304
|
+
return Promise.resolve({ ok: false, message: 'Malformed hunk-apply payload.' });
|
|
24305
|
+
}
|
|
24306
|
+
const target = raw.slice(0, newlineIndex) === 'index' ? 'index' : 'worktree';
|
|
24307
|
+
const patchText = raw.slice(newlineIndex + 1);
|
|
24308
|
+
// The input handler is the source of truth for target — but if a
|
|
24309
|
+
// palette-injected payload mismatches the workflow id, prefer
|
|
24310
|
+
// the workflow id so the user sees the action they asked for.
|
|
24311
|
+
const effectiveTarget = expectedTarget || target;
|
|
24312
|
+
return applyHunkPatch(git, patchText, { target: effectiveTarget });
|
|
24313
|
+
};
|
|
22364
24314
|
const handlers = {
|
|
22365
24315
|
'create-branch': async () => {
|
|
22366
24316
|
const name = payload?.trim();
|
|
@@ -22466,6 +24416,68 @@ function LogInkApp(deps) {
|
|
|
22466
24416
|
message: commit.message,
|
|
22467
24417
|
});
|
|
22468
24418
|
},
|
|
24419
|
+
'revert-commit': async () => {
|
|
24420
|
+
const commit = getSelectedInkCommit(state);
|
|
24421
|
+
if (!commit)
|
|
24422
|
+
return { ok: false, message: 'No commit selected' };
|
|
24423
|
+
return revertCommit(git, {
|
|
24424
|
+
hash: commit.hash,
|
|
24425
|
+
shortHash: commit.shortHash,
|
|
24426
|
+
message: commit.message,
|
|
24427
|
+
});
|
|
24428
|
+
},
|
|
24429
|
+
'reset-to-commit': async () => {
|
|
24430
|
+
const commit = getSelectedInkCommit(state);
|
|
24431
|
+
if (!commit)
|
|
24432
|
+
return { ok: false, message: 'No commit selected' };
|
|
24433
|
+
// Mode arrives via the action's `payload` field — the input
|
|
24434
|
+
// handler runs the reset-mode prompt (kind: 'reset-mode') and
|
|
24435
|
+
// routes the typed value here. Default to `mixed` (git's own
|
|
24436
|
+
// default) when the user submitted an empty value.
|
|
24437
|
+
const raw = payload?.trim().toLowerCase() || 'mixed';
|
|
24438
|
+
if (!isResetMode(raw)) {
|
|
24439
|
+
return { ok: false, message: `Unknown reset mode: ${raw}. Use soft, mixed, or hard.` };
|
|
24440
|
+
}
|
|
24441
|
+
return resetToCommit(git, {
|
|
24442
|
+
hash: commit.hash,
|
|
24443
|
+
shortHash: commit.shortHash,
|
|
24444
|
+
message: commit.message,
|
|
24445
|
+
}, raw);
|
|
24446
|
+
},
|
|
24447
|
+
'interactive-rebase': async () => {
|
|
24448
|
+
const commit = getSelectedInkCommit(state);
|
|
24449
|
+
if (!commit)
|
|
24450
|
+
return { ok: false, message: 'No commit selected' };
|
|
24451
|
+
return startInteractiveRebase(git, {
|
|
24452
|
+
hash: commit.hash,
|
|
24453
|
+
shortHash: commit.shortHash,
|
|
24454
|
+
message: commit.message,
|
|
24455
|
+
});
|
|
24456
|
+
},
|
|
24457
|
+
'create-branch-here': async () => {
|
|
24458
|
+
const commit = getSelectedInkCommit(state);
|
|
24459
|
+
const name = payload?.trim();
|
|
24460
|
+
if (!commit)
|
|
24461
|
+
return { ok: false, message: 'No commit selected' };
|
|
24462
|
+
if (!name)
|
|
24463
|
+
return { ok: false, message: 'Branch name required' };
|
|
24464
|
+
return createBranchFromCommit(git, name, {
|
|
24465
|
+
hash: commit.hash,
|
|
24466
|
+
shortHash: commit.shortHash,
|
|
24467
|
+
});
|
|
24468
|
+
},
|
|
24469
|
+
'create-tag-here': async () => {
|
|
24470
|
+
const commit = getSelectedInkCommit(state);
|
|
24471
|
+
const name = payload?.trim();
|
|
24472
|
+
if (!commit)
|
|
24473
|
+
return { ok: false, message: 'No commit selected' };
|
|
24474
|
+
if (!name)
|
|
24475
|
+
return { ok: false, message: 'Tag name required' };
|
|
24476
|
+
return createTagAtCommit(git, name, {
|
|
24477
|
+
hash: commit.hash,
|
|
24478
|
+
shortHash: commit.shortHash,
|
|
24479
|
+
});
|
|
24480
|
+
},
|
|
22469
24481
|
'checkout-file-from-commit': async () => {
|
|
22470
24482
|
// payload is "<sha> <path>" so we pass both through a single
|
|
22471
24483
|
// string field on the action.
|
|
@@ -22481,6 +24493,8 @@ function LogInkApp(deps) {
|
|
|
22481
24493
|
return { ok: false, message: 'No commit file under cursor' };
|
|
22482
24494
|
return checkoutFileFromCommit(git, sha, path);
|
|
22483
24495
|
},
|
|
24496
|
+
'apply-hunk-worktree': async () => runApplyHunk('worktree', payload),
|
|
24497
|
+
'apply-hunk-index': async () => runApplyHunk('index', payload),
|
|
22484
24498
|
'remove-worktree': async () => {
|
|
22485
24499
|
const all = context.worktreeList?.worktrees || [];
|
|
22486
24500
|
// Resolve the target from the visible (filtered) list so a
|
|
@@ -22516,6 +24530,17 @@ function LogInkApp(deps) {
|
|
|
22516
24530
|
if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
|
|
22517
24531
|
return { ok: false, message: 'No GitHub remote detected for this repo' };
|
|
22518
24532
|
}
|
|
24533
|
+
// History view: prefer the cursored commit's URL so `O` from
|
|
24534
|
+
// a commit context lands the user on the commit page rather
|
|
24535
|
+
// than the repo root or the current PR. The user-visible
|
|
24536
|
+
// intent of `O` is "open whatever I'm cursoring on the web";
|
|
24537
|
+
// a commit is what the cursor is on in the history view.
|
|
24538
|
+
if (state.activeView === 'history') {
|
|
24539
|
+
const commit = getSelectedInkCommit(state);
|
|
24540
|
+
if (commit) {
|
|
24541
|
+
return openProviderUrl(repo, { type: 'commit', commit: commit.hash });
|
|
24542
|
+
}
|
|
24543
|
+
}
|
|
22519
24544
|
const pr = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
|
|
22520
24545
|
if (pr) {
|
|
22521
24546
|
return openProviderUrl(repo, { type: 'pull-request', number: pr.number });
|
|
@@ -22569,6 +24594,32 @@ function LogInkApp(deps) {
|
|
|
22569
24594
|
return { ok: false, message: 'Stash message required' };
|
|
22570
24595
|
return createStash(git, message);
|
|
22571
24596
|
},
|
|
24597
|
+
// #783 — full PR action panel handlers. Each wraps the matching
|
|
24598
|
+
// pullRequestActions verb. Strategy / body arrives via `payload`
|
|
24599
|
+
// — input prompts validate before they reach here, but the
|
|
24600
|
+
// strategy guard stays as a defensive belt-and-suspenders since
|
|
24601
|
+
// a future palette path could call us with a raw value.
|
|
24602
|
+
'merge-pr': async () => {
|
|
24603
|
+
const strategy = (payload || 'merge').toLowerCase();
|
|
24604
|
+
if (!isPullRequestMergeStrategy(strategy)) {
|
|
24605
|
+
return { ok: false, message: `Unknown merge strategy: ${strategy}. Use merge, squash, or rebase.` };
|
|
24606
|
+
}
|
|
24607
|
+
return mergePullRequest(strategy);
|
|
24608
|
+
},
|
|
24609
|
+
'close-pr': async () => closePullRequest(),
|
|
24610
|
+
'approve-pr': async () => approvePullRequest(),
|
|
24611
|
+
'request-changes-pr': async () => {
|
|
24612
|
+
const body = payload?.trim();
|
|
24613
|
+
if (!body)
|
|
24614
|
+
return { ok: false, message: 'Review body required for change-request' };
|
|
24615
|
+
return requestChangesPullRequest(body);
|
|
24616
|
+
},
|
|
24617
|
+
'comment-pr': async () => {
|
|
24618
|
+
const body = payload?.trim();
|
|
24619
|
+
if (!body)
|
|
24620
|
+
return { ok: false, message: 'Comment body required' };
|
|
24621
|
+
return commentPullRequest(body);
|
|
24622
|
+
},
|
|
22572
24623
|
};
|
|
22573
24624
|
const handler = handlers[id];
|
|
22574
24625
|
if (!handler) {
|
|
@@ -22577,9 +24628,21 @@ function LogInkApp(deps) {
|
|
|
22577
24628
|
}
|
|
22578
24629
|
const result = await handler();
|
|
22579
24630
|
dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
|
|
22580
|
-
//
|
|
22581
|
-
//
|
|
22582
|
-
|
|
24631
|
+
// Checkout-branch is the one workflow where we want a *visible*
|
|
24632
|
+
// refresh so the user sees the branches sidebar repaint with the
|
|
24633
|
+
// new current branch (per #806 follow-up). Snap the cursor to
|
|
24634
|
+
// position 0 first so when the refresh completes and the new
|
|
24635
|
+
// current branch lands at the top (per #809's pin-current rule),
|
|
24636
|
+
// the cursor is already there waiting.
|
|
24637
|
+
if (id === 'checkout-branch' && result?.ok) {
|
|
24638
|
+
dispatch({ type: 'resetBranchSelection' });
|
|
24639
|
+
await refreshContext();
|
|
24640
|
+
}
|
|
24641
|
+
else {
|
|
24642
|
+
// Silent refresh so the deleted item disappears from the list
|
|
24643
|
+
// without flickering the surfaces through a 'loading' phase.
|
|
24644
|
+
await refreshContext({ silent: true });
|
|
24645
|
+
}
|
|
22583
24646
|
}, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
|
|
22584
24647
|
state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
|
|
22585
24648
|
state.tagSort]);
|
|
@@ -22859,6 +24922,51 @@ function LogInkApp(deps) {
|
|
|
22859
24922
|
});
|
|
22860
24923
|
})();
|
|
22861
24924
|
}, [dispatch, git, logArgv, state.historyFetchArgs]);
|
|
24925
|
+
// Graph mode toggle (`g` key, #791 follow-up). The header label flips
|
|
24926
|
+
// between "compact graph" and "full graph", but unless we re-fetch with
|
|
24927
|
+
// the right `view`, the underlying rows still come from the user's
|
|
24928
|
+
// initial argv (default `--first-parent --no-merges`) and the renderer
|
|
24929
|
+
// has no topology to draw — defeating the per-lane / junction work.
|
|
24930
|
+
// Mirrors the historyFetchArgs effect: skip first run, request-id ref
|
|
24931
|
+
// for stale-completion guard, swap rows in place via replaceRows.
|
|
24932
|
+
const toggleGraphEffectInitialized = React.useRef(false);
|
|
24933
|
+
const toggleGraphRequestRef = React.useRef(0);
|
|
24934
|
+
React.useEffect(() => {
|
|
24935
|
+
if (!logArgv)
|
|
24936
|
+
return;
|
|
24937
|
+
if (!toggleGraphEffectInitialized.current) {
|
|
24938
|
+
toggleGraphEffectInitialized.current = true;
|
|
24939
|
+
return;
|
|
24940
|
+
}
|
|
24941
|
+
const requestId = toggleGraphRequestRef.current + 1;
|
|
24942
|
+
toggleGraphRequestRef.current = requestId;
|
|
24943
|
+
const merged = buildToggleGraphArgs(logArgv, state.fullGraph);
|
|
24944
|
+
dispatch({
|
|
24945
|
+
type: 'setStatus',
|
|
24946
|
+
value: state.fullGraph
|
|
24947
|
+
? 'Loading full topology…'
|
|
24948
|
+
: 'Loading compact history…',
|
|
24949
|
+
});
|
|
24950
|
+
void (async () => {
|
|
24951
|
+
const nextRows = await safe(getLogRows(git, merged, { limit: LOG_INTERACTIVE_DEFAULT_LIMIT }));
|
|
24952
|
+
if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
|
|
24953
|
+
return;
|
|
24954
|
+
}
|
|
24955
|
+
if (!nextRows) {
|
|
24956
|
+
dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows' });
|
|
24957
|
+
return;
|
|
24958
|
+
}
|
|
24959
|
+
dispatch({ type: 'replaceRows', rows: nextRows });
|
|
24960
|
+
const matched = getCommitRows(nextRows).length;
|
|
24961
|
+
setHasMoreCommits(matched >= LOG_INTERACTIVE_DEFAULT_LIMIT);
|
|
24962
|
+
dispatch({
|
|
24963
|
+
type: 'setStatus',
|
|
24964
|
+
value: state.fullGraph
|
|
24965
|
+
? `Showing ${matched} commits across all branches`
|
|
24966
|
+
: `Showing ${matched} commits (compact)`,
|
|
24967
|
+
});
|
|
24968
|
+
})();
|
|
24969
|
+
}, [dispatch, git, logArgv, state.fullGraph]);
|
|
22862
24970
|
const commitDiffHunkOffsets = React.useMemo(() => (filePreview?.hunks
|
|
22863
24971
|
.map((line, index) => (line.startsWith('@@') ? index : -1))
|
|
22864
24972
|
.filter((index) => index >= 0)), [filePreview]);
|
|
@@ -22953,6 +25061,17 @@ function LogInkApp(deps) {
|
|
|
22953
25061
|
? selected?.hash
|
|
22954
25062
|
: undefined,
|
|
22955
25063
|
worktreeDirty,
|
|
25064
|
+
// H / gH need the actual diff text (not just hunk offsets) to
|
|
25065
|
+
// slice the cursored hunk into a `git apply` patch. Stash uses
|
|
25066
|
+
// the full `git stash show -p` output; commit-diff uses the
|
|
25067
|
+
// per-file `filePreview.hunks` array. Either way, extractDiffHunk
|
|
25068
|
+
// walks `@@` headers and synthesizes a fresh diff --git / --- /
|
|
25069
|
+
// +++ header set using the path the caller already resolved.
|
|
25070
|
+
diffLinesForHunkApply: state.diffSource === 'stash'
|
|
25071
|
+
? stashDiffLines
|
|
25072
|
+
: state.diffSource === 'commit'
|
|
25073
|
+
? filePreview?.hunks
|
|
25074
|
+
: undefined,
|
|
22956
25075
|
}).forEach((event) => {
|
|
22957
25076
|
if (event.type === 'exit') {
|
|
22958
25077
|
exit();
|
|
@@ -23005,6 +25124,7 @@ function LogInkApp(deps) {
|
|
|
23005
25124
|
columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
|
|
23006
25125
|
rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
|
|
23007
25126
|
sidebarFocused: state.focus === 'sidebar',
|
|
25127
|
+
inspectorFocused: state.focus === 'detail',
|
|
23008
25128
|
});
|
|
23009
25129
|
if (layout.tooSmall) {
|
|
23010
25130
|
return h(Box, {
|
|
@@ -23019,7 +25139,7 @@ function LogInkApp(deps) {
|
|
|
23019
25139
|
if (showOnboarding) {
|
|
23020
25140
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
23021
25141
|
}
|
|
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));
|
|
25142
|
+
return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
|
|
23023
25143
|
}
|
|
23024
25144
|
function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
|
|
23025
25145
|
const { Box, Text } = components;
|
|
@@ -23072,7 +25192,7 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
|
|
|
23072
25192
|
? h(Text, { bold: true, color: theme.colors.accent }, `${prLabel}${titleSuffix}`)
|
|
23073
25193
|
: undefined, h(Text, { bold: true, color: modeColor }, ` ${mode}`), search ? h(Text, { dimColor: true }, ` ${truncate$1(search, 36)}`) : undefined);
|
|
23074
25194
|
}
|
|
23075
|
-
function renderSidebar(h, components, state, context, contextStatus, width, theme) {
|
|
25195
|
+
function renderSidebar(h, components, state, context, contextStatus, width, bodyRows, theme) {
|
|
23076
25196
|
const { Box, Text } = components;
|
|
23077
25197
|
const focused = state.focus === 'sidebar';
|
|
23078
25198
|
const tabs = getLogInkSidebarTabs();
|
|
@@ -23096,7 +25216,7 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
|
|
|
23096
25216
|
dimColor: !isActive,
|
|
23097
25217
|
}, headerText));
|
|
23098
25218
|
if (isActive) {
|
|
23099
|
-
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme));
|
|
25219
|
+
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
|
|
23100
25220
|
}
|
|
23101
25221
|
return blocks;
|
|
23102
25222
|
});
|
|
@@ -23115,15 +25235,113 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
|
|
|
23115
25235
|
* surface; every other tab falls through to `sidebarLines` for its
|
|
23116
25236
|
* string-based summary.
|
|
23117
25237
|
*/
|
|
23118
|
-
function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme) {
|
|
25238
|
+
function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme) {
|
|
25239
|
+
// Available rows for the active tab's list. The sidebar chrome
|
|
25240
|
+
// takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
|
|
25241
|
+
// spacers); the branches tab eats 3 more for its summary header
|
|
25242
|
+
// (Current / Worktree / spacer). Floor of 8 keeps short terminals
|
|
25243
|
+
// usable; tall terminals (40+ rows) get noticeably more items.
|
|
25244
|
+
const sidebarChrome = 10;
|
|
25245
|
+
const branchHeaderRows = tab === 'branches' ? 3 : 0;
|
|
25246
|
+
const visibleListCount = Math.max(8, bodyRows - sidebarChrome - branchHeaderRows);
|
|
23119
25247
|
if (tab === 'status') {
|
|
23120
25248
|
return renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme);
|
|
23121
25249
|
}
|
|
23122
|
-
|
|
23123
|
-
|
|
23124
|
-
|
|
23125
|
-
|
|
23126
|
-
|
|
25250
|
+
// Branches / tags / stashes / worktrees: render selectable rows so
|
|
25251
|
+
// ↑/↓ navigates within the sidebar list and Enter / per-entity keys
|
|
25252
|
+
// act on the cursored item without needing to drill into the
|
|
25253
|
+
// dedicated view (#791 follow-up — in-sidebar selection).
|
|
25254
|
+
const focused = state.focus === 'sidebar' && state.sidebarTab === tab;
|
|
25255
|
+
if (tab === 'branches') {
|
|
25256
|
+
if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
|
|
25257
|
+
return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
|
|
25258
|
+
}
|
|
25259
|
+
const branches = context.branches;
|
|
25260
|
+
if (!branches) {
|
|
25261
|
+
return [h(Text, { key: 'tab-branches-empty', dimColor: true }, ' Branches unavailable')];
|
|
25262
|
+
}
|
|
25263
|
+
const sortedBranches = sortBranches(branches.localBranches, state.branchSort);
|
|
25264
|
+
const headerRows = [
|
|
25265
|
+
h(Text, { key: 'tab-branches-current', dimColor: true }, truncate$1(` Current: ${branches.currentBranch || '<detached>'}`, width - 4)),
|
|
25266
|
+
h(Text, { key: 'tab-branches-state', dimColor: true }, ` Worktree: ${branches.dirty ? 'dirty' : 'clean'}`),
|
|
25267
|
+
h(Text, { key: 'tab-branches-spacer' }, ''),
|
|
25268
|
+
];
|
|
25269
|
+
return [
|
|
25270
|
+
...headerRows,
|
|
25271
|
+
...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches', visibleListCount),
|
|
25272
|
+
];
|
|
25273
|
+
}
|
|
25274
|
+
if (tab === 'tags') {
|
|
25275
|
+
if (isLogInkContextKeyLoading(contextStatus, 'tags')) {
|
|
25276
|
+
return [h(Text, { key: 'tab-tags-loading', dimColor: true }, ' Loading tags…')];
|
|
25277
|
+
}
|
|
25278
|
+
const tags = sortTags(context.tags?.tags || [], state.tagSort);
|
|
25279
|
+
if (tags.length === 0) {
|
|
25280
|
+
return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
|
|
25281
|
+
}
|
|
25282
|
+
return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags', visibleListCount);
|
|
25283
|
+
}
|
|
25284
|
+
if (tab === 'stashes') {
|
|
25285
|
+
if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
|
|
25286
|
+
return [h(Text, { key: 'tab-stashes-loading', dimColor: true }, ' Loading stashes…')];
|
|
25287
|
+
}
|
|
25288
|
+
const stashes = context.stashes?.stashes || [];
|
|
25289
|
+
if (stashes.length === 0) {
|
|
25290
|
+
return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
|
|
25291
|
+
}
|
|
25292
|
+
return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes', visibleListCount);
|
|
25293
|
+
}
|
|
25294
|
+
// worktrees
|
|
25295
|
+
if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
|
|
25296
|
+
return [h(Text, { key: 'tab-worktrees-loading', dimColor: true }, ' Loading worktrees…')];
|
|
25297
|
+
}
|
|
25298
|
+
const worktrees = context.worktreeList?.worktrees || [];
|
|
25299
|
+
if (worktrees.length === 0) {
|
|
25300
|
+
return [h(Text, { key: 'tab-worktrees-empty', dimColor: true }, ' No linked worktrees')];
|
|
25301
|
+
}
|
|
25302
|
+
return renderSelectableSidebarRows(h, Text, worktrees, state.selectedWorktreeListIndex, focused, width, theme, (worktree) => {
|
|
25303
|
+
const marker = worktree.current ? '*' : ' ';
|
|
25304
|
+
const wstate = worktree.dirty ? 'dirty' : 'clean';
|
|
25305
|
+
return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
|
|
25306
|
+
}, 'tab-worktrees', visibleListCount);
|
|
25307
|
+
}
|
|
25308
|
+
/**
|
|
25309
|
+
* Render a sliding-window list of selectable sidebar rows. The cursor
|
|
25310
|
+
* highlights the row at `selectedIndex` only when `focused` is true so
|
|
25311
|
+
* an unfocused sidebar doesn't compete visually with the active panel.
|
|
25312
|
+
* Sliding window keeps the cursor in view as the user navigates a long
|
|
25313
|
+
* list; truncation hints surface the count of hidden rows.
|
|
25314
|
+
*/
|
|
25315
|
+
function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount) {
|
|
25316
|
+
if (items.length === 0)
|
|
25317
|
+
return [];
|
|
25318
|
+
const window = getSidebarVisibleWindow(items.length, selectedIndex, visibleCount);
|
|
25319
|
+
const elements = [];
|
|
25320
|
+
if (window.truncatedAbove > 0) {
|
|
25321
|
+
elements.push(h(Text, {
|
|
25322
|
+
key: `${keyPrefix}-trunc-above`,
|
|
25323
|
+
dimColor: true,
|
|
25324
|
+
}, truncate$1(` … ${window.truncatedAbove} more above`, width - 4)));
|
|
25325
|
+
}
|
|
25326
|
+
for (let offset = 0; offset < window.size; offset += 1) {
|
|
25327
|
+
const index = window.start + offset;
|
|
25328
|
+
if (index >= items.length)
|
|
25329
|
+
break;
|
|
25330
|
+
const isSelected = focused && index === selectedIndex;
|
|
25331
|
+
const text = toRowText(items[index], index);
|
|
25332
|
+
elements.push(h(Text, {
|
|
25333
|
+
key: `${keyPrefix}-row-${index}`,
|
|
25334
|
+
backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
25335
|
+
inverse: isSelected,
|
|
25336
|
+
}, truncate$1(` ${text}`, width - 4)));
|
|
25337
|
+
}
|
|
25338
|
+
if (window.truncatedBelow > 0) {
|
|
25339
|
+
elements.push(h(Text, {
|
|
25340
|
+
key: `${keyPrefix}-trunc-below`,
|
|
25341
|
+
dimColor: true,
|
|
25342
|
+
}, truncate$1(` … ${window.truncatedBelow} more below`, width - 4)));
|
|
25343
|
+
}
|
|
25344
|
+
return elements;
|
|
23127
25345
|
}
|
|
23128
25346
|
function renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme) {
|
|
23129
25347
|
if (isLogInkContextKeyLoading(contextStatus, 'worktree')) {
|
|
@@ -23181,6 +25399,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
23181
25399
|
if (state.activeView === 'worktrees') {
|
|
23182
25400
|
return renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
23183
25401
|
}
|
|
25402
|
+
if (state.activeView === 'pull-request') {
|
|
25403
|
+
return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
25404
|
+
}
|
|
23184
25405
|
return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
|
|
23185
25406
|
}
|
|
23186
25407
|
function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
|
|
@@ -23230,15 +25451,46 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
23230
25451
|
}))
|
|
23231
25452
|
: visible.items.map((item, index) => {
|
|
23232
25453
|
if (item.type === 'graph') {
|
|
25454
|
+
if (item.laneSegments && !theme.ascii) {
|
|
25455
|
+
return h(Text, { key: `graph-${index}-${item.graph}` }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`));
|
|
25456
|
+
}
|
|
23233
25457
|
return h(Text, {
|
|
23234
25458
|
key: `graph-${index}-${item.graph}`,
|
|
23235
25459
|
color: theme.noColor ? undefined : theme.colors.muted,
|
|
23236
25460
|
dimColor: theme.noColor,
|
|
23237
25461
|
}, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), 140));
|
|
23238
25462
|
}
|
|
23239
|
-
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index);
|
|
25463
|
+
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, item.laneSegments);
|
|
23240
25464
|
}));
|
|
23241
25465
|
}
|
|
25466
|
+
/**
|
|
25467
|
+
* Render `LaneSegment[]` as a flat list of Text spans, one per lane
|
|
25468
|
+
* (#791 stage 2). Each segment paints in its lane's palette color so
|
|
25469
|
+
* the eye can follow a branch column-by-column; segments without a
|
|
25470
|
+
* lane id (spaces, padding, decorations) fall back to the muted graph
|
|
25471
|
+
* color so they visually recede.
|
|
25472
|
+
*
|
|
25473
|
+
* Final padding is appended as its own span so callers do not need to
|
|
25474
|
+
* pre-pad the graph string before computing lane segments.
|
|
25475
|
+
*/
|
|
25476
|
+
function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
25477
|
+
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25478
|
+
const elements = [];
|
|
25479
|
+
let totalLen = 0;
|
|
25480
|
+
segments.forEach((seg, idx) => {
|
|
25481
|
+
const laneColor = getLaneColor(seg.laneId, theme);
|
|
25482
|
+
elements.push(h(Text, {
|
|
25483
|
+
key: `${keyPrefix}-${idx}`,
|
|
25484
|
+
color: laneColor ?? muted,
|
|
25485
|
+
dimColor: theme.noColor && seg.laneId === undefined,
|
|
25486
|
+
}, seg.text));
|
|
25487
|
+
totalLen += seg.text.length;
|
|
25488
|
+
});
|
|
25489
|
+
if (padTo > totalLen) {
|
|
25490
|
+
elements.push(h(Text, { key: `${keyPrefix}-pad` }, ' '.repeat(padTo - totalLen)));
|
|
25491
|
+
}
|
|
25492
|
+
return elements;
|
|
25493
|
+
}
|
|
23242
25494
|
/**
|
|
23243
25495
|
* Render a single commit row with each segment in its own colored span.
|
|
23244
25496
|
* Graph chars render in `theme.colors.muted` so the topology visually
|
|
@@ -23251,8 +25503,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
23251
25503
|
* Truncation is per-segment so the variable-length message field gets
|
|
23252
25504
|
* the leftover budget after fixed segments are accounted for.
|
|
23253
25505
|
*/
|
|
23254
|
-
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index) {
|
|
23255
|
-
const renderedGraph = substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii });
|
|
25506
|
+
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
|
|
23256
25507
|
const refs = formatInkRefLabels(commit.refs);
|
|
23257
25508
|
const totalWidth = 140;
|
|
23258
25509
|
const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
|
|
@@ -23261,11 +25512,17 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
|
|
|
23261
25512
|
const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
|
|
23262
25513
|
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
23263
25514
|
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25515
|
+
// Lane-colored graph spans when full graph mode + non-ASCII rendering
|
|
25516
|
+
// is in play; otherwise fall back to the legacy single-muted span so
|
|
25517
|
+
// compact mode and legacy terminals stay visually unchanged.
|
|
25518
|
+
const graphChildren = laneSegments && !theme.ascii
|
|
25519
|
+
? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `c${index}`)
|
|
25520
|
+
: [h(Text, { color: muted, dimColor: theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
|
|
23264
25521
|
return h(Text, {
|
|
23265
25522
|
key: `${commit.hash}-${index}`,
|
|
23266
25523
|
backgroundColor: selectedBg,
|
|
23267
25524
|
inverse: selected,
|
|
23268
|
-
},
|
|
25525
|
+
}, ...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
25526
|
}
|
|
23270
25527
|
/**
|
|
23271
25528
|
* Render the synthetic "(+) new commit" affordance shown above the real
|
|
@@ -23498,11 +25755,35 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
23498
25755
|
const cursor = isSelected ? '>' : ' ';
|
|
23499
25756
|
const marker = branchRowMarker(branch, { ascii: theme.ascii });
|
|
23500
25757
|
const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
|
|
25758
|
+
const lastTouched = formatBranchLastTouched(branch.date, new Date());
|
|
25759
|
+
// Split the row into spans so the timestamp stays dim even on the
|
|
25760
|
+
// currently-selected (bold) row. The leading marker + name keep
|
|
25761
|
+
// their original column widths; the timestamp is right-padded so
|
|
25762
|
+
// the divergence column stays aligned across rows.
|
|
25763
|
+
const namePadded = branch.shortName.padEnd(28);
|
|
25764
|
+
const timestampPadded = lastTouched.padEnd(8);
|
|
25765
|
+
const lineDim = !isSelected && !branch.current;
|
|
25766
|
+
const head = `${cursor} ${marker} ${namePadded} `;
|
|
25767
|
+
const trailingDivergence = divergence ? ` ${divergence}` : '';
|
|
25768
|
+
// Truncate the assembled line cooperatively so we never overflow
|
|
25769
|
+
// the panel; the timestamp is short and the divergence is the
|
|
25770
|
+
// most expendable, but the existing 140 cap is ample.
|
|
25771
|
+
const fullText = `${head}${timestampPadded}${trailingDivergence}`;
|
|
25772
|
+
const truncated = truncate$1(fullText, 140);
|
|
25773
|
+
// If truncation chopped into the timestamp/divergence portion,
|
|
25774
|
+
// fall back to a single Text to keep the visible width honest.
|
|
25775
|
+
if (truncated !== fullText) {
|
|
25776
|
+
return h(Text, {
|
|
25777
|
+
key: `branch-${index}`,
|
|
25778
|
+
bold: isSelected,
|
|
25779
|
+
dimColor: lineDim,
|
|
25780
|
+
}, truncated);
|
|
25781
|
+
}
|
|
23501
25782
|
return h(Text, {
|
|
23502
25783
|
key: `branch-${index}`,
|
|
23503
25784
|
bold: isSelected,
|
|
23504
|
-
dimColor:
|
|
23505
|
-
},
|
|
25785
|
+
dimColor: lineDim,
|
|
25786
|
+
}, head, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
|
|
23506
25787
|
});
|
|
23507
25788
|
return h(Box, {
|
|
23508
25789
|
borderColor: focusBorderColor(theme, focused),
|
|
@@ -23655,6 +25936,98 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
23655
25936
|
width,
|
|
23656
25937
|
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Worktrees', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
|
|
23657
25938
|
}
|
|
25939
|
+
/**
|
|
25940
|
+
* Pull-request action panel (#783) — renders the current branch's PR
|
|
25941
|
+
* with header, checks table, reviews summary, and a body preview.
|
|
25942
|
+
* Action keys (m / x / a / R / c / O) are wired in inkInput.ts and
|
|
25943
|
+
* surfaced via the footer; this renderer is read-only.
|
|
25944
|
+
*
|
|
25945
|
+
* Three loading / fallback states matter:
|
|
25946
|
+
* - Provider data still loading → "Loading pull request..."
|
|
25947
|
+
* - GitHub remote present but no PR for the current branch → empty
|
|
25948
|
+
* state hint pointing the user at `C` to create one.
|
|
25949
|
+
* - GitHub CLI missing / unauthenticated → unavailable hint.
|
|
25950
|
+
*/
|
|
25951
|
+
function renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
25952
|
+
const { Box, Text } = components;
|
|
25953
|
+
const focused = state.focus === 'commits';
|
|
25954
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequest');
|
|
25955
|
+
const pullRequestOverview = context.pullRequest;
|
|
25956
|
+
// Use the dedicated `pullRequest` overview only — the `provider`
|
|
25957
|
+
// shape carries a slimmer ProviderPullRequestStatus that lacks
|
|
25958
|
+
// url / headRefName / body / mergeable / reviews. The dedicated
|
|
25959
|
+
// overview hits `gh pr view --json` with the full enriched field
|
|
25960
|
+
// list (PULL_REQUEST_VIEW_JSON_FIELDS) so the panel has everything.
|
|
25961
|
+
const pr = pullRequestOverview?.currentPullRequest;
|
|
25962
|
+
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25963
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
25964
|
+
const containerProps = {
|
|
25965
|
+
borderColor: focusBorderColor(theme, focused),
|
|
25966
|
+
borderStyle: theme.borderStyle,
|
|
25967
|
+
flexDirection: 'column',
|
|
25968
|
+
flexShrink: 0,
|
|
25969
|
+
paddingX: 1,
|
|
25970
|
+
width,
|
|
25971
|
+
};
|
|
25972
|
+
if (loading && !pr) {
|
|
25973
|
+
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' })));
|
|
25974
|
+
}
|
|
25975
|
+
if (!pr) {
|
|
25976
|
+
const hint = pullRequestOverview?.message
|
|
25977
|
+
|| 'No pull request detected for this branch. Press `C` (or `:create-pr`) to create one.';
|
|
25978
|
+
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)));
|
|
25979
|
+
}
|
|
25980
|
+
const checks = summarizePullRequestChecks(pr.statusCheckRollup);
|
|
25981
|
+
const reviews = summarizePullRequestReviews(pr.reviews, pr.reviewDecision);
|
|
25982
|
+
const checkRows = buildPullRequestCheckRows(pr.statusCheckRollup, { ascii: theme.ascii });
|
|
25983
|
+
const checkColor = (s) => {
|
|
25984
|
+
if (theme.noColor)
|
|
25985
|
+
return undefined;
|
|
25986
|
+
if (s === 'success')
|
|
25987
|
+
return theme.colors.success;
|
|
25988
|
+
if (s === 'failure')
|
|
25989
|
+
return theme.colors.danger;
|
|
25990
|
+
if (s === 'pending')
|
|
25991
|
+
return theme.colors.warning;
|
|
25992
|
+
return theme.colors.muted;
|
|
25993
|
+
};
|
|
25994
|
+
// Reserve a few rows for the header/section labels; the rest go to
|
|
25995
|
+
// the checks table. Body preview gets the leftover rows so the
|
|
25996
|
+
// surface stays vertically balanced even on tall terminals.
|
|
25997
|
+
const checkBudget = Math.max(3, Math.min(checkRows.length, Math.floor(bodyRows / 2)));
|
|
25998
|
+
const visibleChecks = checkRows.slice(0, checkBudget);
|
|
25999
|
+
const truncatedChecks = checkRows.length - visibleChecks.length;
|
|
26000
|
+
const bodyPreviewBudget = Math.max(2, bodyRows - 8 - visibleChecks.length);
|
|
26001
|
+
const bodyLines = (pr.body || '').split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
26002
|
+
const visibleBodyLines = bodyLines.slice(0, bodyPreviewBudget);
|
|
26003
|
+
const truncatedBodyLines = bodyLines.length - visibleBodyLines.length;
|
|
26004
|
+
const headerRight = `#${pr.number} · ${pr.headRefName} → ${pr.baseRefName}`;
|
|
26005
|
+
const stateLine = formatPullRequestStateLine(pr);
|
|
26006
|
+
const author = pr.author ? `by @${pr.author}` : '';
|
|
26007
|
+
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, ''),
|
|
26008
|
+
// Checks section
|
|
26009
|
+
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, {
|
|
26010
|
+
key: `pr-check-${index}`,
|
|
26011
|
+
color: checkColor(row.status),
|
|
26012
|
+
}, truncate$1(` ${row.glyph} ${row.name.padEnd(28)} ${row.detail}`, width - 4))), ...(truncatedChecks > 0
|
|
26013
|
+
? [h(Text, { key: 'pr-checks-trunc', dimColor: true }, truncate$1(` … ${truncatedChecks} more`, width - 4))]
|
|
26014
|
+
: []), h(Text, undefined, ''),
|
|
26015
|
+
// Reviews section
|
|
26016
|
+
h(Text, { bold: true, color: accent }, 'Reviews'), h(Text, { dimColor: true }, truncate$1(` ${formatPullRequestReviewsSummary(reviews)}`, width - 4)), h(Text, undefined, ''),
|
|
26017
|
+
// Body preview
|
|
26018
|
+
...(visibleBodyLines.length > 0
|
|
26019
|
+
? [
|
|
26020
|
+
h(Text, { key: 'pr-body-label', bold: true, color: accent }, 'Description'),
|
|
26021
|
+
...visibleBodyLines.map((line, index) => h(Text, {
|
|
26022
|
+
key: `pr-body-${index}`,
|
|
26023
|
+
color: muted,
|
|
26024
|
+
}, truncate$1(` ${line}`, width - 4))),
|
|
26025
|
+
...(truncatedBodyLines > 0
|
|
26026
|
+
? [h(Text, { key: 'pr-body-trunc', dimColor: true }, truncate$1(` … ${truncatedBodyLines} more lines`, width - 4))]
|
|
26027
|
+
: []),
|
|
26028
|
+
]
|
|
26029
|
+
: []));
|
|
26030
|
+
}
|
|
23658
26031
|
/**
|
|
23659
26032
|
* Filter input cursor for the promoted views (branches/tags/stash).
|
|
23660
26033
|
* History already shows the same `filter: foo_` affordance in its header
|
|
@@ -23685,6 +26058,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23685
26058
|
// cherry-picks the file at the cursor.
|
|
23686
26059
|
if (state.diffSource === 'stash') {
|
|
23687
26060
|
const lines = stashDiffLines || [];
|
|
26061
|
+
const splitActive = isSplitDiffViable(state, width);
|
|
26062
|
+
const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
|
|
23688
26063
|
const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
23689
26064
|
const stashFiles = parseStashDiffFiles(lines);
|
|
23690
26065
|
const fileCount = stashFiles.length;
|
|
@@ -23705,11 +26080,19 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23705
26080
|
const currentFileIndex = currentFile
|
|
23706
26081
|
? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
|
|
23707
26082
|
: -1;
|
|
23708
|
-
|
|
23709
|
-
|
|
26083
|
+
// Look up the active stash entry so the panel header can show a
|
|
26084
|
+
// human-identifier instead of the raw `stash@{<iso-date>}` ref.
|
|
26085
|
+
// The git ref is the timestamp form (we fetch with --date=iso for
|
|
26086
|
+
// stable parsing) which reads as noise in the title bar; the
|
|
26087
|
+
// message + branch + index combination is what the user wrote down
|
|
26088
|
+
// when they ran `git stash`. Body still shows the full ref so it
|
|
26089
|
+
// stays unambiguous.
|
|
26090
|
+
const stashIdentity = formatStashHeaderIdentity(state.stashDiffRef, context.stashes?.stashes);
|
|
26091
|
+
const baseHeaderLines = stashDiffLoading
|
|
26092
|
+
? [`Loading diff for ${stashIdentity.subtitle}...`]
|
|
23710
26093
|
: lines.length
|
|
23711
26094
|
? [
|
|
23712
|
-
|
|
26095
|
+
stashIdentity.bodyLine,
|
|
23713
26096
|
fileCount > 0 && currentFile
|
|
23714
26097
|
? `File ${currentFileIndex + 1}/${fileCount}: ${currentFile.path}`
|
|
23715
26098
|
: 'No files in this stash.',
|
|
@@ -23717,6 +26100,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23717
26100
|
'',
|
|
23718
26101
|
]
|
|
23719
26102
|
: ['No diff to display for this stash.'];
|
|
26103
|
+
const headerLines = splitRequestedButTooNarrow
|
|
26104
|
+
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
26105
|
+
: baseHeaderLines;
|
|
26106
|
+
const stashBodyNodes = stashDiffLoading || !lines.length
|
|
26107
|
+
? []
|
|
26108
|
+
: splitActive
|
|
26109
|
+
? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
|
|
26110
|
+
: visibleLines.map((line, index) => h(Text, {
|
|
26111
|
+
key: `stash-diff-line-${state.diffPreviewOffset + index}`,
|
|
26112
|
+
...diffLineProps(line, theme),
|
|
26113
|
+
}, truncate$1(line, width - 4)));
|
|
23720
26114
|
return h(Box, {
|
|
23721
26115
|
borderColor: focusBorderColor(theme, focused),
|
|
23722
26116
|
borderStyle: theme.borderStyle,
|
|
@@ -23724,15 +26118,10 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23724
26118
|
flexShrink: 0,
|
|
23725
26119
|
paddingX: 1,
|
|
23726
26120
|
width,
|
|
23727
|
-
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash diff', focused)), h(Text, { dimColor: true },
|
|
26121
|
+
}, 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
26122
|
key: `stash-diff-header-${index}`,
|
|
23729
26123
|
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)))));
|
|
26124
|
+
}, truncate$1(line, width - 4))), ...stashBodyNodes);
|
|
23736
26125
|
}
|
|
23737
26126
|
// diffSource disambiguates: 'commit' was set when the user opened the
|
|
23738
26127
|
// diff via history → Enter (read-only commit-diff explore), 'worktree'
|
|
@@ -23743,6 +26132,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23743
26132
|
(state.diffSource === undefined && !worktreeFile && Boolean(selectedDetailFile));
|
|
23744
26133
|
if (useCommitDiff) {
|
|
23745
26134
|
const previewHunks = filePreview?.hunks || [];
|
|
26135
|
+
const splitActive = isSplitDiffViable(state, width);
|
|
26136
|
+
const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
|
|
23746
26137
|
const visiblePreviewHunks = previewHunks.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
23747
26138
|
const hunkCount = commitDiffHunkOffsets?.length || 0;
|
|
23748
26139
|
const currentHunkIndex = hunkCount > 0
|
|
@@ -23753,7 +26144,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23753
26144
|
const currentHunkLabel = hunkCount > 0
|
|
23754
26145
|
? `Hunk ${Math.min(hunkCount - currentHunkIndex, hunkCount)}/${hunkCount}`
|
|
23755
26146
|
: 'No hunks for this file.';
|
|
23756
|
-
const
|
|
26147
|
+
const baseHeaderLines = filePreviewLoading
|
|
23757
26148
|
? [`Loading diff for ${selectedDetailFile?.path || 'selected file'}...`]
|
|
23758
26149
|
: previewHunks.length
|
|
23759
26150
|
? [
|
|
@@ -23763,6 +26154,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23763
26154
|
'',
|
|
23764
26155
|
]
|
|
23765
26156
|
: ['No diff preview available for this file.'];
|
|
26157
|
+
const headerLines = splitRequestedButTooNarrow
|
|
26158
|
+
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
26159
|
+
: baseHeaderLines;
|
|
26160
|
+
const commitBodyNodes = filePreviewLoading || !previewHunks.length
|
|
26161
|
+
? []
|
|
26162
|
+
: splitActive
|
|
26163
|
+
? renderSplitDiffBody(h, components, visiblePreviewHunks, state.diffPreviewOffset, width, theme, 'commit-diff-split')
|
|
26164
|
+
: visiblePreviewHunks.map((line, index) => h(Text, {
|
|
26165
|
+
key: `diff-surface-line-${state.diffPreviewOffset + index}`,
|
|
26166
|
+
...diffLineProps(line, theme),
|
|
26167
|
+
}, truncate$1(line, 140)));
|
|
23766
26168
|
return h(Box, {
|
|
23767
26169
|
borderColor: focusBorderColor(theme, focused),
|
|
23768
26170
|
borderStyle: theme.borderStyle,
|
|
@@ -23770,15 +26172,10 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23770
26172
|
flexShrink: 0,
|
|
23771
26173
|
paddingX: 1,
|
|
23772
26174
|
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, {
|
|
26175
|
+
}, 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
26176
|
key: `diff-surface-header-${index}`,
|
|
23775
26177
|
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)))));
|
|
26178
|
+
}, truncate$1(line, 140))), ...commitBodyNodes);
|
|
23782
26179
|
}
|
|
23783
26180
|
const diffLines = worktreeDiff?.lines || [];
|
|
23784
26181
|
const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
|
|
@@ -23819,7 +26216,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23819
26216
|
}, truncate$1(line, 140)))
|
|
23820
26217
|
: []));
|
|
23821
26218
|
}
|
|
23822
|
-
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme) {
|
|
26219
|
+
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme) {
|
|
23823
26220
|
const focused = state.focus === 'detail';
|
|
23824
26221
|
if (state.showHelp) {
|
|
23825
26222
|
return renderHelpPanel(h, components, state, width, theme, focused);
|
|
@@ -23876,16 +26273,11 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
23876
26273
|
if (state.activeView === 'stash') {
|
|
23877
26274
|
return renderStashPreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
|
|
23878
26275
|
}
|
|
23879
|
-
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme, focused);
|
|
26276
|
+
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
|
|
23880
26277
|
}
|
|
23881
|
-
function renderHistoryInspector(h, components, state, context,
|
|
26278
|
+
function renderHistoryInspector(h, components, state, context, _contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, tabbed, theme, focused) {
|
|
23882
26279
|
const { Box, Text } = components;
|
|
23883
26280
|
const selected = getSelectedInkCommit(state);
|
|
23884
|
-
const workflowSections = getLogInkWorkflowSections({
|
|
23885
|
-
...context,
|
|
23886
|
-
contextLoading: isLogInkContextLoading(contextStatus),
|
|
23887
|
-
selectedCommit: selected,
|
|
23888
|
-
});
|
|
23889
26281
|
if (!detail) {
|
|
23890
26282
|
const fallbackLines = [
|
|
23891
26283
|
selected?.message || 'No commit selected.',
|
|
@@ -23901,7 +26293,7 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
|
|
|
23901
26293
|
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
|
|
23902
26294
|
key: `detail-${index}`,
|
|
23903
26295
|
dimColor: index > 1,
|
|
23904
|
-
}, truncate$1(line, width - 4))));
|
|
26296
|
+
}, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
|
|
23905
26297
|
}
|
|
23906
26298
|
const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
|
|
23907
26299
|
// P5.1 — link the commit hash and each ref out to GitHub when we know
|
|
@@ -23912,18 +26304,26 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
|
|
|
23912
26304
|
const refNodes = detail.refs.length
|
|
23913
26305
|
? renderInspectorRefs(h, Text, detail.refs, repository)
|
|
23914
26306
|
: null;
|
|
26307
|
+
// Inspector reorder (PR — drop duplicative Workflows trailer):
|
|
26308
|
+
// 1. Commit message (the headline of what you're looking at)
|
|
26309
|
+
// 2. Metadata (hash / author / date / refs / stats)
|
|
26310
|
+
// 3. Body preview (up to 8 lines now that the trailer is gone)
|
|
26311
|
+
// 4. Changed files list (cursored entry highlights)
|
|
26312
|
+
// 5. Actions cheat-sheet (per-entity keystrokes; destructive marked)
|
|
26313
|
+
// The Workflows: trailer that used to repeat the repo / branch /
|
|
26314
|
+
// status from the top header and left sidebar is intentionally gone.
|
|
23915
26315
|
const headerNodes = [
|
|
23916
26316
|
h(Text, { key: 'detail-msg' }, truncate$1(detail.message, width - 4)),
|
|
23917
26317
|
h(Text, { key: 'detail-spacer-1' }, ''),
|
|
23918
26318
|
h(Text, { key: 'detail-commit', dimColor: true }, 'Commit: ', commitLink),
|
|
23919
26319
|
h(Text, { key: 'detail-author', dimColor: true }, truncate$1(`Author: ${detail.author}`, width - 4)),
|
|
23920
|
-
h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date:
|
|
26320
|
+
h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
|
|
23921
26321
|
refNodes
|
|
23922
|
-
? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs:
|
|
23923
|
-
: h(Text, { key: 'detail-refs', dimColor: true }, 'Refs:
|
|
23924
|
-
h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(statLine
|
|
26322
|
+
? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
|
|
26323
|
+
: h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
|
|
26324
|
+
h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(`Stats: ${statLine}`, width - 4)),
|
|
23925
26325
|
h(Text, { key: 'detail-spacer-2' }, ''),
|
|
23926
|
-
...(detail.body ? detail.body.split('\n').slice(0,
|
|
26326
|
+
...(detail.body ? detail.body.split('\n').slice(0, 8) : ['No commit body.']).map((line, index) => h(Text, {
|
|
23927
26327
|
key: `detail-body-${index}`,
|
|
23928
26328
|
dimColor: true,
|
|
23929
26329
|
}, truncate$1(line, width - 4))),
|
|
@@ -23932,24 +26332,85 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
|
|
|
23932
26332
|
];
|
|
23933
26333
|
const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
|
|
23934
26334
|
const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
|
|
23935
|
-
|
|
23936
|
-
|
|
23937
|
-
|
|
23938
|
-
|
|
23939
|
-
|
|
23940
|
-
|
|
23941
|
-
|
|
23942
|
-
|
|
26335
|
+
// Tabbed mode (#806 follow-up — short terminals): render only the
|
|
26336
|
+
// active inspector tab with a `[Inspector] Actions` header so the
|
|
26337
|
+
// user knows what they're seeing and how to switch (`[/]` while
|
|
26338
|
+
// focus is on the inspector). Tall terminals stack both sections
|
|
26339
|
+
// as before.
|
|
26340
|
+
if (tabbed) {
|
|
26341
|
+
const activeTab = state.inspectorTab;
|
|
26342
|
+
const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
|
|
26343
|
+
bold: activeTab === 'inspector',
|
|
26344
|
+
dimColor: activeTab !== 'inspector',
|
|
26345
|
+
}, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), ' ', h(Text, {
|
|
26346
|
+
bold: activeTab === 'actions',
|
|
26347
|
+
dimColor: activeTab !== 'actions',
|
|
26348
|
+
}, activeTab === 'actions' ? '[Actions]' : ' Actions '));
|
|
26349
|
+
return h(Box, {
|
|
26350
|
+
borderColor: focusBorderColor(theme, focused),
|
|
26351
|
+
borderStyle: theme.borderStyle,
|
|
26352
|
+
flexDirection: 'column',
|
|
26353
|
+
width,
|
|
26354
|
+
paddingX: 1,
|
|
26355
|
+
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...(activeTab === 'inspector'
|
|
26356
|
+
? [...headerNodes, ...fileListNodes]
|
|
26357
|
+
: renderInspectorActionsSection(h, Text, 'history-commit', width, theme)));
|
|
26358
|
+
}
|
|
23943
26359
|
return h(Box, {
|
|
23944
26360
|
borderColor: focusBorderColor(theme, focused),
|
|
23945
26361
|
borderStyle: theme.borderStyle,
|
|
23946
26362
|
flexDirection: 'column',
|
|
23947
26363
|
width,
|
|
23948
26364
|
paddingX: 1,
|
|
23949
|
-
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...
|
|
23950
|
-
|
|
23951
|
-
|
|
23952
|
-
|
|
26365
|
+
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
|
|
26366
|
+
}
|
|
26367
|
+
/**
|
|
26368
|
+
* Render the trailing "Actions:" section that surfaces which keystrokes
|
|
26369
|
+
* apply to whatever the inspector is focused on. Keys are colored with
|
|
26370
|
+
* `theme.colors.accent` so they pop as the actionable element. Destructive
|
|
26371
|
+
* actions get the danger color plus a `[!]` marker so they don't blend
|
|
26372
|
+
* into the cherry-pick / yank rows.
|
|
26373
|
+
*
|
|
26374
|
+
* Truncates labels when the inspector is narrow (down to the 26-cell
|
|
26375
|
+
* minimum from `getLogInkLayout`) so an overflowing label never wraps and
|
|
26376
|
+
* collides with the next row.
|
|
26377
|
+
*/
|
|
26378
|
+
function renderInspectorActionsSection(h, Text, context, width, theme) {
|
|
26379
|
+
const actions = getInspectorActions(context);
|
|
26380
|
+
if (!actions.length)
|
|
26381
|
+
return [];
|
|
26382
|
+
// Width budget for each row: subtract padding + " " gutter, the key
|
|
26383
|
+
// column (left-padded to 5 cells so labels align), the " " gap
|
|
26384
|
+
// between key and label, and the optional " [!]" suffix (5 cells).
|
|
26385
|
+
const KEY_COLUMN = 5;
|
|
26386
|
+
const GAP = ' ';
|
|
26387
|
+
const DESTRUCTIVE_SUFFIX = ' [!]';
|
|
26388
|
+
const labelBudget = Math.max(4, width - 4 /* border + padX */ - KEY_COLUMN - GAP.length - DESTRUCTIVE_SUFFIX.length);
|
|
26389
|
+
const nodes = [
|
|
26390
|
+
h(Text, { key: 'actions-spacer' }, ''),
|
|
26391
|
+
h(Text, { key: 'actions-title' }, 'Actions:'),
|
|
26392
|
+
...actions.map((action, index) => {
|
|
26393
|
+
const keyCell = action.key.padEnd(KEY_COLUMN);
|
|
26394
|
+
const label = truncate$1(action.label, labelBudget);
|
|
26395
|
+
const children = [
|
|
26396
|
+
h(Text, {
|
|
26397
|
+
key: `actions-${index}-key`,
|
|
26398
|
+
color: action.destructive ? theme.colors.danger : theme.colors.accent,
|
|
26399
|
+
}, keyCell),
|
|
26400
|
+
GAP,
|
|
26401
|
+
label,
|
|
26402
|
+
];
|
|
26403
|
+
if (action.destructive) {
|
|
26404
|
+
children.push(h(Text, {
|
|
26405
|
+
key: `actions-${index}-mark`,
|
|
26406
|
+
color: theme.colors.danger,
|
|
26407
|
+
dimColor: false,
|
|
26408
|
+
}, DESTRUCTIVE_SUFFIX));
|
|
26409
|
+
}
|
|
26410
|
+
return h(Text, { key: `actions-${index}` }, ...children);
|
|
26411
|
+
}),
|
|
26412
|
+
];
|
|
26413
|
+
return nodes;
|
|
23953
26414
|
}
|
|
23954
26415
|
/**
|
|
23955
26416
|
* Build a commit URL for the repo when GitHub provider info is available.
|
|
@@ -24253,16 +26714,34 @@ function renderInputPromptPanel(h, components, state, width, theme, focused) {
|
|
|
24253
26714
|
if (!prompt) {
|
|
24254
26715
|
return h(Box, { width });
|
|
24255
26716
|
}
|
|
26717
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
26718
|
+
// Multi-line prompts (#806) split on newline and render one Text
|
|
26719
|
+
// row per buffer line — the cursor sits at the end of the last
|
|
26720
|
+
// line via the trailing `_`. Single-line prompts collapse to the
|
|
26721
|
+
// original one-row layout for muscle-memory continuity.
|
|
26722
|
+
const promptLines = prompt.multiline ? prompt.value.split('\n') : [prompt.value];
|
|
26723
|
+
if (promptLines.length === 0) {
|
|
26724
|
+
promptLines.push('');
|
|
26725
|
+
}
|
|
26726
|
+
const valueRows = promptLines.map((line, index) => {
|
|
26727
|
+
const isLast = index === promptLines.length - 1;
|
|
26728
|
+
const display = isLast ? `${line}_` : line;
|
|
26729
|
+
return h(Text, {
|
|
26730
|
+
key: `prompt-line-${index}`,
|
|
26731
|
+
bold: true,
|
|
26732
|
+
color: accent,
|
|
26733
|
+
}, truncate$1(display, width - 4));
|
|
26734
|
+
});
|
|
26735
|
+
const hint = prompt.multiline
|
|
26736
|
+
? 'Enter newline · Ctrl+d submit · Esc cancel · Ctrl+u clear'
|
|
26737
|
+
: 'Enter submit · Esc cancel · Ctrl+u clear';
|
|
24256
26738
|
return h(Box, {
|
|
24257
26739
|
borderColor: focusBorderColor(theme, focused),
|
|
24258
26740
|
borderStyle: theme.borderStyle,
|
|
24259
26741
|
flexDirection: 'column',
|
|
24260
26742
|
width,
|
|
24261
26743
|
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'));
|
|
26744
|
+
}, 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
26745
|
}
|
|
24267
26746
|
function renderConfirmationPanel(h, components, state, width, theme, focused) {
|
|
24268
26747
|
const { Box, Text } = components;
|
|
@@ -24436,16 +26915,31 @@ function renderCommandPalette(h, components, state, width, theme, focused) {
|
|
|
24436
26915
|
? [h(Text, { key: 'palette-recent-hint', dimColor: true }, '· marks recently-used')]
|
|
24437
26916
|
: []), ...itemLines);
|
|
24438
26917
|
}
|
|
24439
|
-
function renderFooter(h, components, state, theme, idleTip) {
|
|
26918
|
+
function renderFooter(h, components, state, context, theme, idleTip) {
|
|
24440
26919
|
const { Box, Text } = components;
|
|
26920
|
+
// Sidebar item count drives the per-tab footer hints — when items are
|
|
26921
|
+
// present the footer surfaces in-sidebar ops (checkout / apply / pop /
|
|
26922
|
+
// drop), otherwise it falls back to the generic "enter open" hint.
|
|
26923
|
+
const sidebarItemCount = (() => {
|
|
26924
|
+
switch (state.sidebarTab) {
|
|
26925
|
+
case 'branches': return context.branches?.localBranches.length;
|
|
26926
|
+
case 'tags': return context.tags?.tags.length;
|
|
26927
|
+
case 'stashes': return context.stashes?.stashes.length;
|
|
26928
|
+
case 'worktrees': return context.worktreeList?.worktrees.length;
|
|
26929
|
+
default: return undefined;
|
|
26930
|
+
}
|
|
26931
|
+
})();
|
|
24441
26932
|
const hints = getLogInkFooterHints({
|
|
24442
26933
|
activeView: state.activeView,
|
|
24443
26934
|
diffSource: state.diffSource,
|
|
26935
|
+
diffViewMode: state.diffViewMode,
|
|
24444
26936
|
filterMode: state.filterMode,
|
|
24445
26937
|
focus: state.focus,
|
|
24446
26938
|
pendingKey: state.pendingKey,
|
|
24447
26939
|
showCommandPalette: state.showCommandPalette,
|
|
24448
26940
|
showHelp: state.showHelp,
|
|
26941
|
+
sidebarTab: state.sidebarTab,
|
|
26942
|
+
sidebarItemCount,
|
|
24449
26943
|
});
|
|
24450
26944
|
// Real status messages always win; idle tips only fill the slot when it
|
|
24451
26945
|
// would otherwise be empty.
|
|
@@ -24490,7 +26984,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
|
24490
26984
|
const git = options.git || getRepo();
|
|
24491
26985
|
const rows = options.rows || (await getLogRows(git, logArgv));
|
|
24492
26986
|
await startInkInteractiveLog(git, rows, {}, {
|
|
24493
|
-
appLabel: 'coco
|
|
26987
|
+
appLabel: 'coco',
|
|
24494
26988
|
idleTips: config.logTui?.idleTips,
|
|
24495
26989
|
initialView: 'history',
|
|
24496
26990
|
logArgv,
|
|
@@ -24503,7 +26997,7 @@ async function startCocoUi(argv) {
|
|
|
24503
26997
|
const logArgv = createLogArgvFromUiArgv(argv);
|
|
24504
26998
|
const rows = await getLogRows(git, logArgv);
|
|
24505
26999
|
await startInkInteractiveLog(git, rows, {}, {
|
|
24506
|
-
appLabel: 'coco
|
|
27000
|
+
appLabel: 'coco',
|
|
24507
27001
|
idleTips: config.logTui?.idleTips,
|
|
24508
27002
|
initialView: argv.view || 'history',
|
|
24509
27003
|
logArgv,
|