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.esm.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import * as fs from 'fs';
|
|
|
7
7
|
import fs__default, { promises, existsSync, readFileSync, readdirSync } from 'fs';
|
|
8
8
|
import * as ini from 'ini';
|
|
9
9
|
import * as os from 'os';
|
|
10
|
-
import os__default from 'os';
|
|
10
|
+
import os__default, { tmpdir } from 'os';
|
|
11
11
|
import * as path from 'path';
|
|
12
12
|
import path__default, { join, isAbsolute } from 'path';
|
|
13
13
|
import Ajv from 'ajv';
|
|
@@ -45,6 +45,7 @@ import * as crypto from 'node:crypto';
|
|
|
45
45
|
import * as readline from 'readline';
|
|
46
46
|
import readline__default from 'readline';
|
|
47
47
|
import { promisify } from 'util';
|
|
48
|
+
import { randomUUID } from 'crypto';
|
|
48
49
|
import { pathToFileURL } from 'url';
|
|
49
50
|
|
|
50
51
|
// This file is auto-generated - DO NOT EDIT
|
|
@@ -52,7 +53,7 @@ import { pathToFileURL } from 'url';
|
|
|
52
53
|
/**
|
|
53
54
|
* Current build version from package.json
|
|
54
55
|
*/
|
|
55
|
-
const BUILD_VERSION = "0.
|
|
56
|
+
const BUILD_VERSION = "0.39.0";
|
|
56
57
|
|
|
57
58
|
const isInteractive = (config) => {
|
|
58
59
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -13514,8 +13515,11 @@ const builder$3 = (yargs) => {
|
|
|
13514
13515
|
};
|
|
13515
13516
|
|
|
13516
13517
|
const FIELD_SEPARATOR$2 = '\x1f';
|
|
13517
|
-
|
|
13518
|
-
|
|
13518
|
+
// `%P` (parent hashes, space-separated) lets the TUI distinguish
|
|
13519
|
+
// merge commits (parents.length > 1) from regular commits without a
|
|
13520
|
+
// second round-trip to git. See #791 stage 3 — merge glyph + HEAD ring.
|
|
13521
|
+
const LOG_FORMAT = `%x1f%h%x1f%H%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s`;
|
|
13522
|
+
const DETAIL_FORMAT = `%H%x1f%h%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s%x1f%b`;
|
|
13519
13523
|
const LOG_DEFAULT_LIMIT = 30;
|
|
13520
13524
|
const LOG_INTERACTIVE_DEFAULT_LIMIT = 300;
|
|
13521
13525
|
function toArray(value) {
|
|
@@ -13569,12 +13573,13 @@ function parseLogOutput(output) {
|
|
|
13569
13573
|
graph: line,
|
|
13570
13574
|
};
|
|
13571
13575
|
}
|
|
13572
|
-
const [graph, shortHash, hash, date, author, refs, message] = line.split(FIELD_SEPARATOR$2);
|
|
13576
|
+
const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$2);
|
|
13573
13577
|
return {
|
|
13574
13578
|
type: 'commit',
|
|
13575
13579
|
graph: graph.trimEnd(),
|
|
13576
13580
|
shortHash,
|
|
13577
13581
|
hash,
|
|
13582
|
+
parents: parentsStr ? parentsStr.trim().split(' ').filter(Boolean) : [],
|
|
13578
13583
|
date,
|
|
13579
13584
|
author,
|
|
13580
13585
|
refs: cleanRefs(refs),
|
|
@@ -13641,13 +13646,14 @@ function parseNameStatus(output, numstat = []) {
|
|
|
13641
13646
|
});
|
|
13642
13647
|
}
|
|
13643
13648
|
function parseCommitDetail(metadata, files, numstatOutput = '') {
|
|
13644
|
-
const [hash, shortHash, date, author, refs, message, body = ''] = metadata
|
|
13649
|
+
const [hash, shortHash, parentsStr, date, author, refs, message, body = ''] = metadata
|
|
13645
13650
|
.trimEnd()
|
|
13646
13651
|
.split(FIELD_SEPARATOR$2);
|
|
13647
13652
|
const numstat = parseNumstat(numstatOutput);
|
|
13648
13653
|
return {
|
|
13649
13654
|
shortHash,
|
|
13650
13655
|
hash,
|
|
13656
|
+
parents: parentsStr ? parentsStr.trim().split(' ').filter(Boolean) : [],
|
|
13651
13657
|
date,
|
|
13652
13658
|
author,
|
|
13653
13659
|
refs: cleanRefs(refs),
|
|
@@ -13701,6 +13707,24 @@ function buildLogArgs(argv, options = {}) {
|
|
|
13701
13707
|
}
|
|
13702
13708
|
return args;
|
|
13703
13709
|
}
|
|
13710
|
+
/**
|
|
13711
|
+
* Build merged `LogArgv` for the interactive TUI's `g` graph toggle.
|
|
13712
|
+
*
|
|
13713
|
+
* The TUI tracks a transient `fullGraph` boolean; toggling it must produce
|
|
13714
|
+
* a fresh fetch with the right `view` so the renderer actually has graph
|
|
13715
|
+
* topology to draw. When switching to full mode we override `view` to
|
|
13716
|
+
* `'full'` (which `buildLogArgs` already maps to `--all`, dropping
|
|
13717
|
+
* `--first-parent`/`--no-merges`). When switching back we honor the user's
|
|
13718
|
+
* original `view` from argv, defaulting to `'compact'`.
|
|
13719
|
+
*
|
|
13720
|
+
* Pure helper so the effect that calls it stays trivially testable.
|
|
13721
|
+
*/
|
|
13722
|
+
function buildToggleGraphArgs(argv, fullGraph) {
|
|
13723
|
+
if (fullGraph) {
|
|
13724
|
+
return { ...argv, view: 'full' };
|
|
13725
|
+
}
|
|
13726
|
+
return { ...argv, view: argv.view ?? 'compact' };
|
|
13727
|
+
}
|
|
13704
13728
|
async function getLogRows(git, argv, options = {}) {
|
|
13705
13729
|
return parseLogOutput(await git.raw(buildLogArgs(argv, options)));
|
|
13706
13730
|
}
|
|
@@ -13942,7 +13966,7 @@ function splitCommitDraft(draft) {
|
|
|
13942
13966
|
body,
|
|
13943
13967
|
};
|
|
13944
13968
|
}
|
|
13945
|
-
function compactOutputLines$
|
|
13969
|
+
function compactOutputLines$5(output) {
|
|
13946
13970
|
return output
|
|
13947
13971
|
.split('\n')
|
|
13948
13972
|
.map((line) => line.trim())
|
|
@@ -13950,14 +13974,14 @@ function compactOutputLines$4(output) {
|
|
|
13950
13974
|
}
|
|
13951
13975
|
function formatManualCommitFailure(error) {
|
|
13952
13976
|
if (error instanceof PreCommitHookError) {
|
|
13953
|
-
const details = compactOutputLines$
|
|
13977
|
+
const details = compactOutputLines$5(error.hookOutput);
|
|
13954
13978
|
return {
|
|
13955
13979
|
ok: false,
|
|
13956
13980
|
message: `Commit blocked by hook: ${details[0] || 'hook failed'}`,
|
|
13957
13981
|
details: details.slice(1, 6),
|
|
13958
13982
|
};
|
|
13959
13983
|
}
|
|
13960
|
-
const details = compactOutputLines$
|
|
13984
|
+
const details = compactOutputLines$5(error.message);
|
|
13961
13985
|
return {
|
|
13962
13986
|
ok: false,
|
|
13963
13987
|
message: details[0] || 'Commit failed.',
|
|
@@ -14258,7 +14282,7 @@ function formatCommitWorkflowMessage(action, output) {
|
|
|
14258
14282
|
}
|
|
14259
14283
|
return 'Generated commit message.';
|
|
14260
14284
|
}
|
|
14261
|
-
function compactOutputLines$
|
|
14285
|
+
function compactOutputLines$4(output) {
|
|
14262
14286
|
return output
|
|
14263
14287
|
.split('\n')
|
|
14264
14288
|
.map((line) => line.trim())
|
|
@@ -14266,14 +14290,14 @@ function compactOutputLines$3(output) {
|
|
|
14266
14290
|
}
|
|
14267
14291
|
function formatCommitFailure(error) {
|
|
14268
14292
|
if (error instanceof PreCommitHookError) {
|
|
14269
|
-
const details = compactOutputLines$
|
|
14293
|
+
const details = compactOutputLines$4(error.hookOutput);
|
|
14270
14294
|
return {
|
|
14271
14295
|
ok: false,
|
|
14272
14296
|
message: `Commit blocked by hook: ${details[0] || 'hook failed'}`,
|
|
14273
14297
|
details: details.slice(1, 6),
|
|
14274
14298
|
};
|
|
14275
14299
|
}
|
|
14276
|
-
const details = compactOutputLines$
|
|
14300
|
+
const details = compactOutputLines$4(error.message);
|
|
14277
14301
|
return {
|
|
14278
14302
|
ok: false,
|
|
14279
14303
|
message: details[0] || 'Commit action failed.',
|
|
@@ -14306,7 +14330,7 @@ async function runCommitWorkflow({ action, git = getRepo(), noVerify = false, })
|
|
|
14306
14330
|
}
|
|
14307
14331
|
catch (error) {
|
|
14308
14332
|
if (isCommandExitError(error)) {
|
|
14309
|
-
const lines = compactOutputLines$
|
|
14333
|
+
const lines = compactOutputLines$4(output || error.message);
|
|
14310
14334
|
return {
|
|
14311
14335
|
ok: error.code === 0,
|
|
14312
14336
|
message: lines[0] || error.message,
|
|
@@ -14348,7 +14372,7 @@ async function runCommitDraftWorkflow(input = {}) {
|
|
|
14348
14372
|
}
|
|
14349
14373
|
catch (error) {
|
|
14350
14374
|
if (isCommandExitError(error)) {
|
|
14351
|
-
const lines = compactOutputLines$
|
|
14375
|
+
const lines = compactOutputLines$4(error.message);
|
|
14352
14376
|
return {
|
|
14353
14377
|
ok: error.code === 0,
|
|
14354
14378
|
message: lines[0] || error.message,
|
|
@@ -14386,6 +14410,274 @@ function isLogInkContextKeyLoading(status, key) {
|
|
|
14386
14410
|
return status[key] === 'loading';
|
|
14387
14411
|
}
|
|
14388
14412
|
|
|
14413
|
+
/**
|
|
14414
|
+
* The chars `git log --graph` emits for branch topology — `*`, `|`, `\`,
|
|
14415
|
+
* `/`, `_`, ` `. ASCII-only output is bulletproof for legacy terminals
|
|
14416
|
+
* but the angles read poorly when many branches overlap.
|
|
14417
|
+
*
|
|
14418
|
+
* `substituteGraphChars` walks the row left-to-right with one-char
|
|
14419
|
+
* lookahead so it can recognize git's two-char junction patterns and
|
|
14420
|
+
* emit proper box-drawing junctions (├╮ / ├╯) instead of overlapping
|
|
14421
|
+
* pipes (│╲ / │╱). Anything that isn't part of a recognized pattern
|
|
14422
|
+
* falls back to the legacy 1-to-1 substitution.
|
|
14423
|
+
*
|
|
14424
|
+
* `theme.ascii` (TERM=dumb / vt100) bypasses substitution entirely so
|
|
14425
|
+
* legacy terminals get the raw `git log --graph` output. `theme.noColor`
|
|
14426
|
+
* is orthogonal — Unicode chars still render, just without color.
|
|
14427
|
+
*
|
|
14428
|
+
* Kept ASCII-only intentionally:
|
|
14429
|
+
* - alphanumerics (commit refs / annotations git sometimes injects)
|
|
14430
|
+
* - parens / brackets (HEAD decoration markers, not part of the graph)
|
|
14431
|
+
* - hyphens / colons (likewise)
|
|
14432
|
+
*/
|
|
14433
|
+
const ASCII_TO_UNICODE_MAP = {
|
|
14434
|
+
'*': '●',
|
|
14435
|
+
'|': '│',
|
|
14436
|
+
'/': '╱',
|
|
14437
|
+
'\\': '╲',
|
|
14438
|
+
'_': '─',
|
|
14439
|
+
};
|
|
14440
|
+
const DEFAULT_COMMIT_GLYPH = '●';
|
|
14441
|
+
/**
|
|
14442
|
+
* #791 stage 3 — distinct glyphs for merges and HEAD so they stand
|
|
14443
|
+
* out from the run of regular commits. `◆` (filled diamond) flags a
|
|
14444
|
+
* merge commit (`parents.length > 1`); `◉` (fisheye) flags HEAD
|
|
14445
|
+
* regardless of parent count. Both render at the same column width as
|
|
14446
|
+
* `●` so graph alignment stays intact across mixed commit types.
|
|
14447
|
+
*/
|
|
14448
|
+
const MERGE_COMMIT_GLYPH = '◆';
|
|
14449
|
+
const HEAD_COMMIT_GLYPH = '◉';
|
|
14450
|
+
/**
|
|
14451
|
+
* Recognized 2-char junction patterns. The key is the bigram git emits
|
|
14452
|
+
* (lane char + spacer char); the value is the box-drawing pair we render.
|
|
14453
|
+
*
|
|
14454
|
+
* - `|\` (fork): trunk lane gains a right-T (├) and the spacer becomes
|
|
14455
|
+
* the upper-right corner (╮) starting the new lane below.
|
|
14456
|
+
* - `|/` (converge): trunk lane gains a right-T (├) and the spacer
|
|
14457
|
+
* becomes the upper-left corner (╯) absorbing the side lane from
|
|
14458
|
+
* above.
|
|
14459
|
+
*
|
|
14460
|
+
* `*\` and `* /` (commit-row variants) are handled the same way, but
|
|
14461
|
+
* the commit glyph itself stays configurable via `commitGlyph` so
|
|
14462
|
+
* stage 3 can swap in `◆` / `◉` for merges and HEAD.
|
|
14463
|
+
*/
|
|
14464
|
+
const PIPE_FORK = '├╮';
|
|
14465
|
+
const PIPE_CONVERGE = '├╯';
|
|
14466
|
+
const FORK_SPACER = '╮';
|
|
14467
|
+
const CONVERGE_SPACER = '╯';
|
|
14468
|
+
function substituteGraphChars(graph, options) {
|
|
14469
|
+
if (options.ascii) {
|
|
14470
|
+
return graph;
|
|
14471
|
+
}
|
|
14472
|
+
const commitGlyph = options.commitGlyph ?? DEFAULT_COMMIT_GLYPH;
|
|
14473
|
+
let output = '';
|
|
14474
|
+
let i = 0;
|
|
14475
|
+
while (i < graph.length) {
|
|
14476
|
+
const a = graph[i];
|
|
14477
|
+
const b = i + 1 < graph.length ? graph[i + 1] : '';
|
|
14478
|
+
if (a === '|' && b === '\\') {
|
|
14479
|
+
output += PIPE_FORK;
|
|
14480
|
+
i += 2;
|
|
14481
|
+
continue;
|
|
14482
|
+
}
|
|
14483
|
+
if (a === '|' && b === '/') {
|
|
14484
|
+
output += PIPE_CONVERGE;
|
|
14485
|
+
i += 2;
|
|
14486
|
+
continue;
|
|
14487
|
+
}
|
|
14488
|
+
if (a === '*' && b === '\\') {
|
|
14489
|
+
output += commitGlyph + FORK_SPACER;
|
|
14490
|
+
i += 2;
|
|
14491
|
+
continue;
|
|
14492
|
+
}
|
|
14493
|
+
if (a === '*' && b === '/') {
|
|
14494
|
+
output += commitGlyph + CONVERGE_SPACER;
|
|
14495
|
+
i += 2;
|
|
14496
|
+
continue;
|
|
14497
|
+
}
|
|
14498
|
+
if (a === '*') {
|
|
14499
|
+
output += commitGlyph;
|
|
14500
|
+
}
|
|
14501
|
+
else {
|
|
14502
|
+
output += ASCII_TO_UNICODE_MAP[a] ?? a;
|
|
14503
|
+
}
|
|
14504
|
+
i += 1;
|
|
14505
|
+
}
|
|
14506
|
+
return output;
|
|
14507
|
+
}
|
|
14508
|
+
|
|
14509
|
+
/**
|
|
14510
|
+
* Lane tracking + per-lane coloring for the Ink log TUI graph (#791
|
|
14511
|
+
* stage 2).
|
|
14512
|
+
*
|
|
14513
|
+
* `git log --graph` emits topology in 2-char patterns where every even
|
|
14514
|
+
* position is a lane column (`*`, `|`, ` `) and every odd position is
|
|
14515
|
+
* a spacer that may carry a connector (`\`, `/`, `_`). To color graph
|
|
14516
|
+
* chars by which logical lane they belong to, we walk the rows
|
|
14517
|
+
* left-to-right tracking which lane id occupies each column and apply
|
|
14518
|
+
* git's emission rules:
|
|
14519
|
+
*
|
|
14520
|
+
* - `|\` (fork): the spacer spawns a new lane id at column +1 below.
|
|
14521
|
+
* - `|/` (converge): the spacer absorbs the lane at column +1 into
|
|
14522
|
+
* this column; the absorbed lane disappears in the next row.
|
|
14523
|
+
* - `*` is treated like `|` for lane purposes (a commit lives on a
|
|
14524
|
+
* lane and connects through the row).
|
|
14525
|
+
*
|
|
14526
|
+
* Lane ids are stable across rows for the same column unless one of
|
|
14527
|
+
* the transition patterns above fires. Other shifts (multi-step `_`
|
|
14528
|
+
* crossings, octopus merges) degrade gracefully — uncovered chars
|
|
14529
|
+
* just fall back to `undefined` lane id, so they render in the muted
|
|
14530
|
+
* graph color rather than a wrong lane color.
|
|
14531
|
+
*
|
|
14532
|
+
* The segment builder collapses adjacent characters with the same
|
|
14533
|
+
* lane id into one `LaneSegment` so the renderer emits one Text span
|
|
14534
|
+
* per visually-distinct color region instead of per-char.
|
|
14535
|
+
*/
|
|
14536
|
+
function createLaneTrackerState() {
|
|
14537
|
+
return { columnLanes: new Map(), nextLaneId: 0 };
|
|
14538
|
+
}
|
|
14539
|
+
/**
|
|
14540
|
+
* Walk a single graph row left-to-right, mutating the tracker so the
|
|
14541
|
+
* next row sees the updated column → lane id map. Returns lane
|
|
14542
|
+
* segments ready for the renderer. When `options.ascii` is true the
|
|
14543
|
+
* tracker is left untouched and the row is emitted as a single
|
|
14544
|
+
* lane-less segment so legacy terminals get raw ASCII output with no
|
|
14545
|
+
* coloring.
|
|
14546
|
+
*/
|
|
14547
|
+
function renderGraphRowSegments(graph, tracker, options) {
|
|
14548
|
+
if (options.ascii) {
|
|
14549
|
+
return [{ text: graph, laneId: undefined }];
|
|
14550
|
+
}
|
|
14551
|
+
const commitGlyph = options.commitGlyph ?? DEFAULT_COMMIT_GLYPH;
|
|
14552
|
+
const segments = [];
|
|
14553
|
+
const push = (text, laneId) => {
|
|
14554
|
+
const last = segments[segments.length - 1];
|
|
14555
|
+
if (last && last.laneId === laneId) {
|
|
14556
|
+
last.text += text;
|
|
14557
|
+
}
|
|
14558
|
+
else {
|
|
14559
|
+
segments.push({ text, laneId });
|
|
14560
|
+
}
|
|
14561
|
+
};
|
|
14562
|
+
let i = 0;
|
|
14563
|
+
while (i < graph.length) {
|
|
14564
|
+
const c = graph[i];
|
|
14565
|
+
const next = i + 1 < graph.length ? graph[i + 1] : '';
|
|
14566
|
+
const col = i >> 1;
|
|
14567
|
+
const isSpacer = (i & 1) === 1;
|
|
14568
|
+
if (c === ' ') {
|
|
14569
|
+
push(' ', undefined);
|
|
14570
|
+
i += 1;
|
|
14571
|
+
continue;
|
|
14572
|
+
}
|
|
14573
|
+
if (!isSpacer && (c === '|' || c === '*')) {
|
|
14574
|
+
if (!tracker.columnLanes.has(col)) {
|
|
14575
|
+
tracker.columnLanes.set(col, tracker.nextLaneId++);
|
|
14576
|
+
}
|
|
14577
|
+
const laneId = tracker.columnLanes.get(col);
|
|
14578
|
+
const glyph = c === '|' ? '│' : commitGlyph;
|
|
14579
|
+
if (next === '\\') {
|
|
14580
|
+
const newLaneId = tracker.nextLaneId++;
|
|
14581
|
+
tracker.columnLanes.set(col + 1, newLaneId);
|
|
14582
|
+
push(c === '|' ? '├' : commitGlyph, laneId);
|
|
14583
|
+
push('╮', newLaneId);
|
|
14584
|
+
i += 2;
|
|
14585
|
+
continue;
|
|
14586
|
+
}
|
|
14587
|
+
if (next === '/') {
|
|
14588
|
+
const absorbedLaneId = tracker.columnLanes.get(col + 1);
|
|
14589
|
+
push(c === '|' ? '├' : commitGlyph, laneId);
|
|
14590
|
+
push('╯', absorbedLaneId);
|
|
14591
|
+
tracker.columnLanes.delete(col + 1);
|
|
14592
|
+
i += 2;
|
|
14593
|
+
continue;
|
|
14594
|
+
}
|
|
14595
|
+
push(glyph, laneId);
|
|
14596
|
+
i += 1;
|
|
14597
|
+
continue;
|
|
14598
|
+
}
|
|
14599
|
+
// Non-lane chars (standalone `\`, `/`, `_`, decorations) — substitute
|
|
14600
|
+
// 1-to-1 and leave the lane id undefined so they render in the muted
|
|
14601
|
+
// fallback color.
|
|
14602
|
+
push(ASCII_TO_UNICODE_MAP[c] ?? c, undefined);
|
|
14603
|
+
i += 1;
|
|
14604
|
+
}
|
|
14605
|
+
return segments;
|
|
14606
|
+
}
|
|
14607
|
+
/**
|
|
14608
|
+
* Run the tracker over `count` rows starting from `state.rows[0]` so
|
|
14609
|
+
* downstream callers can resume tracking from a specific window
|
|
14610
|
+
* without re-scanning. Used by `getVisibleLogInkHistory` to keep lane
|
|
14611
|
+
* ids stable across scrolling — without this, each scroll would
|
|
14612
|
+
* re-color lanes from a fresh tracker.
|
|
14613
|
+
*/
|
|
14614
|
+
function advanceTrackerThrough(graphs, tracker, count) {
|
|
14615
|
+
for (let i = 0; i < count && i < graphs.length; i++) {
|
|
14616
|
+
renderGraphRowSegments(graphs[i], tracker, { ascii: false });
|
|
14617
|
+
}
|
|
14618
|
+
}
|
|
14619
|
+
/**
|
|
14620
|
+
* Theme-aware lane palette. Default uses bright ANSI named colors that
|
|
14621
|
+
* render reliably on 16-color terminals; catppuccin / gruvbox lift
|
|
14622
|
+
* accent hues from their respective palettes so the graph stays
|
|
14623
|
+
* coherent with the surrounding chrome.
|
|
14624
|
+
*
|
|
14625
|
+
* Selecting 8 colors gives enough variety to distinguish lanes in
|
|
14626
|
+
* practice (most repos peak at 3-4 simultaneous lanes); the modulo
|
|
14627
|
+
* lookup wraps cleanly for the rare case of more.
|
|
14628
|
+
*/
|
|
14629
|
+
const DEFAULT_LANE_PALETTE = [
|
|
14630
|
+
'cyan', 'magenta', 'yellow', 'green', 'blue', 'red', 'cyanBright', 'magentaBright',
|
|
14631
|
+
];
|
|
14632
|
+
const CATPPUCCIN_LANE_PALETTE = [
|
|
14633
|
+
'#89b4fa', '#f5c2e7', '#f9e2af', '#a6e3a1', '#cba6f7', '#fab387', '#94e2d5', '#f5e0dc',
|
|
14634
|
+
];
|
|
14635
|
+
const GRUVBOX_LANE_PALETTE = [
|
|
14636
|
+
'#83a598', '#d3869b', '#fabd2f', '#b8bb26', '#d65d0e', '#fb4934', '#8ec07c', '#fe8019',
|
|
14637
|
+
];
|
|
14638
|
+
function getLanePalette(theme) {
|
|
14639
|
+
if (theme.noColor) {
|
|
14640
|
+
return [];
|
|
14641
|
+
}
|
|
14642
|
+
const accent = theme.colors.accent;
|
|
14643
|
+
if (accent === '#89b4fa') {
|
|
14644
|
+
return CATPPUCCIN_LANE_PALETTE;
|
|
14645
|
+
}
|
|
14646
|
+
if (accent === '#83a598') {
|
|
14647
|
+
return GRUVBOX_LANE_PALETTE;
|
|
14648
|
+
}
|
|
14649
|
+
return DEFAULT_LANE_PALETTE;
|
|
14650
|
+
}
|
|
14651
|
+
function getLaneColor(laneId, theme) {
|
|
14652
|
+
if (laneId === undefined) {
|
|
14653
|
+
return undefined;
|
|
14654
|
+
}
|
|
14655
|
+
const palette = getLanePalette(theme);
|
|
14656
|
+
if (palette.length === 0) {
|
|
14657
|
+
return undefined;
|
|
14658
|
+
}
|
|
14659
|
+
return palette[laneId % palette.length];
|
|
14660
|
+
}
|
|
14661
|
+
|
|
14662
|
+
/**
|
|
14663
|
+
* Pick the commit glyph based on parent count + HEAD-ness so the
|
|
14664
|
+
* renderer can flag merges and the current head visually. HEAD wins
|
|
14665
|
+
* over merge when both apply (HEAD on a merge commit) — the ◉ ring
|
|
14666
|
+
* is the more salient signal and the user can still see the merge
|
|
14667
|
+
* via the lane topology.
|
|
14668
|
+
*/
|
|
14669
|
+
function commitGlyphFor(commit) {
|
|
14670
|
+
if (isHeadCommit$1(commit)) {
|
|
14671
|
+
return HEAD_COMMIT_GLYPH;
|
|
14672
|
+
}
|
|
14673
|
+
if (commit.parents.length > 1) {
|
|
14674
|
+
return MERGE_COMMIT_GLYPH;
|
|
14675
|
+
}
|
|
14676
|
+
return DEFAULT_COMMIT_GLYPH;
|
|
14677
|
+
}
|
|
14678
|
+
function isHeadCommit$1(commit) {
|
|
14679
|
+
return commit.refs.some((ref) => ref === 'HEAD' || ref.startsWith('HEAD ->'));
|
|
14680
|
+
}
|
|
14389
14681
|
function clampWindowStart(index, count, visibleCount) {
|
|
14390
14682
|
return Math.max(0, Math.min(index - Math.floor(visibleCount / 2), Math.max(0, count - visibleCount)));
|
|
14391
14683
|
}
|
|
@@ -14401,6 +14693,11 @@ function toCompactItems(state, visibleCount) {
|
|
|
14401
14693
|
type: 'commit',
|
|
14402
14694
|
commit,
|
|
14403
14695
|
graph: '*',
|
|
14696
|
+
// Compact mode skips lane tracking (no topology to color) but still
|
|
14697
|
+
// wants the merge / HEAD glyph so the user can spot them at a
|
|
14698
|
+
// glance. Lane id stays undefined so the segment renders muted —
|
|
14699
|
+
// matching the legacy compact appearance, just with a richer glyph.
|
|
14700
|
+
laneSegments: [{ text: commitGlyphFor(commit), laneId: undefined }],
|
|
14404
14701
|
selected: start + offset === state.selectedIndex,
|
|
14405
14702
|
}));
|
|
14406
14703
|
}
|
|
@@ -14411,17 +14708,28 @@ function toFullGraphItems(state, visibleCount) {
|
|
|
14411
14708
|
const selected = state.filteredCommits[state.selectedIndex];
|
|
14412
14709
|
const selectedRowIndex = state.rows.findIndex((row) => isSelectedCommit(row, selected));
|
|
14413
14710
|
const start = clampWindowStart(selectedRowIndex >= 0 ? selectedRowIndex : 0, state.rows.length, visibleCount);
|
|
14711
|
+
// Lane tracking is order-dependent — fast-forward the tracker through
|
|
14712
|
+
// every row above the visible window so lane ids stay stable as the
|
|
14713
|
+
// user scrolls. Without this, scrolling would re-color lanes from a
|
|
14714
|
+
// fresh tracker each time.
|
|
14715
|
+
const tracker = createLaneTrackerState();
|
|
14716
|
+
const allGraphs = state.rows.map((row) => (row.type === 'commit' ? row.graph || '*' : row.graph));
|
|
14717
|
+
advanceTrackerThrough(allGraphs, tracker, start);
|
|
14414
14718
|
return state.rows.slice(start, start + visibleCount).map((row) => {
|
|
14415
14719
|
if (row.type === 'graph') {
|
|
14416
14720
|
return {
|
|
14417
14721
|
type: 'graph',
|
|
14418
14722
|
graph: row.graph,
|
|
14723
|
+
laneSegments: renderGraphRowSegments(row.graph, tracker, { ascii: false }),
|
|
14419
14724
|
};
|
|
14420
14725
|
}
|
|
14726
|
+
const graph = row.graph || '*';
|
|
14727
|
+
const commitGlyph = commitGlyphFor(row);
|
|
14421
14728
|
return {
|
|
14422
14729
|
type: 'commit',
|
|
14423
14730
|
commit: row,
|
|
14424
|
-
graph
|
|
14731
|
+
graph,
|
|
14732
|
+
laneSegments: renderGraphRowSegments(graph, tracker, { ascii: false, commitGlyph }),
|
|
14425
14733
|
selected: isSelectedCommit(row, selected),
|
|
14426
14734
|
};
|
|
14427
14735
|
});
|
|
@@ -14439,84 +14747,6 @@ function formatInkRefLabels(refs) {
|
|
|
14439
14747
|
return refs.length ? ` ${refs.map((ref) => `[${ref}]`).join(' ')}` : '';
|
|
14440
14748
|
}
|
|
14441
14749
|
|
|
14442
|
-
function countLabel(count, singular, plural = `${singular}s`) {
|
|
14443
|
-
return `${count} ${count === 1 ? singular : plural}`;
|
|
14444
|
-
}
|
|
14445
|
-
function getLogInkWorkflowSections(context) {
|
|
14446
|
-
const currentBranch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
|
|
14447
|
-
const dirty = context.branches?.dirty ? 'dirty worktree' : 'clean worktree';
|
|
14448
|
-
const loading = context.contextLoading;
|
|
14449
|
-
const currentPullRequest = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
|
|
14450
|
-
const repository = context.provider?.repository;
|
|
14451
|
-
const repoName = repository?.owner && repository.name
|
|
14452
|
-
? `${repository.owner}/${repository.name}`
|
|
14453
|
-
: repository?.message || 'local repository';
|
|
14454
|
-
const operation = context.operation;
|
|
14455
|
-
const worktree = context.worktree;
|
|
14456
|
-
return [
|
|
14457
|
-
{
|
|
14458
|
-
title: 'Branch',
|
|
14459
|
-
lines: [
|
|
14460
|
-
`Current: ${currentBranch}`,
|
|
14461
|
-
`State: ${dirty}`,
|
|
14462
|
-
loading && !context.branches
|
|
14463
|
-
? 'Branch data loading'
|
|
14464
|
-
: context.branches
|
|
14465
|
-
? `${countLabel(context.branches.localBranches.length, 'local branch', 'local branches')} | ${countLabel(context.branches.remoteBranches.length, 'remote branch', 'remote branches')}`
|
|
14466
|
-
: 'Branch data unavailable',
|
|
14467
|
-
],
|
|
14468
|
-
},
|
|
14469
|
-
{
|
|
14470
|
-
title: 'Provider / PR',
|
|
14471
|
-
lines: [
|
|
14472
|
-
`Repository: ${repoName}`,
|
|
14473
|
-
loading && !context.provider && !context.pullRequest
|
|
14474
|
-
? 'Provider and pull request data loading'
|
|
14475
|
-
: currentPullRequest
|
|
14476
|
-
? `PR #${currentPullRequest.number} ${currentPullRequest.state}${currentPullRequest.isDraft ? ' draft' : ''}`
|
|
14477
|
-
: 'No pull request detected for current branch',
|
|
14478
|
-
context.provider?.authenticated === false ? 'Provider auth: offline' : 'Provider auth: available',
|
|
14479
|
-
],
|
|
14480
|
-
},
|
|
14481
|
-
{
|
|
14482
|
-
title: 'Status',
|
|
14483
|
-
lines: loading && !worktree
|
|
14484
|
-
? ['Status data loading']
|
|
14485
|
-
: worktree
|
|
14486
|
-
? [
|
|
14487
|
-
`${countLabel(worktree.stagedCount, 'staged file')}`,
|
|
14488
|
-
`${countLabel(worktree.unstagedCount, 'unstaged file')}`,
|
|
14489
|
-
`${countLabel(worktree.untrackedCount, 'untracked file')}`,
|
|
14490
|
-
]
|
|
14491
|
-
: ['Status data unavailable'],
|
|
14492
|
-
},
|
|
14493
|
-
{
|
|
14494
|
-
title: 'Tags / Stashes / Worktrees',
|
|
14495
|
-
lines: [
|
|
14496
|
-
loading && !context.tags ? 'Tags loading' : context.tags ? countLabel(context.tags.tags.length, 'tag') : 'Tags unavailable',
|
|
14497
|
-
loading && !context.stashes ? 'Stashes loading' : context.stashes ? countLabel(context.stashes.stashes.length, 'stash', 'stashes') : 'Stashes unavailable',
|
|
14498
|
-
context.worktreeList
|
|
14499
|
-
? countLabel(context.worktreeList.worktrees.length, 'worktree')
|
|
14500
|
-
: loading
|
|
14501
|
-
? 'Worktrees loading'
|
|
14502
|
-
: 'Worktrees unavailable',
|
|
14503
|
-
],
|
|
14504
|
-
},
|
|
14505
|
-
{
|
|
14506
|
-
title: 'Operation / AI',
|
|
14507
|
-
lines: [
|
|
14508
|
-
loading && !operation
|
|
14509
|
-
? 'Operation data loading'
|
|
14510
|
-
: operation?.operation
|
|
14511
|
-
? `${operation.operation} in progress with ${countLabel(operation.conflictedFiles.length, 'conflict')}`
|
|
14512
|
-
: 'No merge, rebase, cherry-pick, or revert in progress',
|
|
14513
|
-
context.selectedCommit
|
|
14514
|
-
? `AI actions target ${context.selectedCommit.shortHash}; estimates shown before execution`
|
|
14515
|
-
: 'AI actions require a selected commit',
|
|
14516
|
-
],
|
|
14517
|
-
},
|
|
14518
|
-
];
|
|
14519
|
-
}
|
|
14520
14750
|
function getLogInkWorkflowActions() {
|
|
14521
14751
|
return [
|
|
14522
14752
|
{
|
|
@@ -14570,6 +14800,34 @@ function getLogInkWorkflowActions() {
|
|
|
14570
14800
|
kind: 'destructive',
|
|
14571
14801
|
requiresConfirmation: true,
|
|
14572
14802
|
},
|
|
14803
|
+
{
|
|
14804
|
+
// Per-view-only: scoped to commit-diff and stash-diff explores in
|
|
14805
|
+
// inkInput (key: H). The action is non-destructive in the sense
|
|
14806
|
+
// that `git apply` won't lose any data — `git apply -R` undoes
|
|
14807
|
+
// it cleanly — so it bypasses the y-confirm path. The patch text
|
|
14808
|
+
// travels via the action's `payload` field. Empty key keeps the
|
|
14809
|
+
// workflow palette-discoverable without registering a global
|
|
14810
|
+
// hotkey (the palette path can't synthesize the patch text and
|
|
14811
|
+
// surfaces a hint instead — actual dispatch is from H in diff
|
|
14812
|
+
// view).
|
|
14813
|
+
id: 'apply-hunk-worktree',
|
|
14814
|
+
key: '',
|
|
14815
|
+
label: 'Apply hunk to worktree',
|
|
14816
|
+
description: 'Extract the hunk under the cursor and apply it to the working tree via `git apply`.',
|
|
14817
|
+
kind: 'normal',
|
|
14818
|
+
requiresConfirmation: false,
|
|
14819
|
+
},
|
|
14820
|
+
{
|
|
14821
|
+
// Sibling of `apply-hunk-worktree` — same extraction path, but
|
|
14822
|
+
// `git apply --cached` so the patch lands in the index without
|
|
14823
|
+
// touching the worktree. Bound to the `gH` chord in inkInput.
|
|
14824
|
+
id: 'apply-hunk-index',
|
|
14825
|
+
key: '',
|
|
14826
|
+
label: 'Apply hunk to index',
|
|
14827
|
+
description: 'Extract the hunk under the cursor and apply it to the index via `git apply --cached`.',
|
|
14828
|
+
kind: 'normal',
|
|
14829
|
+
requiresConfirmation: false,
|
|
14830
|
+
},
|
|
14573
14831
|
{
|
|
14574
14832
|
id: 'open-pr',
|
|
14575
14833
|
key: 'O',
|
|
@@ -14662,6 +14920,152 @@ function getLogInkWorkflowActions() {
|
|
|
14662
14920
|
kind: 'destructive',
|
|
14663
14921
|
requiresConfirmation: true,
|
|
14664
14922
|
},
|
|
14923
|
+
// #783 — full PR action panel. All five entries are palette-only
|
|
14924
|
+
// (`key: ''`) — actual dispatch is per-view scoped in inkInput so
|
|
14925
|
+
// the keys stay free outside the pull-request view. Merge / close /
|
|
14926
|
+
// approve / request-changes route through the y-confirm path
|
|
14927
|
+
// because each is irreversible (or near-irreversible) once gh
|
|
14928
|
+
// publishes it; comment is a free-form prompt with no extra
|
|
14929
|
+
// confirmation since the body itself is the affirmative action.
|
|
14930
|
+
{
|
|
14931
|
+
id: 'merge-pr',
|
|
14932
|
+
key: '',
|
|
14933
|
+
label: 'Merge pull request',
|
|
14934
|
+
description: 'Merge the current branch\'s pull request (prompts for merge / squash / rebase, then confirms).',
|
|
14935
|
+
kind: 'destructive',
|
|
14936
|
+
requiresConfirmation: true,
|
|
14937
|
+
},
|
|
14938
|
+
{
|
|
14939
|
+
id: 'close-pr',
|
|
14940
|
+
key: '',
|
|
14941
|
+
label: 'Close pull request',
|
|
14942
|
+
description: 'Close the current pull request without merging.',
|
|
14943
|
+
kind: 'destructive',
|
|
14944
|
+
requiresConfirmation: true,
|
|
14945
|
+
},
|
|
14946
|
+
{
|
|
14947
|
+
id: 'approve-pr',
|
|
14948
|
+
key: '',
|
|
14949
|
+
label: 'Approve pull request',
|
|
14950
|
+
description: 'Submit an approving review on the current pull request.',
|
|
14951
|
+
kind: 'normal',
|
|
14952
|
+
requiresConfirmation: true,
|
|
14953
|
+
},
|
|
14954
|
+
{
|
|
14955
|
+
id: 'request-changes-pr',
|
|
14956
|
+
key: '',
|
|
14957
|
+
label: 'Request changes on pull request',
|
|
14958
|
+
description: 'Submit a change-request review (prompts for the review body, then confirms).',
|
|
14959
|
+
kind: 'normal',
|
|
14960
|
+
requiresConfirmation: true,
|
|
14961
|
+
},
|
|
14962
|
+
{
|
|
14963
|
+
id: 'comment-pr',
|
|
14964
|
+
key: '',
|
|
14965
|
+
label: 'Comment on pull request',
|
|
14966
|
+
description: 'Add a comment to the current pull request (prompts for body).',
|
|
14967
|
+
kind: 'normal',
|
|
14968
|
+
requiresConfirmation: false,
|
|
14969
|
+
},
|
|
14970
|
+
{
|
|
14971
|
+
// Per-view-only: scoped to the history view in inkInput so `R`
|
|
14972
|
+
// doesn't fire elsewhere (it's also `R` for rename in branches
|
|
14973
|
+
// and delete-remote-tag in tags). Empty key keeps it
|
|
14974
|
+
// palette-discoverable without registering a global hotkey.
|
|
14975
|
+
id: 'revert-commit',
|
|
14976
|
+
key: '',
|
|
14977
|
+
label: 'Revert commit',
|
|
14978
|
+
description: 'Revert the cursored commit by adding an inverse commit on top of HEAD.',
|
|
14979
|
+
kind: 'destructive',
|
|
14980
|
+
requiresConfirmation: true,
|
|
14981
|
+
},
|
|
14982
|
+
{
|
|
14983
|
+
// Per-view-only: scoped to the history view in inkInput. Triggers
|
|
14984
|
+
// a mode prompt (soft / mixed / hard) before the reset runs so
|
|
14985
|
+
// `Z` alone never silently rewrites history.
|
|
14986
|
+
id: 'reset-to-commit',
|
|
14987
|
+
key: '',
|
|
14988
|
+
label: 'Reset to commit',
|
|
14989
|
+
description: 'Move the current branch tip to the cursored commit (prompts for soft / mixed / hard).',
|
|
14990
|
+
kind: 'destructive',
|
|
14991
|
+
requiresConfirmation: true,
|
|
14992
|
+
},
|
|
14993
|
+
{
|
|
14994
|
+
// Per-view-only: scoped to the history view in inkInput (key `B`).
|
|
14995
|
+
// The prompt itself is the affirmative gate — the user has to
|
|
14996
|
+
// type a branch name before anything happens — so this skips the
|
|
14997
|
+
// y-confirm path. Empty key keeps it palette-discoverable; the
|
|
14998
|
+
// palette path can't synthesize a branch name and surfaces a
|
|
14999
|
+
// hint instead.
|
|
15000
|
+
//
|
|
15001
|
+
// Distinct from `create-branch` (palette / `+` on branches view),
|
|
15002
|
+
// which uses `git switch -c` and switches onto the new branch.
|
|
15003
|
+
// This workflow uses `git branch <name> <sha>` and stays put —
|
|
15004
|
+
// GitKraken's "create branch here" semantic.
|
|
15005
|
+
id: 'create-branch-here',
|
|
15006
|
+
key: '',
|
|
15007
|
+
label: 'Create branch from commit',
|
|
15008
|
+
description: 'Create a branch pointed at the cursored commit (does not switch).',
|
|
15009
|
+
kind: 'normal',
|
|
15010
|
+
requiresConfirmation: false,
|
|
15011
|
+
},
|
|
15012
|
+
{
|
|
15013
|
+
// Per-view-only: scoped to the history view in inkInput via the
|
|
15014
|
+
// `gT` chord (bare `T` is taken by delete-tag on the tags view).
|
|
15015
|
+
// Same prompt-as-confirmation pattern as create-branch-here.
|
|
15016
|
+
// Lightweight tag — annotated tags remain available through the
|
|
15017
|
+
// existing `+` flow on the tags view.
|
|
15018
|
+
id: 'create-tag-here',
|
|
15019
|
+
key: '',
|
|
15020
|
+
label: 'Create tag at commit',
|
|
15021
|
+
description: 'Create a lightweight tag at the cursored commit.',
|
|
15022
|
+
kind: 'normal',
|
|
15023
|
+
requiresConfirmation: false,
|
|
15024
|
+
},
|
|
15025
|
+
{
|
|
15026
|
+
// Per-view-only: scoped to the history view in inkInput. `i`
|
|
15027
|
+
// (lowercase) is used instead of `I` so the existing `I`
|
|
15028
|
+
// ai-commit-summary workflow stays reachable on the history
|
|
15029
|
+
// view — `i` matches the `git rebase -i` flag mnemonic anyway.
|
|
15030
|
+
id: 'interactive-rebase',
|
|
15031
|
+
key: '',
|
|
15032
|
+
label: 'Interactive rebase',
|
|
15033
|
+
description: 'Start an interactive rebase from the cursored commit (opens $GIT_EDITOR for the todo list).',
|
|
15034
|
+
kind: 'destructive',
|
|
15035
|
+
requiresConfirmation: true,
|
|
15036
|
+
},
|
|
15037
|
+
{
|
|
15038
|
+
// Per-view-only: scoped to the history view in inkInput (key `B`).
|
|
15039
|
+
// The prompt itself is the affirmative gate — the user has to
|
|
15040
|
+
// type a branch name before anything happens — so this skips the
|
|
15041
|
+
// y-confirm path. Empty key keeps it palette-discoverable; the
|
|
15042
|
+
// palette path can't synthesize a branch name and surfaces a
|
|
15043
|
+
// hint instead.
|
|
15044
|
+
//
|
|
15045
|
+
// Distinct from `create-branch` (palette / `+` on branches view),
|
|
15046
|
+
// which uses `git switch -c` and switches onto the new branch.
|
|
15047
|
+
// This workflow uses `git branch <name> <sha>` and stays put —
|
|
15048
|
+
// GitKraken's "create branch here" semantic.
|
|
15049
|
+
id: 'create-branch-here',
|
|
15050
|
+
key: '',
|
|
15051
|
+
label: 'Create branch from commit',
|
|
15052
|
+
description: 'Create a branch pointed at the cursored commit (does not switch).',
|
|
15053
|
+
kind: 'normal',
|
|
15054
|
+
requiresConfirmation: false,
|
|
15055
|
+
},
|
|
15056
|
+
{
|
|
15057
|
+
// Per-view-only: scoped to the history view in inkInput via the
|
|
15058
|
+
// `gT` chord (bare `T` is taken by delete-tag on the tags view).
|
|
15059
|
+
// Same prompt-as-confirmation pattern as create-branch-here.
|
|
15060
|
+
// Lightweight tag — annotated tags remain available through the
|
|
15061
|
+
// existing `+` flow on the tags view.
|
|
15062
|
+
id: 'create-tag-here',
|
|
15063
|
+
key: '',
|
|
15064
|
+
label: 'Create tag at commit',
|
|
15065
|
+
description: 'Create a lightweight tag at the cursored commit.',
|
|
15066
|
+
kind: 'normal',
|
|
15067
|
+
requiresConfirmation: false,
|
|
15068
|
+
},
|
|
14665
15069
|
{
|
|
14666
15070
|
id: 'ai-commit-summary',
|
|
14667
15071
|
key: 'I',
|
|
@@ -14683,6 +15087,15 @@ function getLogInkWorkflowActions() {
|
|
|
14683
15087
|
];
|
|
14684
15088
|
}
|
|
14685
15089
|
function getLogInkWorkflowActionByKey(inputValue) {
|
|
15090
|
+
// Workflow actions with an empty `key` are palette-only — they
|
|
15091
|
+
// exist so the command palette can surface them but should never
|
|
15092
|
+
// match a raw keystroke. Without this guard, any unbound key
|
|
15093
|
+
// (left/right arrow, function keys) that arrives with an empty
|
|
15094
|
+
// inputValue would `find()` the first empty-key entry —
|
|
15095
|
+
// `cherry-pick-commit` — and pop its confirmation dialog.
|
|
15096
|
+
if (!inputValue) {
|
|
15097
|
+
return undefined;
|
|
15098
|
+
}
|
|
14686
15099
|
return getLogInkWorkflowActions().find((action) => action.key === inputValue);
|
|
14687
15100
|
}
|
|
14688
15101
|
function getLogInkWorkflowActionById(id) {
|
|
@@ -14812,6 +15225,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
14812
15225
|
description: 'Toggle compact and full graph display.',
|
|
14813
15226
|
contexts: ['normal', 'commits'],
|
|
14814
15227
|
},
|
|
15228
|
+
{
|
|
15229
|
+
id: 'toggleDiffViewMode',
|
|
15230
|
+
keys: ['d'],
|
|
15231
|
+
label: 'split/unified',
|
|
15232
|
+
description: 'Toggle the diff view between unified and side-by-side split rendering. Falls back to unified on narrow terminals.',
|
|
15233
|
+
contexts: ['commits'],
|
|
15234
|
+
},
|
|
14815
15235
|
{
|
|
14816
15236
|
id: 'navigateHome',
|
|
14817
15237
|
keys: ['gh'],
|
|
@@ -14868,6 +15288,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
14868
15288
|
description: 'Push the linked worktrees view.',
|
|
14869
15289
|
contexts: ['normal'],
|
|
14870
15290
|
},
|
|
15291
|
+
{
|
|
15292
|
+
id: 'navigatePullRequest',
|
|
15293
|
+
keys: ['gp'],
|
|
15294
|
+
label: 'pull request',
|
|
15295
|
+
description: 'Push the dedicated pull-request action panel for the current branch.',
|
|
15296
|
+
contexts: ['normal'],
|
|
15297
|
+
},
|
|
14871
15298
|
{
|
|
14872
15299
|
id: 'navigateBack',
|
|
14873
15300
|
keys: ['<', 'esc'],
|
|
@@ -14996,6 +15423,7 @@ const GLOBAL_BINDING_IDS = [
|
|
|
14996
15423
|
'navigateTags',
|
|
14997
15424
|
'navigateStash',
|
|
14998
15425
|
'navigateWorktrees',
|
|
15426
|
+
'navigatePullRequest',
|
|
14999
15427
|
'navigateBack',
|
|
15000
15428
|
];
|
|
15001
15429
|
const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
|
|
@@ -15056,8 +15484,37 @@ function getLogInkFooterHints(options) {
|
|
|
15056
15484
|
};
|
|
15057
15485
|
}
|
|
15058
15486
|
if (options.focus === 'sidebar') {
|
|
15487
|
+
// Per-tab hints when the active tab has selectable items — the user
|
|
15488
|
+
// can act on the cursored entity without leaving the workstation
|
|
15489
|
+
// view. Status tab + empty content tabs fall back to the generic
|
|
15490
|
+
// "enter open" hint that drills into the dedicated view.
|
|
15491
|
+
const itemsPresent = (options.sidebarItemCount ?? 0) > 0;
|
|
15492
|
+
if (itemsPresent && options.sidebarTab === 'branches') {
|
|
15493
|
+
return {
|
|
15494
|
+
contextual: ['↑/↓ branches', '←/→ tab', 'enter checkout', 'D delete', 'R rename', 'u upstream'],
|
|
15495
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15496
|
+
};
|
|
15497
|
+
}
|
|
15498
|
+
if (itemsPresent && options.sidebarTab === 'stashes') {
|
|
15499
|
+
return {
|
|
15500
|
+
contextual: ['↑/↓ stashes', '←/→ tab', 'enter diff', 'a apply', 'p pop', 'X drop'],
|
|
15501
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15502
|
+
};
|
|
15503
|
+
}
|
|
15504
|
+
if (itemsPresent && options.sidebarTab === 'tags') {
|
|
15505
|
+
return {
|
|
15506
|
+
contextual: ['↑/↓ tags', '←/→ tab', '+ new', 'P push', 'T delete'],
|
|
15507
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15508
|
+
};
|
|
15509
|
+
}
|
|
15510
|
+
if (itemsPresent && options.sidebarTab === 'worktrees') {
|
|
15511
|
+
return {
|
|
15512
|
+
contextual: ['↑/↓ worktrees', '←/→ tab', 'W remove'],
|
|
15513
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15514
|
+
};
|
|
15515
|
+
}
|
|
15059
15516
|
return {
|
|
15060
|
-
contextual: ['
|
|
15517
|
+
contextual: ['←/→ tab', '1-5 jump', 'enter open', 'tab focus'],
|
|
15061
15518
|
global: NORMAL_GLOBAL_HINTS,
|
|
15062
15519
|
};
|
|
15063
15520
|
}
|
|
@@ -15074,17 +15531,23 @@ function getLogInkFooterHints(options) {
|
|
|
15074
15531
|
};
|
|
15075
15532
|
}
|
|
15076
15533
|
if (options.activeView === 'diff') {
|
|
15534
|
+
// Surface what `d` will switch *to* — labels the next mode rather
|
|
15535
|
+
// than the current one so the hint reads as a verb. The split-mode
|
|
15536
|
+
// hint is only shown for the read-only diff sources (commit/stash);
|
|
15537
|
+
// the worktree diff stays unified-only for now.
|
|
15538
|
+
const splitToggleHint = options.diffViewMode === 'split' ? 'd unified' : 'd split';
|
|
15077
15539
|
if (options.diffSource === 'stash') {
|
|
15078
15540
|
return {
|
|
15079
|
-
contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'o edit', 'y yank', 'esc back'],
|
|
15541
|
+
contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', 'o edit', splitToggleHint, 'y yank', 'esc back'],
|
|
15080
15542
|
global: NORMAL_GLOBAL_HINTS,
|
|
15081
15543
|
};
|
|
15082
15544
|
}
|
|
15083
15545
|
if (options.diffSource === 'commit') {
|
|
15084
15546
|
// Commit-diff explore: read-only diff, but `c` cherry-picks the
|
|
15085
|
-
// cursored file from the commit into the worktree
|
|
15547
|
+
// cursored file from the commit into the worktree, and `H`
|
|
15548
|
+
// (or `gH` for index) applies just the cursored hunk.
|
|
15086
15549
|
return {
|
|
15087
|
-
contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'y/Y yank', 'esc back'],
|
|
15550
|
+
contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'y/Y yank', 'esc back'],
|
|
15088
15551
|
global: NORMAL_GLOBAL_HINTS,
|
|
15089
15552
|
};
|
|
15090
15553
|
}
|
|
@@ -15123,8 +15586,26 @@ function getLogInkFooterHints(options) {
|
|
|
15123
15586
|
global: NORMAL_GLOBAL_HINTS,
|
|
15124
15587
|
};
|
|
15125
15588
|
}
|
|
15589
|
+
if (options.activeView === 'pull-request') {
|
|
15590
|
+
return {
|
|
15591
|
+
// #783 — full PR action panel. Five mutating ops scoped to this
|
|
15592
|
+
// view: m / x / a / R / c, plus O for open-in-browser (already
|
|
15593
|
+
// a global). Each routes through y-confirm or an input prompt;
|
|
15594
|
+
// none fire silently.
|
|
15595
|
+
contextual: ['m merge', 'x close', 'a approve', 'R changes', 'c comment', 'O open', 'esc back'],
|
|
15596
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15597
|
+
};
|
|
15598
|
+
}
|
|
15126
15599
|
return {
|
|
15127
|
-
|
|
15600
|
+
// History view default hints. Mutating ops (`c` cherry-pick, `R`
|
|
15601
|
+
// revert, `Z` reset, `i` interactive-rebase) all route through a
|
|
15602
|
+
// y-confirm or mode prompt — none fire silently from the keystroke.
|
|
15603
|
+
// `B` create-branch-here and `gT` create-tag-here use a prompt as
|
|
15604
|
+
// the affirmative gate (typing the name is the confirmation).
|
|
15605
|
+
// Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
|
|
15606
|
+
// the footer stays scannable; full descriptions live in `?` help
|
|
15607
|
+
// and the palette.
|
|
15608
|
+
contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'y/Y yank', '/ search', 'gg/G top/bottom'],
|
|
15128
15609
|
global: NORMAL_GLOBAL_HINTS,
|
|
15129
15610
|
};
|
|
15130
15611
|
}
|
|
@@ -15267,39 +15748,6 @@ function filterLogInkPaletteCommands(commands, filter, recent) {
|
|
|
15267
15748
|
.map((entry) => entry.command);
|
|
15268
15749
|
}
|
|
15269
15750
|
|
|
15270
|
-
/**
|
|
15271
|
-
* The chars `git log --graph` emits for branch topology — `*`, `|`, `\`,
|
|
15272
|
-
* `/`, `_`, ` `. ASCII-only output is bulletproof for legacy terminals
|
|
15273
|
-
* but the angles read poorly when many branches overlap.
|
|
15274
|
-
*
|
|
15275
|
-
* `substituteGraphChars` swaps them for box-drawing / geometric Unicode
|
|
15276
|
-
* equivalents when the terminal can render them; falls back to ASCII
|
|
15277
|
-
* under `theme.ascii` (TERM=dumb / vt100) and `theme.noColor` is
|
|
15278
|
-
* orthogonal — the Unicode chars are still rendered, just without color.
|
|
15279
|
-
*
|
|
15280
|
-
* Kept ASCII-only intentionally:
|
|
15281
|
-
* - alphanumerics (commit refs / annotations git sometimes injects)
|
|
15282
|
-
* - parens / brackets (HEAD decoration markers, not part of the graph)
|
|
15283
|
-
* - hyphens / colons (likewise)
|
|
15284
|
-
*/
|
|
15285
|
-
const ASCII_TO_UNICODE = {
|
|
15286
|
-
'*': '●',
|
|
15287
|
-
'|': '│',
|
|
15288
|
-
'/': '╱',
|
|
15289
|
-
'\\': '╲',
|
|
15290
|
-
'_': '─',
|
|
15291
|
-
};
|
|
15292
|
-
function substituteGraphChars(graph, options) {
|
|
15293
|
-
if (options.ascii) {
|
|
15294
|
-
return graph;
|
|
15295
|
-
}
|
|
15296
|
-
let output = '';
|
|
15297
|
-
for (const character of graph) {
|
|
15298
|
-
output += ASCII_TO_UNICODE[character] ?? character;
|
|
15299
|
-
}
|
|
15300
|
-
return output;
|
|
15301
|
-
}
|
|
15302
|
-
|
|
15303
15751
|
/**
|
|
15304
15752
|
* OSC 8 hyperlink helpers for the Ink TUI (P5.1).
|
|
15305
15753
|
*
|
|
@@ -15371,6 +15819,84 @@ function formatHyperlink(text, url, env = process.env) {
|
|
|
15371
15819
|
return `${OSC_PREFIX}${url}${ST}${text}${OSC_PREFIX}${ST}`;
|
|
15372
15820
|
}
|
|
15373
15821
|
|
|
15822
|
+
/**
|
|
15823
|
+
* Extract a single hunk from a unified-patch diff so it can be fed to
|
|
15824
|
+
* `git apply` (or `git apply --cached`) for a hunk-level cherry-pick.
|
|
15825
|
+
*
|
|
15826
|
+
* The TUI's diff explore views render two flavors of patch text:
|
|
15827
|
+
*
|
|
15828
|
+
* - stash-diff: full `git stash show -p` output, which includes
|
|
15829
|
+
* `diff --git`, `---`, `+++`, and one or more `@@ ... @@` hunks
|
|
15830
|
+
* per file.
|
|
15831
|
+
* - commit-diff: the per-file `filePreview.hunks` array, which is
|
|
15832
|
+
* hunks-only (no `diff --git` / `---` / `+++` headers).
|
|
15833
|
+
*
|
|
15834
|
+
* Either way, this helper walks `lines` from `cursorOffset` backwards
|
|
15835
|
+
* to find the most recent `@@` header, walks forward to the end of
|
|
15836
|
+
* that hunk's body, and synthesizes a fresh `diff --git` /
|
|
15837
|
+
* `---` / `+++` set using the caller-provided path. The output is a
|
|
15838
|
+
* complete, self-contained patch suitable for `git apply` without
|
|
15839
|
+
* having to preserve original headers from `lines`.
|
|
15840
|
+
*/
|
|
15841
|
+
const HUNK_HEADER_PREFIX = '@@';
|
|
15842
|
+
const DIFF_GIT_PREFIX = 'diff --git ';
|
|
15843
|
+
/**
|
|
15844
|
+
* Find the index of the `@@` hunk header at or before `cursorOffset`.
|
|
15845
|
+
* Returns -1 when the cursor sits before the first hunk in the patch
|
|
15846
|
+
* (i.e. on a `diff --git` / `---` / `+++` header line) — caller treats
|
|
15847
|
+
* that as "no hunk at cursor" and surfaces a status message.
|
|
15848
|
+
*/
|
|
15849
|
+
function findHunkHeaderAtOrBefore(lines, cursorOffset) {
|
|
15850
|
+
const start = Math.min(cursorOffset, lines.length - 1);
|
|
15851
|
+
for (let i = start; i >= 0; i -= 1) {
|
|
15852
|
+
if (lines[i]?.startsWith(HUNK_HEADER_PREFIX)) {
|
|
15853
|
+
return i;
|
|
15854
|
+
}
|
|
15855
|
+
}
|
|
15856
|
+
return -1;
|
|
15857
|
+
}
|
|
15858
|
+
/**
|
|
15859
|
+
* Walk forward from a hunk header to either the next `@@` header or
|
|
15860
|
+
* the next `diff --git` line — that's where this hunk's body ends.
|
|
15861
|
+
* The end index is exclusive (the line at `endIndex` is NOT part of
|
|
15862
|
+
* this hunk).
|
|
15863
|
+
*/
|
|
15864
|
+
function findHunkBodyEnd(lines, headerIndex) {
|
|
15865
|
+
for (let i = headerIndex + 1; i < lines.length; i += 1) {
|
|
15866
|
+
const line = lines[i];
|
|
15867
|
+
if (line?.startsWith(HUNK_HEADER_PREFIX) || line?.startsWith(DIFF_GIT_PREFIX)) {
|
|
15868
|
+
return i;
|
|
15869
|
+
}
|
|
15870
|
+
}
|
|
15871
|
+
return lines.length;
|
|
15872
|
+
}
|
|
15873
|
+
function extractDiffHunk(input) {
|
|
15874
|
+
const { lines, cursorOffset, path } = input;
|
|
15875
|
+
if (!lines.length || !path) {
|
|
15876
|
+
return null;
|
|
15877
|
+
}
|
|
15878
|
+
const headerIndex = findHunkHeaderAtOrBefore(lines, cursorOffset);
|
|
15879
|
+
if (headerIndex < 0) {
|
|
15880
|
+
return null;
|
|
15881
|
+
}
|
|
15882
|
+
const bodyEnd = findHunkBodyEnd(lines, headerIndex);
|
|
15883
|
+
// Header itself + at least one body line. An empty hunk body would
|
|
15884
|
+
// mean the patch is malformed and `git apply` would reject it; bail
|
|
15885
|
+
// out early so the caller can surface a clear status message.
|
|
15886
|
+
if (bodyEnd <= headerIndex + 1) {
|
|
15887
|
+
return null;
|
|
15888
|
+
}
|
|
15889
|
+
const hunkLines = lines.slice(headerIndex, bodyEnd);
|
|
15890
|
+
const patchText = [
|
|
15891
|
+
`diff --git a/${path} b/${path}`,
|
|
15892
|
+
`--- a/${path}`,
|
|
15893
|
+
`+++ b/${path}`,
|
|
15894
|
+
...hunkLines,
|
|
15895
|
+
'',
|
|
15896
|
+
].join('\n');
|
|
15897
|
+
return { patchText };
|
|
15898
|
+
}
|
|
15899
|
+
|
|
15374
15900
|
/**
|
|
15375
15901
|
* Sort modes for the promoted views (P4.2).
|
|
15376
15902
|
*
|
|
@@ -15390,23 +15916,31 @@ function cycleBranchSort(mode) {
|
|
|
15390
15916
|
return BRANCH_SORT_MODES[(index + 1) % BRANCH_SORT_MODES.length];
|
|
15391
15917
|
}
|
|
15392
15918
|
function sortBranches(branches, mode) {
|
|
15393
|
-
|
|
15919
|
+
// Pin the current branch at index 0 regardless of sort mode (#806
|
|
15920
|
+
// follow-up). Lands the user's cursor on the active branch by
|
|
15921
|
+
// default and keeps the most-relevant row glued to the top of the
|
|
15922
|
+
// list as they cycle sorts.
|
|
15923
|
+
const current = branches.find((entry) => entry.current);
|
|
15924
|
+
const rest = branches.filter((entry) => !entry.current);
|
|
15925
|
+
const sortedRest = rest.slice();
|
|
15394
15926
|
switch (mode) {
|
|
15395
15927
|
case 'name':
|
|
15396
|
-
|
|
15928
|
+
sortedRest.sort((a, b) => a.shortName.localeCompare(b.shortName));
|
|
15929
|
+
break;
|
|
15397
15930
|
case 'recent':
|
|
15398
15931
|
// ISO-shaped dates compare byte-for-byte; descending so the freshest
|
|
15399
15932
|
// branch sits at the top.
|
|
15400
|
-
|
|
15933
|
+
sortedRest.sort((a, b) => (b.date || '').localeCompare(a.date || '') ||
|
|
15401
15934
|
a.shortName.localeCompare(b.shortName));
|
|
15935
|
+
break;
|
|
15402
15936
|
case 'ahead':
|
|
15403
15937
|
// ahead-first; ties broken by behind, then by name. Keeps "this branch
|
|
15404
15938
|
// has unmerged work" in the user's first scroll.
|
|
15405
|
-
|
|
15939
|
+
sortedRest.sort((a, b) => b.ahead - a.ahead || b.behind - a.behind ||
|
|
15406
15940
|
a.shortName.localeCompare(b.shortName));
|
|
15407
|
-
|
|
15408
|
-
return copy;
|
|
15941
|
+
break;
|
|
15409
15942
|
}
|
|
15943
|
+
return current ? [current, ...sortedRest] : sortedRest;
|
|
15410
15944
|
}
|
|
15411
15945
|
const TAG_SORT_MODES = ['recent', 'name'];
|
|
15412
15946
|
const DEFAULT_TAG_SORT_MODE = 'recent';
|
|
@@ -15740,6 +16274,8 @@ function createLogInkState(rows, options = {}) {
|
|
|
15740
16274
|
sidebarTab: 'status',
|
|
15741
16275
|
userSidebarTab: 'status',
|
|
15742
16276
|
statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
|
|
16277
|
+
diffViewMode: 'unified',
|
|
16278
|
+
inspectorTab: 'inspector',
|
|
15743
16279
|
};
|
|
15744
16280
|
}
|
|
15745
16281
|
function getSelectedInkCommit(state) {
|
|
@@ -15797,6 +16333,28 @@ function applyLogInkAction(state, action) {
|
|
|
15797
16333
|
pendingCommitFocused: false,
|
|
15798
16334
|
pendingKey: undefined,
|
|
15799
16335
|
};
|
|
16336
|
+
case 'selectCommitByHash': {
|
|
16337
|
+
// Locates a commit by its full or short hash within the active
|
|
16338
|
+
// filtered list and snaps the cursor to it. Used by the
|
|
16339
|
+
// branch/tag auto-jump effect (#806 follow-up): cursoring a
|
|
16340
|
+
// branch in the sidebar tracks the history view to that
|
|
16341
|
+
// branch's tip without the user manually scrolling. No-op when
|
|
16342
|
+
// the hash isn't in the loaded list (the runtime surfaces a
|
|
16343
|
+
// status hint in that case).
|
|
16344
|
+
const target = action.hash;
|
|
16345
|
+
const index = state.filteredCommits.findIndex((commit) => commit.hash === target || commit.shortHash === target);
|
|
16346
|
+
if (index < 0) {
|
|
16347
|
+
return state;
|
|
16348
|
+
}
|
|
16349
|
+
return {
|
|
16350
|
+
...state,
|
|
16351
|
+
selectedIndex: index,
|
|
16352
|
+
selectedFileIndex: 0,
|
|
16353
|
+
diffPreviewOffset: 0,
|
|
16354
|
+
pendingCommitFocused: false,
|
|
16355
|
+
pendingKey: undefined,
|
|
16356
|
+
};
|
|
16357
|
+
}
|
|
15800
16358
|
case 'focusPendingCommit':
|
|
15801
16359
|
return {
|
|
15802
16360
|
...state,
|
|
@@ -15836,6 +16394,34 @@ function applyLogInkAction(state, action) {
|
|
|
15836
16394
|
selectedBranchIndex: clampIndex$1(state.selectedBranchIndex + action.delta, action.count),
|
|
15837
16395
|
pendingKey: undefined,
|
|
15838
16396
|
};
|
|
16397
|
+
case 'resetBranchSelection':
|
|
16398
|
+
// Snap the branches sidebar / view cursor back to position 0.
|
|
16399
|
+
// Used after a successful checkout (#806 follow-up): combined
|
|
16400
|
+
// with the "current branch pinned at top" rule from #809, this
|
|
16401
|
+
// lands the user's cursor on the just-checked-out branch.
|
|
16402
|
+
return {
|
|
16403
|
+
...state,
|
|
16404
|
+
selectedBranchIndex: 0,
|
|
16405
|
+
pendingKey: undefined,
|
|
16406
|
+
};
|
|
16407
|
+
case 'setInspectorTab':
|
|
16408
|
+
return {
|
|
16409
|
+
...state,
|
|
16410
|
+
inspectorTab: action.value,
|
|
16411
|
+
pendingKey: undefined,
|
|
16412
|
+
};
|
|
16413
|
+
case 'cycleInspectorTab': {
|
|
16414
|
+
// Two-tab toggle — `delta` is symmetrical so direction does not
|
|
16415
|
+
// matter, but we keep the action shape consistent with the
|
|
16416
|
+
// sidebar's `nextSidebarTab` / `previousSidebarTab` so callers
|
|
16417
|
+
// can mirror the sidebar pattern verbatim.
|
|
16418
|
+
const next = state.inspectorTab === 'inspector' ? 'actions' : 'inspector';
|
|
16419
|
+
return {
|
|
16420
|
+
...state,
|
|
16421
|
+
inspectorTab: next,
|
|
16422
|
+
pendingKey: undefined,
|
|
16423
|
+
};
|
|
16424
|
+
}
|
|
15839
16425
|
case 'moveTag':
|
|
15840
16426
|
return {
|
|
15841
16427
|
...state,
|
|
@@ -15877,6 +16463,7 @@ function applyLogInkAction(state, action) {
|
|
|
15877
16463
|
kind: action.kind,
|
|
15878
16464
|
label: action.label,
|
|
15879
16465
|
value: action.initial || '',
|
|
16466
|
+
multiline: action.multiline,
|
|
15880
16467
|
},
|
|
15881
16468
|
pendingKey: undefined,
|
|
15882
16469
|
};
|
|
@@ -15909,6 +16496,27 @@ function applyLogInkAction(state, action) {
|
|
|
15909
16496
|
}
|
|
15910
16497
|
case 'setHistoryFetchArgs':
|
|
15911
16498
|
return { ...state, historyFetchArgs: action.value, pendingKey: undefined };
|
|
16499
|
+
case 'toggleDiffViewMode':
|
|
16500
|
+
// Reset the scroll offsets so the new mode opens at the top — long
|
|
16501
|
+
// lines wrap differently in split mode (the renderer truncates per
|
|
16502
|
+
// column instead of per row), so the saved offset can land on a
|
|
16503
|
+
// different visual line. Snap to the top is simpler than mapping
|
|
16504
|
+
// unified offsets to split offsets.
|
|
16505
|
+
return {
|
|
16506
|
+
...state,
|
|
16507
|
+
diffViewMode: state.diffViewMode === 'unified' ? 'split' : 'unified',
|
|
16508
|
+
diffPreviewOffset: 0,
|
|
16509
|
+
worktreeDiffOffset: 0,
|
|
16510
|
+
pendingKey: undefined,
|
|
16511
|
+
};
|
|
16512
|
+
case 'setDiffViewMode':
|
|
16513
|
+
return {
|
|
16514
|
+
...state,
|
|
16515
|
+
diffViewMode: action.value,
|
|
16516
|
+
diffPreviewOffset: 0,
|
|
16517
|
+
worktreeDiffOffset: 0,
|
|
16518
|
+
pendingKey: undefined,
|
|
16519
|
+
};
|
|
15912
16520
|
case 'moveToBottom':
|
|
15913
16521
|
return {
|
|
15914
16522
|
...state,
|
|
@@ -16187,12 +16795,151 @@ function applyLogInkAction(state, action) {
|
|
|
16187
16795
|
}
|
|
16188
16796
|
}
|
|
16189
16797
|
|
|
16798
|
+
/**
|
|
16799
|
+
* In-sidebar selection helpers (#791 follow-up — sidebar entity ops).
|
|
16800
|
+
*
|
|
16801
|
+
* The workstation sidebar's branches / tags / stashes / worktrees tabs
|
|
16802
|
+
* used to be read-only previews — to act on an entity the user had to
|
|
16803
|
+
* drill into the dedicated promoted view. With the per-entity ops
|
|
16804
|
+
* gated to also fire on `state.focus === 'sidebar'` plus a matching
|
|
16805
|
+
* `sidebarTab`, j/k navigates the visible list inside the sidebar
|
|
16806
|
+
* itself, Enter performs the primary action (checkout / open diff),
|
|
16807
|
+
* and the existing per-view secondary keys (a/p/X/D/R/u/+P) are now
|
|
16808
|
+
* reachable without leaving the workstation view.
|
|
16809
|
+
*
|
|
16810
|
+
* The sidebar accordion is short — the visible window for an active
|
|
16811
|
+
* tab is capped (defaults below) so a long branch list doesn't
|
|
16812
|
+
* collapse the rest of the chrome. When the cursor scrolls past the
|
|
16813
|
+
* visible window, this module produces a sliding window that keeps it
|
|
16814
|
+
* in view; the dedicated view stays the right home for "show me all
|
|
16815
|
+
* 80 branches at once."
|
|
16816
|
+
*/
|
|
16817
|
+
const DEFAULT_SIDEBAR_VISIBLE = 8;
|
|
16818
|
+
/**
|
|
16819
|
+
* Compute the sliding window so that `selected` stays inside it while
|
|
16820
|
+
* the window remains anchored at the top whenever possible (so short
|
|
16821
|
+
* lists don't scroll for no reason). When the cursor moves past the
|
|
16822
|
+
* window, the window slides just enough to keep the cursor in view —
|
|
16823
|
+
* matching the commit history's `clampWindowStart` behaviour for
|
|
16824
|
+
* familiarity.
|
|
16825
|
+
*/
|
|
16826
|
+
function getSidebarVisibleWindow(total, selected, visible = DEFAULT_SIDEBAR_VISIBLE) {
|
|
16827
|
+
const size = Math.max(1, Math.min(visible, total));
|
|
16828
|
+
if (total <= visible) {
|
|
16829
|
+
return { start: 0, size, truncatedAbove: 0, truncatedBelow: 0 };
|
|
16830
|
+
}
|
|
16831
|
+
const half = Math.floor(size / 2);
|
|
16832
|
+
const idealStart = selected - half;
|
|
16833
|
+
const maxStart = total - size;
|
|
16834
|
+
const start = Math.max(0, Math.min(idealStart, maxStart));
|
|
16835
|
+
return {
|
|
16836
|
+
start,
|
|
16837
|
+
size,
|
|
16838
|
+
truncatedAbove: start,
|
|
16839
|
+
truncatedBelow: total - (start + size),
|
|
16840
|
+
};
|
|
16841
|
+
}
|
|
16842
|
+
/**
|
|
16843
|
+
* True when an in-sidebar action (j/k move, Enter checkout, etc.)
|
|
16844
|
+
* should fire instead of the generic drill-in / tab-cycle behaviour.
|
|
16845
|
+
*
|
|
16846
|
+
* Status tab is excluded because its preview shows worktree files —
|
|
16847
|
+
* those have their own selection model in the dedicated status view
|
|
16848
|
+
* and the sidebar doesn't surface them as selectable rows.
|
|
16849
|
+
*/
|
|
16850
|
+
function sidebarTabHasSelectableItems(sidebarTab, itemCount) {
|
|
16851
|
+
if (!itemCount || itemCount <= 0)
|
|
16852
|
+
return false;
|
|
16853
|
+
return sidebarTab === 'branches' ||
|
|
16854
|
+
sidebarTab === 'tags' ||
|
|
16855
|
+
sidebarTab === 'stashes' ||
|
|
16856
|
+
sidebarTab === 'worktrees';
|
|
16857
|
+
}
|
|
16858
|
+
|
|
16190
16859
|
function action(actionValue) {
|
|
16191
16860
|
return {
|
|
16192
16861
|
type: 'action',
|
|
16193
16862
|
action: actionValue,
|
|
16194
16863
|
};
|
|
16195
16864
|
}
|
|
16865
|
+
/**
|
|
16866
|
+
* Build the events needed to apply the hunk under the diff cursor. The
|
|
16867
|
+
* runtime workflow handler expects payload format `<target>\n<patch>`
|
|
16868
|
+
* — splitting on the first newline keeps the patch body intact for
|
|
16869
|
+
* targets like `worktree` and `index` (no newlines in the prefix).
|
|
16870
|
+
*
|
|
16871
|
+
* Returns [] when the user isn't on a commit-diff / stash-diff explore,
|
|
16872
|
+
* or when no hunk can be extracted at the current cursor offset
|
|
16873
|
+
* (e.g. cursor sits on a `diff --git` header before the first `@@`).
|
|
16874
|
+
* Callers fall back to a contextual status message when this returns [].
|
|
16875
|
+
*/
|
|
16876
|
+
function buildApplyHunkEvents(state, context, target) {
|
|
16877
|
+
if (state.activeView !== 'diff')
|
|
16878
|
+
return [];
|
|
16879
|
+
if (state.diffSource !== 'commit' && state.diffSource !== 'stash')
|
|
16880
|
+
return [];
|
|
16881
|
+
const lines = context.diffLinesForHunkApply;
|
|
16882
|
+
if (!lines || lines.length === 0)
|
|
16883
|
+
return [];
|
|
16884
|
+
const path = state.diffSource === 'stash'
|
|
16885
|
+
? context.stashDiffSelectedPath
|
|
16886
|
+
: context.commitDiffSelectedPath;
|
|
16887
|
+
if (!path)
|
|
16888
|
+
return [];
|
|
16889
|
+
const extracted = extractDiffHunk({
|
|
16890
|
+
lines,
|
|
16891
|
+
cursorOffset: state.diffPreviewOffset,
|
|
16892
|
+
path,
|
|
16893
|
+
});
|
|
16894
|
+
if (!extracted)
|
|
16895
|
+
return [];
|
|
16896
|
+
const id = target === 'index' ? 'apply-hunk-index' : 'apply-hunk-worktree';
|
|
16897
|
+
return [{
|
|
16898
|
+
type: 'runWorkflowAction',
|
|
16899
|
+
id,
|
|
16900
|
+
payload: `${target}\n${extracted.patchText}`,
|
|
16901
|
+
}];
|
|
16902
|
+
}
|
|
16903
|
+
/**
|
|
16904
|
+
* Per-entity action-target predicates. The promoted views (`branches`,
|
|
16905
|
+
* `tags`, `stash`, `worktrees`) each scope a set of ops to their
|
|
16906
|
+
* dedicated surface. The same ops also fire when the user has the
|
|
16907
|
+
* sidebar focused on the matching tab — that's how in-sidebar
|
|
16908
|
+
* selection (#791 follow-up) lets the user checkout / apply / drop
|
|
16909
|
+
* without leaving the workstation view.
|
|
16910
|
+
*/
|
|
16911
|
+
function isBranchActionTarget(state) {
|
|
16912
|
+
return (state.activeView === 'branches' && state.focus === 'commits') ||
|
|
16913
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
16914
|
+
}
|
|
16915
|
+
function isTagActionTarget(state) {
|
|
16916
|
+
return (state.activeView === 'tags' && state.focus === 'commits') ||
|
|
16917
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
16918
|
+
}
|
|
16919
|
+
function isStashActionTarget(state) {
|
|
16920
|
+
return (state.activeView === 'stash' && state.focus === 'commits') ||
|
|
16921
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'stashes');
|
|
16922
|
+
}
|
|
16923
|
+
function isWorktreeActionTarget(state) {
|
|
16924
|
+
return (state.activeView === 'worktrees' && state.focus === 'commits') ||
|
|
16925
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
|
|
16926
|
+
}
|
|
16927
|
+
/**
|
|
16928
|
+
* Item count for the active sidebar tab — used by the generic
|
|
16929
|
+
* sidebar-Enter handler to decide whether to defer to the per-entity
|
|
16930
|
+
* Enter (when items are present and the user is cursoring through
|
|
16931
|
+
* them) or to drill into the dedicated view (when the tab is empty
|
|
16932
|
+
* or has no per-entity Enter handler defined).
|
|
16933
|
+
*/
|
|
16934
|
+
function getSidebarItemCount(sidebarTab, context) {
|
|
16935
|
+
switch (sidebarTab) {
|
|
16936
|
+
case 'branches': return context.branchCount;
|
|
16937
|
+
case 'tags': return context.tagCount;
|
|
16938
|
+
case 'stashes': return context.stashCount;
|
|
16939
|
+
case 'worktrees': return context.worktreeListCount;
|
|
16940
|
+
default: return undefined;
|
|
16941
|
+
}
|
|
16942
|
+
}
|
|
16196
16943
|
/**
|
|
16197
16944
|
* Translate a palette command into the same events its keystroke would have
|
|
16198
16945
|
* produced. Phase 6 makes `:` a real launcher: this is the single mapping
|
|
@@ -16272,6 +17019,8 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
16272
17019
|
return [action({ type: 'pushView', value: 'stash' })];
|
|
16273
17020
|
case 'navigateWorktrees':
|
|
16274
17021
|
return [action({ type: 'pushView', value: 'worktrees' })];
|
|
17022
|
+
case 'navigatePullRequest':
|
|
17023
|
+
return [action({ type: 'pushView', value: 'pull-request' })];
|
|
16275
17024
|
case 'navigateBack':
|
|
16276
17025
|
return [action({ type: 'popView' })];
|
|
16277
17026
|
case 'openSelected': {
|
|
@@ -16329,10 +17078,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
16329
17078
|
case 'clearSearch':
|
|
16330
17079
|
return [action({ type: 'clearFilter' })];
|
|
16331
17080
|
case 'cycleSort':
|
|
16332
|
-
if (state
|
|
17081
|
+
if (isBranchActionTarget(state)) {
|
|
16333
17082
|
return [action({ type: 'cycleBranchSort' })];
|
|
16334
17083
|
}
|
|
16335
|
-
if (state
|
|
17084
|
+
if (isTagActionTarget(state)) {
|
|
16336
17085
|
return [action({ type: 'cycleTagSort' })];
|
|
16337
17086
|
}
|
|
16338
17087
|
return [action({
|
|
@@ -16369,6 +17118,77 @@ function hasUnsavedComposeDraft(state) {
|
|
|
16369
17118
|
}
|
|
16370
17119
|
return Boolean(compose.summary.trim() || compose.body.trim());
|
|
16371
17120
|
}
|
|
17121
|
+
/**
|
|
17122
|
+
* Submit the active input prompt — used by Enter on single-line
|
|
17123
|
+
* prompts and by Ctrl+D on multi-line prompts (#806). Most prompt
|
|
17124
|
+
* kinds dispatch a workflow whose id matches the kind
|
|
17125
|
+
* (`create-branch`, `rename-branch`, etc.). A few are exceptions:
|
|
17126
|
+
* - `reset-mode` (#777) collects soft/mixed/hard and forwards the
|
|
17127
|
+
* mode as the payload to `reset-to-commit`.
|
|
17128
|
+
* - `pr-merge-strategy` (#783) validates the strategy and routes to
|
|
17129
|
+
* `merge-pr` via the y-confirm path.
|
|
17130
|
+
* - `pr-comment` dispatches `comment-pr` directly — the body itself
|
|
17131
|
+
* is the affirmative action.
|
|
17132
|
+
* - `pr-request-changes` routes to `request-changes-pr` via
|
|
17133
|
+
* y-confirm because the review is publicly visible.
|
|
17134
|
+
* Each exception validates here so a typo doesn't surface as a
|
|
17135
|
+
* "workflow not yet wired" status downstream.
|
|
17136
|
+
*
|
|
17137
|
+
* Empty values yield a hint instead of a no-op so the user knows what
|
|
17138
|
+
* to do — the same UX whether they pressed Enter (single-line) or
|
|
17139
|
+
* Ctrl+D (multi-line).
|
|
17140
|
+
*/
|
|
17141
|
+
function submitInputPrompt(state) {
|
|
17142
|
+
if (!state.inputPrompt)
|
|
17143
|
+
return [];
|
|
17144
|
+
const value = state.inputPrompt.value.trim();
|
|
17145
|
+
if (!value) {
|
|
17146
|
+
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
|
|
17147
|
+
}
|
|
17148
|
+
if (state.inputPrompt.kind === 'reset-mode') {
|
|
17149
|
+
const mode = value.toLowerCase();
|
|
17150
|
+
if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
|
|
17151
|
+
return [action({
|
|
17152
|
+
type: 'setStatus',
|
|
17153
|
+
value: `Unknown reset mode: ${value}. Use soft, mixed, or hard.`,
|
|
17154
|
+
})];
|
|
17155
|
+
}
|
|
17156
|
+
return [
|
|
17157
|
+
{ type: 'runWorkflowAction', id: 'reset-to-commit', payload: mode },
|
|
17158
|
+
action({ type: 'closeInputPrompt' }),
|
|
17159
|
+
];
|
|
17160
|
+
}
|
|
17161
|
+
if (state.inputPrompt.kind === 'pr-merge-strategy') {
|
|
17162
|
+
const strategy = value.toLowerCase();
|
|
17163
|
+
if (strategy !== 'merge' && strategy !== 'squash' && strategy !== 'rebase') {
|
|
17164
|
+
return [action({
|
|
17165
|
+
type: 'setStatus',
|
|
17166
|
+
value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
|
|
17167
|
+
})];
|
|
17168
|
+
}
|
|
17169
|
+
return [
|
|
17170
|
+
action({ type: 'setPendingConfirmation', value: 'merge-pr', payload: strategy }),
|
|
17171
|
+
action({ type: 'closeInputPrompt' }),
|
|
17172
|
+
];
|
|
17173
|
+
}
|
|
17174
|
+
if (state.inputPrompt.kind === 'pr-comment') {
|
|
17175
|
+
return [
|
|
17176
|
+
{ type: 'runWorkflowAction', id: 'comment-pr', payload: value },
|
|
17177
|
+
action({ type: 'closeInputPrompt' }),
|
|
17178
|
+
];
|
|
17179
|
+
}
|
|
17180
|
+
if (state.inputPrompt.kind === 'pr-request-changes') {
|
|
17181
|
+
return [
|
|
17182
|
+
action({ type: 'setPendingConfirmation', value: 'request-changes-pr', payload: value }),
|
|
17183
|
+
action({ type: 'closeInputPrompt' }),
|
|
17184
|
+
];
|
|
17185
|
+
}
|
|
17186
|
+
const id = state.inputPrompt.kind;
|
|
17187
|
+
return [
|
|
17188
|
+
{ type: 'runWorkflowAction', id, payload: value },
|
|
17189
|
+
action({ type: 'closeInputPrompt' }),
|
|
17190
|
+
];
|
|
17191
|
+
}
|
|
16372
17192
|
function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
16373
17193
|
if (key.ctrl && inputValue === 'c') {
|
|
16374
17194
|
if (hasUnsavedComposeDraft(state) && !state.pendingMutationConfirmation) {
|
|
@@ -16381,22 +17201,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16381
17201
|
// filter/confirmation/compose handlers so a prompt opened from inside
|
|
16382
17202
|
// any of those still captures focus cleanly.
|
|
16383
17203
|
if (state.inputPrompt) {
|
|
17204
|
+
const isMultiline = Boolean(state.inputPrompt.multiline);
|
|
16384
17205
|
if (key.escape) {
|
|
16385
17206
|
return [
|
|
16386
17207
|
action({ type: 'closeInputPrompt' }),
|
|
16387
17208
|
action({ type: 'setStatus', value: 'cancelled' }),
|
|
16388
17209
|
];
|
|
16389
17210
|
}
|
|
17211
|
+
// Multi-line prompts (#806): Ctrl+D submits (Unix EOF convention,
|
|
17212
|
+
// mirrors `git commit -m -` and HEREDOC patterns). Plain Enter
|
|
17213
|
+
// inserts a newline so the user can compose review bodies / PR
|
|
17214
|
+
// comments naturally without opening $EDITOR.
|
|
17215
|
+
if (isMultiline && key.ctrl && inputValue === 'd') {
|
|
17216
|
+
return submitInputPrompt(state);
|
|
17217
|
+
}
|
|
17218
|
+
if (isMultiline && key.return) {
|
|
17219
|
+
return [action({ type: 'appendInputPrompt', value: '\n' })];
|
|
17220
|
+
}
|
|
16390
17221
|
if (key.return) {
|
|
16391
|
-
|
|
16392
|
-
if (!value) {
|
|
16393
|
-
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
|
|
16394
|
-
}
|
|
16395
|
-
const id = state.inputPrompt.kind;
|
|
16396
|
-
return [
|
|
16397
|
-
{ type: 'runWorkflowAction', id, payload: value },
|
|
16398
|
-
action({ type: 'closeInputPrompt' }),
|
|
16399
|
-
];
|
|
17222
|
+
return submitInputPrompt(state);
|
|
16400
17223
|
}
|
|
16401
17224
|
if (key.backspace || key.delete) {
|
|
16402
17225
|
return [action({ type: 'backspaceInputPrompt' })];
|
|
@@ -16658,6 +17481,56 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16658
17481
|
action({ type: 'setStatus', value: 'jumped to worktrees' }),
|
|
16659
17482
|
];
|
|
16660
17483
|
}
|
|
17484
|
+
// `gp` jumps to the dedicated pull-request action panel (#783).
|
|
17485
|
+
// Lowercase `p` matches the pattern of other navigation chords
|
|
17486
|
+
// (gh / gs / gd / gc / gb / gt / gz / gw). The panel renders the
|
|
17487
|
+
// current branch's PR via `gh pr view --json` enriched fields and
|
|
17488
|
+
// exposes m / x / a / R / c action keys scoped to the view.
|
|
17489
|
+
if (state.pendingKey === 'g' && inputValue === 'p') {
|
|
17490
|
+
return [
|
|
17491
|
+
action({ type: 'pushView', value: 'pull-request' }),
|
|
17492
|
+
action({ type: 'setStatus', value: 'jumped to pull request' }),
|
|
17493
|
+
];
|
|
17494
|
+
}
|
|
17495
|
+
// `gH` chord: apply the cursored hunk to the index (`git apply
|
|
17496
|
+
// --cached`). Sibling of bare `H` which targets the worktree.
|
|
17497
|
+
// Discoverable via the footer hint on diff views and the help
|
|
17498
|
+
// overlay; the explicit chord keeps `H` (single keystroke) for
|
|
17499
|
+
// the more common worktree case.
|
|
17500
|
+
if (state.pendingKey === 'g' && inputValue === 'H') {
|
|
17501
|
+
const events = buildApplyHunkEvents(state, context, 'index');
|
|
17502
|
+
if (events.length) {
|
|
17503
|
+
return [action({ type: 'setPendingKey', value: undefined }), ...events];
|
|
17504
|
+
}
|
|
17505
|
+
return [
|
|
17506
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
17507
|
+
action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
|
|
17508
|
+
];
|
|
17509
|
+
}
|
|
17510
|
+
// `gT` chord: create a lightweight tag at the cursored commit on the
|
|
17511
|
+
// history view. Bare `T` is taken (delete-tag on the tags view) so we
|
|
17512
|
+
// use the chord. Mirrors `gH` exactly — uppercase letter after the
|
|
17513
|
+
// `g` chord prefix, distinct from the lowercase `gt` chord which
|
|
17514
|
+
// jumps to the tags view. The prompt is the affirmative gate.
|
|
17515
|
+
if (state.pendingKey === 'g' && inputValue === 'T') {
|
|
17516
|
+
if (state.activeView === 'history' &&
|
|
17517
|
+
state.focus === 'commits' &&
|
|
17518
|
+
state.filteredCommits.length > 0 &&
|
|
17519
|
+
!state.pendingCommitFocused) {
|
|
17520
|
+
return [
|
|
17521
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
17522
|
+
action({
|
|
17523
|
+
type: 'openInputPrompt',
|
|
17524
|
+
kind: 'create-tag-here',
|
|
17525
|
+
label: 'New tag name (at cursored commit)',
|
|
17526
|
+
}),
|
|
17527
|
+
];
|
|
17528
|
+
}
|
|
17529
|
+
return [
|
|
17530
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
17531
|
+
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
|
|
17532
|
+
];
|
|
17533
|
+
}
|
|
16661
17534
|
if (inputValue === 'g') {
|
|
16662
17535
|
if (state.pendingKey === 'g') {
|
|
16663
17536
|
return [
|
|
@@ -16667,6 +17540,22 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16667
17540
|
}
|
|
16668
17541
|
return [action({ type: 'setPendingKey', value: 'g' })];
|
|
16669
17542
|
}
|
|
17543
|
+
// `d` on the diff view toggles between unified and side-by-side split
|
|
17544
|
+
// rendering (#785). Scoped to the diff view so the letter stays free
|
|
17545
|
+
// for other surfaces. The chord branch above already claimed `gd`,
|
|
17546
|
+
// so by the time we get here `pendingKey` is not `g`.
|
|
17547
|
+
if (inputValue === 'd' && state.activeView === 'diff') {
|
|
17548
|
+
const next = state.diffViewMode === 'unified' ? 'split' : 'unified';
|
|
17549
|
+
return [
|
|
17550
|
+
action({ type: 'toggleDiffViewMode' }),
|
|
17551
|
+
action({
|
|
17552
|
+
type: 'setStatus',
|
|
17553
|
+
value: next === 'split'
|
|
17554
|
+
? 'Switched to side-by-side diff'
|
|
17555
|
+
: 'Switched to unified diff',
|
|
17556
|
+
}),
|
|
17557
|
+
];
|
|
17558
|
+
}
|
|
16670
17559
|
if (inputValue === '\\') {
|
|
16671
17560
|
return [action({ type: 'toggleGraph' })];
|
|
16672
17561
|
}
|
|
@@ -16689,10 +17578,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16689
17578
|
return [{ type: 'refreshContext' }];
|
|
16690
17579
|
}
|
|
16691
17580
|
if (inputValue === 's') {
|
|
16692
|
-
if (state
|
|
17581
|
+
if (isBranchActionTarget(state)) {
|
|
16693
17582
|
return [action({ type: 'cycleBranchSort' })];
|
|
16694
17583
|
}
|
|
16695
|
-
if (state
|
|
17584
|
+
if (isTagActionTarget(state)) {
|
|
16696
17585
|
return [action({ type: 'cycleTagSort' })];
|
|
16697
17586
|
}
|
|
16698
17587
|
// Falls through so other views (history/status/diff/compose/stash) still
|
|
@@ -16723,6 +17612,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16723
17612
|
hunkOffsets: context.commitDiffHunkOffsets,
|
|
16724
17613
|
})];
|
|
16725
17614
|
}
|
|
17615
|
+
// Inspector focused: cycle the inspector tab. The renderer only
|
|
17616
|
+
// honors the tab field on short terminals (where the inspector
|
|
17617
|
+
// collapses into a tabbed layout), but we let the user pre-set
|
|
17618
|
+
// their preference on tall terminals too.
|
|
17619
|
+
if (state.focus === 'detail') {
|
|
17620
|
+
return [action({ type: 'cycleInspectorTab', delta: -1 })];
|
|
17621
|
+
}
|
|
16726
17622
|
return [action({ type: 'previousSidebarTab' })];
|
|
16727
17623
|
}
|
|
16728
17624
|
if (inputValue === ']') {
|
|
@@ -16747,6 +17643,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16747
17643
|
hunkOffsets: context.commitDiffHunkOffsets,
|
|
16748
17644
|
})];
|
|
16749
17645
|
}
|
|
17646
|
+
if (state.focus === 'detail') {
|
|
17647
|
+
return [action({ type: 'cycleInspectorTab', delta: 1 })];
|
|
17648
|
+
}
|
|
16750
17649
|
return [action({ type: 'nextSidebarTab' })];
|
|
16751
17650
|
}
|
|
16752
17651
|
// Status surface intercepts 1/2/3 before the sidebar-tab numeric
|
|
@@ -16763,6 +17662,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16763
17662
|
if (key.tab) {
|
|
16764
17663
|
return [action({ type: key.shift ? 'focusPrevious' : 'focusNext' })];
|
|
16765
17664
|
}
|
|
17665
|
+
// ←/→ on the sidebar switch tabs (Status ↔ Branches ↔ Tags ↔
|
|
17666
|
+
// Stashes ↔ Worktrees) — the horizontal axis is "between tabs", the
|
|
17667
|
+
// vertical axis (↑/↓ below) is "within the active tab's items".
|
|
17668
|
+
// [/] still works as a keyboard alternative for users who prefer
|
|
17669
|
+
// non-arrow keys.
|
|
17670
|
+
if (key.leftArrow && state.focus === 'sidebar') {
|
|
17671
|
+
return [action({ type: 'previousSidebarTab' })];
|
|
17672
|
+
}
|
|
17673
|
+
if (key.rightArrow && state.focus === 'sidebar') {
|
|
17674
|
+
return [action({ type: 'nextSidebarTab' })];
|
|
17675
|
+
}
|
|
16766
17676
|
if (key.upArrow || inputValue === 'k') {
|
|
16767
17677
|
if (state.focus === 'detail' && context.detailFileCount) {
|
|
16768
17678
|
return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
|
|
@@ -16792,16 +17702,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16792
17702
|
previewLineCount: context.previewLineCount,
|
|
16793
17703
|
})];
|
|
16794
17704
|
}
|
|
16795
|
-
if (state
|
|
17705
|
+
if (isBranchActionTarget(state) && context.branchCount) {
|
|
16796
17706
|
return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
|
|
16797
17707
|
}
|
|
16798
|
-
if (state
|
|
17708
|
+
if (isTagActionTarget(state) && context.tagCount) {
|
|
16799
17709
|
return [action({ type: 'moveTag', delta: -1, count: context.tagCount })];
|
|
16800
17710
|
}
|
|
16801
|
-
if (state
|
|
17711
|
+
if (isStashActionTarget(state) && context.stashCount) {
|
|
16802
17712
|
return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
|
|
16803
17713
|
}
|
|
16804
|
-
if (state
|
|
17714
|
+
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
16805
17715
|
return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
|
|
16806
17716
|
}
|
|
16807
17717
|
if (state.activeView === 'history' &&
|
|
@@ -16811,6 +17721,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16811
17721
|
context.worktreeDirty) {
|
|
16812
17722
|
return [action({ type: 'focusPendingCommit' })];
|
|
16813
17723
|
}
|
|
17724
|
+
// Sidebar fallback: when no entity claim above succeeds (status
|
|
17725
|
+
// tab or empty content tab), ↑ falls through to cycling sidebar
|
|
17726
|
+
// tabs so the user always has a way to navigate. With ←/→ above
|
|
17727
|
+
// already handling tab switching, this is mostly a vim-style
|
|
17728
|
+
// safety net for `k`.
|
|
16814
17729
|
return [
|
|
16815
17730
|
action(state.focus === 'sidebar'
|
|
16816
17731
|
? { type: 'previousSidebarTab' }
|
|
@@ -16845,16 +17760,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16845
17760
|
previewLineCount: context.previewLineCount,
|
|
16846
17761
|
})];
|
|
16847
17762
|
}
|
|
16848
|
-
if (state
|
|
17763
|
+
if (isBranchActionTarget(state) && context.branchCount) {
|
|
16849
17764
|
return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
|
|
16850
17765
|
}
|
|
16851
|
-
if (state
|
|
17766
|
+
if (isTagActionTarget(state) && context.tagCount) {
|
|
16852
17767
|
return [action({ type: 'moveTag', delta: 1, count: context.tagCount })];
|
|
16853
17768
|
}
|
|
16854
|
-
if (state
|
|
17769
|
+
if (isStashActionTarget(state) && context.stashCount) {
|
|
16855
17770
|
return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
|
|
16856
17771
|
}
|
|
16857
|
-
if (state
|
|
17772
|
+
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
16858
17773
|
return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
|
|
16859
17774
|
}
|
|
16860
17775
|
return [
|
|
@@ -16958,30 +17873,42 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16958
17873
|
}
|
|
16959
17874
|
}
|
|
16960
17875
|
// Enter on a sidebar tab drills into the corresponding promoted view
|
|
16961
|
-
// (status / branches / tags / stash)
|
|
16962
|
-
//
|
|
16963
|
-
//
|
|
16964
|
-
// the
|
|
17876
|
+
// (status / branches / tags / stash) — but only when the sidebar tab
|
|
17877
|
+
// either has no per-entity Enter handler defined (status, tags,
|
|
17878
|
+
// worktrees) or has zero items (so the dedicated view's empty-state
|
|
17879
|
+
// tells the user what to do next).
|
|
16965
17880
|
//
|
|
16966
|
-
//
|
|
16967
|
-
//
|
|
16968
|
-
//
|
|
17881
|
+
// When the sidebar IS focused on a content tab WITH items, this
|
|
17882
|
+
// handler defers to the per-entity Enter below (checkout-branch for
|
|
17883
|
+
// branches, navigateOpenDiffForStash for stashes) so the user can
|
|
17884
|
+
// act on the cursored item without leaving the workstation view —
|
|
17885
|
+
// the in-sidebar selection win from #791 follow-up.
|
|
17886
|
+
//
|
|
17887
|
+
// The drill-in moves focus out of the sidebar into the newly opened
|
|
17888
|
+
// list — otherwise ↑/↓ keep navigating the sidebar instead of the
|
|
17889
|
+
// just-opened view, which made the drill-in feel half-done.
|
|
16969
17890
|
if (key.return && state.focus === 'sidebar') {
|
|
16970
|
-
const
|
|
16971
|
-
|
|
16972
|
-
|
|
16973
|
-
|
|
16974
|
-
|
|
16975
|
-
|
|
16976
|
-
|
|
16977
|
-
|
|
16978
|
-
|
|
16979
|
-
|
|
16980
|
-
|
|
16981
|
-
|
|
16982
|
-
|
|
17891
|
+
const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
|
|
17892
|
+
const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
|
|
17893
|
+
sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
|
|
17894
|
+
if (!hasInSidebarPrimaryAction) {
|
|
17895
|
+
const tabToView = {
|
|
17896
|
+
status: 'status',
|
|
17897
|
+
branches: 'branches',
|
|
17898
|
+
tags: 'tags',
|
|
17899
|
+
stashes: 'stash',
|
|
17900
|
+
worktrees: 'worktrees',
|
|
17901
|
+
};
|
|
17902
|
+
const target = tabToView[state.sidebarTab];
|
|
17903
|
+
if (target) {
|
|
17904
|
+
return [
|
|
17905
|
+
action({ type: 'pushView', value: target }),
|
|
17906
|
+
action({ type: 'setFocus', value: 'commits' }),
|
|
17907
|
+
];
|
|
17908
|
+
}
|
|
17909
|
+
return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
|
|
16983
17910
|
}
|
|
16984
|
-
|
|
17911
|
+
// Fall through — per-entity Enter handler below claims the keystroke.
|
|
16985
17912
|
}
|
|
16986
17913
|
if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
|
|
16987
17914
|
return [action({
|
|
@@ -16990,8 +17917,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16990
17917
|
})];
|
|
16991
17918
|
}
|
|
16992
17919
|
// Enter on a branch row checks the branch out. Non-destructive workflow
|
|
16993
|
-
// action — no confirmation prompt.
|
|
16994
|
-
|
|
17920
|
+
// action — no confirmation prompt. Fires from either the dedicated
|
|
17921
|
+
// branches view or from the sidebar when the branches tab is focused
|
|
17922
|
+
// with items.
|
|
17923
|
+
if (key.return && isBranchActionTarget(state) && context.branchCount) {
|
|
16995
17924
|
return [{ type: 'runWorkflowAction', id: 'checkout-branch' }];
|
|
16996
17925
|
}
|
|
16997
17926
|
// `+` opens a create-branch / create-tag prompt depending on context.
|
|
@@ -17018,32 +17947,33 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17018
17947
|
}
|
|
17019
17948
|
// Per-view stash actions: `a` apply (keep the stash), `p` pop (apply
|
|
17020
17949
|
// then drop). Drop is the existing destructive `X` workflow which
|
|
17021
|
-
// routes through the y-confirm path. Scoped to the stash
|
|
17022
|
-
// letters stay free elsewhere
|
|
17023
|
-
|
|
17950
|
+
// routes through the y-confirm path. Scoped to the stash target so
|
|
17951
|
+
// the letters stay free elsewhere — the target predicate also fires
|
|
17952
|
+
// when the sidebar's stashes tab is focused with items.
|
|
17953
|
+
if (inputValue === 'a' && isStashActionTarget(state) && context.stashCount) {
|
|
17024
17954
|
return [{ type: 'runWorkflowAction', id: 'apply-stash' }];
|
|
17025
17955
|
}
|
|
17026
|
-
if (inputValue === 'p' && state
|
|
17956
|
+
if (inputValue === 'p' && isStashActionTarget(state) && context.stashCount) {
|
|
17027
17957
|
return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
|
|
17028
17958
|
}
|
|
17029
17959
|
// Per-view tag action: `P` pushes the selected tag to origin. Letter
|
|
17030
|
-
// is scoped to the tags
|
|
17960
|
+
// is scoped to the tags target so it doesn't collide with `p` for
|
|
17031
17961
|
// pop-stash. Note: this also takes precedence over the global
|
|
17032
17962
|
// push-current-branch workflow's `P` key.
|
|
17033
|
-
if (inputValue === 'P' && state
|
|
17963
|
+
if (inputValue === 'P' && isTagActionTarget(state) && context.tagCount) {
|
|
17034
17964
|
return [{ type: 'runWorkflowAction', id: 'push-tag' }];
|
|
17035
17965
|
}
|
|
17036
17966
|
// Per-view branches actions: `R` renames the selected branch, `u`
|
|
17037
17967
|
// sets its upstream. Both open the input prompt so the user can type
|
|
17038
17968
|
// the new value. Pre-fills are handled by the prompt's `initial`.
|
|
17039
|
-
if (inputValue === 'R' && state
|
|
17969
|
+
if (inputValue === 'R' && isBranchActionTarget(state) && context.branchCount) {
|
|
17040
17970
|
return [action({
|
|
17041
17971
|
type: 'openInputPrompt',
|
|
17042
17972
|
kind: 'rename-branch',
|
|
17043
17973
|
label: 'Rename branch to',
|
|
17044
17974
|
})];
|
|
17045
17975
|
}
|
|
17046
|
-
if (inputValue === 'u' && state
|
|
17976
|
+
if (inputValue === 'u' && isBranchActionTarget(state) && context.branchCount) {
|
|
17047
17977
|
return [action({
|
|
17048
17978
|
type: 'openInputPrompt',
|
|
17049
17979
|
kind: 'set-upstream',
|
|
@@ -17051,11 +17981,48 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17051
17981
|
})];
|
|
17052
17982
|
}
|
|
17053
17983
|
// Per-view tag action: `R` deletes the tag from the remote (after
|
|
17054
|
-
// confirmation). Scoped per-
|
|
17055
|
-
// (especially the `R` rename binding on the branches
|
|
17056
|
-
if (inputValue === 'R' && state
|
|
17984
|
+
// confirmation). Scoped per-target so this letter is free elsewhere
|
|
17985
|
+
// (especially the `R` rename binding on the branches target).
|
|
17986
|
+
if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
|
|
17057
17987
|
return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
|
|
17058
17988
|
}
|
|
17989
|
+
// #783 — full PR action panel keys, scoped to the pull-request view.
|
|
17990
|
+
// All five wrap a `gh pr <verb>` invocation; merge / request-changes /
|
|
17991
|
+
// comment open prompts first, the rest route through the y-confirm
|
|
17992
|
+
// path because they're irreversible (or near-irreversible).
|
|
17993
|
+
if (inputValue === 'm' && state.activeView === 'pull-request') {
|
|
17994
|
+
return [action({
|
|
17995
|
+
type: 'openInputPrompt',
|
|
17996
|
+
kind: 'pr-merge-strategy',
|
|
17997
|
+
label: 'Merge strategy (merge / squash / rebase)',
|
|
17998
|
+
})];
|
|
17999
|
+
}
|
|
18000
|
+
if (inputValue === 'x' && state.activeView === 'pull-request') {
|
|
18001
|
+
return [action({ type: 'setPendingConfirmation', value: 'close-pr' })];
|
|
18002
|
+
}
|
|
18003
|
+
if (inputValue === 'a' && state.activeView === 'pull-request') {
|
|
18004
|
+
return [action({ type: 'setPendingConfirmation', value: 'approve-pr' })];
|
|
18005
|
+
}
|
|
18006
|
+
if (inputValue === 'R' && state.activeView === 'pull-request') {
|
|
18007
|
+
// Free-form review body — multi-line so the reviewer can structure
|
|
18008
|
+
// their feedback naturally without opening $EDITOR (#806).
|
|
18009
|
+
return [action({
|
|
18010
|
+
type: 'openInputPrompt',
|
|
18011
|
+
kind: 'pr-request-changes',
|
|
18012
|
+
label: 'Request changes — review body (Enter newline · Ctrl+D submit)',
|
|
18013
|
+
multiline: true,
|
|
18014
|
+
})];
|
|
18015
|
+
}
|
|
18016
|
+
if (inputValue === 'c' && state.activeView === 'pull-request') {
|
|
18017
|
+
// Free-form comment body — multi-line for the same reason as
|
|
18018
|
+
// pr-request-changes.
|
|
18019
|
+
return [action({
|
|
18020
|
+
type: 'openInputPrompt',
|
|
18021
|
+
kind: 'pr-comment',
|
|
18022
|
+
label: 'Comment body (Enter newline · Ctrl+D submit)',
|
|
18023
|
+
multiline: true,
|
|
18024
|
+
})];
|
|
18025
|
+
}
|
|
17059
18026
|
// Global stash hotkey: `S` opens a stash-message prompt and
|
|
17060
18027
|
// `createStash` runs once submitted. Available everywhere there's
|
|
17061
18028
|
// not a more modal handler in front of it.
|
|
@@ -17113,6 +18080,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17113
18080
|
payload: `${context.commitDiffSelectedSha} ${context.commitDiffSelectedPath}`,
|
|
17114
18081
|
})];
|
|
17115
18082
|
}
|
|
18083
|
+
// `H` on a commit-diff or stash-diff explore extracts the hunk under
|
|
18084
|
+
// the cursor and applies it to the working tree (`git apply`). The
|
|
18085
|
+
// sibling `gH` chord targets the index (`git apply --cached`). Both
|
|
18086
|
+
// bypass the y-confirm path because `git apply` is non-destructive
|
|
18087
|
+
// (it'll fail loudly on conflict and `git apply -R` undoes a clean
|
|
18088
|
+
// apply).
|
|
18089
|
+
if (inputValue === 'H') {
|
|
18090
|
+
const events = buildApplyHunkEvents(state, context, 'worktree');
|
|
18091
|
+
if (events.length) {
|
|
18092
|
+
return events;
|
|
18093
|
+
}
|
|
18094
|
+
if (state.activeView === 'diff' && (state.diffSource === 'commit' || state.diffSource === 'stash')) {
|
|
18095
|
+
return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first' })];
|
|
18096
|
+
}
|
|
18097
|
+
}
|
|
17116
18098
|
// `c` on the history view cherry-picks the full selected commit on
|
|
17117
18099
|
// top of the current branch. Routed through the y-confirm flow since
|
|
17118
18100
|
// it can produce conflicts and is a real working-tree mutation.
|
|
@@ -17123,6 +18105,62 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17123
18105
|
!state.pendingCommitFocused) {
|
|
17124
18106
|
return [action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' })];
|
|
17125
18107
|
}
|
|
18108
|
+
// `R` reverts the cursored commit by adding an inverse commit on top
|
|
18109
|
+
// of HEAD. Same y-confirm gate as cherry-pick — non-rewriting but
|
|
18110
|
+
// still a real mutation.
|
|
18111
|
+
if (inputValue === 'R' &&
|
|
18112
|
+
state.activeView === 'history' &&
|
|
18113
|
+
state.focus === 'commits' &&
|
|
18114
|
+
state.filteredCommits.length > 0 &&
|
|
18115
|
+
!state.pendingCommitFocused) {
|
|
18116
|
+
return [action({ type: 'setPendingConfirmation', value: 'revert-commit' })];
|
|
18117
|
+
}
|
|
18118
|
+
// `Z` resets the current branch tip to the cursored commit. Opens a
|
|
18119
|
+
// mode prompt (soft / mixed / hard) instead of jumping straight to
|
|
18120
|
+
// confirmation because the choice changes the destructiveness
|
|
18121
|
+
// dramatically — `--hard` discards working-tree changes. The prompt
|
|
18122
|
+
// submission special-cases `kind === 'reset-mode'` to forward the
|
|
18123
|
+
// mode through `reset-to-commit` (see prompt-submit handler above).
|
|
18124
|
+
// No `initial` value: existing prompts append to initial rather than
|
|
18125
|
+
// replacing it, which would surprise the user typing the mode.
|
|
18126
|
+
if (inputValue === 'Z' &&
|
|
18127
|
+
state.activeView === 'history' &&
|
|
18128
|
+
state.focus === 'commits' &&
|
|
18129
|
+
state.filteredCommits.length > 0 &&
|
|
18130
|
+
!state.pendingCommitFocused) {
|
|
18131
|
+
return [action({
|
|
18132
|
+
type: 'openInputPrompt',
|
|
18133
|
+
kind: 'reset-mode',
|
|
18134
|
+
label: 'Reset mode (soft / mixed / hard)',
|
|
18135
|
+
})];
|
|
18136
|
+
}
|
|
18137
|
+
// `i` (lowercase) starts an interactive rebase from the cursored
|
|
18138
|
+
// commit's parent. Lowercase keeps the existing global `I`
|
|
18139
|
+
// ai-commit-summary workflow reachable on the history view; `i`
|
|
18140
|
+
// also matches the `git rebase -i` flag mnemonic.
|
|
18141
|
+
if (inputValue === 'i' &&
|
|
18142
|
+
state.activeView === 'history' &&
|
|
18143
|
+
state.focus === 'commits' &&
|
|
18144
|
+
state.filteredCommits.length > 0 &&
|
|
18145
|
+
!state.pendingCommitFocused) {
|
|
18146
|
+
return [action({ type: 'setPendingConfirmation', value: 'interactive-rebase' })];
|
|
18147
|
+
}
|
|
18148
|
+
// `B` opens a create-branch prompt rooted at the cursored commit
|
|
18149
|
+
// (`git branch <name> <sha>` — does NOT switch to the new branch).
|
|
18150
|
+
// The prompt itself is the affirmative gate, so no separate y-confirm.
|
|
18151
|
+
// Bare uppercase `B` since the lowercase `b` is used by the `gb`
|
|
18152
|
+
// chord prefix and we want a single keystroke for this common op.
|
|
18153
|
+
if (inputValue === 'B' &&
|
|
18154
|
+
state.activeView === 'history' &&
|
|
18155
|
+
state.focus === 'commits' &&
|
|
18156
|
+
state.filteredCommits.length > 0 &&
|
|
18157
|
+
!state.pendingCommitFocused) {
|
|
18158
|
+
return [action({
|
|
18159
|
+
type: 'openInputPrompt',
|
|
18160
|
+
kind: 'create-branch-here',
|
|
18161
|
+
label: 'New branch name (at cursored commit)',
|
|
18162
|
+
})];
|
|
18163
|
+
}
|
|
17126
18164
|
// `y` / `Y` yank the contextually relevant identifier from the active
|
|
17127
18165
|
// view to the system clipboard:
|
|
17128
18166
|
// history → cursored commit hash (Y for short hash)
|
|
@@ -17138,13 +18176,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17138
18176
|
if (state.activeView === 'history' && state.filteredCommits.length > 0) {
|
|
17139
18177
|
return [{ type: 'yankFromActiveView', short }];
|
|
17140
18178
|
}
|
|
17141
|
-
if (state
|
|
18179
|
+
if (isBranchActionTarget(state) && context.branchCount) {
|
|
17142
18180
|
return [{ type: 'yankFromActiveView' }];
|
|
17143
18181
|
}
|
|
17144
|
-
if (state
|
|
18182
|
+
if (isTagActionTarget(state) && context.tagCount) {
|
|
17145
18183
|
return [{ type: 'yankFromActiveView' }];
|
|
17146
18184
|
}
|
|
17147
|
-
if (state
|
|
18185
|
+
if (isStashActionTarget(state) && context.stashCount && context.stashSelectedRef) {
|
|
17148
18186
|
return [{ type: 'yankFromActiveView' }];
|
|
17149
18187
|
}
|
|
17150
18188
|
if (state.activeView === 'status' && context.worktreeSelectedPath) {
|
|
@@ -17162,8 +18200,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17162
18200
|
// Enter on a stash row pushes the diff view scoped to that stash.
|
|
17163
18201
|
// The runtime loads `git stash show -p <ref>` once the view is
|
|
17164
18202
|
// active. The stash ref is passed via the action so we don't need a
|
|
17165
|
-
// context lookup here.
|
|
17166
|
-
|
|
18203
|
+
// context lookup here. Fires from either the dedicated stash view or
|
|
18204
|
+
// from the sidebar when the stashes tab is focused with items.
|
|
18205
|
+
if (key.return && isStashActionTarget(state) && context.stashCount && context.stashSelectedRef) {
|
|
17167
18206
|
return [action({
|
|
17168
18207
|
type: 'navigateOpenDiffForStash',
|
|
17169
18208
|
ref: context.stashSelectedRef,
|
|
@@ -17224,6 +18263,45 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17224
18263
|
* fall back to "already seen" so we never block startup.
|
|
17225
18264
|
*/
|
|
17226
18265
|
const MARKER_BASENAME = 'onboarding.seen';
|
|
18266
|
+
function resolveCacheDir$2() {
|
|
18267
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
18268
|
+
if (xdg && xdg.trim().length > 0) {
|
|
18269
|
+
return path$1.join(xdg, 'coco');
|
|
18270
|
+
}
|
|
18271
|
+
return path$1.join(os$1.homedir(), '.cache', 'coco');
|
|
18272
|
+
}
|
|
18273
|
+
function getOnboardingMarkerPath() {
|
|
18274
|
+
return path$1.join(resolveCacheDir$2(), MARKER_BASENAME);
|
|
18275
|
+
}
|
|
18276
|
+
function hasSeenOnboarding() {
|
|
18277
|
+
try {
|
|
18278
|
+
return fs$1.existsSync(getOnboardingMarkerPath());
|
|
18279
|
+
}
|
|
18280
|
+
catch {
|
|
18281
|
+
// If we can't even stat the path (sandboxed env, etc.), treat the
|
|
18282
|
+
// user as "seen" so we don't keep showing a panel they can never
|
|
18283
|
+
// dismiss persistently.
|
|
18284
|
+
return true;
|
|
18285
|
+
}
|
|
18286
|
+
}
|
|
18287
|
+
function markOnboardingSeen() {
|
|
18288
|
+
const markerPath = getOnboardingMarkerPath();
|
|
18289
|
+
try {
|
|
18290
|
+
fs$1.mkdirSync(path$1.dirname(markerPath), { recursive: true });
|
|
18291
|
+
fs$1.writeFileSync(markerPath, '');
|
|
18292
|
+
}
|
|
18293
|
+
catch {
|
|
18294
|
+
// Best-effort persistence; swallow.
|
|
18295
|
+
}
|
|
18296
|
+
}
|
|
18297
|
+
|
|
18298
|
+
/**
|
|
18299
|
+
* Persist the user's preferred diff view mode (unified vs side-by-side
|
|
18300
|
+
* split — #785) per repo. Mirrors `inkSidebarPersistence.ts` so the
|
|
18301
|
+
* cache layout, error model, and key derivation stay consistent across
|
|
18302
|
+
* settings: best-effort, XDG-friendly, no PII in the cache filename.
|
|
18303
|
+
*/
|
|
18304
|
+
const VALID_MODES = ['unified', 'split'];
|
|
17227
18305
|
function resolveCacheDir$1() {
|
|
17228
18306
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
17229
18307
|
if (xdg && xdg.trim().length > 0) {
|
|
@@ -17231,25 +18309,32 @@ function resolveCacheDir$1() {
|
|
|
17231
18309
|
}
|
|
17232
18310
|
return path$1.join(os$1.homedir(), '.cache', 'coco');
|
|
17233
18311
|
}
|
|
17234
|
-
function
|
|
17235
|
-
|
|
18312
|
+
function repoKey$1(repoPath) {
|
|
18313
|
+
// sha1 is used here as a non-security cache-key derivation — we just
|
|
18314
|
+
// need a deterministic short identifier for the marker filename. No
|
|
18315
|
+
// PII or auth context is hashed.
|
|
18316
|
+
// DevSkim: ignore DS126858
|
|
18317
|
+
return crypto.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
17236
18318
|
}
|
|
17237
|
-
function
|
|
18319
|
+
function getDiffViewModeMarkerPath(repoPath) {
|
|
18320
|
+
return path$1.join(resolveCacheDir$1(), `diff-view-mode.${repoKey$1(repoPath)}`);
|
|
18321
|
+
}
|
|
18322
|
+
function getSavedDiffViewMode(repoPath) {
|
|
17238
18323
|
try {
|
|
17239
|
-
|
|
18324
|
+
const raw = fs$1.readFileSync(getDiffViewModeMarkerPath(repoPath), 'utf8').trim();
|
|
18325
|
+
return VALID_MODES.includes(raw)
|
|
18326
|
+
? raw
|
|
18327
|
+
: undefined;
|
|
17240
18328
|
}
|
|
17241
18329
|
catch {
|
|
17242
|
-
|
|
17243
|
-
// user as "seen" so we don't keep showing a panel they can never
|
|
17244
|
-
// dismiss persistently.
|
|
17245
|
-
return true;
|
|
18330
|
+
return undefined;
|
|
17246
18331
|
}
|
|
17247
18332
|
}
|
|
17248
|
-
function
|
|
17249
|
-
const
|
|
18333
|
+
function saveDiffViewMode(repoPath, mode) {
|
|
18334
|
+
const marker = getDiffViewModeMarkerPath(repoPath);
|
|
17250
18335
|
try {
|
|
17251
|
-
fs$1.mkdirSync(path$1.dirname(
|
|
17252
|
-
fs$1.writeFileSync(
|
|
18336
|
+
fs$1.mkdirSync(path$1.dirname(marker), { recursive: true });
|
|
18337
|
+
fs$1.writeFileSync(marker, mode);
|
|
17253
18338
|
}
|
|
17254
18339
|
catch {
|
|
17255
18340
|
// Best-effort persistence; swallow.
|
|
@@ -17315,6 +18400,144 @@ function saveSidebarTab(repoPath, tab) {
|
|
|
17315
18400
|
}
|
|
17316
18401
|
}
|
|
17317
18402
|
|
|
18403
|
+
/**
|
|
18404
|
+
* Pair-alignment helper for the side-by-side diff view (#785).
|
|
18405
|
+
*
|
|
18406
|
+
* Takes the unified-diff line array that the renderer already paints (one
|
|
18407
|
+
* line per element, the leading character drives `+`/`-`/context coloring)
|
|
18408
|
+
* and re-shapes it into two-column rows the split renderer can lay out
|
|
18409
|
+
* without further parsing. Pure / synchronous so it can be exercised from
|
|
18410
|
+
* tests without spinning up Ink.
|
|
18411
|
+
*
|
|
18412
|
+
* Algorithm:
|
|
18413
|
+
* 1. Walk lines in order. `@@` headers seed a new hunk and reset the
|
|
18414
|
+
* `oldLineNo` / `newLineNo` cursors from the header range.
|
|
18415
|
+
* 2. Inside a hunk, group the consecutive runs of `-` and `+` lines that
|
|
18416
|
+
* follow each other. Each run of removals + the immediately-following
|
|
18417
|
+
* run of additions forms a "change block" that pairs up element-wise:
|
|
18418
|
+
* row[i] = { left: removals[i], right: additions[i] }. When one side
|
|
18419
|
+
* is shorter, pad with `kind: 'empty'` rows so the columns stay
|
|
18420
|
+
* aligned.
|
|
18421
|
+
* 3. Context lines emit as a paired row with the same text on both
|
|
18422
|
+
* sides and the synthesized line numbers from each cursor.
|
|
18423
|
+
* 4. Diff metadata (`diff `, `index `, `--- `, `+++ `, etc.) emit as
|
|
18424
|
+
* `kind: 'header'` rows so the split view still has a section break.
|
|
18425
|
+
* 5. A context line that interrupts a change block forces the in-flight
|
|
18426
|
+
* block to flush before the context row is emitted — pairs are never
|
|
18427
|
+
* drawn across context boundaries (matches lazygit / fugitive
|
|
18428
|
+
* behavior, and is what the issue specifies).
|
|
18429
|
+
*
|
|
18430
|
+
* Long lines are not wrapped here — the renderer truncates per column at
|
|
18431
|
+
* paint time so this helper stays pure and trivially testable.
|
|
18432
|
+
*/
|
|
18433
|
+
const EMPTY_LEFT = { text: '', kind: 'empty' };
|
|
18434
|
+
const EMPTY_RIGHT = { text: '', kind: 'empty' };
|
|
18435
|
+
/**
|
|
18436
|
+
* Parse the start line numbers out of an `@@ -A,B +C,D @@` header. Returns
|
|
18437
|
+
* `[oldStart, newStart]`; either falls back to 1 when the header is
|
|
18438
|
+
* malformed (which only happens with synthetic / hand-crafted patches).
|
|
18439
|
+
*/
|
|
18440
|
+
function parseHunkHeader(line) {
|
|
18441
|
+
const match = /@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/.exec(line);
|
|
18442
|
+
if (!match) {
|
|
18443
|
+
return [1, 1];
|
|
18444
|
+
}
|
|
18445
|
+
return [Number(match[1]) || 1, Number(match[2]) || 1];
|
|
18446
|
+
}
|
|
18447
|
+
function isDiffHeader(line) {
|
|
18448
|
+
return (line.startsWith('diff ') ||
|
|
18449
|
+
line.startsWith('index ') ||
|
|
18450
|
+
line.startsWith('--- ') ||
|
|
18451
|
+
line.startsWith('+++ ') ||
|
|
18452
|
+
line.startsWith('similarity ') ||
|
|
18453
|
+
line.startsWith('rename ') ||
|
|
18454
|
+
line.startsWith('copy ') ||
|
|
18455
|
+
line.startsWith('new file ') ||
|
|
18456
|
+
line.startsWith('deleted file ') ||
|
|
18457
|
+
line.startsWith('old mode ') ||
|
|
18458
|
+
line.startsWith('new mode ') ||
|
|
18459
|
+
line.startsWith('Binary files '));
|
|
18460
|
+
}
|
|
18461
|
+
/**
|
|
18462
|
+
* Flush a pending change block (removals + additions accumulated from a
|
|
18463
|
+
* contiguous `-`/`+` run) into paired rows. Pads the shorter side with
|
|
18464
|
+
* empty placeholders so columns stay aligned.
|
|
18465
|
+
*/
|
|
18466
|
+
function flushChangeBlock(removals, additions, rows) {
|
|
18467
|
+
const max = Math.max(removals.length, additions.length);
|
|
18468
|
+
for (let i = 0; i < max; i++) {
|
|
18469
|
+
const left = removals[i] || EMPTY_LEFT;
|
|
18470
|
+
const right = additions[i] || EMPTY_RIGHT;
|
|
18471
|
+
rows.push({ left, right });
|
|
18472
|
+
}
|
|
18473
|
+
removals.length = 0;
|
|
18474
|
+
additions.length = 0;
|
|
18475
|
+
}
|
|
18476
|
+
function buildSplitDiffRows(unifiedLines) {
|
|
18477
|
+
const rows = [];
|
|
18478
|
+
let oldLineNo = 0;
|
|
18479
|
+
let newLineNo = 0;
|
|
18480
|
+
let inHunk = false;
|
|
18481
|
+
const removals = [];
|
|
18482
|
+
const additions = [];
|
|
18483
|
+
const flushHeader = (text) => {
|
|
18484
|
+
flushChangeBlock(removals, additions, rows);
|
|
18485
|
+
rows.push({
|
|
18486
|
+
left: { text, kind: 'header' },
|
|
18487
|
+
right: { text, kind: 'header' },
|
|
18488
|
+
});
|
|
18489
|
+
};
|
|
18490
|
+
for (const raw of unifiedLines) {
|
|
18491
|
+
if (raw.startsWith('@@')) {
|
|
18492
|
+
flushChangeBlock(removals, additions, rows);
|
|
18493
|
+
const [oldStart, newStart] = parseHunkHeader(raw);
|
|
18494
|
+
oldLineNo = oldStart;
|
|
18495
|
+
newLineNo = newStart;
|
|
18496
|
+
inHunk = true;
|
|
18497
|
+
rows.push({
|
|
18498
|
+
left: { text: raw, kind: 'header' },
|
|
18499
|
+
right: { text: raw, kind: 'header' },
|
|
18500
|
+
});
|
|
18501
|
+
continue;
|
|
18502
|
+
}
|
|
18503
|
+
if (!inHunk || isDiffHeader(raw)) {
|
|
18504
|
+
flushHeader(raw);
|
|
18505
|
+
continue;
|
|
18506
|
+
}
|
|
18507
|
+
if (raw.startsWith('-')) {
|
|
18508
|
+
removals.push({
|
|
18509
|
+
text: raw.slice(1),
|
|
18510
|
+
lineNumber: oldLineNo,
|
|
18511
|
+
kind: 'remove',
|
|
18512
|
+
});
|
|
18513
|
+
oldLineNo += 1;
|
|
18514
|
+
continue;
|
|
18515
|
+
}
|
|
18516
|
+
if (raw.startsWith('+')) {
|
|
18517
|
+
additions.push({
|
|
18518
|
+
text: raw.slice(1),
|
|
18519
|
+
lineNumber: newLineNo,
|
|
18520
|
+
kind: 'add',
|
|
18521
|
+
});
|
|
18522
|
+
newLineNo += 1;
|
|
18523
|
+
continue;
|
|
18524
|
+
}
|
|
18525
|
+
// Context line (or `` marker, which we
|
|
18526
|
+
// treat like a context row so it lands on both sides — readers
|
|
18527
|
+
// expect to see it in either column).
|
|
18528
|
+
flushChangeBlock(removals, additions, rows);
|
|
18529
|
+
const text = raw.startsWith(' ') ? raw.slice(1) : raw;
|
|
18530
|
+
rows.push({
|
|
18531
|
+
left: { text, lineNumber: oldLineNo, kind: 'context' },
|
|
18532
|
+
right: { text, lineNumber: newLineNo, kind: 'context' },
|
|
18533
|
+
});
|
|
18534
|
+
oldLineNo += 1;
|
|
18535
|
+
newLineNo += 1;
|
|
18536
|
+
}
|
|
18537
|
+
flushChangeBlock(removals, additions, rows);
|
|
18538
|
+
return rows;
|
|
18539
|
+
}
|
|
18540
|
+
|
|
17318
18541
|
/**
|
|
17319
18542
|
* Promoted-view selection rectification on filter changes (P4.5).
|
|
17320
18543
|
*
|
|
@@ -17564,10 +18787,25 @@ const LOG_INK_MIN_COLUMNS = 80;
|
|
|
17564
18787
|
const LOG_INK_MIN_ROWS = 24;
|
|
17565
18788
|
const LOG_INK_DEFAULT_COLUMNS = 120;
|
|
17566
18789
|
const LOG_INK_DEFAULT_ROWS = 40;
|
|
18790
|
+
/**
|
|
18791
|
+
* Terminal-row threshold below which the inspector switches to a
|
|
18792
|
+
* tabbed layout (commit-detail vs actions). Picked empirically: at
|
|
18793
|
+
* 28 rows the inspector's full stack (~30 rows when fully populated)
|
|
18794
|
+
* starts clipping the actions section; below that, the tabbed mode
|
|
18795
|
+
* gives both views their own air.
|
|
18796
|
+
*/
|
|
18797
|
+
const INSPECTOR_TABBED_BELOW_ROWS = 28;
|
|
17567
18798
|
function getLogInkLayout(input) {
|
|
17568
18799
|
const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
|
|
17569
18800
|
const rows = input.rows || LOG_INK_DEFAULT_ROWS;
|
|
17570
|
-
|
|
18801
|
+
// Inspector width — at rest 20-32 cells (~22% of width), focused
|
|
18802
|
+
// 36-60 cells (~40% of width). Narrow rest state keeps the commit
|
|
18803
|
+
// graph dominant; focus expansion gives the inspector room for long
|
|
18804
|
+
// commit bodies / file lists / action labels. Mirrors the sidebar
|
|
18805
|
+
// pattern (sidebarFocused above): instant transition per render.
|
|
18806
|
+
const detailWidth = input.inspectorFocused
|
|
18807
|
+
? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
|
|
18808
|
+
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
17571
18809
|
// Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
|
|
17572
18810
|
// (~36% of width). The transition is instant per render — focus tab to
|
|
17573
18811
|
// expand, focus away to collapse.
|
|
@@ -17582,6 +18820,7 @@ function getLogInkLayout(input) {
|
|
|
17582
18820
|
rows,
|
|
17583
18821
|
sidebarWidth,
|
|
17584
18822
|
tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
|
|
18823
|
+
inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
|
|
17585
18824
|
};
|
|
17586
18825
|
}
|
|
17587
18826
|
|
|
@@ -17736,7 +18975,8 @@ function createLogInkTheme(options = {}) {
|
|
|
17736
18975
|
/**
|
|
17737
18976
|
* Format a branch's relationship to its upstream.
|
|
17738
18977
|
* - no upstream → "no upstream"
|
|
17739
|
-
* - even → "
|
|
18978
|
+
* - even → "" (the boring default — keep the row tight; the row
|
|
18979
|
+
* marker already encodes "synced")
|
|
17740
18980
|
* - divergent → "↑<ahead> ↓<behind> <upstream>" (only the non-zero side
|
|
17741
18981
|
* is rendered so the line stays tight). ASCII mode falls back to the
|
|
17742
18982
|
* legacy `+N/-N` form.
|
|
@@ -17746,7 +18986,7 @@ function formatBranchDivergence(branch, options = {}) {
|
|
|
17746
18986
|
return 'no upstream';
|
|
17747
18987
|
}
|
|
17748
18988
|
if (branch.ahead === 0 && branch.behind === 0) {
|
|
17749
|
-
return
|
|
18989
|
+
return '';
|
|
17750
18990
|
}
|
|
17751
18991
|
if (options.ascii) {
|
|
17752
18992
|
return `+${branch.ahead}/-${branch.behind} ${branch.upstream}`;
|
|
@@ -17760,14 +19000,76 @@ function formatBranchDivergence(branch, options = {}) {
|
|
|
17760
19000
|
}
|
|
17761
19001
|
/**
|
|
17762
19002
|
* Single-cell marker shown to the left of a branch name in lists.
|
|
17763
|
-
*
|
|
19003
|
+
*
|
|
19004
|
+
* - `*` — current branch (regardless of remote state)
|
|
19005
|
+
* - `◌` — no upstream
|
|
19006
|
+
* - `≡` — has upstream + synced (ahead === 0 && behind === 0)
|
|
19007
|
+
* - `↕` — has upstream + diverged (any non-zero ahead/behind)
|
|
19008
|
+
* - ` ` — fallback / no info
|
|
19009
|
+
*
|
|
19010
|
+
* ASCII fallbacks (legible without box-drawing/arrow glyphs):
|
|
19011
|
+
* - `?` for "no upstream", `=` for synced, `~` for diverged.
|
|
17764
19012
|
*/
|
|
17765
19013
|
function branchRowMarker(branch, options = {}) {
|
|
17766
19014
|
if (branch.current)
|
|
17767
19015
|
return '*';
|
|
17768
19016
|
if (!branch.upstream)
|
|
17769
19017
|
return options.ascii ? '?' : '◌';
|
|
17770
|
-
|
|
19018
|
+
const ahead = branch.ahead ?? 0;
|
|
19019
|
+
const behind = branch.behind ?? 0;
|
|
19020
|
+
if (ahead === 0 && behind === 0) {
|
|
19021
|
+
return options.ascii ? '=' : '≡';
|
|
19022
|
+
}
|
|
19023
|
+
return options.ascii ? '~' : '↕';
|
|
19024
|
+
}
|
|
19025
|
+
/**
|
|
19026
|
+
* Compact, human-friendly relative timestamp for the branch row.
|
|
19027
|
+
* Inputs:
|
|
19028
|
+
* - `iso` — committer-date in `YYYY-MM-DD` form (as produced by
|
|
19029
|
+
* `for-each-ref` with `committerdate:short`).
|
|
19030
|
+
* - `now` — reference instant; pass it explicitly so callers can pin it
|
|
19031
|
+
* for deterministic tests.
|
|
19032
|
+
*
|
|
19033
|
+
* Outputs (rounded toward the nearest unit):
|
|
19034
|
+
* - `today`, `1d ago`, `2d ago` … up to 13d
|
|
19035
|
+
* - `2w ago` … up to 8w
|
|
19036
|
+
* - `2mo ago` … up to 12mo
|
|
19037
|
+
* - `2y ago` for older
|
|
19038
|
+
* - `''` for malformed inputs (caller renders nothing).
|
|
19039
|
+
*
|
|
19040
|
+
* "in the future" inputs (clock skew, bad data) collapse to `today`.
|
|
19041
|
+
*/
|
|
19042
|
+
function formatBranchLastTouched(iso, now) {
|
|
19043
|
+
if (!iso)
|
|
19044
|
+
return '';
|
|
19045
|
+
// Tolerate either `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS…` ISO strings.
|
|
19046
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
|
19047
|
+
if (!match)
|
|
19048
|
+
return '';
|
|
19049
|
+
const year = Number.parseInt(match[1], 10);
|
|
19050
|
+
const month = Number.parseInt(match[2], 10);
|
|
19051
|
+
const day = Number.parseInt(match[3], 10);
|
|
19052
|
+
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day))
|
|
19053
|
+
return '';
|
|
19054
|
+
// Compare at day granularity in UTC so a branch touched "yesterday"
|
|
19055
|
+
// never reads "today" depending on the operator's timezone.
|
|
19056
|
+
const branchUtc = Date.UTC(year, month - 1, day);
|
|
19057
|
+
const nowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
19058
|
+
const diffMs = nowUtc - branchUtc;
|
|
19059
|
+
const oneDay = 24 * 60 * 60 * 1000;
|
|
19060
|
+
const days = Math.floor(diffMs / oneDay);
|
|
19061
|
+
if (days <= 0)
|
|
19062
|
+
return 'today';
|
|
19063
|
+
if (days < 14)
|
|
19064
|
+
return `${days}d ago`;
|
|
19065
|
+
const weeks = Math.floor(days / 7);
|
|
19066
|
+
if (weeks < 9)
|
|
19067
|
+
return `${weeks}w ago`;
|
|
19068
|
+
const months = Math.floor(days / 30);
|
|
19069
|
+
if (months < 12)
|
|
19070
|
+
return `${months}mo ago`;
|
|
19071
|
+
const years = Math.floor(days / 365);
|
|
19072
|
+
return `${years}y ago`;
|
|
17771
19073
|
}
|
|
17772
19074
|
/**
|
|
17773
19075
|
* Pick the glyph + color for a PR state badge.
|
|
@@ -18197,7 +19499,7 @@ function createChangelogArgv(input) {
|
|
|
18197
19499
|
...input,
|
|
18198
19500
|
};
|
|
18199
19501
|
}
|
|
18200
|
-
function compactOutputLines$
|
|
19502
|
+
function compactOutputLines$3(output) {
|
|
18201
19503
|
return output
|
|
18202
19504
|
.split('\n')
|
|
18203
19505
|
.map((line) => line.trim())
|
|
@@ -18221,7 +19523,7 @@ async function captureStdout(action) {
|
|
|
18221
19523
|
}
|
|
18222
19524
|
}
|
|
18223
19525
|
function formatCapturedAiOutput(output) {
|
|
18224
|
-
const lines = compactOutputLines$
|
|
19526
|
+
const lines = compactOutputLines$3(output);
|
|
18225
19527
|
const telemetry = lines.filter((line) => line.includes('[llm:summary]'));
|
|
18226
19528
|
const content = lines.filter((line) => !line.includes('[llm]') && !line.includes('[llm:summary]'));
|
|
18227
19529
|
const editable = content.join('\n');
|
|
@@ -18518,8 +19820,65 @@ function parsePullRequestInfo(output) {
|
|
|
18518
19820
|
if (!trimmed) {
|
|
18519
19821
|
return undefined;
|
|
18520
19822
|
}
|
|
18521
|
-
|
|
19823
|
+
const raw = JSON.parse(trimmed);
|
|
19824
|
+
const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
|
|
19825
|
+
? String(raw.author.login)
|
|
19826
|
+
: undefined;
|
|
19827
|
+
return {
|
|
19828
|
+
number: raw.number,
|
|
19829
|
+
title: raw.title,
|
|
19830
|
+
url: raw.url,
|
|
19831
|
+
state: raw.state,
|
|
19832
|
+
isDraft: raw.isDraft,
|
|
19833
|
+
headRefName: raw.headRefName,
|
|
19834
|
+
baseRefName: raw.baseRefName,
|
|
19835
|
+
body: typeof raw.body === 'string' ? raw.body : undefined,
|
|
19836
|
+
author,
|
|
19837
|
+
reviewDecision: typeof raw.reviewDecision === 'string' ? raw.reviewDecision : undefined,
|
|
19838
|
+
mergeable: typeof raw.mergeable === 'string' ? raw.mergeable : undefined,
|
|
19839
|
+
mergeStateStatus: typeof raw.mergeStateStatus === 'string' ? raw.mergeStateStatus : undefined,
|
|
19840
|
+
statusCheckRollup: Array.isArray(raw.statusCheckRollup)
|
|
19841
|
+
? raw.statusCheckRollup.map((entry) => ({
|
|
19842
|
+
name: String(entry.name || entry.context || 'check'),
|
|
19843
|
+
status: typeof entry.status === 'string' ? entry.status : undefined,
|
|
19844
|
+
conclusion: typeof entry.conclusion === 'string' ? entry.conclusion : undefined,
|
|
19845
|
+
}))
|
|
19846
|
+
: undefined,
|
|
19847
|
+
reviews: Array.isArray(raw.reviews)
|
|
19848
|
+
? raw.reviews.map((entry) => {
|
|
19849
|
+
const author = entry.author && typeof entry.author === 'object' && 'login' in entry.author
|
|
19850
|
+
? String(entry.author.login)
|
|
19851
|
+
: '';
|
|
19852
|
+
return {
|
|
19853
|
+
author,
|
|
19854
|
+
state: typeof entry.state === 'string' ? entry.state : '',
|
|
19855
|
+
};
|
|
19856
|
+
}).filter((review) => review.author)
|
|
19857
|
+
: undefined,
|
|
19858
|
+
};
|
|
18522
19859
|
}
|
|
19860
|
+
/**
|
|
19861
|
+
* `gh pr view --json` field list. Centralized so the data fetcher and
|
|
19862
|
+
* any future re-fetch (e.g., refresh after a merge action) request the
|
|
19863
|
+
* same shape — the parser depends on every field being present, even
|
|
19864
|
+
* if optional, so they're safe to deserialize.
|
|
19865
|
+
*/
|
|
19866
|
+
const PULL_REQUEST_VIEW_JSON_FIELDS = [
|
|
19867
|
+
'number',
|
|
19868
|
+
'title',
|
|
19869
|
+
'url',
|
|
19870
|
+
'state',
|
|
19871
|
+
'isDraft',
|
|
19872
|
+
'headRefName',
|
|
19873
|
+
'baseRefName',
|
|
19874
|
+
'body',
|
|
19875
|
+
'author',
|
|
19876
|
+
'reviewDecision',
|
|
19877
|
+
'mergeable',
|
|
19878
|
+
'mergeStateStatus',
|
|
19879
|
+
'statusCheckRollup',
|
|
19880
|
+
'reviews',
|
|
19881
|
+
].join(',');
|
|
18523
19882
|
async function getPullRequestOverview(git, runner = defaultGhRunner) {
|
|
18524
19883
|
const [repository, currentBranchOutput] = await Promise.all([
|
|
18525
19884
|
getGitHubRepository(git),
|
|
@@ -18551,7 +19910,7 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
|
|
|
18551
19910
|
'pr',
|
|
18552
19911
|
'view',
|
|
18553
19912
|
'--json',
|
|
18554
|
-
|
|
19913
|
+
PULL_REQUEST_VIEW_JSON_FIELDS,
|
|
18555
19914
|
]);
|
|
18556
19915
|
return {
|
|
18557
19916
|
available: true,
|
|
@@ -18718,7 +20077,7 @@ function providerBranchName(branch) {
|
|
|
18718
20077
|
return branch.shortName;
|
|
18719
20078
|
}
|
|
18720
20079
|
|
|
18721
|
-
function compactOutputLines$
|
|
20080
|
+
function compactOutputLines$2(output) {
|
|
18722
20081
|
return output
|
|
18723
20082
|
.split('\n')
|
|
18724
20083
|
.map((line) => line.trim())
|
|
@@ -18733,7 +20092,7 @@ async function runAction$5(action, successMessage) {
|
|
|
18733
20092
|
};
|
|
18734
20093
|
}
|
|
18735
20094
|
catch (error) {
|
|
18736
|
-
const lines = compactOutputLines$
|
|
20095
|
+
const lines = compactOutputLines$2(error.message);
|
|
18737
20096
|
return {
|
|
18738
20097
|
ok: false,
|
|
18739
20098
|
message: lines[0] || 'History action failed.',
|
|
@@ -18875,7 +20234,7 @@ async function compareCommits(git, from, to) {
|
|
|
18875
20234
|
}
|
|
18876
20235
|
try {
|
|
18877
20236
|
const output = await git.raw(['diff', '--stat', '--color=never', `${from.hash}..${to.hash}`]);
|
|
18878
|
-
const lines = compactOutputLines$
|
|
20237
|
+
const lines = compactOutputLines$2(output);
|
|
18879
20238
|
return {
|
|
18880
20239
|
ok: true,
|
|
18881
20240
|
message: `Compared ${from.shortHash}..${to.shortHash}`,
|
|
@@ -18957,6 +20316,63 @@ function resetToCommit(git, commit, mode) {
|
|
|
18957
20316
|
: result.details,
|
|
18958
20317
|
}));
|
|
18959
20318
|
}
|
|
20319
|
+
/**
|
|
20320
|
+
* Create a new local branch pointed at <commit>, without switching to it.
|
|
20321
|
+
*
|
|
20322
|
+
* This is the "create branch from cursored commit" history action — the
|
|
20323
|
+
* user types the new branch name into an input prompt and we run
|
|
20324
|
+
* `git branch <name> <sha>` (NOT `git switch -c`, which is what
|
|
20325
|
+
* `branchActions.createBranch` does for the create-branch-at-HEAD flow).
|
|
20326
|
+
* The split exists because GitKraken-style "create branch here" is
|
|
20327
|
+
* specifically about marking a historical commit, not about switching
|
|
20328
|
+
* onto a new working branch.
|
|
20329
|
+
*
|
|
20330
|
+
* Note for the inspector follow-up: workflow surfacing is driven by the
|
|
20331
|
+
* registry in `inkWorkflows.ts`, not a hardcoded action list — adding
|
|
20332
|
+
* `create-branch-here` there is enough for the inspector / palette to
|
|
20333
|
+
* pick this up.
|
|
20334
|
+
*/
|
|
20335
|
+
function createBranchFromCommit(git, name, commit) {
|
|
20336
|
+
const trimmedName = name.trim();
|
|
20337
|
+
if (!commit) {
|
|
20338
|
+
return Promise.resolve({
|
|
20339
|
+
ok: false,
|
|
20340
|
+
message: 'No commit selected.',
|
|
20341
|
+
});
|
|
20342
|
+
}
|
|
20343
|
+
if (!trimmedName) {
|
|
20344
|
+
return Promise.resolve({
|
|
20345
|
+
ok: false,
|
|
20346
|
+
message: 'Branch name required.',
|
|
20347
|
+
});
|
|
20348
|
+
}
|
|
20349
|
+
return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['branch', trimmedName, commit.hash]), `Created branch ${trimmedName} at ${commit.shortHash}`)));
|
|
20350
|
+
}
|
|
20351
|
+
/**
|
|
20352
|
+
* Create a lightweight tag pointed at <commit>.
|
|
20353
|
+
*
|
|
20354
|
+
* Mirrors `createBranchFromCommit` for the tag side: the user types a
|
|
20355
|
+
* tag name into an input prompt and we run `git tag <name> <sha>`
|
|
20356
|
+
* (lightweight, no `-a`/`-m`). Annotated tags remain available through
|
|
20357
|
+
* the existing `+` flow on the tags view; this is the per-commit
|
|
20358
|
+
* shortcut.
|
|
20359
|
+
*/
|
|
20360
|
+
function createTagAtCommit(git, name, commit) {
|
|
20361
|
+
const trimmedName = name.trim();
|
|
20362
|
+
if (!commit) {
|
|
20363
|
+
return Promise.resolve({
|
|
20364
|
+
ok: false,
|
|
20365
|
+
message: 'No commit selected.',
|
|
20366
|
+
});
|
|
20367
|
+
}
|
|
20368
|
+
if (!trimmedName) {
|
|
20369
|
+
return Promise.resolve({
|
|
20370
|
+
ok: false,
|
|
20371
|
+
message: 'Tag name required.',
|
|
20372
|
+
});
|
|
20373
|
+
}
|
|
20374
|
+
return guardNoInProgressOperation(git).then((blocked) => (blocked || runAction$5(() => git.raw(['tag', trimmedName, commit.hash]), `Created tag ${trimmedName} at ${commit.shortHash}`)));
|
|
20375
|
+
}
|
|
18960
20376
|
function startInteractiveRebase(git, commit) {
|
|
18961
20377
|
if (!commit) {
|
|
18962
20378
|
return Promise.resolve({
|
|
@@ -19042,6 +20458,61 @@ function createPullRequest(input, runner = defaultGhRunner) {
|
|
|
19042
20458
|
};
|
|
19043
20459
|
});
|
|
19044
20460
|
}
|
|
20461
|
+
function isPullRequestMergeStrategy(value) {
|
|
20462
|
+
return value === 'merge' || value === 'squash' || value === 'rebase';
|
|
20463
|
+
}
|
|
20464
|
+
function buildMergePullRequestArgs(strategy) {
|
|
20465
|
+
// `--auto` and `--admin` are intentionally omitted — they're rarely
|
|
20466
|
+
// what a user wants from a TUI and require explicit gh auth scopes.
|
|
20467
|
+
// `--delete-branch` is opt-in via a future flag; default leaves the
|
|
20468
|
+
// branch in place so the user can verify before cleanup.
|
|
20469
|
+
return ['pr', 'merge', `--${strategy}`];
|
|
20470
|
+
}
|
|
20471
|
+
function mergePullRequest(strategy, runner = defaultGhRunner) {
|
|
20472
|
+
return runGhAction(runner, buildMergePullRequestArgs(strategy), (output) => ({
|
|
20473
|
+
ok: true,
|
|
20474
|
+
message: output.trim() || `Merged pull request with ${strategy}`,
|
|
20475
|
+
}));
|
|
20476
|
+
}
|
|
20477
|
+
function closePullRequest(runner = defaultGhRunner) {
|
|
20478
|
+
return runGhAction(runner, ['pr', 'close'], (output) => ({
|
|
20479
|
+
ok: true,
|
|
20480
|
+
message: output.trim() || 'Closed pull request',
|
|
20481
|
+
}));
|
|
20482
|
+
}
|
|
20483
|
+
/**
|
|
20484
|
+
* `gh pr review --approve` requires the user's gh auth to have scope
|
|
20485
|
+
* to write reviews — same scope that the in-browser approve button
|
|
20486
|
+
* uses. The runner surfaces auth failures via the standard error path.
|
|
20487
|
+
*/
|
|
20488
|
+
function approvePullRequest(runner = defaultGhRunner) {
|
|
20489
|
+
return runGhAction(runner, ['pr', 'review', '--approve'], (output) => ({
|
|
20490
|
+
ok: true,
|
|
20491
|
+
message: output.trim() || 'Approved pull request',
|
|
20492
|
+
}));
|
|
20493
|
+
}
|
|
20494
|
+
/**
|
|
20495
|
+
* Request changes — `gh pr review` requires a body with this verb so
|
|
20496
|
+
* the empty-body case is rejected upstream by the input prompt.
|
|
20497
|
+
*/
|
|
20498
|
+
function requestChangesPullRequest(body, runner = defaultGhRunner) {
|
|
20499
|
+
if (!body.trim()) {
|
|
20500
|
+
return Promise.resolve({ ok: false, message: 'Review body required for change-request' });
|
|
20501
|
+
}
|
|
20502
|
+
return runGhAction(runner, ['pr', 'review', '--request-changes', '--body', body], (output) => ({
|
|
20503
|
+
ok: true,
|
|
20504
|
+
message: output.trim() || 'Requested changes',
|
|
20505
|
+
}));
|
|
20506
|
+
}
|
|
20507
|
+
function commentPullRequest(body, runner = defaultGhRunner) {
|
|
20508
|
+
if (!body.trim()) {
|
|
20509
|
+
return Promise.resolve({ ok: false, message: 'Comment body required' });
|
|
20510
|
+
}
|
|
20511
|
+
return runGhAction(runner, ['pr', 'comment', '--body', body], (output) => ({
|
|
20512
|
+
ok: true,
|
|
20513
|
+
message: output.trim() || 'Comment added',
|
|
20514
|
+
}));
|
|
20515
|
+
}
|
|
19045
20516
|
|
|
19046
20517
|
async function runAction$4(action, successMessage) {
|
|
19047
20518
|
try {
|
|
@@ -19666,7 +21137,7 @@ function applyLogTuiAction(state, action) {
|
|
|
19666
21137
|
}
|
|
19667
21138
|
}
|
|
19668
21139
|
|
|
19669
|
-
function compactOutputLines(output) {
|
|
21140
|
+
function compactOutputLines$1(output) {
|
|
19670
21141
|
return output
|
|
19671
21142
|
.split('\n')
|
|
19672
21143
|
.map((line) => line.trim())
|
|
@@ -19681,7 +21152,7 @@ async function runAction(action, successMessage) {
|
|
|
19681
21152
|
};
|
|
19682
21153
|
}
|
|
19683
21154
|
catch (error) {
|
|
19684
|
-
const details = compactOutputLines(error.message);
|
|
21155
|
+
const details = compactOutputLines$1(error.message);
|
|
19685
21156
|
return {
|
|
19686
21157
|
ok: false,
|
|
19687
21158
|
message: details[0] || 'Git operation action failed.',
|
|
@@ -21360,6 +22831,356 @@ async function startInteractiveLog(git, rows, streams = {}) {
|
|
|
21360
22831
|
});
|
|
21361
22832
|
}
|
|
21362
22833
|
|
|
22834
|
+
function compactOutputLines(output) {
|
|
22835
|
+
return output
|
|
22836
|
+
.split('\n')
|
|
22837
|
+
.map((line) => line.trim())
|
|
22838
|
+
.filter(Boolean);
|
|
22839
|
+
}
|
|
22840
|
+
async function safeUnlink(path) {
|
|
22841
|
+
try {
|
|
22842
|
+
await promises.unlink(path);
|
|
22843
|
+
}
|
|
22844
|
+
catch (error) {
|
|
22845
|
+
// ENOENT is fine — the temp file was never created or already
|
|
22846
|
+
// cleaned up. Anything else we silently swallow because the
|
|
22847
|
+
// worst-case impact is a single ~1KB file in $TMPDIR.
|
|
22848
|
+
error.code;
|
|
22849
|
+
}
|
|
22850
|
+
}
|
|
22851
|
+
/**
|
|
22852
|
+
* Write a unified-diff patch to a temp file and feed it to
|
|
22853
|
+
* `git apply` (or `git apply --cached` when target === 'index').
|
|
22854
|
+
*
|
|
22855
|
+
* This is the runner behind the `apply-hunk-worktree` /
|
|
22856
|
+
* `apply-hunk-index` workflow actions — the input handler builds
|
|
22857
|
+
* `patchText` from the cursored hunk via `extractDiffHunk` and the
|
|
22858
|
+
* runtime hands it here.
|
|
22859
|
+
*
|
|
22860
|
+
* `--whitespace=nowarn` keeps `git apply` quiet about trailing
|
|
22861
|
+
* whitespace differences (the most common false positive when the
|
|
22862
|
+
* patch comes from a stash made on a different platform). Real
|
|
22863
|
+
* conflicts still surface via the non-zero exit code.
|
|
22864
|
+
*
|
|
22865
|
+
* The patch is written to a temp file rather than piped on stdin
|
|
22866
|
+
* because some `simple-git` adapters don't expose a clean stdin
|
|
22867
|
+
* channel for `git.raw`; the tempfile path keeps the runner
|
|
22868
|
+
* portable across environments.
|
|
22869
|
+
*/
|
|
22870
|
+
async function applyHunkPatch(git, patchText, options) {
|
|
22871
|
+
if (!patchText.trim()) {
|
|
22872
|
+
return {
|
|
22873
|
+
ok: false,
|
|
22874
|
+
message: 'No hunk under cursor to apply.',
|
|
22875
|
+
};
|
|
22876
|
+
}
|
|
22877
|
+
const targetLabel = options.target === 'index' ? 'index' : 'worktree';
|
|
22878
|
+
const tempPath = join(tmpdir(), `coco-hunk-${randomUUID()}.patch`);
|
|
22879
|
+
try {
|
|
22880
|
+
await promises.writeFile(tempPath, patchText, 'utf8');
|
|
22881
|
+
const args = ['apply'];
|
|
22882
|
+
if (options.target === 'index') {
|
|
22883
|
+
args.push('--cached');
|
|
22884
|
+
}
|
|
22885
|
+
args.push('--whitespace=nowarn');
|
|
22886
|
+
args.push(tempPath);
|
|
22887
|
+
try {
|
|
22888
|
+
await git.raw(args);
|
|
22889
|
+
return {
|
|
22890
|
+
ok: true,
|
|
22891
|
+
message: `Applied hunk to ${targetLabel}`,
|
|
22892
|
+
};
|
|
22893
|
+
}
|
|
22894
|
+
catch (error) {
|
|
22895
|
+
const lines = compactOutputLines(error.message);
|
|
22896
|
+
return {
|
|
22897
|
+
ok: false,
|
|
22898
|
+
message: lines[0] || `Failed to apply hunk to ${targetLabel}`,
|
|
22899
|
+
details: lines.slice(1, 6),
|
|
22900
|
+
};
|
|
22901
|
+
}
|
|
22902
|
+
}
|
|
22903
|
+
catch (error) {
|
|
22904
|
+
return {
|
|
22905
|
+
ok: false,
|
|
22906
|
+
message: `Could not stage hunk for apply: ${error.message}`,
|
|
22907
|
+
};
|
|
22908
|
+
}
|
|
22909
|
+
finally {
|
|
22910
|
+
await safeUnlink(tempPath);
|
|
22911
|
+
}
|
|
22912
|
+
}
|
|
22913
|
+
|
|
22914
|
+
function formatStashHeaderIdentity(ref, stashes) {
|
|
22915
|
+
if (!ref) {
|
|
22916
|
+
return { subtitle: 'no stash', bodyLine: 'Stash:' };
|
|
22917
|
+
}
|
|
22918
|
+
const index = stashes?.findIndex((entry) => entry.ref === ref) ?? -1;
|
|
22919
|
+
const entry = index >= 0 ? stashes[index] : undefined;
|
|
22920
|
+
if (!entry) {
|
|
22921
|
+
return {
|
|
22922
|
+
subtitle: ref,
|
|
22923
|
+
bodyLine: `Stash: ${ref}`,
|
|
22924
|
+
};
|
|
22925
|
+
}
|
|
22926
|
+
const onBranch = entry.branch && entry.branch !== '<unknown>' ? ` on ${entry.branch}` : '';
|
|
22927
|
+
const message = entry.message?.trim() || '(no message)';
|
|
22928
|
+
return {
|
|
22929
|
+
subtitle: `@{${index}} ${message}${onBranch}`,
|
|
22930
|
+
bodyLine: `Stash: ${ref}${onBranch} — ${message}`,
|
|
22931
|
+
};
|
|
22932
|
+
}
|
|
22933
|
+
|
|
22934
|
+
/**
|
|
22935
|
+
* Normalize gh's two parallel signals (`status` for in-flight check
|
|
22936
|
+
* runs, `conclusion` for completed runs and status contexts) into a
|
|
22937
|
+
* single status enum the renderer can map to a glyph + color.
|
|
22938
|
+
*/
|
|
22939
|
+
function normalizePullRequestCheckStatus(check) {
|
|
22940
|
+
const status = (check.status || '').toUpperCase();
|
|
22941
|
+
const conclusion = (check.conclusion || '').toUpperCase();
|
|
22942
|
+
// In-flight check runs: gh emits `status: IN_PROGRESS|QUEUED` with
|
|
22943
|
+
// no conclusion yet. `PENDING` covers status-context runs that are
|
|
22944
|
+
// still waiting on a reporter.
|
|
22945
|
+
if (!conclusion && (status === 'IN_PROGRESS' || status === 'QUEUED' || status === 'PENDING')) {
|
|
22946
|
+
return 'pending';
|
|
22947
|
+
}
|
|
22948
|
+
switch (conclusion || status) {
|
|
22949
|
+
case 'SUCCESS':
|
|
22950
|
+
return 'success';
|
|
22951
|
+
case 'FAILURE':
|
|
22952
|
+
case 'ERROR':
|
|
22953
|
+
case 'TIMED_OUT':
|
|
22954
|
+
case 'ACTION_REQUIRED':
|
|
22955
|
+
return 'failure';
|
|
22956
|
+
case 'NEUTRAL':
|
|
22957
|
+
return 'neutral';
|
|
22958
|
+
case 'SKIPPED':
|
|
22959
|
+
case 'CANCELLED':
|
|
22960
|
+
return 'skipped';
|
|
22961
|
+
default:
|
|
22962
|
+
return 'pending';
|
|
22963
|
+
}
|
|
22964
|
+
}
|
|
22965
|
+
/**
|
|
22966
|
+
* Glyph for a normalized check status. ASCII fallbacks keep the panel
|
|
22967
|
+
* usable on legacy terminals where the geometric shapes block isn't
|
|
22968
|
+
* rendered.
|
|
22969
|
+
*/
|
|
22970
|
+
function pullRequestCheckGlyph(status, options = {}) {
|
|
22971
|
+
if (options.ascii) {
|
|
22972
|
+
switch (status) {
|
|
22973
|
+
case 'success': return '+';
|
|
22974
|
+
case 'failure': return 'x';
|
|
22975
|
+
case 'pending': return '.';
|
|
22976
|
+
case 'neutral': return '-';
|
|
22977
|
+
case 'skipped': return '/';
|
|
22978
|
+
}
|
|
22979
|
+
}
|
|
22980
|
+
switch (status) {
|
|
22981
|
+
case 'success': return '✓';
|
|
22982
|
+
case 'failure': return '✗';
|
|
22983
|
+
case 'pending': return '◌';
|
|
22984
|
+
case 'neutral': return '○';
|
|
22985
|
+
case 'skipped': return '∼';
|
|
22986
|
+
}
|
|
22987
|
+
}
|
|
22988
|
+
function summarizePullRequestChecks(checks) {
|
|
22989
|
+
const summary = {
|
|
22990
|
+
total: 0, success: 0, failure: 0, pending: 0, neutral: 0, skipped: 0,
|
|
22991
|
+
};
|
|
22992
|
+
if (!checks)
|
|
22993
|
+
return summary;
|
|
22994
|
+
for (const check of checks) {
|
|
22995
|
+
summary.total += 1;
|
|
22996
|
+
summary[normalizePullRequestCheckStatus(check)] += 1;
|
|
22997
|
+
}
|
|
22998
|
+
return summary;
|
|
22999
|
+
}
|
|
23000
|
+
/**
|
|
23001
|
+
* One-line summary like `5 checks · 4 ✓ · 1 ◌` for the panel header.
|
|
23002
|
+
* Hides zero-count categories so the line stays scannable.
|
|
23003
|
+
*/
|
|
23004
|
+
function formatPullRequestChecksSummary(summary, options = {}) {
|
|
23005
|
+
if (summary.total === 0) {
|
|
23006
|
+
return 'No status checks reported';
|
|
23007
|
+
}
|
|
23008
|
+
const parts = [`${summary.total} ${summary.total === 1 ? 'check' : 'checks'}`];
|
|
23009
|
+
const push = (count, status) => {
|
|
23010
|
+
if (count > 0)
|
|
23011
|
+
parts.push(`${count} ${pullRequestCheckGlyph(status, options)}`);
|
|
23012
|
+
};
|
|
23013
|
+
push(summary.success, 'success');
|
|
23014
|
+
push(summary.failure, 'failure');
|
|
23015
|
+
push(summary.pending, 'pending');
|
|
23016
|
+
push(summary.neutral, 'neutral');
|
|
23017
|
+
push(summary.skipped, 'skipped');
|
|
23018
|
+
return parts.join(' · ');
|
|
23019
|
+
}
|
|
23020
|
+
function buildPullRequestCheckRows(checks, options = {}) {
|
|
23021
|
+
if (!checks)
|
|
23022
|
+
return [];
|
|
23023
|
+
return checks.map((check) => {
|
|
23024
|
+
const status = normalizePullRequestCheckStatus(check);
|
|
23025
|
+
return {
|
|
23026
|
+
glyph: pullRequestCheckGlyph(status, options),
|
|
23027
|
+
name: check.name,
|
|
23028
|
+
status,
|
|
23029
|
+
detail: (check.conclusion || check.status || '').toLowerCase(),
|
|
23030
|
+
};
|
|
23031
|
+
});
|
|
23032
|
+
}
|
|
23033
|
+
function summarizePullRequestReviews(reviews, reviewDecision) {
|
|
23034
|
+
const summary = {
|
|
23035
|
+
total: 0, approved: 0, changesRequested: 0, commented: 0, dismissed: 0, pending: 0,
|
|
23036
|
+
decisionLabel: reviewDecision || undefined,
|
|
23037
|
+
};
|
|
23038
|
+
if (!reviews)
|
|
23039
|
+
return summary;
|
|
23040
|
+
for (const review of reviews) {
|
|
23041
|
+
summary.total += 1;
|
|
23042
|
+
switch (review.state.toUpperCase()) {
|
|
23043
|
+
case 'APPROVED':
|
|
23044
|
+
summary.approved += 1;
|
|
23045
|
+
break;
|
|
23046
|
+
case 'CHANGES_REQUESTED':
|
|
23047
|
+
summary.changesRequested += 1;
|
|
23048
|
+
break;
|
|
23049
|
+
case 'COMMENTED':
|
|
23050
|
+
summary.commented += 1;
|
|
23051
|
+
break;
|
|
23052
|
+
case 'DISMISSED':
|
|
23053
|
+
summary.dismissed += 1;
|
|
23054
|
+
break;
|
|
23055
|
+
case 'PENDING':
|
|
23056
|
+
summary.pending += 1;
|
|
23057
|
+
break;
|
|
23058
|
+
}
|
|
23059
|
+
}
|
|
23060
|
+
return summary;
|
|
23061
|
+
}
|
|
23062
|
+
function formatPullRequestReviewsSummary(summary) {
|
|
23063
|
+
const decision = summary.decisionLabel
|
|
23064
|
+
? summary.decisionLabel.replace(/_/g, ' ').toLowerCase()
|
|
23065
|
+
: undefined;
|
|
23066
|
+
if (summary.total === 0) {
|
|
23067
|
+
return decision ? `No reviews · ${decision}` : 'No reviews submitted';
|
|
23068
|
+
}
|
|
23069
|
+
const parts = [`${summary.total} ${summary.total === 1 ? 'review' : 'reviews'}`];
|
|
23070
|
+
if (summary.approved > 0)
|
|
23071
|
+
parts.push(`${summary.approved} approved`);
|
|
23072
|
+
if (summary.changesRequested > 0)
|
|
23073
|
+
parts.push(`${summary.changesRequested} changes requested`);
|
|
23074
|
+
if (summary.commented > 0)
|
|
23075
|
+
parts.push(`${summary.commented} commented`);
|
|
23076
|
+
if (summary.pending > 0)
|
|
23077
|
+
parts.push(`${summary.pending} pending`);
|
|
23078
|
+
if (summary.dismissed > 0)
|
|
23079
|
+
parts.push(`${summary.dismissed} dismissed`);
|
|
23080
|
+
if (decision)
|
|
23081
|
+
parts.push(`decision: ${decision}`);
|
|
23082
|
+
return parts.join(' · ');
|
|
23083
|
+
}
|
|
23084
|
+
/**
|
|
23085
|
+
* One-line state badge for the header, e.g. `OPEN · draft` or `MERGED`.
|
|
23086
|
+
* Mergeable / merge-state is appended as a secondary chip when the PR
|
|
23087
|
+
* is open so the user sees `MERGEABLE` / `CONFLICTING` at a glance.
|
|
23088
|
+
*/
|
|
23089
|
+
function formatPullRequestStateLine(pr) {
|
|
23090
|
+
const parts = [pr.state];
|
|
23091
|
+
if (pr.isDraft)
|
|
23092
|
+
parts.push('draft');
|
|
23093
|
+
if (pr.state === 'OPEN' && pr.mergeable) {
|
|
23094
|
+
parts.push(pr.mergeable.toLowerCase());
|
|
23095
|
+
}
|
|
23096
|
+
if (pr.state === 'OPEN' && pr.mergeStateStatus && pr.mergeStateStatus !== 'CLEAN') {
|
|
23097
|
+
parts.push(pr.mergeStateStatus.toLowerCase());
|
|
23098
|
+
}
|
|
23099
|
+
return parts.join(' · ');
|
|
23100
|
+
}
|
|
23101
|
+
|
|
23102
|
+
/**
|
|
23103
|
+
* Hardcoded per-entity action lists surfaced inside the right-hand
|
|
23104
|
+
* inspector panel. The inspector used to repeat the repo / branch /
|
|
23105
|
+
* status content the top header and left sidebar already show; we drop
|
|
23106
|
+
* that trailer in favor of an actionable cheat-sheet so the user knows
|
|
23107
|
+
* exactly which keystrokes apply to whatever they have under the cursor.
|
|
23108
|
+
*
|
|
23109
|
+
* Why hardcoded instead of introspecting `LOG_INK_KEY_BINDINGS`:
|
|
23110
|
+
* - Most per-entity actions live in `inkInput.ts` as direct keystroke
|
|
23111
|
+
* handlers (e.g. `c` cherry-pick, `R` revert) rather than as
|
|
23112
|
+
* globally-registered bindings, so the registry would be a partial
|
|
23113
|
+
* view at best.
|
|
23114
|
+
* - The bindings registry's `contexts` model (normal / search / focus
|
|
23115
|
+
* name) does not cleanly map to inspector entity types like "branch"
|
|
23116
|
+
* or "tag". Filtering it would mean replicating the same per-view
|
|
23117
|
+
* scoping logic the input dispatcher already encodes.
|
|
23118
|
+
* - New per-entity actions are added infrequently — the maintenance
|
|
23119
|
+
* cost of mirroring them here is low and keeps this file the single
|
|
23120
|
+
* source of truth for "what shows in the inspector".
|
|
23121
|
+
*
|
|
23122
|
+
* If you wire up a new per-entity keystroke in `inkInput.ts` — for
|
|
23123
|
+
* example a "create branch from this commit" or "create tag from this
|
|
23124
|
+
* commit" action — add the matching row to the relevant array below so
|
|
23125
|
+
* it shows up in the inspector automatically.
|
|
23126
|
+
*/
|
|
23127
|
+
const HISTORY_COMMIT_ACTIONS = [
|
|
23128
|
+
{ key: 'enter', label: 'Open diff' },
|
|
23129
|
+
{ key: 'c', label: 'Cherry-pick' },
|
|
23130
|
+
{ key: 'R', label: 'Revert', destructive: true },
|
|
23131
|
+
{ key: 'Z', label: 'Reset to commit', destructive: true },
|
|
23132
|
+
{ key: 'i', label: 'Interactive rebase', destructive: true },
|
|
23133
|
+
{ key: 'y', label: 'Yank hash' },
|
|
23134
|
+
{ key: 'Y', label: 'Yank short hash' },
|
|
23135
|
+
{ key: 'O', label: 'Open in browser' },
|
|
23136
|
+
];
|
|
23137
|
+
const BRANCH_ACTIONS = [
|
|
23138
|
+
{ key: 'enter', label: 'Checkout' },
|
|
23139
|
+
{ key: '+', label: 'New branch' },
|
|
23140
|
+
{ key: 'R', label: 'Rename' },
|
|
23141
|
+
{ key: 'u', label: 'Set upstream' },
|
|
23142
|
+
{ key: 'D', label: 'Delete', destructive: true },
|
|
23143
|
+
{ key: 'P', label: 'Push current' },
|
|
23144
|
+
{ key: 'F', label: 'Fetch all' },
|
|
23145
|
+
{ key: 'y', label: 'Yank name' },
|
|
23146
|
+
];
|
|
23147
|
+
const TAG_ACTIONS = [
|
|
23148
|
+
{ key: '+', label: 'New tag' },
|
|
23149
|
+
{ key: 'P', label: 'Push tag' },
|
|
23150
|
+
{ key: 'T', label: 'Delete', destructive: true },
|
|
23151
|
+
{ key: 'R', label: 'Delete remote', destructive: true },
|
|
23152
|
+
{ key: 'y', label: 'Yank name' },
|
|
23153
|
+
];
|
|
23154
|
+
const STASH_ACTIONS = [
|
|
23155
|
+
{ key: 'enter', label: 'Open diff' },
|
|
23156
|
+
{ key: 'a', label: 'Apply' },
|
|
23157
|
+
{ key: 'p', label: 'Pop' },
|
|
23158
|
+
{ key: 'X', label: 'Drop', destructive: true },
|
|
23159
|
+
{ key: 'y', label: 'Yank ref' },
|
|
23160
|
+
];
|
|
23161
|
+
const WORKTREE_ACTIONS = [
|
|
23162
|
+
{ key: 'W', label: 'Remove', destructive: true },
|
|
23163
|
+
{ key: 'y', label: 'Yank path' },
|
|
23164
|
+
];
|
|
23165
|
+
function getInspectorActions(context) {
|
|
23166
|
+
switch (context) {
|
|
23167
|
+
case 'history-commit':
|
|
23168
|
+
return HISTORY_COMMIT_ACTIONS;
|
|
23169
|
+
case 'branch':
|
|
23170
|
+
return BRANCH_ACTIONS;
|
|
23171
|
+
case 'tag':
|
|
23172
|
+
return TAG_ACTIONS;
|
|
23173
|
+
case 'stash':
|
|
23174
|
+
return STASH_ACTIONS;
|
|
23175
|
+
case 'worktree':
|
|
23176
|
+
return WORKTREE_ACTIONS;
|
|
23177
|
+
default: {
|
|
23178
|
+
const exhaustive = context;
|
|
23179
|
+
throw new Error(`Unhandled inspector action context: ${String(exhaustive)}`);
|
|
23180
|
+
}
|
|
23181
|
+
}
|
|
23182
|
+
}
|
|
23183
|
+
|
|
21363
23184
|
function sectionLines(title, diff) {
|
|
21364
23185
|
const lines = diff.split('\n').map((line) => line.trimEnd());
|
|
21365
23186
|
return [
|
|
@@ -21575,6 +23396,89 @@ function diffLineProps(line, theme) {
|
|
|
21575
23396
|
}
|
|
21576
23397
|
return {};
|
|
21577
23398
|
}
|
|
23399
|
+
/**
|
|
23400
|
+
* Minimum terminal width below which the split diff falls back to
|
|
23401
|
+
* unified rendering (#785). Each column needs ~50 columns for code to
|
|
23402
|
+
* read comfortably plus border + padding overhead, so anything narrower
|
|
23403
|
+
* than ~120 columns gets the unified view regardless of the user's
|
|
23404
|
+
* preference. The preference is preserved — switching back to a wide
|
|
23405
|
+
* terminal restores split mode automatically.
|
|
23406
|
+
*/
|
|
23407
|
+
const MIN_SPLIT_DIFF_WIDTH = 120;
|
|
23408
|
+
function isSplitDiffViable(state, width) {
|
|
23409
|
+
return state.diffViewMode === 'split' && width >= MIN_SPLIT_DIFF_WIDTH;
|
|
23410
|
+
}
|
|
23411
|
+
/**
|
|
23412
|
+
* Style props for one side of a split-diff row, derived from the row's
|
|
23413
|
+
* `kind` rather than the leading character (because the helper has
|
|
23414
|
+
* already stripped the leading +/-/space). Keeps the colors aligned with
|
|
23415
|
+
* `diffLineProps`.
|
|
23416
|
+
*/
|
|
23417
|
+
function splitDiffSideProps(kind, theme) {
|
|
23418
|
+
if (kind === 'header') {
|
|
23419
|
+
if (theme.noColor)
|
|
23420
|
+
return { dimColor: true };
|
|
23421
|
+
return { color: theme.colors.accent };
|
|
23422
|
+
}
|
|
23423
|
+
if (kind === 'empty') {
|
|
23424
|
+
return { dimColor: true };
|
|
23425
|
+
}
|
|
23426
|
+
if (theme.noColor) {
|
|
23427
|
+
return { dimColor: kind === 'context' };
|
|
23428
|
+
}
|
|
23429
|
+
if (kind === 'add')
|
|
23430
|
+
return { color: theme.colors.gitAdded };
|
|
23431
|
+
if (kind === 'remove')
|
|
23432
|
+
return { color: theme.colors.gitDeleted };
|
|
23433
|
+
return {};
|
|
23434
|
+
}
|
|
23435
|
+
/**
|
|
23436
|
+
* Format one column of a split-diff row: an optional 4-digit line
|
|
23437
|
+
* number prefix + the line text, padded/truncated to the column width.
|
|
23438
|
+
* Empty rows render a faint `·` placeholder so the alignment gap is
|
|
23439
|
+
* visible at a glance.
|
|
23440
|
+
*/
|
|
23441
|
+
function formatSplitDiffCell(side, columnWidth) {
|
|
23442
|
+
if (side.kind === 'empty') {
|
|
23443
|
+
const placeholder = ' · ';
|
|
23444
|
+
return placeholder.padEnd(columnWidth);
|
|
23445
|
+
}
|
|
23446
|
+
if (side.kind === 'header') {
|
|
23447
|
+
return truncate$1(side.text, columnWidth).padEnd(columnWidth);
|
|
23448
|
+
}
|
|
23449
|
+
const lineNo = side.lineNumber !== undefined ? String(side.lineNumber).padStart(4) : ' ';
|
|
23450
|
+
// Strip the trailing newline that some diffs include. Keeps column
|
|
23451
|
+
// widths predictable.
|
|
23452
|
+
const text = side.text.replace(/\n$/, '');
|
|
23453
|
+
// 4 digits + 1 space gutter = 5 chars; reserve that off the column
|
|
23454
|
+
// before truncating the text.
|
|
23455
|
+
const textRoom = Math.max(1, columnWidth - 5);
|
|
23456
|
+
return `${lineNo} ${truncate$1(text, textRoom)}`.padEnd(columnWidth);
|
|
23457
|
+
}
|
|
23458
|
+
/**
|
|
23459
|
+
* Render the split-diff body as a list of two-column rows. The caller
|
|
23460
|
+
* is responsible for slicing the unified-line array to the visible
|
|
23461
|
+
* window — the helper just transforms that slice into Ink nodes.
|
|
23462
|
+
*/
|
|
23463
|
+
function renderSplitDiffBody(h, components, unifiedSlice, startOffset, width, theme, keyPrefix) {
|
|
23464
|
+
const { Box, Text } = components;
|
|
23465
|
+
const rows = buildSplitDiffRows(unifiedSlice);
|
|
23466
|
+
// Reserve 3 columns of gutter (1 left padding from the Box + 1 column
|
|
23467
|
+
// separator + 1 right padding) so neither side touches the border.
|
|
23468
|
+
const usable = Math.max(20, width - 4);
|
|
23469
|
+
const gutter = 1;
|
|
23470
|
+
const half = Math.max(10, Math.floor((usable - gutter) / 2));
|
|
23471
|
+
return rows.map((row, index) => {
|
|
23472
|
+
const leftProps = splitDiffSideProps(row.left.kind, theme);
|
|
23473
|
+
const rightProps = splitDiffSideProps(row.right.kind, theme);
|
|
23474
|
+
const leftText = formatSplitDiffCell(row.left, half);
|
|
23475
|
+
const rightText = formatSplitDiffCell(row.right, half);
|
|
23476
|
+
return h(Box, {
|
|
23477
|
+
key: `${keyPrefix}-${startOffset + index}`,
|
|
23478
|
+
flexDirection: 'row',
|
|
23479
|
+
}, 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)));
|
|
23480
|
+
});
|
|
23481
|
+
}
|
|
21578
23482
|
/**
|
|
21579
23483
|
* Pick a theme color for a single name-status code (`A`, `M`, `D`,
|
|
21580
23484
|
* `R100`, etc.) so the inspector and commit-diff file list render with
|
|
@@ -21628,68 +23532,6 @@ function sidebarTabLabel(tab) {
|
|
|
21628
23532
|
return tab;
|
|
21629
23533
|
}
|
|
21630
23534
|
}
|
|
21631
|
-
function sidebarLines(context, contextStatus, tab, width, state, theme) {
|
|
21632
|
-
if (tab === 'status') {
|
|
21633
|
-
const worktree = context.worktree;
|
|
21634
|
-
if (isLogInkContextKeyLoading(contextStatus, 'worktree')) {
|
|
21635
|
-
return ['Loading status...'];
|
|
21636
|
-
}
|
|
21637
|
-
if (!worktree) {
|
|
21638
|
-
return ['Status unavailable'];
|
|
21639
|
-
}
|
|
21640
|
-
return [
|
|
21641
|
-
`${worktree.stagedCount} staged`,
|
|
21642
|
-
`${worktree.unstagedCount} unstaged`,
|
|
21643
|
-
`${worktree.untrackedCount} untracked`,
|
|
21644
|
-
'',
|
|
21645
|
-
...worktree.files.slice(0, 12).map((file) => `${file.indexStatus}${file.worktreeStatus} ${truncate$1(file.path, width - 3)}`),
|
|
21646
|
-
];
|
|
21647
|
-
}
|
|
21648
|
-
if (tab === 'branches') {
|
|
21649
|
-
const branches = context.branches;
|
|
21650
|
-
if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
|
|
21651
|
-
return ['Loading branches...'];
|
|
21652
|
-
}
|
|
21653
|
-
if (!branches) {
|
|
21654
|
-
return ['Branches unavailable'];
|
|
21655
|
-
}
|
|
21656
|
-
const sortedBranches = sortBranches(branches.localBranches, state.branchSort);
|
|
21657
|
-
return [
|
|
21658
|
-
`Current: ${branches.currentBranch || '<detached>'}`,
|
|
21659
|
-
branches.dirty ? 'Worktree: dirty' : 'Worktree: clean',
|
|
21660
|
-
'',
|
|
21661
|
-
...sortedBranches.slice(0, 8).map((branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${truncate$1(branch.shortName, width - 4)}`),
|
|
21662
|
-
...sortedBranches.slice(0, 4).map((branch) => ` ${truncate$1(formatBranchDivergence(branch, { ascii: theme.ascii }), width - 2)}`),
|
|
21663
|
-
];
|
|
21664
|
-
}
|
|
21665
|
-
if (tab === 'tags') {
|
|
21666
|
-
if (isLogInkContextKeyLoading(contextStatus, 'tags')) {
|
|
21667
|
-
return ['Loading tags...'];
|
|
21668
|
-
}
|
|
21669
|
-
const sortedTags = sortTags(context.tags?.tags || [], state.tagSort);
|
|
21670
|
-
return sortedTags.length
|
|
21671
|
-
? sortedTags.slice(0, 12).map((tag) => `${truncate$1(tag.name, 16)} ${truncate$1(tag.subject, Math.max(8, width - 18))}`)
|
|
21672
|
-
: ['No tags found'];
|
|
21673
|
-
}
|
|
21674
|
-
if (tab === 'stashes') {
|
|
21675
|
-
if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
|
|
21676
|
-
return ['Loading stashes...'];
|
|
21677
|
-
}
|
|
21678
|
-
return context.stashes?.stashes.length
|
|
21679
|
-
? context.stashes.stashes.slice(0, 12).map((stash) => `${stash.ref} ${truncate$1(stash.message, Math.max(8, width - stash.ref.length - 1))}`)
|
|
21680
|
-
: ['No stashes found'];
|
|
21681
|
-
}
|
|
21682
|
-
if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
|
|
21683
|
-
return ['Loading worktrees...'];
|
|
21684
|
-
}
|
|
21685
|
-
return context.worktreeList?.worktrees.length
|
|
21686
|
-
? context.worktreeList.worktrees.slice(0, 12).map((worktree) => {
|
|
21687
|
-
const marker = worktree.current ? '*' : ' ';
|
|
21688
|
-
const state = worktree.dirty ? 'dirty' : 'clean';
|
|
21689
|
-
return `${marker} ${truncate$1(worktree.branch || worktree.path, Math.max(8, width - 8))} ${state}`;
|
|
21690
|
-
})
|
|
21691
|
-
: ['No linked worktrees'];
|
|
21692
|
-
}
|
|
21693
23535
|
async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
21694
23536
|
const input = streams.input || process.stdin;
|
|
21695
23537
|
const output = streams.output || process.stdout;
|
|
@@ -22031,6 +23873,13 @@ function LogInkApp(deps) {
|
|
|
22031
23873
|
if (saved && saved !== state.userSidebarTab) {
|
|
22032
23874
|
dispatch({ type: 'restoreSidebarTab', value: saved });
|
|
22033
23875
|
}
|
|
23876
|
+
// Diff view mode persistence (#785). Same per-repo cache pattern
|
|
23877
|
+
// as the sidebar tab — restore the user's last preference if
|
|
23878
|
+
// they had one. New repos / fresh installs default to unified.
|
|
23879
|
+
const savedDiffMode = getSavedDiffViewMode(repoRoot);
|
|
23880
|
+
if (savedDiffMode && savedDiffMode !== state.diffViewMode) {
|
|
23881
|
+
dispatch({ type: 'setDiffViewMode', value: savedDiffMode });
|
|
23882
|
+
}
|
|
22034
23883
|
}
|
|
22035
23884
|
catch {
|
|
22036
23885
|
// Not in a worktree, or revparse failed; nothing to restore.
|
|
@@ -22044,6 +23893,12 @@ function LogInkApp(deps) {
|
|
|
22044
23893
|
return;
|
|
22045
23894
|
saveSidebarTab(repoRoot, state.userSidebarTab);
|
|
22046
23895
|
}, [state.userSidebarTab]);
|
|
23896
|
+
React.useEffect(() => {
|
|
23897
|
+
const repoRoot = repoRootRef.current;
|
|
23898
|
+
if (!repoRoot)
|
|
23899
|
+
return;
|
|
23900
|
+
saveDiffViewMode(repoRoot, state.diffViewMode);
|
|
23901
|
+
}, [state.diffViewMode]);
|
|
22047
23902
|
// P-stash-explorer: load `git stash show -p <ref>` once the diff view
|
|
22048
23903
|
// becomes active with diffSource='stash'. Best-effort — empty stashes
|
|
22049
23904
|
// or read errors fall through to a "no diff" hint at the render site.
|
|
@@ -22125,6 +23980,80 @@ function LogInkApp(deps) {
|
|
|
22125
23980
|
active = false;
|
|
22126
23981
|
};
|
|
22127
23982
|
}, [git, selected?.hash]);
|
|
23983
|
+
// #806 follow-up — auto-jump the history view to whichever branch /
|
|
23984
|
+
// tag the user is currently cursoring in the sidebar (or the
|
|
23985
|
+
// dedicated branches / tags view). Debounced so cursor-scrolling
|
|
23986
|
+
// through a long branch list doesn't dispatch on every keystroke.
|
|
23987
|
+
// No-op when the cursored ref's tip isn't in the loaded commit
|
|
23988
|
+
// window (under compact mode the cursored branch's tip may not be
|
|
23989
|
+
// fetched yet); a status hint surfaces in that case so the user
|
|
23990
|
+
// knows to toggle full graph or load older commits.
|
|
23991
|
+
React.useEffect(() => {
|
|
23992
|
+
const onBranchTab = state.activeView === 'branches' ||
|
|
23993
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
23994
|
+
const onTagTab = state.activeView === 'tags' ||
|
|
23995
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
23996
|
+
if (!onBranchTab && !onTagTab)
|
|
23997
|
+
return;
|
|
23998
|
+
let cancelled = false;
|
|
23999
|
+
const timer = setTimeout(() => {
|
|
24000
|
+
if (cancelled)
|
|
24001
|
+
return;
|
|
24002
|
+
let targetHash;
|
|
24003
|
+
let targetLabel;
|
|
24004
|
+
if (onBranchTab) {
|
|
24005
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
24006
|
+
const visible = state.filter
|
|
24007
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
|
|
24008
|
+
: all;
|
|
24009
|
+
const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
|
|
24010
|
+
if (branch) {
|
|
24011
|
+
targetHash = branch.hash;
|
|
24012
|
+
targetLabel = `branch ${branch.shortName}`;
|
|
24013
|
+
}
|
|
24014
|
+
}
|
|
24015
|
+
else if (onTagTab) {
|
|
24016
|
+
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24017
|
+
const visible = state.filter
|
|
24018
|
+
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
|
|
24019
|
+
: all;
|
|
24020
|
+
const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
|
|
24021
|
+
if (tag) {
|
|
24022
|
+
targetHash = tag.hash;
|
|
24023
|
+
targetLabel = `tag ${tag.name}`;
|
|
24024
|
+
}
|
|
24025
|
+
}
|
|
24026
|
+
if (!targetHash)
|
|
24027
|
+
return;
|
|
24028
|
+
const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
|
|
24029
|
+
if (loaded) {
|
|
24030
|
+
dispatch({ type: 'selectCommitByHash', hash: targetHash });
|
|
24031
|
+
// Confirmation status message so the user gets feedback even
|
|
24032
|
+
// when the dedicated branches / tags view is occupying the
|
|
24033
|
+
// main panel and the history cursor moves invisibly behind it.
|
|
24034
|
+
dispatch({
|
|
24035
|
+
type: 'setStatus',
|
|
24036
|
+
value: `Synced history to ${targetLabel} tip`,
|
|
24037
|
+
});
|
|
24038
|
+
}
|
|
24039
|
+
else {
|
|
24040
|
+
dispatch({
|
|
24041
|
+
type: 'setStatus',
|
|
24042
|
+
value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
|
|
24043
|
+
});
|
|
24044
|
+
}
|
|
24045
|
+
}, 150);
|
|
24046
|
+
return () => {
|
|
24047
|
+
cancelled = true;
|
|
24048
|
+
clearTimeout(timer);
|
|
24049
|
+
};
|
|
24050
|
+
}, [
|
|
24051
|
+
dispatch, context.branches, context.tags,
|
|
24052
|
+
state.activeView, state.focus, state.sidebarTab,
|
|
24053
|
+
state.selectedBranchIndex, state.selectedTagIndex,
|
|
24054
|
+
state.branchSort, state.tagSort, state.filter,
|
|
24055
|
+
state.filteredCommits,
|
|
24056
|
+
]);
|
|
22128
24057
|
React.useEffect(() => {
|
|
22129
24058
|
let active = true;
|
|
22130
24059
|
async function loadWorktreeDiff() {
|
|
@@ -22336,6 +24265,27 @@ function LogInkApp(deps) {
|
|
|
22336
24265
|
// disappears. Called from the y-confirm path for delete-branch / delete-
|
|
22337
24266
|
// tag / drop-stash / remove-worktree / abort-operation.
|
|
22338
24267
|
const runWorkflowAction = React.useCallback(async (id, payload) => {
|
|
24268
|
+
// Hunk-apply payload format: `<target>\n<patchText>` — the input
|
|
24269
|
+
// handler synthesizes both pieces (target from the keystroke,
|
|
24270
|
+
// patch text from extractDiffHunk against the live diff lines)
|
|
24271
|
+
// and packs them into the single `payload` field. Splitting on
|
|
24272
|
+
// the first newline keeps the patch body intact.
|
|
24273
|
+
const runApplyHunk = (expectedTarget, raw) => {
|
|
24274
|
+
if (!raw) {
|
|
24275
|
+
return Promise.resolve({ ok: false, message: 'No hunk under cursor to apply.' });
|
|
24276
|
+
}
|
|
24277
|
+
const newlineIndex = raw.indexOf('\n');
|
|
24278
|
+
if (newlineIndex < 0) {
|
|
24279
|
+
return Promise.resolve({ ok: false, message: 'Malformed hunk-apply payload.' });
|
|
24280
|
+
}
|
|
24281
|
+
const target = raw.slice(0, newlineIndex) === 'index' ? 'index' : 'worktree';
|
|
24282
|
+
const patchText = raw.slice(newlineIndex + 1);
|
|
24283
|
+
// The input handler is the source of truth for target — but if a
|
|
24284
|
+
// palette-injected payload mismatches the workflow id, prefer
|
|
24285
|
+
// the workflow id so the user sees the action they asked for.
|
|
24286
|
+
const effectiveTarget = expectedTarget || target;
|
|
24287
|
+
return applyHunkPatch(git, patchText, { target: effectiveTarget });
|
|
24288
|
+
};
|
|
22339
24289
|
const handlers = {
|
|
22340
24290
|
'create-branch': async () => {
|
|
22341
24291
|
const name = payload?.trim();
|
|
@@ -22441,6 +24391,68 @@ function LogInkApp(deps) {
|
|
|
22441
24391
|
message: commit.message,
|
|
22442
24392
|
});
|
|
22443
24393
|
},
|
|
24394
|
+
'revert-commit': async () => {
|
|
24395
|
+
const commit = getSelectedInkCommit(state);
|
|
24396
|
+
if (!commit)
|
|
24397
|
+
return { ok: false, message: 'No commit selected' };
|
|
24398
|
+
return revertCommit(git, {
|
|
24399
|
+
hash: commit.hash,
|
|
24400
|
+
shortHash: commit.shortHash,
|
|
24401
|
+
message: commit.message,
|
|
24402
|
+
});
|
|
24403
|
+
},
|
|
24404
|
+
'reset-to-commit': async () => {
|
|
24405
|
+
const commit = getSelectedInkCommit(state);
|
|
24406
|
+
if (!commit)
|
|
24407
|
+
return { ok: false, message: 'No commit selected' };
|
|
24408
|
+
// Mode arrives via the action's `payload` field — the input
|
|
24409
|
+
// handler runs the reset-mode prompt (kind: 'reset-mode') and
|
|
24410
|
+
// routes the typed value here. Default to `mixed` (git's own
|
|
24411
|
+
// default) when the user submitted an empty value.
|
|
24412
|
+
const raw = payload?.trim().toLowerCase() || 'mixed';
|
|
24413
|
+
if (!isResetMode(raw)) {
|
|
24414
|
+
return { ok: false, message: `Unknown reset mode: ${raw}. Use soft, mixed, or hard.` };
|
|
24415
|
+
}
|
|
24416
|
+
return resetToCommit(git, {
|
|
24417
|
+
hash: commit.hash,
|
|
24418
|
+
shortHash: commit.shortHash,
|
|
24419
|
+
message: commit.message,
|
|
24420
|
+
}, raw);
|
|
24421
|
+
},
|
|
24422
|
+
'interactive-rebase': async () => {
|
|
24423
|
+
const commit = getSelectedInkCommit(state);
|
|
24424
|
+
if (!commit)
|
|
24425
|
+
return { ok: false, message: 'No commit selected' };
|
|
24426
|
+
return startInteractiveRebase(git, {
|
|
24427
|
+
hash: commit.hash,
|
|
24428
|
+
shortHash: commit.shortHash,
|
|
24429
|
+
message: commit.message,
|
|
24430
|
+
});
|
|
24431
|
+
},
|
|
24432
|
+
'create-branch-here': async () => {
|
|
24433
|
+
const commit = getSelectedInkCommit(state);
|
|
24434
|
+
const name = payload?.trim();
|
|
24435
|
+
if (!commit)
|
|
24436
|
+
return { ok: false, message: 'No commit selected' };
|
|
24437
|
+
if (!name)
|
|
24438
|
+
return { ok: false, message: 'Branch name required' };
|
|
24439
|
+
return createBranchFromCommit(git, name, {
|
|
24440
|
+
hash: commit.hash,
|
|
24441
|
+
shortHash: commit.shortHash,
|
|
24442
|
+
});
|
|
24443
|
+
},
|
|
24444
|
+
'create-tag-here': async () => {
|
|
24445
|
+
const commit = getSelectedInkCommit(state);
|
|
24446
|
+
const name = payload?.trim();
|
|
24447
|
+
if (!commit)
|
|
24448
|
+
return { ok: false, message: 'No commit selected' };
|
|
24449
|
+
if (!name)
|
|
24450
|
+
return { ok: false, message: 'Tag name required' };
|
|
24451
|
+
return createTagAtCommit(git, name, {
|
|
24452
|
+
hash: commit.hash,
|
|
24453
|
+
shortHash: commit.shortHash,
|
|
24454
|
+
});
|
|
24455
|
+
},
|
|
22444
24456
|
'checkout-file-from-commit': async () => {
|
|
22445
24457
|
// payload is "<sha> <path>" so we pass both through a single
|
|
22446
24458
|
// string field on the action.
|
|
@@ -22456,6 +24468,8 @@ function LogInkApp(deps) {
|
|
|
22456
24468
|
return { ok: false, message: 'No commit file under cursor' };
|
|
22457
24469
|
return checkoutFileFromCommit(git, sha, path);
|
|
22458
24470
|
},
|
|
24471
|
+
'apply-hunk-worktree': async () => runApplyHunk('worktree', payload),
|
|
24472
|
+
'apply-hunk-index': async () => runApplyHunk('index', payload),
|
|
22459
24473
|
'remove-worktree': async () => {
|
|
22460
24474
|
const all = context.worktreeList?.worktrees || [];
|
|
22461
24475
|
// Resolve the target from the visible (filtered) list so a
|
|
@@ -22491,6 +24505,17 @@ function LogInkApp(deps) {
|
|
|
22491
24505
|
if (!repo || repo.provider !== 'github' || !repo.owner || !repo.name) {
|
|
22492
24506
|
return { ok: false, message: 'No GitHub remote detected for this repo' };
|
|
22493
24507
|
}
|
|
24508
|
+
// History view: prefer the cursored commit's URL so `O` from
|
|
24509
|
+
// a commit context lands the user on the commit page rather
|
|
24510
|
+
// than the repo root or the current PR. The user-visible
|
|
24511
|
+
// intent of `O` is "open whatever I'm cursoring on the web";
|
|
24512
|
+
// a commit is what the cursor is on in the history view.
|
|
24513
|
+
if (state.activeView === 'history') {
|
|
24514
|
+
const commit = getSelectedInkCommit(state);
|
|
24515
|
+
if (commit) {
|
|
24516
|
+
return openProviderUrl(repo, { type: 'commit', commit: commit.hash });
|
|
24517
|
+
}
|
|
24518
|
+
}
|
|
22494
24519
|
const pr = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
|
|
22495
24520
|
if (pr) {
|
|
22496
24521
|
return openProviderUrl(repo, { type: 'pull-request', number: pr.number });
|
|
@@ -22544,6 +24569,32 @@ function LogInkApp(deps) {
|
|
|
22544
24569
|
return { ok: false, message: 'Stash message required' };
|
|
22545
24570
|
return createStash(git, message);
|
|
22546
24571
|
},
|
|
24572
|
+
// #783 — full PR action panel handlers. Each wraps the matching
|
|
24573
|
+
// pullRequestActions verb. Strategy / body arrives via `payload`
|
|
24574
|
+
// — input prompts validate before they reach here, but the
|
|
24575
|
+
// strategy guard stays as a defensive belt-and-suspenders since
|
|
24576
|
+
// a future palette path could call us with a raw value.
|
|
24577
|
+
'merge-pr': async () => {
|
|
24578
|
+
const strategy = (payload || 'merge').toLowerCase();
|
|
24579
|
+
if (!isPullRequestMergeStrategy(strategy)) {
|
|
24580
|
+
return { ok: false, message: `Unknown merge strategy: ${strategy}. Use merge, squash, or rebase.` };
|
|
24581
|
+
}
|
|
24582
|
+
return mergePullRequest(strategy);
|
|
24583
|
+
},
|
|
24584
|
+
'close-pr': async () => closePullRequest(),
|
|
24585
|
+
'approve-pr': async () => approvePullRequest(),
|
|
24586
|
+
'request-changes-pr': async () => {
|
|
24587
|
+
const body = payload?.trim();
|
|
24588
|
+
if (!body)
|
|
24589
|
+
return { ok: false, message: 'Review body required for change-request' };
|
|
24590
|
+
return requestChangesPullRequest(body);
|
|
24591
|
+
},
|
|
24592
|
+
'comment-pr': async () => {
|
|
24593
|
+
const body = payload?.trim();
|
|
24594
|
+
if (!body)
|
|
24595
|
+
return { ok: false, message: 'Comment body required' };
|
|
24596
|
+
return commentPullRequest(body);
|
|
24597
|
+
},
|
|
22547
24598
|
};
|
|
22548
24599
|
const handler = handlers[id];
|
|
22549
24600
|
if (!handler) {
|
|
@@ -22552,9 +24603,21 @@ function LogInkApp(deps) {
|
|
|
22552
24603
|
}
|
|
22553
24604
|
const result = await handler();
|
|
22554
24605
|
dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
|
|
22555
|
-
//
|
|
22556
|
-
//
|
|
22557
|
-
|
|
24606
|
+
// Checkout-branch is the one workflow where we want a *visible*
|
|
24607
|
+
// refresh so the user sees the branches sidebar repaint with the
|
|
24608
|
+
// new current branch (per #806 follow-up). Snap the cursor to
|
|
24609
|
+
// position 0 first so when the refresh completes and the new
|
|
24610
|
+
// current branch lands at the top (per #809's pin-current rule),
|
|
24611
|
+
// the cursor is already there waiting.
|
|
24612
|
+
if (id === 'checkout-branch' && result?.ok) {
|
|
24613
|
+
dispatch({ type: 'resetBranchSelection' });
|
|
24614
|
+
await refreshContext();
|
|
24615
|
+
}
|
|
24616
|
+
else {
|
|
24617
|
+
// Silent refresh so the deleted item disappears from the list
|
|
24618
|
+
// without flickering the surfaces through a 'loading' phase.
|
|
24619
|
+
await refreshContext({ silent: true });
|
|
24620
|
+
}
|
|
22558
24621
|
}, [context, dispatch, git, refreshContext, state.branchSort, state.filter, state.selectedBranchIndex,
|
|
22559
24622
|
state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
|
|
22560
24623
|
state.tagSort]);
|
|
@@ -22834,6 +24897,51 @@ function LogInkApp(deps) {
|
|
|
22834
24897
|
});
|
|
22835
24898
|
})();
|
|
22836
24899
|
}, [dispatch, git, logArgv, state.historyFetchArgs]);
|
|
24900
|
+
// Graph mode toggle (`g` key, #791 follow-up). The header label flips
|
|
24901
|
+
// between "compact graph" and "full graph", but unless we re-fetch with
|
|
24902
|
+
// the right `view`, the underlying rows still come from the user's
|
|
24903
|
+
// initial argv (default `--first-parent --no-merges`) and the renderer
|
|
24904
|
+
// has no topology to draw — defeating the per-lane / junction work.
|
|
24905
|
+
// Mirrors the historyFetchArgs effect: skip first run, request-id ref
|
|
24906
|
+
// for stale-completion guard, swap rows in place via replaceRows.
|
|
24907
|
+
const toggleGraphEffectInitialized = React.useRef(false);
|
|
24908
|
+
const toggleGraphRequestRef = React.useRef(0);
|
|
24909
|
+
React.useEffect(() => {
|
|
24910
|
+
if (!logArgv)
|
|
24911
|
+
return;
|
|
24912
|
+
if (!toggleGraphEffectInitialized.current) {
|
|
24913
|
+
toggleGraphEffectInitialized.current = true;
|
|
24914
|
+
return;
|
|
24915
|
+
}
|
|
24916
|
+
const requestId = toggleGraphRequestRef.current + 1;
|
|
24917
|
+
toggleGraphRequestRef.current = requestId;
|
|
24918
|
+
const merged = buildToggleGraphArgs(logArgv, state.fullGraph);
|
|
24919
|
+
dispatch({
|
|
24920
|
+
type: 'setStatus',
|
|
24921
|
+
value: state.fullGraph
|
|
24922
|
+
? 'Loading full topology…'
|
|
24923
|
+
: 'Loading compact history…',
|
|
24924
|
+
});
|
|
24925
|
+
void (async () => {
|
|
24926
|
+
const nextRows = await safe(getLogRows(git, merged, { limit: LOG_INTERACTIVE_DEFAULT_LIMIT }));
|
|
24927
|
+
if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
|
|
24928
|
+
return;
|
|
24929
|
+
}
|
|
24930
|
+
if (!nextRows) {
|
|
24931
|
+
dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows' });
|
|
24932
|
+
return;
|
|
24933
|
+
}
|
|
24934
|
+
dispatch({ type: 'replaceRows', rows: nextRows });
|
|
24935
|
+
const matched = getCommitRows(nextRows).length;
|
|
24936
|
+
setHasMoreCommits(matched >= LOG_INTERACTIVE_DEFAULT_LIMIT);
|
|
24937
|
+
dispatch({
|
|
24938
|
+
type: 'setStatus',
|
|
24939
|
+
value: state.fullGraph
|
|
24940
|
+
? `Showing ${matched} commits across all branches`
|
|
24941
|
+
: `Showing ${matched} commits (compact)`,
|
|
24942
|
+
});
|
|
24943
|
+
})();
|
|
24944
|
+
}, [dispatch, git, logArgv, state.fullGraph]);
|
|
22837
24945
|
const commitDiffHunkOffsets = React.useMemo(() => (filePreview?.hunks
|
|
22838
24946
|
.map((line, index) => (line.startsWith('@@') ? index : -1))
|
|
22839
24947
|
.filter((index) => index >= 0)), [filePreview]);
|
|
@@ -22928,6 +25036,17 @@ function LogInkApp(deps) {
|
|
|
22928
25036
|
? selected?.hash
|
|
22929
25037
|
: undefined,
|
|
22930
25038
|
worktreeDirty,
|
|
25039
|
+
// H / gH need the actual diff text (not just hunk offsets) to
|
|
25040
|
+
// slice the cursored hunk into a `git apply` patch. Stash uses
|
|
25041
|
+
// the full `git stash show -p` output; commit-diff uses the
|
|
25042
|
+
// per-file `filePreview.hunks` array. Either way, extractDiffHunk
|
|
25043
|
+
// walks `@@` headers and synthesizes a fresh diff --git / --- /
|
|
25044
|
+
// +++ header set using the path the caller already resolved.
|
|
25045
|
+
diffLinesForHunkApply: state.diffSource === 'stash'
|
|
25046
|
+
? stashDiffLines
|
|
25047
|
+
: state.diffSource === 'commit'
|
|
25048
|
+
? filePreview?.hunks
|
|
25049
|
+
: undefined,
|
|
22931
25050
|
}).forEach((event) => {
|
|
22932
25051
|
if (event.type === 'exit') {
|
|
22933
25052
|
exit();
|
|
@@ -22980,6 +25099,7 @@ function LogInkApp(deps) {
|
|
|
22980
25099
|
columns: windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS,
|
|
22981
25100
|
rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
|
|
22982
25101
|
sidebarFocused: state.focus === 'sidebar',
|
|
25102
|
+
inspectorFocused: state.focus === 'detail',
|
|
22983
25103
|
});
|
|
22984
25104
|
if (layout.tooSmall) {
|
|
22985
25105
|
return h(Box, {
|
|
@@ -22994,7 +25114,7 @@ function LogInkApp(deps) {
|
|
|
22994
25114
|
if (showOnboarding) {
|
|
22995
25115
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
22996
25116
|
}
|
|
22997
|
-
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));
|
|
25117
|
+
return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
|
|
22998
25118
|
}
|
|
22999
25119
|
function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
|
|
23000
25120
|
const { Box, Text } = components;
|
|
@@ -23047,7 +25167,7 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
|
|
|
23047
25167
|
? h(Text, { bold: true, color: theme.colors.accent }, `${prLabel}${titleSuffix}`)
|
|
23048
25168
|
: undefined, h(Text, { bold: true, color: modeColor }, ` ${mode}`), search ? h(Text, { dimColor: true }, ` ${truncate$1(search, 36)}`) : undefined);
|
|
23049
25169
|
}
|
|
23050
|
-
function renderSidebar(h, components, state, context, contextStatus, width, theme) {
|
|
25170
|
+
function renderSidebar(h, components, state, context, contextStatus, width, bodyRows, theme) {
|
|
23051
25171
|
const { Box, Text } = components;
|
|
23052
25172
|
const focused = state.focus === 'sidebar';
|
|
23053
25173
|
const tabs = getLogInkSidebarTabs();
|
|
@@ -23071,7 +25191,7 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
|
|
|
23071
25191
|
dimColor: !isActive,
|
|
23072
25192
|
}, headerText));
|
|
23073
25193
|
if (isActive) {
|
|
23074
|
-
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme));
|
|
25194
|
+
blocks.push(...renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme));
|
|
23075
25195
|
}
|
|
23076
25196
|
return blocks;
|
|
23077
25197
|
});
|
|
@@ -23090,15 +25210,113 @@ function renderSidebar(h, components, state, context, contextStatus, width, them
|
|
|
23090
25210
|
* surface; every other tab falls through to `sidebarLines` for its
|
|
23091
25211
|
* string-based summary.
|
|
23092
25212
|
*/
|
|
23093
|
-
function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, theme) {
|
|
25213
|
+
function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus, width, bodyRows, theme) {
|
|
25214
|
+
// Available rows for the active tab's list. The sidebar chrome
|
|
25215
|
+
// takes ~10 rows (panel title + spacer + 5 tab headers + 4 inter-tab
|
|
25216
|
+
// spacers); the branches tab eats 3 more for its summary header
|
|
25217
|
+
// (Current / Worktree / spacer). Floor of 8 keeps short terminals
|
|
25218
|
+
// usable; tall terminals (40+ rows) get noticeably more items.
|
|
25219
|
+
const sidebarChrome = 10;
|
|
25220
|
+
const branchHeaderRows = tab === 'branches' ? 3 : 0;
|
|
25221
|
+
const visibleListCount = Math.max(8, bodyRows - sidebarChrome - branchHeaderRows);
|
|
23094
25222
|
if (tab === 'status') {
|
|
23095
25223
|
return renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme);
|
|
23096
25224
|
}
|
|
23097
|
-
|
|
23098
|
-
|
|
23099
|
-
|
|
23100
|
-
|
|
23101
|
-
|
|
25225
|
+
// Branches / tags / stashes / worktrees: render selectable rows so
|
|
25226
|
+
// ↑/↓ navigates within the sidebar list and Enter / per-entity keys
|
|
25227
|
+
// act on the cursored item without needing to drill into the
|
|
25228
|
+
// dedicated view (#791 follow-up — in-sidebar selection).
|
|
25229
|
+
const focused = state.focus === 'sidebar' && state.sidebarTab === tab;
|
|
25230
|
+
if (tab === 'branches') {
|
|
25231
|
+
if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
|
|
25232
|
+
return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
|
|
25233
|
+
}
|
|
25234
|
+
const branches = context.branches;
|
|
25235
|
+
if (!branches) {
|
|
25236
|
+
return [h(Text, { key: 'tab-branches-empty', dimColor: true }, ' Branches unavailable')];
|
|
25237
|
+
}
|
|
25238
|
+
const sortedBranches = sortBranches(branches.localBranches, state.branchSort);
|
|
25239
|
+
const headerRows = [
|
|
25240
|
+
h(Text, { key: 'tab-branches-current', dimColor: true }, truncate$1(` Current: ${branches.currentBranch || '<detached>'}`, width - 4)),
|
|
25241
|
+
h(Text, { key: 'tab-branches-state', dimColor: true }, ` Worktree: ${branches.dirty ? 'dirty' : 'clean'}`),
|
|
25242
|
+
h(Text, { key: 'tab-branches-spacer' }, ''),
|
|
25243
|
+
];
|
|
25244
|
+
return [
|
|
25245
|
+
...headerRows,
|
|
25246
|
+
...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches', visibleListCount),
|
|
25247
|
+
];
|
|
25248
|
+
}
|
|
25249
|
+
if (tab === 'tags') {
|
|
25250
|
+
if (isLogInkContextKeyLoading(contextStatus, 'tags')) {
|
|
25251
|
+
return [h(Text, { key: 'tab-tags-loading', dimColor: true }, ' Loading tags…')];
|
|
25252
|
+
}
|
|
25253
|
+
const tags = sortTags(context.tags?.tags || [], state.tagSort);
|
|
25254
|
+
if (tags.length === 0) {
|
|
25255
|
+
return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
|
|
25256
|
+
}
|
|
25257
|
+
return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags', visibleListCount);
|
|
25258
|
+
}
|
|
25259
|
+
if (tab === 'stashes') {
|
|
25260
|
+
if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
|
|
25261
|
+
return [h(Text, { key: 'tab-stashes-loading', dimColor: true }, ' Loading stashes…')];
|
|
25262
|
+
}
|
|
25263
|
+
const stashes = context.stashes?.stashes || [];
|
|
25264
|
+
if (stashes.length === 0) {
|
|
25265
|
+
return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
|
|
25266
|
+
}
|
|
25267
|
+
return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes', visibleListCount);
|
|
25268
|
+
}
|
|
25269
|
+
// worktrees
|
|
25270
|
+
if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
|
|
25271
|
+
return [h(Text, { key: 'tab-worktrees-loading', dimColor: true }, ' Loading worktrees…')];
|
|
25272
|
+
}
|
|
25273
|
+
const worktrees = context.worktreeList?.worktrees || [];
|
|
25274
|
+
if (worktrees.length === 0) {
|
|
25275
|
+
return [h(Text, { key: 'tab-worktrees-empty', dimColor: true }, ' No linked worktrees')];
|
|
25276
|
+
}
|
|
25277
|
+
return renderSelectableSidebarRows(h, Text, worktrees, state.selectedWorktreeListIndex, focused, width, theme, (worktree) => {
|
|
25278
|
+
const marker = worktree.current ? '*' : ' ';
|
|
25279
|
+
const wstate = worktree.dirty ? 'dirty' : 'clean';
|
|
25280
|
+
return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
|
|
25281
|
+
}, 'tab-worktrees', visibleListCount);
|
|
25282
|
+
}
|
|
25283
|
+
/**
|
|
25284
|
+
* Render a sliding-window list of selectable sidebar rows. The cursor
|
|
25285
|
+
* highlights the row at `selectedIndex` only when `focused` is true so
|
|
25286
|
+
* an unfocused sidebar doesn't compete visually with the active panel.
|
|
25287
|
+
* Sliding window keeps the cursor in view as the user navigates a long
|
|
25288
|
+
* list; truncation hints surface the count of hidden rows.
|
|
25289
|
+
*/
|
|
25290
|
+
function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix, visibleCount) {
|
|
25291
|
+
if (items.length === 0)
|
|
25292
|
+
return [];
|
|
25293
|
+
const window = getSidebarVisibleWindow(items.length, selectedIndex, visibleCount);
|
|
25294
|
+
const elements = [];
|
|
25295
|
+
if (window.truncatedAbove > 0) {
|
|
25296
|
+
elements.push(h(Text, {
|
|
25297
|
+
key: `${keyPrefix}-trunc-above`,
|
|
25298
|
+
dimColor: true,
|
|
25299
|
+
}, truncate$1(` … ${window.truncatedAbove} more above`, width - 4)));
|
|
25300
|
+
}
|
|
25301
|
+
for (let offset = 0; offset < window.size; offset += 1) {
|
|
25302
|
+
const index = window.start + offset;
|
|
25303
|
+
if (index >= items.length)
|
|
25304
|
+
break;
|
|
25305
|
+
const isSelected = focused && index === selectedIndex;
|
|
25306
|
+
const text = toRowText(items[index], index);
|
|
25307
|
+
elements.push(h(Text, {
|
|
25308
|
+
key: `${keyPrefix}-row-${index}`,
|
|
25309
|
+
backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
25310
|
+
inverse: isSelected,
|
|
25311
|
+
}, truncate$1(` ${text}`, width - 4)));
|
|
25312
|
+
}
|
|
25313
|
+
if (window.truncatedBelow > 0) {
|
|
25314
|
+
elements.push(h(Text, {
|
|
25315
|
+
key: `${keyPrefix}-trunc-below`,
|
|
25316
|
+
dimColor: true,
|
|
25317
|
+
}, truncate$1(` … ${window.truncatedBelow} more below`, width - 4)));
|
|
25318
|
+
}
|
|
25319
|
+
return elements;
|
|
23102
25320
|
}
|
|
23103
25321
|
function renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme) {
|
|
23104
25322
|
if (isLogInkContextKeyLoading(contextStatus, 'worktree')) {
|
|
@@ -23156,6 +25374,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
23156
25374
|
if (state.activeView === 'worktrees') {
|
|
23157
25375
|
return renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
23158
25376
|
}
|
|
25377
|
+
if (state.activeView === 'pull-request') {
|
|
25378
|
+
return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
25379
|
+
}
|
|
23159
25380
|
return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
|
|
23160
25381
|
}
|
|
23161
25382
|
function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
|
|
@@ -23205,15 +25426,46 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
23205
25426
|
}))
|
|
23206
25427
|
: visible.items.map((item, index) => {
|
|
23207
25428
|
if (item.type === 'graph') {
|
|
25429
|
+
if (item.laneSegments && !theme.ascii) {
|
|
25430
|
+
return h(Text, { key: `graph-${index}-${item.graph}` }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`));
|
|
25431
|
+
}
|
|
23208
25432
|
return h(Text, {
|
|
23209
25433
|
key: `graph-${index}-${item.graph}`,
|
|
23210
25434
|
color: theme.noColor ? undefined : theme.colors.muted,
|
|
23211
25435
|
dimColor: theme.noColor,
|
|
23212
25436
|
}, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), 140));
|
|
23213
25437
|
}
|
|
23214
|
-
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index);
|
|
25438
|
+
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, item.laneSegments);
|
|
23215
25439
|
}));
|
|
23216
25440
|
}
|
|
25441
|
+
/**
|
|
25442
|
+
* Render `LaneSegment[]` as a flat list of Text spans, one per lane
|
|
25443
|
+
* (#791 stage 2). Each segment paints in its lane's palette color so
|
|
25444
|
+
* the eye can follow a branch column-by-column; segments without a
|
|
25445
|
+
* lane id (spaces, padding, decorations) fall back to the muted graph
|
|
25446
|
+
* color so they visually recede.
|
|
25447
|
+
*
|
|
25448
|
+
* Final padding is appended as its own span so callers do not need to
|
|
25449
|
+
* pre-pad the graph string before computing lane segments.
|
|
25450
|
+
*/
|
|
25451
|
+
function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
25452
|
+
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25453
|
+
const elements = [];
|
|
25454
|
+
let totalLen = 0;
|
|
25455
|
+
segments.forEach((seg, idx) => {
|
|
25456
|
+
const laneColor = getLaneColor(seg.laneId, theme);
|
|
25457
|
+
elements.push(h(Text, {
|
|
25458
|
+
key: `${keyPrefix}-${idx}`,
|
|
25459
|
+
color: laneColor ?? muted,
|
|
25460
|
+
dimColor: theme.noColor && seg.laneId === undefined,
|
|
25461
|
+
}, seg.text));
|
|
25462
|
+
totalLen += seg.text.length;
|
|
25463
|
+
});
|
|
25464
|
+
if (padTo > totalLen) {
|
|
25465
|
+
elements.push(h(Text, { key: `${keyPrefix}-pad` }, ' '.repeat(padTo - totalLen)));
|
|
25466
|
+
}
|
|
25467
|
+
return elements;
|
|
25468
|
+
}
|
|
23217
25469
|
/**
|
|
23218
25470
|
* Render a single commit row with each segment in its own colored span.
|
|
23219
25471
|
* Graph chars render in `theme.colors.muted` so the topology visually
|
|
@@ -23226,8 +25478,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
23226
25478
|
* Truncation is per-segment so the variable-length message field gets
|
|
23227
25479
|
* the leftover budget after fixed segments are accounted for.
|
|
23228
25480
|
*/
|
|
23229
|
-
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index) {
|
|
23230
|
-
const renderedGraph = substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii });
|
|
25481
|
+
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
|
|
23231
25482
|
const refs = formatInkRefLabels(commit.refs);
|
|
23232
25483
|
const totalWidth = 140;
|
|
23233
25484
|
const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
|
|
@@ -23236,11 +25487,17 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
|
|
|
23236
25487
|
const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
|
|
23237
25488
|
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
23238
25489
|
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25490
|
+
// Lane-colored graph spans when full graph mode + non-ASCII rendering
|
|
25491
|
+
// is in play; otherwise fall back to the legacy single-muted span so
|
|
25492
|
+
// compact mode and legacy terminals stay visually unchanged.
|
|
25493
|
+
const graphChildren = laneSegments && !theme.ascii
|
|
25494
|
+
? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `c${index}`)
|
|
25495
|
+
: [h(Text, { color: muted, dimColor: theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
|
|
23239
25496
|
return h(Text, {
|
|
23240
25497
|
key: `${commit.hash}-${index}`,
|
|
23241
25498
|
backgroundColor: selectedBg,
|
|
23242
25499
|
inverse: selected,
|
|
23243
|
-
},
|
|
25500
|
+
}, ...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);
|
|
23244
25501
|
}
|
|
23245
25502
|
/**
|
|
23246
25503
|
* Render the synthetic "(+) new commit" affordance shown above the real
|
|
@@ -23473,11 +25730,35 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
23473
25730
|
const cursor = isSelected ? '>' : ' ';
|
|
23474
25731
|
const marker = branchRowMarker(branch, { ascii: theme.ascii });
|
|
23475
25732
|
const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
|
|
25733
|
+
const lastTouched = formatBranchLastTouched(branch.date, new Date());
|
|
25734
|
+
// Split the row into spans so the timestamp stays dim even on the
|
|
25735
|
+
// currently-selected (bold) row. The leading marker + name keep
|
|
25736
|
+
// their original column widths; the timestamp is right-padded so
|
|
25737
|
+
// the divergence column stays aligned across rows.
|
|
25738
|
+
const namePadded = branch.shortName.padEnd(28);
|
|
25739
|
+
const timestampPadded = lastTouched.padEnd(8);
|
|
25740
|
+
const lineDim = !isSelected && !branch.current;
|
|
25741
|
+
const head = `${cursor} ${marker} ${namePadded} `;
|
|
25742
|
+
const trailingDivergence = divergence ? ` ${divergence}` : '';
|
|
25743
|
+
// Truncate the assembled line cooperatively so we never overflow
|
|
25744
|
+
// the panel; the timestamp is short and the divergence is the
|
|
25745
|
+
// most expendable, but the existing 140 cap is ample.
|
|
25746
|
+
const fullText = `${head}${timestampPadded}${trailingDivergence}`;
|
|
25747
|
+
const truncated = truncate$1(fullText, 140);
|
|
25748
|
+
// If truncation chopped into the timestamp/divergence portion,
|
|
25749
|
+
// fall back to a single Text to keep the visible width honest.
|
|
25750
|
+
if (truncated !== fullText) {
|
|
25751
|
+
return h(Text, {
|
|
25752
|
+
key: `branch-${index}`,
|
|
25753
|
+
bold: isSelected,
|
|
25754
|
+
dimColor: lineDim,
|
|
25755
|
+
}, truncated);
|
|
25756
|
+
}
|
|
23476
25757
|
return h(Text, {
|
|
23477
25758
|
key: `branch-${index}`,
|
|
23478
25759
|
bold: isSelected,
|
|
23479
|
-
dimColor:
|
|
23480
|
-
},
|
|
25760
|
+
dimColor: lineDim,
|
|
25761
|
+
}, head, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
|
|
23481
25762
|
});
|
|
23482
25763
|
return h(Box, {
|
|
23483
25764
|
borderColor: focusBorderColor(theme, focused),
|
|
@@ -23630,6 +25911,98 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
23630
25911
|
width,
|
|
23631
25912
|
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Worktrees', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
|
|
23632
25913
|
}
|
|
25914
|
+
/**
|
|
25915
|
+
* Pull-request action panel (#783) — renders the current branch's PR
|
|
25916
|
+
* with header, checks table, reviews summary, and a body preview.
|
|
25917
|
+
* Action keys (m / x / a / R / c / O) are wired in inkInput.ts and
|
|
25918
|
+
* surfaced via the footer; this renderer is read-only.
|
|
25919
|
+
*
|
|
25920
|
+
* Three loading / fallback states matter:
|
|
25921
|
+
* - Provider data still loading → "Loading pull request..."
|
|
25922
|
+
* - GitHub remote present but no PR for the current branch → empty
|
|
25923
|
+
* state hint pointing the user at `C` to create one.
|
|
25924
|
+
* - GitHub CLI missing / unauthenticated → unavailable hint.
|
|
25925
|
+
*/
|
|
25926
|
+
function renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
25927
|
+
const { Box, Text } = components;
|
|
25928
|
+
const focused = state.focus === 'commits';
|
|
25929
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequest');
|
|
25930
|
+
const pullRequestOverview = context.pullRequest;
|
|
25931
|
+
// Use the dedicated `pullRequest` overview only — the `provider`
|
|
25932
|
+
// shape carries a slimmer ProviderPullRequestStatus that lacks
|
|
25933
|
+
// url / headRefName / body / mergeable / reviews. The dedicated
|
|
25934
|
+
// overview hits `gh pr view --json` with the full enriched field
|
|
25935
|
+
// list (PULL_REQUEST_VIEW_JSON_FIELDS) so the panel has everything.
|
|
25936
|
+
const pr = pullRequestOverview?.currentPullRequest;
|
|
25937
|
+
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25938
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
25939
|
+
const containerProps = {
|
|
25940
|
+
borderColor: focusBorderColor(theme, focused),
|
|
25941
|
+
borderStyle: theme.borderStyle,
|
|
25942
|
+
flexDirection: 'column',
|
|
25943
|
+
flexShrink: 0,
|
|
25944
|
+
paddingX: 1,
|
|
25945
|
+
width,
|
|
25946
|
+
};
|
|
25947
|
+
if (loading && !pr) {
|
|
25948
|
+
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' })));
|
|
25949
|
+
}
|
|
25950
|
+
if (!pr) {
|
|
25951
|
+
const hint = pullRequestOverview?.message
|
|
25952
|
+
|| 'No pull request detected for this branch. Press `C` (or `:create-pr`) to create one.';
|
|
25953
|
+
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)));
|
|
25954
|
+
}
|
|
25955
|
+
const checks = summarizePullRequestChecks(pr.statusCheckRollup);
|
|
25956
|
+
const reviews = summarizePullRequestReviews(pr.reviews, pr.reviewDecision);
|
|
25957
|
+
const checkRows = buildPullRequestCheckRows(pr.statusCheckRollup, { ascii: theme.ascii });
|
|
25958
|
+
const checkColor = (s) => {
|
|
25959
|
+
if (theme.noColor)
|
|
25960
|
+
return undefined;
|
|
25961
|
+
if (s === 'success')
|
|
25962
|
+
return theme.colors.success;
|
|
25963
|
+
if (s === 'failure')
|
|
25964
|
+
return theme.colors.danger;
|
|
25965
|
+
if (s === 'pending')
|
|
25966
|
+
return theme.colors.warning;
|
|
25967
|
+
return theme.colors.muted;
|
|
25968
|
+
};
|
|
25969
|
+
// Reserve a few rows for the header/section labels; the rest go to
|
|
25970
|
+
// the checks table. Body preview gets the leftover rows so the
|
|
25971
|
+
// surface stays vertically balanced even on tall terminals.
|
|
25972
|
+
const checkBudget = Math.max(3, Math.min(checkRows.length, Math.floor(bodyRows / 2)));
|
|
25973
|
+
const visibleChecks = checkRows.slice(0, checkBudget);
|
|
25974
|
+
const truncatedChecks = checkRows.length - visibleChecks.length;
|
|
25975
|
+
const bodyPreviewBudget = Math.max(2, bodyRows - 8 - visibleChecks.length);
|
|
25976
|
+
const bodyLines = (pr.body || '').split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
25977
|
+
const visibleBodyLines = bodyLines.slice(0, bodyPreviewBudget);
|
|
25978
|
+
const truncatedBodyLines = bodyLines.length - visibleBodyLines.length;
|
|
25979
|
+
const headerRight = `#${pr.number} · ${pr.headRefName} → ${pr.baseRefName}`;
|
|
25980
|
+
const stateLine = formatPullRequestStateLine(pr);
|
|
25981
|
+
const author = pr.author ? `by @${pr.author}` : '';
|
|
25982
|
+
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, ''),
|
|
25983
|
+
// Checks section
|
|
25984
|
+
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, {
|
|
25985
|
+
key: `pr-check-${index}`,
|
|
25986
|
+
color: checkColor(row.status),
|
|
25987
|
+
}, truncate$1(` ${row.glyph} ${row.name.padEnd(28)} ${row.detail}`, width - 4))), ...(truncatedChecks > 0
|
|
25988
|
+
? [h(Text, { key: 'pr-checks-trunc', dimColor: true }, truncate$1(` … ${truncatedChecks} more`, width - 4))]
|
|
25989
|
+
: []), h(Text, undefined, ''),
|
|
25990
|
+
// Reviews section
|
|
25991
|
+
h(Text, { bold: true, color: accent }, 'Reviews'), h(Text, { dimColor: true }, truncate$1(` ${formatPullRequestReviewsSummary(reviews)}`, width - 4)), h(Text, undefined, ''),
|
|
25992
|
+
// Body preview
|
|
25993
|
+
...(visibleBodyLines.length > 0
|
|
25994
|
+
? [
|
|
25995
|
+
h(Text, { key: 'pr-body-label', bold: true, color: accent }, 'Description'),
|
|
25996
|
+
...visibleBodyLines.map((line, index) => h(Text, {
|
|
25997
|
+
key: `pr-body-${index}`,
|
|
25998
|
+
color: muted,
|
|
25999
|
+
}, truncate$1(` ${line}`, width - 4))),
|
|
26000
|
+
...(truncatedBodyLines > 0
|
|
26001
|
+
? [h(Text, { key: 'pr-body-trunc', dimColor: true }, truncate$1(` … ${truncatedBodyLines} more lines`, width - 4))]
|
|
26002
|
+
: []),
|
|
26003
|
+
]
|
|
26004
|
+
: []));
|
|
26005
|
+
}
|
|
23633
26006
|
/**
|
|
23634
26007
|
* Filter input cursor for the promoted views (branches/tags/stash).
|
|
23635
26008
|
* History already shows the same `filter: foo_` affordance in its header
|
|
@@ -23660,6 +26033,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23660
26033
|
// cherry-picks the file at the cursor.
|
|
23661
26034
|
if (state.diffSource === 'stash') {
|
|
23662
26035
|
const lines = stashDiffLines || [];
|
|
26036
|
+
const splitActive = isSplitDiffViable(state, width);
|
|
26037
|
+
const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
|
|
23663
26038
|
const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
23664
26039
|
const stashFiles = parseStashDiffFiles(lines);
|
|
23665
26040
|
const fileCount = stashFiles.length;
|
|
@@ -23680,11 +26055,19 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23680
26055
|
const currentFileIndex = currentFile
|
|
23681
26056
|
? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
|
|
23682
26057
|
: -1;
|
|
23683
|
-
|
|
23684
|
-
|
|
26058
|
+
// Look up the active stash entry so the panel header can show a
|
|
26059
|
+
// human-identifier instead of the raw `stash@{<iso-date>}` ref.
|
|
26060
|
+
// The git ref is the timestamp form (we fetch with --date=iso for
|
|
26061
|
+
// stable parsing) which reads as noise in the title bar; the
|
|
26062
|
+
// message + branch + index combination is what the user wrote down
|
|
26063
|
+
// when they ran `git stash`. Body still shows the full ref so it
|
|
26064
|
+
// stays unambiguous.
|
|
26065
|
+
const stashIdentity = formatStashHeaderIdentity(state.stashDiffRef, context.stashes?.stashes);
|
|
26066
|
+
const baseHeaderLines = stashDiffLoading
|
|
26067
|
+
? [`Loading diff for ${stashIdentity.subtitle}...`]
|
|
23685
26068
|
: lines.length
|
|
23686
26069
|
? [
|
|
23687
|
-
|
|
26070
|
+
stashIdentity.bodyLine,
|
|
23688
26071
|
fileCount > 0 && currentFile
|
|
23689
26072
|
? `File ${currentFileIndex + 1}/${fileCount}: ${currentFile.path}`
|
|
23690
26073
|
: 'No files in this stash.',
|
|
@@ -23692,6 +26075,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23692
26075
|
'',
|
|
23693
26076
|
]
|
|
23694
26077
|
: ['No diff to display for this stash.'];
|
|
26078
|
+
const headerLines = splitRequestedButTooNarrow
|
|
26079
|
+
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
26080
|
+
: baseHeaderLines;
|
|
26081
|
+
const stashBodyNodes = stashDiffLoading || !lines.length
|
|
26082
|
+
? []
|
|
26083
|
+
: splitActive
|
|
26084
|
+
? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
|
|
26085
|
+
: visibleLines.map((line, index) => h(Text, {
|
|
26086
|
+
key: `stash-diff-line-${state.diffPreviewOffset + index}`,
|
|
26087
|
+
...diffLineProps(line, theme),
|
|
26088
|
+
}, truncate$1(line, width - 4)));
|
|
23695
26089
|
return h(Box, {
|
|
23696
26090
|
borderColor: focusBorderColor(theme, focused),
|
|
23697
26091
|
borderStyle: theme.borderStyle,
|
|
@@ -23699,15 +26093,10 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23699
26093
|
flexShrink: 0,
|
|
23700
26094
|
paddingX: 1,
|
|
23701
26095
|
width,
|
|
23702
|
-
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash diff', focused)), h(Text, { dimColor: true },
|
|
26096
|
+
}, 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, {
|
|
23703
26097
|
key: `stash-diff-header-${index}`,
|
|
23704
26098
|
dimColor: index > 0,
|
|
23705
|
-
}, truncate$1(line, width - 4))), ...
|
|
23706
|
-
? []
|
|
23707
|
-
: visibleLines.map((line, index) => h(Text, {
|
|
23708
|
-
key: `stash-diff-line-${state.diffPreviewOffset + index}`,
|
|
23709
|
-
...diffLineProps(line, theme),
|
|
23710
|
-
}, truncate$1(line, width - 4)))));
|
|
26099
|
+
}, truncate$1(line, width - 4))), ...stashBodyNodes);
|
|
23711
26100
|
}
|
|
23712
26101
|
// diffSource disambiguates: 'commit' was set when the user opened the
|
|
23713
26102
|
// diff via history → Enter (read-only commit-diff explore), 'worktree'
|
|
@@ -23718,6 +26107,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23718
26107
|
(state.diffSource === undefined && !worktreeFile && Boolean(selectedDetailFile));
|
|
23719
26108
|
if (useCommitDiff) {
|
|
23720
26109
|
const previewHunks = filePreview?.hunks || [];
|
|
26110
|
+
const splitActive = isSplitDiffViable(state, width);
|
|
26111
|
+
const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
|
|
23721
26112
|
const visiblePreviewHunks = previewHunks.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
23722
26113
|
const hunkCount = commitDiffHunkOffsets?.length || 0;
|
|
23723
26114
|
const currentHunkIndex = hunkCount > 0
|
|
@@ -23728,7 +26119,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23728
26119
|
const currentHunkLabel = hunkCount > 0
|
|
23729
26120
|
? `Hunk ${Math.min(hunkCount - currentHunkIndex, hunkCount)}/${hunkCount}`
|
|
23730
26121
|
: 'No hunks for this file.';
|
|
23731
|
-
const
|
|
26122
|
+
const baseHeaderLines = filePreviewLoading
|
|
23732
26123
|
? [`Loading diff for ${selectedDetailFile?.path || 'selected file'}...`]
|
|
23733
26124
|
: previewHunks.length
|
|
23734
26125
|
? [
|
|
@@ -23738,6 +26129,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23738
26129
|
'',
|
|
23739
26130
|
]
|
|
23740
26131
|
: ['No diff preview available for this file.'];
|
|
26132
|
+
const headerLines = splitRequestedButTooNarrow
|
|
26133
|
+
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
26134
|
+
: baseHeaderLines;
|
|
26135
|
+
const commitBodyNodes = filePreviewLoading || !previewHunks.length
|
|
26136
|
+
? []
|
|
26137
|
+
: splitActive
|
|
26138
|
+
? renderSplitDiffBody(h, components, visiblePreviewHunks, state.diffPreviewOffset, width, theme, 'commit-diff-split')
|
|
26139
|
+
: visiblePreviewHunks.map((line, index) => h(Text, {
|
|
26140
|
+
key: `diff-surface-line-${state.diffPreviewOffset + index}`,
|
|
26141
|
+
...diffLineProps(line, theme),
|
|
26142
|
+
}, truncate$1(line, 140)));
|
|
23741
26143
|
return h(Box, {
|
|
23742
26144
|
borderColor: focusBorderColor(theme, focused),
|
|
23743
26145
|
borderStyle: theme.borderStyle,
|
|
@@ -23745,15 +26147,10 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23745
26147
|
flexShrink: 0,
|
|
23746
26148
|
paddingX: 1,
|
|
23747
26149
|
width,
|
|
23748
|
-
}, 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, {
|
|
26150
|
+
}, 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, {
|
|
23749
26151
|
key: `diff-surface-header-${index}`,
|
|
23750
26152
|
dimColor: index > 0,
|
|
23751
|
-
}, truncate$1(line, 140))), ...
|
|
23752
|
-
? []
|
|
23753
|
-
: visiblePreviewHunks.map((line, index) => h(Text, {
|
|
23754
|
-
key: `diff-surface-line-${state.diffPreviewOffset + index}`,
|
|
23755
|
-
...diffLineProps(line, theme),
|
|
23756
|
-
}, truncate$1(line, 140)))));
|
|
26153
|
+
}, truncate$1(line, 140))), ...commitBodyNodes);
|
|
23757
26154
|
}
|
|
23758
26155
|
const diffLines = worktreeDiff?.lines || [];
|
|
23759
26156
|
const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
|
|
@@ -23794,7 +26191,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23794
26191
|
}, truncate$1(line, 140)))
|
|
23795
26192
|
: []));
|
|
23796
26193
|
}
|
|
23797
|
-
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme) {
|
|
26194
|
+
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme) {
|
|
23798
26195
|
const focused = state.focus === 'detail';
|
|
23799
26196
|
if (state.showHelp) {
|
|
23800
26197
|
return renderHelpPanel(h, components, state, width, theme, focused);
|
|
@@ -23851,16 +26248,11 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
23851
26248
|
if (state.activeView === 'stash') {
|
|
23852
26249
|
return renderStashPreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
|
|
23853
26250
|
}
|
|
23854
|
-
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, theme, focused);
|
|
26251
|
+
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
|
|
23855
26252
|
}
|
|
23856
|
-
function renderHistoryInspector(h, components, state, context,
|
|
26253
|
+
function renderHistoryInspector(h, components, state, context, _contextStatus, detail, loading, _filePreview, _filePreviewLoading, width, tabbed, theme, focused) {
|
|
23857
26254
|
const { Box, Text } = components;
|
|
23858
26255
|
const selected = getSelectedInkCommit(state);
|
|
23859
|
-
const workflowSections = getLogInkWorkflowSections({
|
|
23860
|
-
...context,
|
|
23861
|
-
contextLoading: isLogInkContextLoading(contextStatus),
|
|
23862
|
-
selectedCommit: selected,
|
|
23863
|
-
});
|
|
23864
26256
|
if (!detail) {
|
|
23865
26257
|
const fallbackLines = [
|
|
23866
26258
|
selected?.message || 'No commit selected.',
|
|
@@ -23876,7 +26268,7 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
|
|
|
23876
26268
|
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...fallbackLines.map((line, index) => h(Text, {
|
|
23877
26269
|
key: `detail-${index}`,
|
|
23878
26270
|
dimColor: index > 1,
|
|
23879
|
-
}, truncate$1(line, width - 4))));
|
|
26271
|
+
}, truncate$1(line, width - 4))), ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
|
|
23880
26272
|
}
|
|
23881
26273
|
const statLine = `${detail.stats.filesChanged} files +${detail.stats.insertions}/-${detail.stats.deletions}`;
|
|
23882
26274
|
// P5.1 — link the commit hash and each ref out to GitHub when we know
|
|
@@ -23887,18 +26279,26 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
|
|
|
23887
26279
|
const refNodes = detail.refs.length
|
|
23888
26280
|
? renderInspectorRefs(h, Text, detail.refs, repository)
|
|
23889
26281
|
: null;
|
|
26282
|
+
// Inspector reorder (PR — drop duplicative Workflows trailer):
|
|
26283
|
+
// 1. Commit message (the headline of what you're looking at)
|
|
26284
|
+
// 2. Metadata (hash / author / date / refs / stats)
|
|
26285
|
+
// 3. Body preview (up to 8 lines now that the trailer is gone)
|
|
26286
|
+
// 4. Changed files list (cursored entry highlights)
|
|
26287
|
+
// 5. Actions cheat-sheet (per-entity keystrokes; destructive marked)
|
|
26288
|
+
// The Workflows: trailer that used to repeat the repo / branch /
|
|
26289
|
+
// status from the top header and left sidebar is intentionally gone.
|
|
23890
26290
|
const headerNodes = [
|
|
23891
26291
|
h(Text, { key: 'detail-msg' }, truncate$1(detail.message, width - 4)),
|
|
23892
26292
|
h(Text, { key: 'detail-spacer-1' }, ''),
|
|
23893
26293
|
h(Text, { key: 'detail-commit', dimColor: true }, 'Commit: ', commitLink),
|
|
23894
26294
|
h(Text, { key: 'detail-author', dimColor: true }, truncate$1(`Author: ${detail.author}`, width - 4)),
|
|
23895
|
-
h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date:
|
|
26295
|
+
h(Text, { key: 'detail-date', dimColor: true }, truncate$1(`Date: ${detail.date}`, width - 4)),
|
|
23896
26296
|
refNodes
|
|
23897
|
-
? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs:
|
|
23898
|
-
: h(Text, { key: 'detail-refs', dimColor: true }, 'Refs:
|
|
23899
|
-
h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(statLine
|
|
26297
|
+
? h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: ', ...refNodes)
|
|
26298
|
+
: h(Text, { key: 'detail-refs', dimColor: true }, 'Refs: none'),
|
|
26299
|
+
h(Text, { key: 'detail-stat', dimColor: true }, truncate$1(`Stats: ${statLine}`, width - 4)),
|
|
23900
26300
|
h(Text, { key: 'detail-spacer-2' }, ''),
|
|
23901
|
-
...(detail.body ? detail.body.split('\n').slice(0,
|
|
26301
|
+
...(detail.body ? detail.body.split('\n').slice(0, 8) : ['No commit body.']).map((line, index) => h(Text, {
|
|
23902
26302
|
key: `detail-body-${index}`,
|
|
23903
26303
|
dimColor: true,
|
|
23904
26304
|
}, truncate$1(line, width - 4))),
|
|
@@ -23907,24 +26307,85 @@ function renderHistoryInspector(h, components, state, context, contextStatus, de
|
|
|
23907
26307
|
];
|
|
23908
26308
|
const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
|
|
23909
26309
|
const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, focused, fileListMaxRows, width, theme);
|
|
23910
|
-
|
|
23911
|
-
|
|
23912
|
-
|
|
23913
|
-
|
|
23914
|
-
|
|
23915
|
-
|
|
23916
|
-
|
|
23917
|
-
|
|
26310
|
+
// Tabbed mode (#806 follow-up — short terminals): render only the
|
|
26311
|
+
// active inspector tab with a `[Inspector] Actions` header so the
|
|
26312
|
+
// user knows what they're seeing and how to switch (`[/]` while
|
|
26313
|
+
// focus is on the inspector). Tall terminals stack both sections
|
|
26314
|
+
// as before.
|
|
26315
|
+
if (tabbed) {
|
|
26316
|
+
const activeTab = state.inspectorTab;
|
|
26317
|
+
const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
|
|
26318
|
+
bold: activeTab === 'inspector',
|
|
26319
|
+
dimColor: activeTab !== 'inspector',
|
|
26320
|
+
}, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), ' ', h(Text, {
|
|
26321
|
+
bold: activeTab === 'actions',
|
|
26322
|
+
dimColor: activeTab !== 'actions',
|
|
26323
|
+
}, activeTab === 'actions' ? '[Actions]' : ' Actions '));
|
|
26324
|
+
return h(Box, {
|
|
26325
|
+
borderColor: focusBorderColor(theme, focused),
|
|
26326
|
+
borderStyle: theme.borderStyle,
|
|
26327
|
+
flexDirection: 'column',
|
|
26328
|
+
width,
|
|
26329
|
+
paddingX: 1,
|
|
26330
|
+
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...(activeTab === 'inspector'
|
|
26331
|
+
? [...headerNodes, ...fileListNodes]
|
|
26332
|
+
: renderInspectorActionsSection(h, Text, 'history-commit', width, theme)));
|
|
26333
|
+
}
|
|
23918
26334
|
return h(Box, {
|
|
23919
26335
|
borderColor: focusBorderColor(theme, focused),
|
|
23920
26336
|
borderStyle: theme.borderStyle,
|
|
23921
26337
|
flexDirection: 'column',
|
|
23922
26338
|
width,
|
|
23923
26339
|
paddingX: 1,
|
|
23924
|
-
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...
|
|
23925
|
-
|
|
23926
|
-
|
|
23927
|
-
|
|
26340
|
+
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme));
|
|
26341
|
+
}
|
|
26342
|
+
/**
|
|
26343
|
+
* Render the trailing "Actions:" section that surfaces which keystrokes
|
|
26344
|
+
* apply to whatever the inspector is focused on. Keys are colored with
|
|
26345
|
+
* `theme.colors.accent` so they pop as the actionable element. Destructive
|
|
26346
|
+
* actions get the danger color plus a `[!]` marker so they don't blend
|
|
26347
|
+
* into the cherry-pick / yank rows.
|
|
26348
|
+
*
|
|
26349
|
+
* Truncates labels when the inspector is narrow (down to the 26-cell
|
|
26350
|
+
* minimum from `getLogInkLayout`) so an overflowing label never wraps and
|
|
26351
|
+
* collides with the next row.
|
|
26352
|
+
*/
|
|
26353
|
+
function renderInspectorActionsSection(h, Text, context, width, theme) {
|
|
26354
|
+
const actions = getInspectorActions(context);
|
|
26355
|
+
if (!actions.length)
|
|
26356
|
+
return [];
|
|
26357
|
+
// Width budget for each row: subtract padding + " " gutter, the key
|
|
26358
|
+
// column (left-padded to 5 cells so labels align), the " " gap
|
|
26359
|
+
// between key and label, and the optional " [!]" suffix (5 cells).
|
|
26360
|
+
const KEY_COLUMN = 5;
|
|
26361
|
+
const GAP = ' ';
|
|
26362
|
+
const DESTRUCTIVE_SUFFIX = ' [!]';
|
|
26363
|
+
const labelBudget = Math.max(4, width - 4 /* border + padX */ - KEY_COLUMN - GAP.length - DESTRUCTIVE_SUFFIX.length);
|
|
26364
|
+
const nodes = [
|
|
26365
|
+
h(Text, { key: 'actions-spacer' }, ''),
|
|
26366
|
+
h(Text, { key: 'actions-title' }, 'Actions:'),
|
|
26367
|
+
...actions.map((action, index) => {
|
|
26368
|
+
const keyCell = action.key.padEnd(KEY_COLUMN);
|
|
26369
|
+
const label = truncate$1(action.label, labelBudget);
|
|
26370
|
+
const children = [
|
|
26371
|
+
h(Text, {
|
|
26372
|
+
key: `actions-${index}-key`,
|
|
26373
|
+
color: action.destructive ? theme.colors.danger : theme.colors.accent,
|
|
26374
|
+
}, keyCell),
|
|
26375
|
+
GAP,
|
|
26376
|
+
label,
|
|
26377
|
+
];
|
|
26378
|
+
if (action.destructive) {
|
|
26379
|
+
children.push(h(Text, {
|
|
26380
|
+
key: `actions-${index}-mark`,
|
|
26381
|
+
color: theme.colors.danger,
|
|
26382
|
+
dimColor: false,
|
|
26383
|
+
}, DESTRUCTIVE_SUFFIX));
|
|
26384
|
+
}
|
|
26385
|
+
return h(Text, { key: `actions-${index}` }, ...children);
|
|
26386
|
+
}),
|
|
26387
|
+
];
|
|
26388
|
+
return nodes;
|
|
23928
26389
|
}
|
|
23929
26390
|
/**
|
|
23930
26391
|
* Build a commit URL for the repo when GitHub provider info is available.
|
|
@@ -24228,16 +26689,34 @@ function renderInputPromptPanel(h, components, state, width, theme, focused) {
|
|
|
24228
26689
|
if (!prompt) {
|
|
24229
26690
|
return h(Box, { width });
|
|
24230
26691
|
}
|
|
26692
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
26693
|
+
// Multi-line prompts (#806) split on newline and render one Text
|
|
26694
|
+
// row per buffer line — the cursor sits at the end of the last
|
|
26695
|
+
// line via the trailing `_`. Single-line prompts collapse to the
|
|
26696
|
+
// original one-row layout for muscle-memory continuity.
|
|
26697
|
+
const promptLines = prompt.multiline ? prompt.value.split('\n') : [prompt.value];
|
|
26698
|
+
if (promptLines.length === 0) {
|
|
26699
|
+
promptLines.push('');
|
|
26700
|
+
}
|
|
26701
|
+
const valueRows = promptLines.map((line, index) => {
|
|
26702
|
+
const isLast = index === promptLines.length - 1;
|
|
26703
|
+
const display = isLast ? `${line}_` : line;
|
|
26704
|
+
return h(Text, {
|
|
26705
|
+
key: `prompt-line-${index}`,
|
|
26706
|
+
bold: true,
|
|
26707
|
+
color: accent,
|
|
26708
|
+
}, truncate$1(display, width - 4));
|
|
26709
|
+
});
|
|
26710
|
+
const hint = prompt.multiline
|
|
26711
|
+
? 'Enter newline · Ctrl+d submit · Esc cancel · Ctrl+u clear'
|
|
26712
|
+
: 'Enter submit · Esc cancel · Ctrl+u clear';
|
|
24231
26713
|
return h(Box, {
|
|
24232
26714
|
borderColor: focusBorderColor(theme, focused),
|
|
24233
26715
|
borderStyle: theme.borderStyle,
|
|
24234
26716
|
flexDirection: 'column',
|
|
24235
26717
|
width,
|
|
24236
26718
|
paddingX: 1,
|
|
24237
|
-
}, h(Text, { bold: true }, panelTitle('Prompt', focused)), h(Text, { dimColor: true }, truncate$1(prompt.label, width - 4)), h(Text, undefined, ''), h(Text, {
|
|
24238
|
-
bold: true,
|
|
24239
|
-
color: theme.noColor ? undefined : theme.colors.accent,
|
|
24240
|
-
}, truncate$1(`${prompt.value}_`, width - 4)), h(Text, undefined, ''), h(Text, { dimColor: true }, 'Enter to submit · Esc to cancel · Ctrl+u to clear'));
|
|
26719
|
+
}, 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));
|
|
24241
26720
|
}
|
|
24242
26721
|
function renderConfirmationPanel(h, components, state, width, theme, focused) {
|
|
24243
26722
|
const { Box, Text } = components;
|
|
@@ -24411,16 +26890,31 @@ function renderCommandPalette(h, components, state, width, theme, focused) {
|
|
|
24411
26890
|
? [h(Text, { key: 'palette-recent-hint', dimColor: true }, '· marks recently-used')]
|
|
24412
26891
|
: []), ...itemLines);
|
|
24413
26892
|
}
|
|
24414
|
-
function renderFooter(h, components, state, theme, idleTip) {
|
|
26893
|
+
function renderFooter(h, components, state, context, theme, idleTip) {
|
|
24415
26894
|
const { Box, Text } = components;
|
|
26895
|
+
// Sidebar item count drives the per-tab footer hints — when items are
|
|
26896
|
+
// present the footer surfaces in-sidebar ops (checkout / apply / pop /
|
|
26897
|
+
// drop), otherwise it falls back to the generic "enter open" hint.
|
|
26898
|
+
const sidebarItemCount = (() => {
|
|
26899
|
+
switch (state.sidebarTab) {
|
|
26900
|
+
case 'branches': return context.branches?.localBranches.length;
|
|
26901
|
+
case 'tags': return context.tags?.tags.length;
|
|
26902
|
+
case 'stashes': return context.stashes?.stashes.length;
|
|
26903
|
+
case 'worktrees': return context.worktreeList?.worktrees.length;
|
|
26904
|
+
default: return undefined;
|
|
26905
|
+
}
|
|
26906
|
+
})();
|
|
24416
26907
|
const hints = getLogInkFooterHints({
|
|
24417
26908
|
activeView: state.activeView,
|
|
24418
26909
|
diffSource: state.diffSource,
|
|
26910
|
+
diffViewMode: state.diffViewMode,
|
|
24419
26911
|
filterMode: state.filterMode,
|
|
24420
26912
|
focus: state.focus,
|
|
24421
26913
|
pendingKey: state.pendingKey,
|
|
24422
26914
|
showCommandPalette: state.showCommandPalette,
|
|
24423
26915
|
showHelp: state.showHelp,
|
|
26916
|
+
sidebarTab: state.sidebarTab,
|
|
26917
|
+
sidebarItemCount,
|
|
24424
26918
|
});
|
|
24425
26919
|
// Real status messages always win; idle tips only fill the slot when it
|
|
24426
26920
|
// would otherwise be empty.
|
|
@@ -24465,7 +26959,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
|
24465
26959
|
const git = options.git || getRepo();
|
|
24466
26960
|
const rows = options.rows || (await getLogRows(git, logArgv));
|
|
24467
26961
|
await startInkInteractiveLog(git, rows, {}, {
|
|
24468
|
-
appLabel: 'coco
|
|
26962
|
+
appLabel: 'coco',
|
|
24469
26963
|
idleTips: config.logTui?.idleTips,
|
|
24470
26964
|
initialView: 'history',
|
|
24471
26965
|
logArgv,
|
|
@@ -24478,7 +26972,7 @@ async function startCocoUi(argv) {
|
|
|
24478
26972
|
const logArgv = createLogArgvFromUiArgv(argv);
|
|
24479
26973
|
const rows = await getLogRows(git, logArgv);
|
|
24480
26974
|
await startInkInteractiveLog(git, rows, {}, {
|
|
24481
|
-
appLabel: 'coco
|
|
26975
|
+
appLabel: 'coco',
|
|
24482
26976
|
idleTips: config.logTui?.idleTips,
|
|
24483
26977
|
initialView: argv.view || 'history',
|
|
24484
26978
|
logArgv,
|