git-coco 0.37.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.esm.mjs +2304 -257
- package/dist/index.js +2303 -256
- package/package.json +1 -1
package/dist/index.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.38.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
|
});
|
|
@@ -14570,6 +14878,34 @@ function getLogInkWorkflowActions() {
|
|
|
14570
14878
|
kind: 'destructive',
|
|
14571
14879
|
requiresConfirmation: true,
|
|
14572
14880
|
},
|
|
14881
|
+
{
|
|
14882
|
+
// Per-view-only: scoped to commit-diff and stash-diff explores in
|
|
14883
|
+
// inkInput (key: H). The action is non-destructive in the sense
|
|
14884
|
+
// that `git apply` won't lose any data — `git apply -R` undoes
|
|
14885
|
+
// it cleanly — so it bypasses the y-confirm path. The patch text
|
|
14886
|
+
// travels via the action's `payload` field. Empty key keeps the
|
|
14887
|
+
// workflow palette-discoverable without registering a global
|
|
14888
|
+
// hotkey (the palette path can't synthesize the patch text and
|
|
14889
|
+
// surfaces a hint instead — actual dispatch is from H in diff
|
|
14890
|
+
// view).
|
|
14891
|
+
id: 'apply-hunk-worktree',
|
|
14892
|
+
key: '',
|
|
14893
|
+
label: 'Apply hunk to worktree',
|
|
14894
|
+
description: 'Extract the hunk under the cursor and apply it to the working tree via `git apply`.',
|
|
14895
|
+
kind: 'normal',
|
|
14896
|
+
requiresConfirmation: false,
|
|
14897
|
+
},
|
|
14898
|
+
{
|
|
14899
|
+
// Sibling of `apply-hunk-worktree` — same extraction path, but
|
|
14900
|
+
// `git apply --cached` so the patch lands in the index without
|
|
14901
|
+
// touching the worktree. Bound to the `gH` chord in inkInput.
|
|
14902
|
+
id: 'apply-hunk-index',
|
|
14903
|
+
key: '',
|
|
14904
|
+
label: 'Apply hunk to index',
|
|
14905
|
+
description: 'Extract the hunk under the cursor and apply it to the index via `git apply --cached`.',
|
|
14906
|
+
kind: 'normal',
|
|
14907
|
+
requiresConfirmation: false,
|
|
14908
|
+
},
|
|
14573
14909
|
{
|
|
14574
14910
|
id: 'open-pr',
|
|
14575
14911
|
key: 'O',
|
|
@@ -14662,6 +14998,88 @@ function getLogInkWorkflowActions() {
|
|
|
14662
14998
|
kind: 'destructive',
|
|
14663
14999
|
requiresConfirmation: true,
|
|
14664
15000
|
},
|
|
15001
|
+
// #783 — full PR action panel. All five entries are palette-only
|
|
15002
|
+
// (`key: ''`) — actual dispatch is per-view scoped in inkInput so
|
|
15003
|
+
// the keys stay free outside the pull-request view. Merge / close /
|
|
15004
|
+
// approve / request-changes route through the y-confirm path
|
|
15005
|
+
// because each is irreversible (or near-irreversible) once gh
|
|
15006
|
+
// publishes it; comment is a free-form prompt with no extra
|
|
15007
|
+
// confirmation since the body itself is the affirmative action.
|
|
15008
|
+
{
|
|
15009
|
+
id: 'merge-pr',
|
|
15010
|
+
key: '',
|
|
15011
|
+
label: 'Merge pull request',
|
|
15012
|
+
description: 'Merge the current branch\'s pull request (prompts for merge / squash / rebase, then confirms).',
|
|
15013
|
+
kind: 'destructive',
|
|
15014
|
+
requiresConfirmation: true,
|
|
15015
|
+
},
|
|
15016
|
+
{
|
|
15017
|
+
id: 'close-pr',
|
|
15018
|
+
key: '',
|
|
15019
|
+
label: 'Close pull request',
|
|
15020
|
+
description: 'Close the current pull request without merging.',
|
|
15021
|
+
kind: 'destructive',
|
|
15022
|
+
requiresConfirmation: true,
|
|
15023
|
+
},
|
|
15024
|
+
{
|
|
15025
|
+
id: 'approve-pr',
|
|
15026
|
+
key: '',
|
|
15027
|
+
label: 'Approve pull request',
|
|
15028
|
+
description: 'Submit an approving review on the current pull request.',
|
|
15029
|
+
kind: 'normal',
|
|
15030
|
+
requiresConfirmation: true,
|
|
15031
|
+
},
|
|
15032
|
+
{
|
|
15033
|
+
id: 'request-changes-pr',
|
|
15034
|
+
key: '',
|
|
15035
|
+
label: 'Request changes on pull request',
|
|
15036
|
+
description: 'Submit a change-request review (prompts for the review body, then confirms).',
|
|
15037
|
+
kind: 'normal',
|
|
15038
|
+
requiresConfirmation: true,
|
|
15039
|
+
},
|
|
15040
|
+
{
|
|
15041
|
+
id: 'comment-pr',
|
|
15042
|
+
key: '',
|
|
15043
|
+
label: 'Comment on pull request',
|
|
15044
|
+
description: 'Add a comment to the current pull request (prompts for body).',
|
|
15045
|
+
kind: 'normal',
|
|
15046
|
+
requiresConfirmation: false,
|
|
15047
|
+
},
|
|
15048
|
+
{
|
|
15049
|
+
// Per-view-only: scoped to the history view in inkInput so `R`
|
|
15050
|
+
// doesn't fire elsewhere (it's also `R` for rename in branches
|
|
15051
|
+
// and delete-remote-tag in tags). Empty key keeps it
|
|
15052
|
+
// palette-discoverable without registering a global hotkey.
|
|
15053
|
+
id: 'revert-commit',
|
|
15054
|
+
key: '',
|
|
15055
|
+
label: 'Revert commit',
|
|
15056
|
+
description: 'Revert the cursored commit by adding an inverse commit on top of HEAD.',
|
|
15057
|
+
kind: 'destructive',
|
|
15058
|
+
requiresConfirmation: true,
|
|
15059
|
+
},
|
|
15060
|
+
{
|
|
15061
|
+
// Per-view-only: scoped to the history view in inkInput. Triggers
|
|
15062
|
+
// a mode prompt (soft / mixed / hard) before the reset runs so
|
|
15063
|
+
// `Z` alone never silently rewrites history.
|
|
15064
|
+
id: 'reset-to-commit',
|
|
15065
|
+
key: '',
|
|
15066
|
+
label: 'Reset to commit',
|
|
15067
|
+
description: 'Move the current branch tip to the cursored commit (prompts for soft / mixed / hard).',
|
|
15068
|
+
kind: 'destructive',
|
|
15069
|
+
requiresConfirmation: true,
|
|
15070
|
+
},
|
|
15071
|
+
{
|
|
15072
|
+
// Per-view-only: scoped to the history view in inkInput. `i`
|
|
15073
|
+
// (lowercase) is used instead of `I` so the existing `I`
|
|
15074
|
+
// ai-commit-summary workflow stays reachable on the history
|
|
15075
|
+
// view — `i` matches the `git rebase -i` flag mnemonic anyway.
|
|
15076
|
+
id: 'interactive-rebase',
|
|
15077
|
+
key: '',
|
|
15078
|
+
label: 'Interactive rebase',
|
|
15079
|
+
description: 'Start an interactive rebase from the cursored commit (opens $GIT_EDITOR for the todo list).',
|
|
15080
|
+
kind: 'destructive',
|
|
15081
|
+
requiresConfirmation: true,
|
|
15082
|
+
},
|
|
14665
15083
|
{
|
|
14666
15084
|
id: 'ai-commit-summary',
|
|
14667
15085
|
key: 'I',
|
|
@@ -14683,6 +15101,15 @@ function getLogInkWorkflowActions() {
|
|
|
14683
15101
|
];
|
|
14684
15102
|
}
|
|
14685
15103
|
function getLogInkWorkflowActionByKey(inputValue) {
|
|
15104
|
+
// Workflow actions with an empty `key` are palette-only — they
|
|
15105
|
+
// exist so the command palette can surface them but should never
|
|
15106
|
+
// match a raw keystroke. Without this guard, any unbound key
|
|
15107
|
+
// (left/right arrow, function keys) that arrives with an empty
|
|
15108
|
+
// inputValue would `find()` the first empty-key entry —
|
|
15109
|
+
// `cherry-pick-commit` — and pop its confirmation dialog.
|
|
15110
|
+
if (!inputValue) {
|
|
15111
|
+
return undefined;
|
|
15112
|
+
}
|
|
14686
15113
|
return getLogInkWorkflowActions().find((action) => action.key === inputValue);
|
|
14687
15114
|
}
|
|
14688
15115
|
function getLogInkWorkflowActionById(id) {
|
|
@@ -14812,6 +15239,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
14812
15239
|
description: 'Toggle compact and full graph display.',
|
|
14813
15240
|
contexts: ['normal', 'commits'],
|
|
14814
15241
|
},
|
|
15242
|
+
{
|
|
15243
|
+
id: 'toggleDiffViewMode',
|
|
15244
|
+
keys: ['d'],
|
|
15245
|
+
label: 'split/unified',
|
|
15246
|
+
description: 'Toggle the diff view between unified and side-by-side split rendering. Falls back to unified on narrow terminals.',
|
|
15247
|
+
contexts: ['commits'],
|
|
15248
|
+
},
|
|
14815
15249
|
{
|
|
14816
15250
|
id: 'navigateHome',
|
|
14817
15251
|
keys: ['gh'],
|
|
@@ -14868,6 +15302,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
14868
15302
|
description: 'Push the linked worktrees view.',
|
|
14869
15303
|
contexts: ['normal'],
|
|
14870
15304
|
},
|
|
15305
|
+
{
|
|
15306
|
+
id: 'navigatePullRequest',
|
|
15307
|
+
keys: ['gp'],
|
|
15308
|
+
label: 'pull request',
|
|
15309
|
+
description: 'Push the dedicated pull-request action panel for the current branch.',
|
|
15310
|
+
contexts: ['normal'],
|
|
15311
|
+
},
|
|
14871
15312
|
{
|
|
14872
15313
|
id: 'navigateBack',
|
|
14873
15314
|
keys: ['<', 'esc'],
|
|
@@ -14996,6 +15437,7 @@ const GLOBAL_BINDING_IDS = [
|
|
|
14996
15437
|
'navigateTags',
|
|
14997
15438
|
'navigateStash',
|
|
14998
15439
|
'navigateWorktrees',
|
|
15440
|
+
'navigatePullRequest',
|
|
14999
15441
|
'navigateBack',
|
|
15000
15442
|
];
|
|
15001
15443
|
const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
|
|
@@ -15056,8 +15498,37 @@ function getLogInkFooterHints(options) {
|
|
|
15056
15498
|
};
|
|
15057
15499
|
}
|
|
15058
15500
|
if (options.focus === 'sidebar') {
|
|
15501
|
+
// Per-tab hints when the active tab has selectable items — the user
|
|
15502
|
+
// can act on the cursored entity without leaving the workstation
|
|
15503
|
+
// view. Status tab + empty content tabs fall back to the generic
|
|
15504
|
+
// "enter open" hint that drills into the dedicated view.
|
|
15505
|
+
const itemsPresent = (options.sidebarItemCount ?? 0) > 0;
|
|
15506
|
+
if (itemsPresent && options.sidebarTab === 'branches') {
|
|
15507
|
+
return {
|
|
15508
|
+
contextual: ['↑/↓ branches', '←/→ tab', 'enter checkout', 'D delete', 'R rename', 'u upstream'],
|
|
15509
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15510
|
+
};
|
|
15511
|
+
}
|
|
15512
|
+
if (itemsPresent && options.sidebarTab === 'stashes') {
|
|
15513
|
+
return {
|
|
15514
|
+
contextual: ['↑/↓ stashes', '←/→ tab', 'enter diff', 'a apply', 'p pop', 'X drop'],
|
|
15515
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15516
|
+
};
|
|
15517
|
+
}
|
|
15518
|
+
if (itemsPresent && options.sidebarTab === 'tags') {
|
|
15519
|
+
return {
|
|
15520
|
+
contextual: ['↑/↓ tags', '←/→ tab', '+ new', 'P push', 'T delete'],
|
|
15521
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15522
|
+
};
|
|
15523
|
+
}
|
|
15524
|
+
if (itemsPresent && options.sidebarTab === 'worktrees') {
|
|
15525
|
+
return {
|
|
15526
|
+
contextual: ['↑/↓ worktrees', '←/→ tab', 'W remove'],
|
|
15527
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15528
|
+
};
|
|
15529
|
+
}
|
|
15059
15530
|
return {
|
|
15060
|
-
contextual: ['
|
|
15531
|
+
contextual: ['←/→ tab', '1-5 jump', 'enter open', 'tab focus'],
|
|
15061
15532
|
global: NORMAL_GLOBAL_HINTS,
|
|
15062
15533
|
};
|
|
15063
15534
|
}
|
|
@@ -15074,17 +15545,23 @@ function getLogInkFooterHints(options) {
|
|
|
15074
15545
|
};
|
|
15075
15546
|
}
|
|
15076
15547
|
if (options.activeView === 'diff') {
|
|
15548
|
+
// Surface what `d` will switch *to* — labels the next mode rather
|
|
15549
|
+
// than the current one so the hint reads as a verb. The split-mode
|
|
15550
|
+
// hint is only shown for the read-only diff sources (commit/stash);
|
|
15551
|
+
// the worktree diff stays unified-only for now.
|
|
15552
|
+
const splitToggleHint = options.diffViewMode === 'split' ? 'd unified' : 'd split';
|
|
15077
15553
|
if (options.diffSource === 'stash') {
|
|
15078
15554
|
return {
|
|
15079
|
-
contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'o edit', 'y yank', 'esc back'],
|
|
15555
|
+
contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', 'o edit', splitToggleHint, 'y yank', 'esc back'],
|
|
15080
15556
|
global: NORMAL_GLOBAL_HINTS,
|
|
15081
15557
|
};
|
|
15082
15558
|
}
|
|
15083
15559
|
if (options.diffSource === 'commit') {
|
|
15084
15560
|
// Commit-diff explore: read-only diff, but `c` cherry-picks the
|
|
15085
|
-
// cursored file from the commit into the worktree
|
|
15561
|
+
// cursored file from the commit into the worktree, and `H`
|
|
15562
|
+
// (or `gH` for index) applies just the cursored hunk.
|
|
15086
15563
|
return {
|
|
15087
|
-
contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'y/Y yank', 'esc back'],
|
|
15564
|
+
contextual: ['j/k hunks', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'y/Y yank', 'esc back'],
|
|
15088
15565
|
global: NORMAL_GLOBAL_HINTS,
|
|
15089
15566
|
};
|
|
15090
15567
|
}
|
|
@@ -15123,8 +15600,23 @@ function getLogInkFooterHints(options) {
|
|
|
15123
15600
|
global: NORMAL_GLOBAL_HINTS,
|
|
15124
15601
|
};
|
|
15125
15602
|
}
|
|
15603
|
+
if (options.activeView === 'pull-request') {
|
|
15604
|
+
return {
|
|
15605
|
+
// #783 — full PR action panel. Five mutating ops scoped to this
|
|
15606
|
+
// view: m / x / a / R / c, plus O for open-in-browser (already
|
|
15607
|
+
// a global). Each routes through y-confirm or an input prompt;
|
|
15608
|
+
// none fire silently.
|
|
15609
|
+
contextual: ['m merge', 'x close', 'a approve', 'R changes', 'c comment', 'O open', 'esc back'],
|
|
15610
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
15611
|
+
};
|
|
15612
|
+
}
|
|
15126
15613
|
return {
|
|
15127
|
-
|
|
15614
|
+
// History view default hints. Mutating ops (`c` cherry-pick, `R`
|
|
15615
|
+
// revert, `Z` reset, `i` interactive-rebase) all route through a
|
|
15616
|
+
// y-confirm or mode prompt — none fire silently from the keystroke.
|
|
15617
|
+
// Grouped into a compact `c/R/Z/i mutate` chip so the footer stays
|
|
15618
|
+
// scannable; full descriptions live in `?` help and the palette.
|
|
15619
|
+
contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'y/Y yank', '/ search', 'gg/G top/bottom'],
|
|
15128
15620
|
global: NORMAL_GLOBAL_HINTS,
|
|
15129
15621
|
};
|
|
15130
15622
|
}
|
|
@@ -15267,39 +15759,6 @@ function filterLogInkPaletteCommands(commands, filter, recent) {
|
|
|
15267
15759
|
.map((entry) => entry.command);
|
|
15268
15760
|
}
|
|
15269
15761
|
|
|
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
15762
|
/**
|
|
15304
15763
|
* OSC 8 hyperlink helpers for the Ink TUI (P5.1).
|
|
15305
15764
|
*
|
|
@@ -15371,6 +15830,84 @@ function formatHyperlink(text, url, env = process.env) {
|
|
|
15371
15830
|
return `${OSC_PREFIX}${url}${ST}${text}${OSC_PREFIX}${ST}`;
|
|
15372
15831
|
}
|
|
15373
15832
|
|
|
15833
|
+
/**
|
|
15834
|
+
* Extract a single hunk from a unified-patch diff so it can be fed to
|
|
15835
|
+
* `git apply` (or `git apply --cached`) for a hunk-level cherry-pick.
|
|
15836
|
+
*
|
|
15837
|
+
* The TUI's diff explore views render two flavors of patch text:
|
|
15838
|
+
*
|
|
15839
|
+
* - stash-diff: full `git stash show -p` output, which includes
|
|
15840
|
+
* `diff --git`, `---`, `+++`, and one or more `@@ ... @@` hunks
|
|
15841
|
+
* per file.
|
|
15842
|
+
* - commit-diff: the per-file `filePreview.hunks` array, which is
|
|
15843
|
+
* hunks-only (no `diff --git` / `---` / `+++` headers).
|
|
15844
|
+
*
|
|
15845
|
+
* Either way, this helper walks `lines` from `cursorOffset` backwards
|
|
15846
|
+
* to find the most recent `@@` header, walks forward to the end of
|
|
15847
|
+
* that hunk's body, and synthesizes a fresh `diff --git` /
|
|
15848
|
+
* `---` / `+++` set using the caller-provided path. The output is a
|
|
15849
|
+
* complete, self-contained patch suitable for `git apply` without
|
|
15850
|
+
* having to preserve original headers from `lines`.
|
|
15851
|
+
*/
|
|
15852
|
+
const HUNK_HEADER_PREFIX = '@@';
|
|
15853
|
+
const DIFF_GIT_PREFIX = 'diff --git ';
|
|
15854
|
+
/**
|
|
15855
|
+
* Find the index of the `@@` hunk header at or before `cursorOffset`.
|
|
15856
|
+
* Returns -1 when the cursor sits before the first hunk in the patch
|
|
15857
|
+
* (i.e. on a `diff --git` / `---` / `+++` header line) — caller treats
|
|
15858
|
+
* that as "no hunk at cursor" and surfaces a status message.
|
|
15859
|
+
*/
|
|
15860
|
+
function findHunkHeaderAtOrBefore(lines, cursorOffset) {
|
|
15861
|
+
const start = Math.min(cursorOffset, lines.length - 1);
|
|
15862
|
+
for (let i = start; i >= 0; i -= 1) {
|
|
15863
|
+
if (lines[i]?.startsWith(HUNK_HEADER_PREFIX)) {
|
|
15864
|
+
return i;
|
|
15865
|
+
}
|
|
15866
|
+
}
|
|
15867
|
+
return -1;
|
|
15868
|
+
}
|
|
15869
|
+
/**
|
|
15870
|
+
* Walk forward from a hunk header to either the next `@@` header or
|
|
15871
|
+
* the next `diff --git` line — that's where this hunk's body ends.
|
|
15872
|
+
* The end index is exclusive (the line at `endIndex` is NOT part of
|
|
15873
|
+
* this hunk).
|
|
15874
|
+
*/
|
|
15875
|
+
function findHunkBodyEnd(lines, headerIndex) {
|
|
15876
|
+
for (let i = headerIndex + 1; i < lines.length; i += 1) {
|
|
15877
|
+
const line = lines[i];
|
|
15878
|
+
if (line?.startsWith(HUNK_HEADER_PREFIX) || line?.startsWith(DIFF_GIT_PREFIX)) {
|
|
15879
|
+
return i;
|
|
15880
|
+
}
|
|
15881
|
+
}
|
|
15882
|
+
return lines.length;
|
|
15883
|
+
}
|
|
15884
|
+
function extractDiffHunk(input) {
|
|
15885
|
+
const { lines, cursorOffset, path } = input;
|
|
15886
|
+
if (!lines.length || !path) {
|
|
15887
|
+
return null;
|
|
15888
|
+
}
|
|
15889
|
+
const headerIndex = findHunkHeaderAtOrBefore(lines, cursorOffset);
|
|
15890
|
+
if (headerIndex < 0) {
|
|
15891
|
+
return null;
|
|
15892
|
+
}
|
|
15893
|
+
const bodyEnd = findHunkBodyEnd(lines, headerIndex);
|
|
15894
|
+
// Header itself + at least one body line. An empty hunk body would
|
|
15895
|
+
// mean the patch is malformed and `git apply` would reject it; bail
|
|
15896
|
+
// out early so the caller can surface a clear status message.
|
|
15897
|
+
if (bodyEnd <= headerIndex + 1) {
|
|
15898
|
+
return null;
|
|
15899
|
+
}
|
|
15900
|
+
const hunkLines = lines.slice(headerIndex, bodyEnd);
|
|
15901
|
+
const patchText = [
|
|
15902
|
+
`diff --git a/${path} b/${path}`,
|
|
15903
|
+
`--- a/${path}`,
|
|
15904
|
+
`+++ b/${path}`,
|
|
15905
|
+
...hunkLines,
|
|
15906
|
+
'',
|
|
15907
|
+
].join('\n');
|
|
15908
|
+
return { patchText };
|
|
15909
|
+
}
|
|
15910
|
+
|
|
15374
15911
|
/**
|
|
15375
15912
|
* Sort modes for the promoted views (P4.2).
|
|
15376
15913
|
*
|
|
@@ -15740,6 +16277,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
15740
16277
|
sidebarTab: 'status',
|
|
15741
16278
|
userSidebarTab: 'status',
|
|
15742
16279
|
statusFilterMask: { ...DEFAULT_LOG_INK_STATUS_FILTER_MASK },
|
|
16280
|
+
diffViewMode: 'unified',
|
|
15743
16281
|
};
|
|
15744
16282
|
}
|
|
15745
16283
|
function getSelectedInkCommit(state) {
|
|
@@ -15877,6 +16415,7 @@ function applyLogInkAction(state, action) {
|
|
|
15877
16415
|
kind: action.kind,
|
|
15878
16416
|
label: action.label,
|
|
15879
16417
|
value: action.initial || '',
|
|
16418
|
+
multiline: action.multiline,
|
|
15880
16419
|
},
|
|
15881
16420
|
pendingKey: undefined,
|
|
15882
16421
|
};
|
|
@@ -15909,6 +16448,27 @@ function applyLogInkAction(state, action) {
|
|
|
15909
16448
|
}
|
|
15910
16449
|
case 'setHistoryFetchArgs':
|
|
15911
16450
|
return { ...state, historyFetchArgs: action.value, pendingKey: undefined };
|
|
16451
|
+
case 'toggleDiffViewMode':
|
|
16452
|
+
// Reset the scroll offsets so the new mode opens at the top — long
|
|
16453
|
+
// lines wrap differently in split mode (the renderer truncates per
|
|
16454
|
+
// column instead of per row), so the saved offset can land on a
|
|
16455
|
+
// different visual line. Snap to the top is simpler than mapping
|
|
16456
|
+
// unified offsets to split offsets.
|
|
16457
|
+
return {
|
|
16458
|
+
...state,
|
|
16459
|
+
diffViewMode: state.diffViewMode === 'unified' ? 'split' : 'unified',
|
|
16460
|
+
diffPreviewOffset: 0,
|
|
16461
|
+
worktreeDiffOffset: 0,
|
|
16462
|
+
pendingKey: undefined,
|
|
16463
|
+
};
|
|
16464
|
+
case 'setDiffViewMode':
|
|
16465
|
+
return {
|
|
16466
|
+
...state,
|
|
16467
|
+
diffViewMode: action.value,
|
|
16468
|
+
diffPreviewOffset: 0,
|
|
16469
|
+
worktreeDiffOffset: 0,
|
|
16470
|
+
pendingKey: undefined,
|
|
16471
|
+
};
|
|
15912
16472
|
case 'moveToBottom':
|
|
15913
16473
|
return {
|
|
15914
16474
|
...state,
|
|
@@ -16187,12 +16747,151 @@ function applyLogInkAction(state, action) {
|
|
|
16187
16747
|
}
|
|
16188
16748
|
}
|
|
16189
16749
|
|
|
16750
|
+
/**
|
|
16751
|
+
* In-sidebar selection helpers (#791 follow-up — sidebar entity ops).
|
|
16752
|
+
*
|
|
16753
|
+
* The workstation sidebar's branches / tags / stashes / worktrees tabs
|
|
16754
|
+
* used to be read-only previews — to act on an entity the user had to
|
|
16755
|
+
* drill into the dedicated promoted view. With the per-entity ops
|
|
16756
|
+
* gated to also fire on `state.focus === 'sidebar'` plus a matching
|
|
16757
|
+
* `sidebarTab`, j/k navigates the visible list inside the sidebar
|
|
16758
|
+
* itself, Enter performs the primary action (checkout / open diff),
|
|
16759
|
+
* and the existing per-view secondary keys (a/p/X/D/R/u/+P) are now
|
|
16760
|
+
* reachable without leaving the workstation view.
|
|
16761
|
+
*
|
|
16762
|
+
* The sidebar accordion is short — the visible window for an active
|
|
16763
|
+
* tab is capped (defaults below) so a long branch list doesn't
|
|
16764
|
+
* collapse the rest of the chrome. When the cursor scrolls past the
|
|
16765
|
+
* visible window, this module produces a sliding window that keeps it
|
|
16766
|
+
* in view; the dedicated view stays the right home for "show me all
|
|
16767
|
+
* 80 branches at once."
|
|
16768
|
+
*/
|
|
16769
|
+
const DEFAULT_SIDEBAR_VISIBLE = 8;
|
|
16770
|
+
/**
|
|
16771
|
+
* Compute the sliding window so that `selected` stays inside it while
|
|
16772
|
+
* the window remains anchored at the top whenever possible (so short
|
|
16773
|
+
* lists don't scroll for no reason). When the cursor moves past the
|
|
16774
|
+
* window, the window slides just enough to keep the cursor in view —
|
|
16775
|
+
* matching the commit history's `clampWindowStart` behaviour for
|
|
16776
|
+
* familiarity.
|
|
16777
|
+
*/
|
|
16778
|
+
function getSidebarVisibleWindow(total, selected, visible = DEFAULT_SIDEBAR_VISIBLE) {
|
|
16779
|
+
const size = Math.max(1, Math.min(visible, total));
|
|
16780
|
+
if (total <= visible) {
|
|
16781
|
+
return { start: 0, size, truncatedAbove: 0, truncatedBelow: 0 };
|
|
16782
|
+
}
|
|
16783
|
+
const half = Math.floor(size / 2);
|
|
16784
|
+
const idealStart = selected - half;
|
|
16785
|
+
const maxStart = total - size;
|
|
16786
|
+
const start = Math.max(0, Math.min(idealStart, maxStart));
|
|
16787
|
+
return {
|
|
16788
|
+
start,
|
|
16789
|
+
size,
|
|
16790
|
+
truncatedAbove: start,
|
|
16791
|
+
truncatedBelow: total - (start + size),
|
|
16792
|
+
};
|
|
16793
|
+
}
|
|
16794
|
+
/**
|
|
16795
|
+
* True when an in-sidebar action (j/k move, Enter checkout, etc.)
|
|
16796
|
+
* should fire instead of the generic drill-in / tab-cycle behaviour.
|
|
16797
|
+
*
|
|
16798
|
+
* Status tab is excluded because its preview shows worktree files —
|
|
16799
|
+
* those have their own selection model in the dedicated status view
|
|
16800
|
+
* and the sidebar doesn't surface them as selectable rows.
|
|
16801
|
+
*/
|
|
16802
|
+
function sidebarTabHasSelectableItems(sidebarTab, itemCount) {
|
|
16803
|
+
if (!itemCount || itemCount <= 0)
|
|
16804
|
+
return false;
|
|
16805
|
+
return sidebarTab === 'branches' ||
|
|
16806
|
+
sidebarTab === 'tags' ||
|
|
16807
|
+
sidebarTab === 'stashes' ||
|
|
16808
|
+
sidebarTab === 'worktrees';
|
|
16809
|
+
}
|
|
16810
|
+
|
|
16190
16811
|
function action(actionValue) {
|
|
16191
16812
|
return {
|
|
16192
16813
|
type: 'action',
|
|
16193
16814
|
action: actionValue,
|
|
16194
16815
|
};
|
|
16195
16816
|
}
|
|
16817
|
+
/**
|
|
16818
|
+
* Build the events needed to apply the hunk under the diff cursor. The
|
|
16819
|
+
* runtime workflow handler expects payload format `<target>\n<patch>`
|
|
16820
|
+
* — splitting on the first newline keeps the patch body intact for
|
|
16821
|
+
* targets like `worktree` and `index` (no newlines in the prefix).
|
|
16822
|
+
*
|
|
16823
|
+
* Returns [] when the user isn't on a commit-diff / stash-diff explore,
|
|
16824
|
+
* or when no hunk can be extracted at the current cursor offset
|
|
16825
|
+
* (e.g. cursor sits on a `diff --git` header before the first `@@`).
|
|
16826
|
+
* Callers fall back to a contextual status message when this returns [].
|
|
16827
|
+
*/
|
|
16828
|
+
function buildApplyHunkEvents(state, context, target) {
|
|
16829
|
+
if (state.activeView !== 'diff')
|
|
16830
|
+
return [];
|
|
16831
|
+
if (state.diffSource !== 'commit' && state.diffSource !== 'stash')
|
|
16832
|
+
return [];
|
|
16833
|
+
const lines = context.diffLinesForHunkApply;
|
|
16834
|
+
if (!lines || lines.length === 0)
|
|
16835
|
+
return [];
|
|
16836
|
+
const path = state.diffSource === 'stash'
|
|
16837
|
+
? context.stashDiffSelectedPath
|
|
16838
|
+
: context.commitDiffSelectedPath;
|
|
16839
|
+
if (!path)
|
|
16840
|
+
return [];
|
|
16841
|
+
const extracted = extractDiffHunk({
|
|
16842
|
+
lines,
|
|
16843
|
+
cursorOffset: state.diffPreviewOffset,
|
|
16844
|
+
path,
|
|
16845
|
+
});
|
|
16846
|
+
if (!extracted)
|
|
16847
|
+
return [];
|
|
16848
|
+
const id = target === 'index' ? 'apply-hunk-index' : 'apply-hunk-worktree';
|
|
16849
|
+
return [{
|
|
16850
|
+
type: 'runWorkflowAction',
|
|
16851
|
+
id,
|
|
16852
|
+
payload: `${target}\n${extracted.patchText}`,
|
|
16853
|
+
}];
|
|
16854
|
+
}
|
|
16855
|
+
/**
|
|
16856
|
+
* Per-entity action-target predicates. The promoted views (`branches`,
|
|
16857
|
+
* `tags`, `stash`, `worktrees`) each scope a set of ops to their
|
|
16858
|
+
* dedicated surface. The same ops also fire when the user has the
|
|
16859
|
+
* sidebar focused on the matching tab — that's how in-sidebar
|
|
16860
|
+
* selection (#791 follow-up) lets the user checkout / apply / drop
|
|
16861
|
+
* without leaving the workstation view.
|
|
16862
|
+
*/
|
|
16863
|
+
function isBranchActionTarget(state) {
|
|
16864
|
+
return (state.activeView === 'branches' && state.focus === 'commits') ||
|
|
16865
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
16866
|
+
}
|
|
16867
|
+
function isTagActionTarget(state) {
|
|
16868
|
+
return (state.activeView === 'tags' && state.focus === 'commits') ||
|
|
16869
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
16870
|
+
}
|
|
16871
|
+
function isStashActionTarget(state) {
|
|
16872
|
+
return (state.activeView === 'stash' && state.focus === 'commits') ||
|
|
16873
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'stashes');
|
|
16874
|
+
}
|
|
16875
|
+
function isWorktreeActionTarget(state) {
|
|
16876
|
+
return (state.activeView === 'worktrees' && state.focus === 'commits') ||
|
|
16877
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
|
|
16878
|
+
}
|
|
16879
|
+
/**
|
|
16880
|
+
* Item count for the active sidebar tab — used by the generic
|
|
16881
|
+
* sidebar-Enter handler to decide whether to defer to the per-entity
|
|
16882
|
+
* Enter (when items are present and the user is cursoring through
|
|
16883
|
+
* them) or to drill into the dedicated view (when the tab is empty
|
|
16884
|
+
* or has no per-entity Enter handler defined).
|
|
16885
|
+
*/
|
|
16886
|
+
function getSidebarItemCount(sidebarTab, context) {
|
|
16887
|
+
switch (sidebarTab) {
|
|
16888
|
+
case 'branches': return context.branchCount;
|
|
16889
|
+
case 'tags': return context.tagCount;
|
|
16890
|
+
case 'stashes': return context.stashCount;
|
|
16891
|
+
case 'worktrees': return context.worktreeListCount;
|
|
16892
|
+
default: return undefined;
|
|
16893
|
+
}
|
|
16894
|
+
}
|
|
16196
16895
|
/**
|
|
16197
16896
|
* Translate a palette command into the same events its keystroke would have
|
|
16198
16897
|
* produced. Phase 6 makes `:` a real launcher: this is the single mapping
|
|
@@ -16272,6 +16971,8 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
16272
16971
|
return [action({ type: 'pushView', value: 'stash' })];
|
|
16273
16972
|
case 'navigateWorktrees':
|
|
16274
16973
|
return [action({ type: 'pushView', value: 'worktrees' })];
|
|
16974
|
+
case 'navigatePullRequest':
|
|
16975
|
+
return [action({ type: 'pushView', value: 'pull-request' })];
|
|
16275
16976
|
case 'navigateBack':
|
|
16276
16977
|
return [action({ type: 'popView' })];
|
|
16277
16978
|
case 'openSelected': {
|
|
@@ -16329,10 +17030,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
16329
17030
|
case 'clearSearch':
|
|
16330
17031
|
return [action({ type: 'clearFilter' })];
|
|
16331
17032
|
case 'cycleSort':
|
|
16332
|
-
if (state
|
|
17033
|
+
if (isBranchActionTarget(state)) {
|
|
16333
17034
|
return [action({ type: 'cycleBranchSort' })];
|
|
16334
17035
|
}
|
|
16335
|
-
if (state
|
|
17036
|
+
if (isTagActionTarget(state)) {
|
|
16336
17037
|
return [action({ type: 'cycleTagSort' })];
|
|
16337
17038
|
}
|
|
16338
17039
|
return [action({
|
|
@@ -16369,6 +17070,77 @@ function hasUnsavedComposeDraft(state) {
|
|
|
16369
17070
|
}
|
|
16370
17071
|
return Boolean(compose.summary.trim() || compose.body.trim());
|
|
16371
17072
|
}
|
|
17073
|
+
/**
|
|
17074
|
+
* Submit the active input prompt — used by Enter on single-line
|
|
17075
|
+
* prompts and by Ctrl+D on multi-line prompts (#806). Most prompt
|
|
17076
|
+
* kinds dispatch a workflow whose id matches the kind
|
|
17077
|
+
* (`create-branch`, `rename-branch`, etc.). A few are exceptions:
|
|
17078
|
+
* - `reset-mode` (#777) collects soft/mixed/hard and forwards the
|
|
17079
|
+
* mode as the payload to `reset-to-commit`.
|
|
17080
|
+
* - `pr-merge-strategy` (#783) validates the strategy and routes to
|
|
17081
|
+
* `merge-pr` via the y-confirm path.
|
|
17082
|
+
* - `pr-comment` dispatches `comment-pr` directly — the body itself
|
|
17083
|
+
* is the affirmative action.
|
|
17084
|
+
* - `pr-request-changes` routes to `request-changes-pr` via
|
|
17085
|
+
* y-confirm because the review is publicly visible.
|
|
17086
|
+
* Each exception validates here so a typo doesn't surface as a
|
|
17087
|
+
* "workflow not yet wired" status downstream.
|
|
17088
|
+
*
|
|
17089
|
+
* Empty values yield a hint instead of a no-op so the user knows what
|
|
17090
|
+
* to do — the same UX whether they pressed Enter (single-line) or
|
|
17091
|
+
* Ctrl+D (multi-line).
|
|
17092
|
+
*/
|
|
17093
|
+
function submitInputPrompt(state) {
|
|
17094
|
+
if (!state.inputPrompt)
|
|
17095
|
+
return [];
|
|
17096
|
+
const value = state.inputPrompt.value.trim();
|
|
17097
|
+
if (!value) {
|
|
17098
|
+
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
|
|
17099
|
+
}
|
|
17100
|
+
if (state.inputPrompt.kind === 'reset-mode') {
|
|
17101
|
+
const mode = value.toLowerCase();
|
|
17102
|
+
if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
|
|
17103
|
+
return [action({
|
|
17104
|
+
type: 'setStatus',
|
|
17105
|
+
value: `Unknown reset mode: ${value}. Use soft, mixed, or hard.`,
|
|
17106
|
+
})];
|
|
17107
|
+
}
|
|
17108
|
+
return [
|
|
17109
|
+
{ type: 'runWorkflowAction', id: 'reset-to-commit', payload: mode },
|
|
17110
|
+
action({ type: 'closeInputPrompt' }),
|
|
17111
|
+
];
|
|
17112
|
+
}
|
|
17113
|
+
if (state.inputPrompt.kind === 'pr-merge-strategy') {
|
|
17114
|
+
const strategy = value.toLowerCase();
|
|
17115
|
+
if (strategy !== 'merge' && strategy !== 'squash' && strategy !== 'rebase') {
|
|
17116
|
+
return [action({
|
|
17117
|
+
type: 'setStatus',
|
|
17118
|
+
value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
|
|
17119
|
+
})];
|
|
17120
|
+
}
|
|
17121
|
+
return [
|
|
17122
|
+
action({ type: 'setPendingConfirmation', value: 'merge-pr', payload: strategy }),
|
|
17123
|
+
action({ type: 'closeInputPrompt' }),
|
|
17124
|
+
];
|
|
17125
|
+
}
|
|
17126
|
+
if (state.inputPrompt.kind === 'pr-comment') {
|
|
17127
|
+
return [
|
|
17128
|
+
{ type: 'runWorkflowAction', id: 'comment-pr', payload: value },
|
|
17129
|
+
action({ type: 'closeInputPrompt' }),
|
|
17130
|
+
];
|
|
17131
|
+
}
|
|
17132
|
+
if (state.inputPrompt.kind === 'pr-request-changes') {
|
|
17133
|
+
return [
|
|
17134
|
+
action({ type: 'setPendingConfirmation', value: 'request-changes-pr', payload: value }),
|
|
17135
|
+
action({ type: 'closeInputPrompt' }),
|
|
17136
|
+
];
|
|
17137
|
+
}
|
|
17138
|
+
const id = state.inputPrompt.kind;
|
|
17139
|
+
return [
|
|
17140
|
+
{ type: 'runWorkflowAction', id, payload: value },
|
|
17141
|
+
action({ type: 'closeInputPrompt' }),
|
|
17142
|
+
];
|
|
17143
|
+
}
|
|
16372
17144
|
function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
16373
17145
|
if (key.ctrl && inputValue === 'c') {
|
|
16374
17146
|
if (hasUnsavedComposeDraft(state) && !state.pendingMutationConfirmation) {
|
|
@@ -16381,22 +17153,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16381
17153
|
// filter/confirmation/compose handlers so a prompt opened from inside
|
|
16382
17154
|
// any of those still captures focus cleanly.
|
|
16383
17155
|
if (state.inputPrompt) {
|
|
17156
|
+
const isMultiline = Boolean(state.inputPrompt.multiline);
|
|
16384
17157
|
if (key.escape) {
|
|
16385
17158
|
return [
|
|
16386
17159
|
action({ type: 'closeInputPrompt' }),
|
|
16387
17160
|
action({ type: 'setStatus', value: 'cancelled' }),
|
|
16388
17161
|
];
|
|
16389
17162
|
}
|
|
17163
|
+
// Multi-line prompts (#806): Ctrl+D submits (Unix EOF convention,
|
|
17164
|
+
// mirrors `git commit -m -` and HEREDOC patterns). Plain Enter
|
|
17165
|
+
// inserts a newline so the user can compose review bodies / PR
|
|
17166
|
+
// comments naturally without opening $EDITOR.
|
|
17167
|
+
if (isMultiline && key.ctrl && inputValue === 'd') {
|
|
17168
|
+
return submitInputPrompt(state);
|
|
17169
|
+
}
|
|
17170
|
+
if (isMultiline && key.return) {
|
|
17171
|
+
return [action({ type: 'appendInputPrompt', value: '\n' })];
|
|
17172
|
+
}
|
|
16390
17173
|
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
|
-
];
|
|
17174
|
+
return submitInputPrompt(state);
|
|
16400
17175
|
}
|
|
16401
17176
|
if (key.backspace || key.delete) {
|
|
16402
17177
|
return [action({ type: 'backspaceInputPrompt' })];
|
|
@@ -16648,14 +17423,40 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16648
17423
|
}
|
|
16649
17424
|
if (state.pendingKey === 'g' && inputValue === 'z') {
|
|
16650
17425
|
return [
|
|
16651
|
-
action({ type: 'pushView', value: 'stash' }),
|
|
16652
|
-
action({ type: 'setStatus', value: 'jumped to stash' }),
|
|
17426
|
+
action({ type: 'pushView', value: 'stash' }),
|
|
17427
|
+
action({ type: 'setStatus', value: 'jumped to stash' }),
|
|
17428
|
+
];
|
|
17429
|
+
}
|
|
17430
|
+
if (state.pendingKey === 'g' && inputValue === 'w') {
|
|
17431
|
+
return [
|
|
17432
|
+
action({ type: 'pushView', value: 'worktrees' }),
|
|
17433
|
+
action({ type: 'setStatus', value: 'jumped to worktrees' }),
|
|
17434
|
+
];
|
|
17435
|
+
}
|
|
17436
|
+
// `gp` jumps to the dedicated pull-request action panel (#783).
|
|
17437
|
+
// Lowercase `p` matches the pattern of other navigation chords
|
|
17438
|
+
// (gh / gs / gd / gc / gb / gt / gz / gw). The panel renders the
|
|
17439
|
+
// current branch's PR via `gh pr view --json` enriched fields and
|
|
17440
|
+
// exposes m / x / a / R / c action keys scoped to the view.
|
|
17441
|
+
if (state.pendingKey === 'g' && inputValue === 'p') {
|
|
17442
|
+
return [
|
|
17443
|
+
action({ type: 'pushView', value: 'pull-request' }),
|
|
17444
|
+
action({ type: 'setStatus', value: 'jumped to pull request' }),
|
|
16653
17445
|
];
|
|
16654
17446
|
}
|
|
16655
|
-
|
|
17447
|
+
// `gH` chord: apply the cursored hunk to the index (`git apply
|
|
17448
|
+
// --cached`). Sibling of bare `H` which targets the worktree.
|
|
17449
|
+
// Discoverable via the footer hint on diff views and the help
|
|
17450
|
+
// overlay; the explicit chord keeps `H` (single keystroke) for
|
|
17451
|
+
// the more common worktree case.
|
|
17452
|
+
if (state.pendingKey === 'g' && inputValue === 'H') {
|
|
17453
|
+
const events = buildApplyHunkEvents(state, context, 'index');
|
|
17454
|
+
if (events.length) {
|
|
17455
|
+
return [action({ type: 'setPendingKey', value: undefined }), ...events];
|
|
17456
|
+
}
|
|
16656
17457
|
return [
|
|
16657
|
-
action({ type: '
|
|
16658
|
-
action({ type: 'setStatus', value: '
|
|
17458
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
17459
|
+
action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
|
|
16659
17460
|
];
|
|
16660
17461
|
}
|
|
16661
17462
|
if (inputValue === 'g') {
|
|
@@ -16667,6 +17468,22 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16667
17468
|
}
|
|
16668
17469
|
return [action({ type: 'setPendingKey', value: 'g' })];
|
|
16669
17470
|
}
|
|
17471
|
+
// `d` on the diff view toggles between unified and side-by-side split
|
|
17472
|
+
// rendering (#785). Scoped to the diff view so the letter stays free
|
|
17473
|
+
// for other surfaces. The chord branch above already claimed `gd`,
|
|
17474
|
+
// so by the time we get here `pendingKey` is not `g`.
|
|
17475
|
+
if (inputValue === 'd' && state.activeView === 'diff') {
|
|
17476
|
+
const next = state.diffViewMode === 'unified' ? 'split' : 'unified';
|
|
17477
|
+
return [
|
|
17478
|
+
action({ type: 'toggleDiffViewMode' }),
|
|
17479
|
+
action({
|
|
17480
|
+
type: 'setStatus',
|
|
17481
|
+
value: next === 'split'
|
|
17482
|
+
? 'Switched to side-by-side diff'
|
|
17483
|
+
: 'Switched to unified diff',
|
|
17484
|
+
}),
|
|
17485
|
+
];
|
|
17486
|
+
}
|
|
16670
17487
|
if (inputValue === '\\') {
|
|
16671
17488
|
return [action({ type: 'toggleGraph' })];
|
|
16672
17489
|
}
|
|
@@ -16689,10 +17506,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16689
17506
|
return [{ type: 'refreshContext' }];
|
|
16690
17507
|
}
|
|
16691
17508
|
if (inputValue === 's') {
|
|
16692
|
-
if (state
|
|
17509
|
+
if (isBranchActionTarget(state)) {
|
|
16693
17510
|
return [action({ type: 'cycleBranchSort' })];
|
|
16694
17511
|
}
|
|
16695
|
-
if (state
|
|
17512
|
+
if (isTagActionTarget(state)) {
|
|
16696
17513
|
return [action({ type: 'cycleTagSort' })];
|
|
16697
17514
|
}
|
|
16698
17515
|
// Falls through so other views (history/status/diff/compose/stash) still
|
|
@@ -16763,6 +17580,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16763
17580
|
if (key.tab) {
|
|
16764
17581
|
return [action({ type: key.shift ? 'focusPrevious' : 'focusNext' })];
|
|
16765
17582
|
}
|
|
17583
|
+
// ←/→ on the sidebar switch tabs (Status ↔ Branches ↔ Tags ↔
|
|
17584
|
+
// Stashes ↔ Worktrees) — the horizontal axis is "between tabs", the
|
|
17585
|
+
// vertical axis (↑/↓ below) is "within the active tab's items".
|
|
17586
|
+
// [/] still works as a keyboard alternative for users who prefer
|
|
17587
|
+
// non-arrow keys.
|
|
17588
|
+
if (key.leftArrow && state.focus === 'sidebar') {
|
|
17589
|
+
return [action({ type: 'previousSidebarTab' })];
|
|
17590
|
+
}
|
|
17591
|
+
if (key.rightArrow && state.focus === 'sidebar') {
|
|
17592
|
+
return [action({ type: 'nextSidebarTab' })];
|
|
17593
|
+
}
|
|
16766
17594
|
if (key.upArrow || inputValue === 'k') {
|
|
16767
17595
|
if (state.focus === 'detail' && context.detailFileCount) {
|
|
16768
17596
|
return [action({ type: 'moveDetailFile', delta: -1, fileCount: context.detailFileCount })];
|
|
@@ -16792,16 +17620,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16792
17620
|
previewLineCount: context.previewLineCount,
|
|
16793
17621
|
})];
|
|
16794
17622
|
}
|
|
16795
|
-
if (state
|
|
17623
|
+
if (isBranchActionTarget(state) && context.branchCount) {
|
|
16796
17624
|
return [action({ type: 'moveBranch', delta: -1, count: context.branchCount })];
|
|
16797
17625
|
}
|
|
16798
|
-
if (state
|
|
17626
|
+
if (isTagActionTarget(state) && context.tagCount) {
|
|
16799
17627
|
return [action({ type: 'moveTag', delta: -1, count: context.tagCount })];
|
|
16800
17628
|
}
|
|
16801
|
-
if (state
|
|
17629
|
+
if (isStashActionTarget(state) && context.stashCount) {
|
|
16802
17630
|
return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
|
|
16803
17631
|
}
|
|
16804
|
-
if (state
|
|
17632
|
+
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
16805
17633
|
return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
|
|
16806
17634
|
}
|
|
16807
17635
|
if (state.activeView === 'history' &&
|
|
@@ -16811,6 +17639,11 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16811
17639
|
context.worktreeDirty) {
|
|
16812
17640
|
return [action({ type: 'focusPendingCommit' })];
|
|
16813
17641
|
}
|
|
17642
|
+
// Sidebar fallback: when no entity claim above succeeds (status
|
|
17643
|
+
// tab or empty content tab), ↑ falls through to cycling sidebar
|
|
17644
|
+
// tabs so the user always has a way to navigate. With ←/→ above
|
|
17645
|
+
// already handling tab switching, this is mostly a vim-style
|
|
17646
|
+
// safety net for `k`.
|
|
16814
17647
|
return [
|
|
16815
17648
|
action(state.focus === 'sidebar'
|
|
16816
17649
|
? { type: 'previousSidebarTab' }
|
|
@@ -16845,16 +17678,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16845
17678
|
previewLineCount: context.previewLineCount,
|
|
16846
17679
|
})];
|
|
16847
17680
|
}
|
|
16848
|
-
if (state
|
|
17681
|
+
if (isBranchActionTarget(state) && context.branchCount) {
|
|
16849
17682
|
return [action({ type: 'moveBranch', delta: 1, count: context.branchCount })];
|
|
16850
17683
|
}
|
|
16851
|
-
if (state
|
|
17684
|
+
if (isTagActionTarget(state) && context.tagCount) {
|
|
16852
17685
|
return [action({ type: 'moveTag', delta: 1, count: context.tagCount })];
|
|
16853
17686
|
}
|
|
16854
|
-
if (state
|
|
17687
|
+
if (isStashActionTarget(state) && context.stashCount) {
|
|
16855
17688
|
return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
|
|
16856
17689
|
}
|
|
16857
|
-
if (state
|
|
17690
|
+
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
16858
17691
|
return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
|
|
16859
17692
|
}
|
|
16860
17693
|
return [
|
|
@@ -16958,30 +17791,42 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16958
17791
|
}
|
|
16959
17792
|
}
|
|
16960
17793
|
// Enter on a sidebar tab drills into the corresponding promoted view
|
|
16961
|
-
// (status / branches / tags / stash)
|
|
16962
|
-
//
|
|
16963
|
-
//
|
|
16964
|
-
// the
|
|
17794
|
+
// (status / branches / tags / stash) — but only when the sidebar tab
|
|
17795
|
+
// either has no per-entity Enter handler defined (status, tags,
|
|
17796
|
+
// worktrees) or has zero items (so the dedicated view's empty-state
|
|
17797
|
+
// tells the user what to do next).
|
|
17798
|
+
//
|
|
17799
|
+
// When the sidebar IS focused on a content tab WITH items, this
|
|
17800
|
+
// handler defers to the per-entity Enter below (checkout-branch for
|
|
17801
|
+
// branches, navigateOpenDiffForStash for stashes) so the user can
|
|
17802
|
+
// act on the cursored item without leaving the workstation view —
|
|
17803
|
+
// the in-sidebar selection win from #791 follow-up.
|
|
16965
17804
|
//
|
|
16966
|
-
// The
|
|
16967
|
-
// list — otherwise ↑/↓ keep
|
|
16968
|
-
//
|
|
17805
|
+
// The drill-in moves focus out of the sidebar into the newly opened
|
|
17806
|
+
// list — otherwise ↑/↓ keep navigating the sidebar instead of the
|
|
17807
|
+
// just-opened view, which made the drill-in feel half-done.
|
|
16969
17808
|
if (key.return && state.focus === 'sidebar') {
|
|
16970
|
-
const
|
|
16971
|
-
|
|
16972
|
-
|
|
16973
|
-
|
|
16974
|
-
|
|
16975
|
-
|
|
16976
|
-
|
|
16977
|
-
|
|
16978
|
-
|
|
16979
|
-
|
|
16980
|
-
|
|
16981
|
-
|
|
16982
|
-
|
|
17809
|
+
const sidebarItemCount = getSidebarItemCount(state.sidebarTab, context);
|
|
17810
|
+
const hasInSidebarPrimaryAction = (state.sidebarTab === 'branches' || state.sidebarTab === 'stashes') &&
|
|
17811
|
+
sidebarTabHasSelectableItems(state.sidebarTab, sidebarItemCount);
|
|
17812
|
+
if (!hasInSidebarPrimaryAction) {
|
|
17813
|
+
const tabToView = {
|
|
17814
|
+
status: 'status',
|
|
17815
|
+
branches: 'branches',
|
|
17816
|
+
tags: 'tags',
|
|
17817
|
+
stashes: 'stash',
|
|
17818
|
+
worktrees: 'worktrees',
|
|
17819
|
+
};
|
|
17820
|
+
const target = tabToView[state.sidebarTab];
|
|
17821
|
+
if (target) {
|
|
17822
|
+
return [
|
|
17823
|
+
action({ type: 'pushView', value: target }),
|
|
17824
|
+
action({ type: 'setFocus', value: 'commits' }),
|
|
17825
|
+
];
|
|
17826
|
+
}
|
|
17827
|
+
return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
|
|
16983
17828
|
}
|
|
16984
|
-
|
|
17829
|
+
// Fall through — per-entity Enter handler below claims the keystroke.
|
|
16985
17830
|
}
|
|
16986
17831
|
if (key.return && state.activeView === 'status' && state.focus === 'commits' && context.worktreeFileCount) {
|
|
16987
17832
|
return [action({
|
|
@@ -16990,8 +17835,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
16990
17835
|
})];
|
|
16991
17836
|
}
|
|
16992
17837
|
// Enter on a branch row checks the branch out. Non-destructive workflow
|
|
16993
|
-
// action — no confirmation prompt.
|
|
16994
|
-
|
|
17838
|
+
// action — no confirmation prompt. Fires from either the dedicated
|
|
17839
|
+
// branches view or from the sidebar when the branches tab is focused
|
|
17840
|
+
// with items.
|
|
17841
|
+
if (key.return && isBranchActionTarget(state) && context.branchCount) {
|
|
16995
17842
|
return [{ type: 'runWorkflowAction', id: 'checkout-branch' }];
|
|
16996
17843
|
}
|
|
16997
17844
|
// `+` opens a create-branch / create-tag prompt depending on context.
|
|
@@ -17018,32 +17865,33 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17018
17865
|
}
|
|
17019
17866
|
// Per-view stash actions: `a` apply (keep the stash), `p` pop (apply
|
|
17020
17867
|
// 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
|
-
|
|
17868
|
+
// routes through the y-confirm path. Scoped to the stash target so
|
|
17869
|
+
// the letters stay free elsewhere — the target predicate also fires
|
|
17870
|
+
// when the sidebar's stashes tab is focused with items.
|
|
17871
|
+
if (inputValue === 'a' && isStashActionTarget(state) && context.stashCount) {
|
|
17024
17872
|
return [{ type: 'runWorkflowAction', id: 'apply-stash' }];
|
|
17025
17873
|
}
|
|
17026
|
-
if (inputValue === 'p' && state
|
|
17874
|
+
if (inputValue === 'p' && isStashActionTarget(state) && context.stashCount) {
|
|
17027
17875
|
return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
|
|
17028
17876
|
}
|
|
17029
17877
|
// Per-view tag action: `P` pushes the selected tag to origin. Letter
|
|
17030
|
-
// is scoped to the tags
|
|
17878
|
+
// is scoped to the tags target so it doesn't collide with `p` for
|
|
17031
17879
|
// pop-stash. Note: this also takes precedence over the global
|
|
17032
17880
|
// push-current-branch workflow's `P` key.
|
|
17033
|
-
if (inputValue === 'P' && state
|
|
17881
|
+
if (inputValue === 'P' && isTagActionTarget(state) && context.tagCount) {
|
|
17034
17882
|
return [{ type: 'runWorkflowAction', id: 'push-tag' }];
|
|
17035
17883
|
}
|
|
17036
17884
|
// Per-view branches actions: `R` renames the selected branch, `u`
|
|
17037
17885
|
// sets its upstream. Both open the input prompt so the user can type
|
|
17038
17886
|
// the new value. Pre-fills are handled by the prompt's `initial`.
|
|
17039
|
-
if (inputValue === 'R' && state
|
|
17887
|
+
if (inputValue === 'R' && isBranchActionTarget(state) && context.branchCount) {
|
|
17040
17888
|
return [action({
|
|
17041
17889
|
type: 'openInputPrompt',
|
|
17042
17890
|
kind: 'rename-branch',
|
|
17043
17891
|
label: 'Rename branch to',
|
|
17044
17892
|
})];
|
|
17045
17893
|
}
|
|
17046
|
-
if (inputValue === 'u' && state
|
|
17894
|
+
if (inputValue === 'u' && isBranchActionTarget(state) && context.branchCount) {
|
|
17047
17895
|
return [action({
|
|
17048
17896
|
type: 'openInputPrompt',
|
|
17049
17897
|
kind: 'set-upstream',
|
|
@@ -17051,11 +17899,48 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17051
17899
|
})];
|
|
17052
17900
|
}
|
|
17053
17901
|
// 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
|
|
17902
|
+
// confirmation). Scoped per-target so this letter is free elsewhere
|
|
17903
|
+
// (especially the `R` rename binding on the branches target).
|
|
17904
|
+
if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
|
|
17057
17905
|
return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
|
|
17058
17906
|
}
|
|
17907
|
+
// #783 — full PR action panel keys, scoped to the pull-request view.
|
|
17908
|
+
// All five wrap a `gh pr <verb>` invocation; merge / request-changes /
|
|
17909
|
+
// comment open prompts first, the rest route through the y-confirm
|
|
17910
|
+
// path because they're irreversible (or near-irreversible).
|
|
17911
|
+
if (inputValue === 'm' && state.activeView === 'pull-request') {
|
|
17912
|
+
return [action({
|
|
17913
|
+
type: 'openInputPrompt',
|
|
17914
|
+
kind: 'pr-merge-strategy',
|
|
17915
|
+
label: 'Merge strategy (merge / squash / rebase)',
|
|
17916
|
+
})];
|
|
17917
|
+
}
|
|
17918
|
+
if (inputValue === 'x' && state.activeView === 'pull-request') {
|
|
17919
|
+
return [action({ type: 'setPendingConfirmation', value: 'close-pr' })];
|
|
17920
|
+
}
|
|
17921
|
+
if (inputValue === 'a' && state.activeView === 'pull-request') {
|
|
17922
|
+
return [action({ type: 'setPendingConfirmation', value: 'approve-pr' })];
|
|
17923
|
+
}
|
|
17924
|
+
if (inputValue === 'R' && state.activeView === 'pull-request') {
|
|
17925
|
+
// Free-form review body — multi-line so the reviewer can structure
|
|
17926
|
+
// their feedback naturally without opening $EDITOR (#806).
|
|
17927
|
+
return [action({
|
|
17928
|
+
type: 'openInputPrompt',
|
|
17929
|
+
kind: 'pr-request-changes',
|
|
17930
|
+
label: 'Request changes — review body (Enter newline · Ctrl+D submit)',
|
|
17931
|
+
multiline: true,
|
|
17932
|
+
})];
|
|
17933
|
+
}
|
|
17934
|
+
if (inputValue === 'c' && state.activeView === 'pull-request') {
|
|
17935
|
+
// Free-form comment body — multi-line for the same reason as
|
|
17936
|
+
// pr-request-changes.
|
|
17937
|
+
return [action({
|
|
17938
|
+
type: 'openInputPrompt',
|
|
17939
|
+
kind: 'pr-comment',
|
|
17940
|
+
label: 'Comment body (Enter newline · Ctrl+D submit)',
|
|
17941
|
+
multiline: true,
|
|
17942
|
+
})];
|
|
17943
|
+
}
|
|
17059
17944
|
// Global stash hotkey: `S` opens a stash-message prompt and
|
|
17060
17945
|
// `createStash` runs once submitted. Available everywhere there's
|
|
17061
17946
|
// not a more modal handler in front of it.
|
|
@@ -17113,6 +17998,21 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17113
17998
|
payload: `${context.commitDiffSelectedSha} ${context.commitDiffSelectedPath}`,
|
|
17114
17999
|
})];
|
|
17115
18000
|
}
|
|
18001
|
+
// `H` on a commit-diff or stash-diff explore extracts the hunk under
|
|
18002
|
+
// the cursor and applies it to the working tree (`git apply`). The
|
|
18003
|
+
// sibling `gH` chord targets the index (`git apply --cached`). Both
|
|
18004
|
+
// bypass the y-confirm path because `git apply` is non-destructive
|
|
18005
|
+
// (it'll fail loudly on conflict and `git apply -R` undoes a clean
|
|
18006
|
+
// apply).
|
|
18007
|
+
if (inputValue === 'H') {
|
|
18008
|
+
const events = buildApplyHunkEvents(state, context, 'worktree');
|
|
18009
|
+
if (events.length) {
|
|
18010
|
+
return events;
|
|
18011
|
+
}
|
|
18012
|
+
if (state.activeView === 'diff' && (state.diffSource === 'commit' || state.diffSource === 'stash')) {
|
|
18013
|
+
return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first' })];
|
|
18014
|
+
}
|
|
18015
|
+
}
|
|
17116
18016
|
// `c` on the history view cherry-picks the full selected commit on
|
|
17117
18017
|
// top of the current branch. Routed through the y-confirm flow since
|
|
17118
18018
|
// it can produce conflicts and is a real working-tree mutation.
|
|
@@ -17123,6 +18023,46 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17123
18023
|
!state.pendingCommitFocused) {
|
|
17124
18024
|
return [action({ type: 'setPendingConfirmation', value: 'cherry-pick-commit' })];
|
|
17125
18025
|
}
|
|
18026
|
+
// `R` reverts the cursored commit by adding an inverse commit on top
|
|
18027
|
+
// of HEAD. Same y-confirm gate as cherry-pick — non-rewriting but
|
|
18028
|
+
// still a real mutation.
|
|
18029
|
+
if (inputValue === 'R' &&
|
|
18030
|
+
state.activeView === 'history' &&
|
|
18031
|
+
state.focus === 'commits' &&
|
|
18032
|
+
state.filteredCommits.length > 0 &&
|
|
18033
|
+
!state.pendingCommitFocused) {
|
|
18034
|
+
return [action({ type: 'setPendingConfirmation', value: 'revert-commit' })];
|
|
18035
|
+
}
|
|
18036
|
+
// `Z` resets the current branch tip to the cursored commit. Opens a
|
|
18037
|
+
// mode prompt (soft / mixed / hard) instead of jumping straight to
|
|
18038
|
+
// confirmation because the choice changes the destructiveness
|
|
18039
|
+
// dramatically — `--hard` discards working-tree changes. The prompt
|
|
18040
|
+
// submission special-cases `kind === 'reset-mode'` to forward the
|
|
18041
|
+
// mode through `reset-to-commit` (see prompt-submit handler above).
|
|
18042
|
+
// No `initial` value: existing prompts append to initial rather than
|
|
18043
|
+
// replacing it, which would surprise the user typing the mode.
|
|
18044
|
+
if (inputValue === 'Z' &&
|
|
18045
|
+
state.activeView === 'history' &&
|
|
18046
|
+
state.focus === 'commits' &&
|
|
18047
|
+
state.filteredCommits.length > 0 &&
|
|
18048
|
+
!state.pendingCommitFocused) {
|
|
18049
|
+
return [action({
|
|
18050
|
+
type: 'openInputPrompt',
|
|
18051
|
+
kind: 'reset-mode',
|
|
18052
|
+
label: 'Reset mode (soft / mixed / hard)',
|
|
18053
|
+
})];
|
|
18054
|
+
}
|
|
18055
|
+
// `i` (lowercase) starts an interactive rebase from the cursored
|
|
18056
|
+
// commit's parent. Lowercase keeps the existing global `I`
|
|
18057
|
+
// ai-commit-summary workflow reachable on the history view; `i`
|
|
18058
|
+
// also matches the `git rebase -i` flag mnemonic.
|
|
18059
|
+
if (inputValue === 'i' &&
|
|
18060
|
+
state.activeView === 'history' &&
|
|
18061
|
+
state.focus === 'commits' &&
|
|
18062
|
+
state.filteredCommits.length > 0 &&
|
|
18063
|
+
!state.pendingCommitFocused) {
|
|
18064
|
+
return [action({ type: 'setPendingConfirmation', value: 'interactive-rebase' })];
|
|
18065
|
+
}
|
|
17126
18066
|
// `y` / `Y` yank the contextually relevant identifier from the active
|
|
17127
18067
|
// view to the system clipboard:
|
|
17128
18068
|
// history → cursored commit hash (Y for short hash)
|
|
@@ -17138,13 +18078,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17138
18078
|
if (state.activeView === 'history' && state.filteredCommits.length > 0) {
|
|
17139
18079
|
return [{ type: 'yankFromActiveView', short }];
|
|
17140
18080
|
}
|
|
17141
|
-
if (state
|
|
18081
|
+
if (isBranchActionTarget(state) && context.branchCount) {
|
|
17142
18082
|
return [{ type: 'yankFromActiveView' }];
|
|
17143
18083
|
}
|
|
17144
|
-
if (state
|
|
18084
|
+
if (isTagActionTarget(state) && context.tagCount) {
|
|
17145
18085
|
return [{ type: 'yankFromActiveView' }];
|
|
17146
18086
|
}
|
|
17147
|
-
if (state
|
|
18087
|
+
if (isStashActionTarget(state) && context.stashCount && context.stashSelectedRef) {
|
|
17148
18088
|
return [{ type: 'yankFromActiveView' }];
|
|
17149
18089
|
}
|
|
17150
18090
|
if (state.activeView === 'status' && context.worktreeSelectedPath) {
|
|
@@ -17162,8 +18102,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17162
18102
|
// Enter on a stash row pushes the diff view scoped to that stash.
|
|
17163
18103
|
// The runtime loads `git stash show -p <ref>` once the view is
|
|
17164
18104
|
// active. The stash ref is passed via the action so we don't need a
|
|
17165
|
-
// context lookup here.
|
|
17166
|
-
|
|
18105
|
+
// context lookup here. Fires from either the dedicated stash view or
|
|
18106
|
+
// from the sidebar when the stashes tab is focused with items.
|
|
18107
|
+
if (key.return && isStashActionTarget(state) && context.stashCount && context.stashSelectedRef) {
|
|
17167
18108
|
return [action({
|
|
17168
18109
|
type: 'navigateOpenDiffForStash',
|
|
17169
18110
|
ref: context.stashSelectedRef,
|
|
@@ -17224,7 +18165,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17224
18165
|
* fall back to "already seen" so we never block startup.
|
|
17225
18166
|
*/
|
|
17226
18167
|
const MARKER_BASENAME = 'onboarding.seen';
|
|
17227
|
-
function resolveCacheDir$
|
|
18168
|
+
function resolveCacheDir$2() {
|
|
17228
18169
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
17229
18170
|
if (xdg && xdg.trim().length > 0) {
|
|
17230
18171
|
return path$1.join(xdg, 'coco');
|
|
@@ -17232,7 +18173,7 @@ function resolveCacheDir$1() {
|
|
|
17232
18173
|
return path$1.join(os$1.homedir(), '.cache', 'coco');
|
|
17233
18174
|
}
|
|
17234
18175
|
function getOnboardingMarkerPath() {
|
|
17235
|
-
return path$1.join(resolveCacheDir$
|
|
18176
|
+
return path$1.join(resolveCacheDir$2(), MARKER_BASENAME);
|
|
17236
18177
|
}
|
|
17237
18178
|
function hasSeenOnboarding() {
|
|
17238
18179
|
try {
|
|
@@ -17256,6 +18197,52 @@ function markOnboardingSeen() {
|
|
|
17256
18197
|
}
|
|
17257
18198
|
}
|
|
17258
18199
|
|
|
18200
|
+
/**
|
|
18201
|
+
* Persist the user's preferred diff view mode (unified vs side-by-side
|
|
18202
|
+
* split — #785) per repo. Mirrors `inkSidebarPersistence.ts` so the
|
|
18203
|
+
* cache layout, error model, and key derivation stay consistent across
|
|
18204
|
+
* settings: best-effort, XDG-friendly, no PII in the cache filename.
|
|
18205
|
+
*/
|
|
18206
|
+
const VALID_MODES = ['unified', 'split'];
|
|
18207
|
+
function resolveCacheDir$1() {
|
|
18208
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
18209
|
+
if (xdg && xdg.trim().length > 0) {
|
|
18210
|
+
return path$1.join(xdg, 'coco');
|
|
18211
|
+
}
|
|
18212
|
+
return path$1.join(os$1.homedir(), '.cache', 'coco');
|
|
18213
|
+
}
|
|
18214
|
+
function repoKey$1(repoPath) {
|
|
18215
|
+
// sha1 is used here as a non-security cache-key derivation — we just
|
|
18216
|
+
// need a deterministic short identifier for the marker filename. No
|
|
18217
|
+
// PII or auth context is hashed.
|
|
18218
|
+
// DevSkim: ignore DS126858
|
|
18219
|
+
return crypto.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
18220
|
+
}
|
|
18221
|
+
function getDiffViewModeMarkerPath(repoPath) {
|
|
18222
|
+
return path$1.join(resolveCacheDir$1(), `diff-view-mode.${repoKey$1(repoPath)}`);
|
|
18223
|
+
}
|
|
18224
|
+
function getSavedDiffViewMode(repoPath) {
|
|
18225
|
+
try {
|
|
18226
|
+
const raw = fs$1.readFileSync(getDiffViewModeMarkerPath(repoPath), 'utf8').trim();
|
|
18227
|
+
return VALID_MODES.includes(raw)
|
|
18228
|
+
? raw
|
|
18229
|
+
: undefined;
|
|
18230
|
+
}
|
|
18231
|
+
catch {
|
|
18232
|
+
return undefined;
|
|
18233
|
+
}
|
|
18234
|
+
}
|
|
18235
|
+
function saveDiffViewMode(repoPath, mode) {
|
|
18236
|
+
const marker = getDiffViewModeMarkerPath(repoPath);
|
|
18237
|
+
try {
|
|
18238
|
+
fs$1.mkdirSync(path$1.dirname(marker), { recursive: true });
|
|
18239
|
+
fs$1.writeFileSync(marker, mode);
|
|
18240
|
+
}
|
|
18241
|
+
catch {
|
|
18242
|
+
// Best-effort persistence; swallow.
|
|
18243
|
+
}
|
|
18244
|
+
}
|
|
18245
|
+
|
|
17259
18246
|
/**
|
|
17260
18247
|
* Persist which sidebar tab the user last had active, keyed per repo so
|
|
17261
18248
|
* switching projects doesn't reset every other repo's preference. The
|
|
@@ -17315,6 +18302,144 @@ function saveSidebarTab(repoPath, tab) {
|
|
|
17315
18302
|
}
|
|
17316
18303
|
}
|
|
17317
18304
|
|
|
18305
|
+
/**
|
|
18306
|
+
* Pair-alignment helper for the side-by-side diff view (#785).
|
|
18307
|
+
*
|
|
18308
|
+
* Takes the unified-diff line array that the renderer already paints (one
|
|
18309
|
+
* line per element, the leading character drives `+`/`-`/context coloring)
|
|
18310
|
+
* and re-shapes it into two-column rows the split renderer can lay out
|
|
18311
|
+
* without further parsing. Pure / synchronous so it can be exercised from
|
|
18312
|
+
* tests without spinning up Ink.
|
|
18313
|
+
*
|
|
18314
|
+
* Algorithm:
|
|
18315
|
+
* 1. Walk lines in order. `@@` headers seed a new hunk and reset the
|
|
18316
|
+
* `oldLineNo` / `newLineNo` cursors from the header range.
|
|
18317
|
+
* 2. Inside a hunk, group the consecutive runs of `-` and `+` lines that
|
|
18318
|
+
* follow each other. Each run of removals + the immediately-following
|
|
18319
|
+
* run of additions forms a "change block" that pairs up element-wise:
|
|
18320
|
+
* row[i] = { left: removals[i], right: additions[i] }. When one side
|
|
18321
|
+
* is shorter, pad with `kind: 'empty'` rows so the columns stay
|
|
18322
|
+
* aligned.
|
|
18323
|
+
* 3. Context lines emit as a paired row with the same text on both
|
|
18324
|
+
* sides and the synthesized line numbers from each cursor.
|
|
18325
|
+
* 4. Diff metadata (`diff `, `index `, `--- `, `+++ `, etc.) emit as
|
|
18326
|
+
* `kind: 'header'` rows so the split view still has a section break.
|
|
18327
|
+
* 5. A context line that interrupts a change block forces the in-flight
|
|
18328
|
+
* block to flush before the context row is emitted — pairs are never
|
|
18329
|
+
* drawn across context boundaries (matches lazygit / fugitive
|
|
18330
|
+
* behavior, and is what the issue specifies).
|
|
18331
|
+
*
|
|
18332
|
+
* Long lines are not wrapped here — the renderer truncates per column at
|
|
18333
|
+
* paint time so this helper stays pure and trivially testable.
|
|
18334
|
+
*/
|
|
18335
|
+
const EMPTY_LEFT = { text: '', kind: 'empty' };
|
|
18336
|
+
const EMPTY_RIGHT = { text: '', kind: 'empty' };
|
|
18337
|
+
/**
|
|
18338
|
+
* Parse the start line numbers out of an `@@ -A,B +C,D @@` header. Returns
|
|
18339
|
+
* `[oldStart, newStart]`; either falls back to 1 when the header is
|
|
18340
|
+
* malformed (which only happens with synthetic / hand-crafted patches).
|
|
18341
|
+
*/
|
|
18342
|
+
function parseHunkHeader(line) {
|
|
18343
|
+
const match = /@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/.exec(line);
|
|
18344
|
+
if (!match) {
|
|
18345
|
+
return [1, 1];
|
|
18346
|
+
}
|
|
18347
|
+
return [Number(match[1]) || 1, Number(match[2]) || 1];
|
|
18348
|
+
}
|
|
18349
|
+
function isDiffHeader(line) {
|
|
18350
|
+
return (line.startsWith('diff ') ||
|
|
18351
|
+
line.startsWith('index ') ||
|
|
18352
|
+
line.startsWith('--- ') ||
|
|
18353
|
+
line.startsWith('+++ ') ||
|
|
18354
|
+
line.startsWith('similarity ') ||
|
|
18355
|
+
line.startsWith('rename ') ||
|
|
18356
|
+
line.startsWith('copy ') ||
|
|
18357
|
+
line.startsWith('new file ') ||
|
|
18358
|
+
line.startsWith('deleted file ') ||
|
|
18359
|
+
line.startsWith('old mode ') ||
|
|
18360
|
+
line.startsWith('new mode ') ||
|
|
18361
|
+
line.startsWith('Binary files '));
|
|
18362
|
+
}
|
|
18363
|
+
/**
|
|
18364
|
+
* Flush a pending change block (removals + additions accumulated from a
|
|
18365
|
+
* contiguous `-`/`+` run) into paired rows. Pads the shorter side with
|
|
18366
|
+
* empty placeholders so columns stay aligned.
|
|
18367
|
+
*/
|
|
18368
|
+
function flushChangeBlock(removals, additions, rows) {
|
|
18369
|
+
const max = Math.max(removals.length, additions.length);
|
|
18370
|
+
for (let i = 0; i < max; i++) {
|
|
18371
|
+
const left = removals[i] || EMPTY_LEFT;
|
|
18372
|
+
const right = additions[i] || EMPTY_RIGHT;
|
|
18373
|
+
rows.push({ left, right });
|
|
18374
|
+
}
|
|
18375
|
+
removals.length = 0;
|
|
18376
|
+
additions.length = 0;
|
|
18377
|
+
}
|
|
18378
|
+
function buildSplitDiffRows(unifiedLines) {
|
|
18379
|
+
const rows = [];
|
|
18380
|
+
let oldLineNo = 0;
|
|
18381
|
+
let newLineNo = 0;
|
|
18382
|
+
let inHunk = false;
|
|
18383
|
+
const removals = [];
|
|
18384
|
+
const additions = [];
|
|
18385
|
+
const flushHeader = (text) => {
|
|
18386
|
+
flushChangeBlock(removals, additions, rows);
|
|
18387
|
+
rows.push({
|
|
18388
|
+
left: { text, kind: 'header' },
|
|
18389
|
+
right: { text, kind: 'header' },
|
|
18390
|
+
});
|
|
18391
|
+
};
|
|
18392
|
+
for (const raw of unifiedLines) {
|
|
18393
|
+
if (raw.startsWith('@@')) {
|
|
18394
|
+
flushChangeBlock(removals, additions, rows);
|
|
18395
|
+
const [oldStart, newStart] = parseHunkHeader(raw);
|
|
18396
|
+
oldLineNo = oldStart;
|
|
18397
|
+
newLineNo = newStart;
|
|
18398
|
+
inHunk = true;
|
|
18399
|
+
rows.push({
|
|
18400
|
+
left: { text: raw, kind: 'header' },
|
|
18401
|
+
right: { text: raw, kind: 'header' },
|
|
18402
|
+
});
|
|
18403
|
+
continue;
|
|
18404
|
+
}
|
|
18405
|
+
if (!inHunk || isDiffHeader(raw)) {
|
|
18406
|
+
flushHeader(raw);
|
|
18407
|
+
continue;
|
|
18408
|
+
}
|
|
18409
|
+
if (raw.startsWith('-')) {
|
|
18410
|
+
removals.push({
|
|
18411
|
+
text: raw.slice(1),
|
|
18412
|
+
lineNumber: oldLineNo,
|
|
18413
|
+
kind: 'remove',
|
|
18414
|
+
});
|
|
18415
|
+
oldLineNo += 1;
|
|
18416
|
+
continue;
|
|
18417
|
+
}
|
|
18418
|
+
if (raw.startsWith('+')) {
|
|
18419
|
+
additions.push({
|
|
18420
|
+
text: raw.slice(1),
|
|
18421
|
+
lineNumber: newLineNo,
|
|
18422
|
+
kind: 'add',
|
|
18423
|
+
});
|
|
18424
|
+
newLineNo += 1;
|
|
18425
|
+
continue;
|
|
18426
|
+
}
|
|
18427
|
+
// Context line (or `` marker, which we
|
|
18428
|
+
// treat like a context row so it lands on both sides — readers
|
|
18429
|
+
// expect to see it in either column).
|
|
18430
|
+
flushChangeBlock(removals, additions, rows);
|
|
18431
|
+
const text = raw.startsWith(' ') ? raw.slice(1) : raw;
|
|
18432
|
+
rows.push({
|
|
18433
|
+
left: { text, lineNumber: oldLineNo, kind: 'context' },
|
|
18434
|
+
right: { text, lineNumber: newLineNo, kind: 'context' },
|
|
18435
|
+
});
|
|
18436
|
+
oldLineNo += 1;
|
|
18437
|
+
newLineNo += 1;
|
|
18438
|
+
}
|
|
18439
|
+
flushChangeBlock(removals, additions, rows);
|
|
18440
|
+
return rows;
|
|
18441
|
+
}
|
|
18442
|
+
|
|
17318
18443
|
/**
|
|
17319
18444
|
* Promoted-view selection rectification on filter changes (P4.5).
|
|
17320
18445
|
*
|
|
@@ -17736,7 +18861,8 @@ function createLogInkTheme(options = {}) {
|
|
|
17736
18861
|
/**
|
|
17737
18862
|
* Format a branch's relationship to its upstream.
|
|
17738
18863
|
* - no upstream → "no upstream"
|
|
17739
|
-
* - even → "
|
|
18864
|
+
* - even → "" (the boring default — keep the row tight; the row
|
|
18865
|
+
* marker already encodes "synced")
|
|
17740
18866
|
* - divergent → "↑<ahead> ↓<behind> <upstream>" (only the non-zero side
|
|
17741
18867
|
* is rendered so the line stays tight). ASCII mode falls back to the
|
|
17742
18868
|
* legacy `+N/-N` form.
|
|
@@ -17746,7 +18872,7 @@ function formatBranchDivergence(branch, options = {}) {
|
|
|
17746
18872
|
return 'no upstream';
|
|
17747
18873
|
}
|
|
17748
18874
|
if (branch.ahead === 0 && branch.behind === 0) {
|
|
17749
|
-
return
|
|
18875
|
+
return '';
|
|
17750
18876
|
}
|
|
17751
18877
|
if (options.ascii) {
|
|
17752
18878
|
return `+${branch.ahead}/-${branch.behind} ${branch.upstream}`;
|
|
@@ -17760,14 +18886,76 @@ function formatBranchDivergence(branch, options = {}) {
|
|
|
17760
18886
|
}
|
|
17761
18887
|
/**
|
|
17762
18888
|
* Single-cell marker shown to the left of a branch name in lists.
|
|
17763
|
-
*
|
|
18889
|
+
*
|
|
18890
|
+
* - `*` — current branch (regardless of remote state)
|
|
18891
|
+
* - `◌` — no upstream
|
|
18892
|
+
* - `≡` — has upstream + synced (ahead === 0 && behind === 0)
|
|
18893
|
+
* - `↕` — has upstream + diverged (any non-zero ahead/behind)
|
|
18894
|
+
* - ` ` — fallback / no info
|
|
18895
|
+
*
|
|
18896
|
+
* ASCII fallbacks (legible without box-drawing/arrow glyphs):
|
|
18897
|
+
* - `?` for "no upstream", `=` for synced, `~` for diverged.
|
|
17764
18898
|
*/
|
|
17765
18899
|
function branchRowMarker(branch, options = {}) {
|
|
17766
18900
|
if (branch.current)
|
|
17767
18901
|
return '*';
|
|
17768
18902
|
if (!branch.upstream)
|
|
17769
18903
|
return options.ascii ? '?' : '◌';
|
|
17770
|
-
|
|
18904
|
+
const ahead = branch.ahead ?? 0;
|
|
18905
|
+
const behind = branch.behind ?? 0;
|
|
18906
|
+
if (ahead === 0 && behind === 0) {
|
|
18907
|
+
return options.ascii ? '=' : '≡';
|
|
18908
|
+
}
|
|
18909
|
+
return options.ascii ? '~' : '↕';
|
|
18910
|
+
}
|
|
18911
|
+
/**
|
|
18912
|
+
* Compact, human-friendly relative timestamp for the branch row.
|
|
18913
|
+
* Inputs:
|
|
18914
|
+
* - `iso` — committer-date in `YYYY-MM-DD` form (as produced by
|
|
18915
|
+
* `for-each-ref` with `committerdate:short`).
|
|
18916
|
+
* - `now` — reference instant; pass it explicitly so callers can pin it
|
|
18917
|
+
* for deterministic tests.
|
|
18918
|
+
*
|
|
18919
|
+
* Outputs (rounded toward the nearest unit):
|
|
18920
|
+
* - `today`, `1d ago`, `2d ago` … up to 13d
|
|
18921
|
+
* - `2w ago` … up to 8w
|
|
18922
|
+
* - `2mo ago` … up to 12mo
|
|
18923
|
+
* - `2y ago` for older
|
|
18924
|
+
* - `''` for malformed inputs (caller renders nothing).
|
|
18925
|
+
*
|
|
18926
|
+
* "in the future" inputs (clock skew, bad data) collapse to `today`.
|
|
18927
|
+
*/
|
|
18928
|
+
function formatBranchLastTouched(iso, now) {
|
|
18929
|
+
if (!iso)
|
|
18930
|
+
return '';
|
|
18931
|
+
// Tolerate either `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS…` ISO strings.
|
|
18932
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
|
18933
|
+
if (!match)
|
|
18934
|
+
return '';
|
|
18935
|
+
const year = Number.parseInt(match[1], 10);
|
|
18936
|
+
const month = Number.parseInt(match[2], 10);
|
|
18937
|
+
const day = Number.parseInt(match[3], 10);
|
|
18938
|
+
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day))
|
|
18939
|
+
return '';
|
|
18940
|
+
// Compare at day granularity in UTC so a branch touched "yesterday"
|
|
18941
|
+
// never reads "today" depending on the operator's timezone.
|
|
18942
|
+
const branchUtc = Date.UTC(year, month - 1, day);
|
|
18943
|
+
const nowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
18944
|
+
const diffMs = nowUtc - branchUtc;
|
|
18945
|
+
const oneDay = 24 * 60 * 60 * 1000;
|
|
18946
|
+
const days = Math.floor(diffMs / oneDay);
|
|
18947
|
+
if (days <= 0)
|
|
18948
|
+
return 'today';
|
|
18949
|
+
if (days < 14)
|
|
18950
|
+
return `${days}d ago`;
|
|
18951
|
+
const weeks = Math.floor(days / 7);
|
|
18952
|
+
if (weeks < 9)
|
|
18953
|
+
return `${weeks}w ago`;
|
|
18954
|
+
const months = Math.floor(days / 30);
|
|
18955
|
+
if (months < 12)
|
|
18956
|
+
return `${months}mo ago`;
|
|
18957
|
+
const years = Math.floor(days / 365);
|
|
18958
|
+
return `${years}y ago`;
|
|
17771
18959
|
}
|
|
17772
18960
|
/**
|
|
17773
18961
|
* Pick the glyph + color for a PR state badge.
|
|
@@ -18197,7 +19385,7 @@ function createChangelogArgv(input) {
|
|
|
18197
19385
|
...input,
|
|
18198
19386
|
};
|
|
18199
19387
|
}
|
|
18200
|
-
function compactOutputLines$
|
|
19388
|
+
function compactOutputLines$3(output) {
|
|
18201
19389
|
return output
|
|
18202
19390
|
.split('\n')
|
|
18203
19391
|
.map((line) => line.trim())
|
|
@@ -18221,7 +19409,7 @@ async function captureStdout(action) {
|
|
|
18221
19409
|
}
|
|
18222
19410
|
}
|
|
18223
19411
|
function formatCapturedAiOutput(output) {
|
|
18224
|
-
const lines = compactOutputLines$
|
|
19412
|
+
const lines = compactOutputLines$3(output);
|
|
18225
19413
|
const telemetry = lines.filter((line) => line.includes('[llm:summary]'));
|
|
18226
19414
|
const content = lines.filter((line) => !line.includes('[llm]') && !line.includes('[llm:summary]'));
|
|
18227
19415
|
const editable = content.join('\n');
|
|
@@ -18518,8 +19706,65 @@ function parsePullRequestInfo(output) {
|
|
|
18518
19706
|
if (!trimmed) {
|
|
18519
19707
|
return undefined;
|
|
18520
19708
|
}
|
|
18521
|
-
|
|
19709
|
+
const raw = JSON.parse(trimmed);
|
|
19710
|
+
const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
|
|
19711
|
+
? String(raw.author.login)
|
|
19712
|
+
: undefined;
|
|
19713
|
+
return {
|
|
19714
|
+
number: raw.number,
|
|
19715
|
+
title: raw.title,
|
|
19716
|
+
url: raw.url,
|
|
19717
|
+
state: raw.state,
|
|
19718
|
+
isDraft: raw.isDraft,
|
|
19719
|
+
headRefName: raw.headRefName,
|
|
19720
|
+
baseRefName: raw.baseRefName,
|
|
19721
|
+
body: typeof raw.body === 'string' ? raw.body : undefined,
|
|
19722
|
+
author,
|
|
19723
|
+
reviewDecision: typeof raw.reviewDecision === 'string' ? raw.reviewDecision : undefined,
|
|
19724
|
+
mergeable: typeof raw.mergeable === 'string' ? raw.mergeable : undefined,
|
|
19725
|
+
mergeStateStatus: typeof raw.mergeStateStatus === 'string' ? raw.mergeStateStatus : undefined,
|
|
19726
|
+
statusCheckRollup: Array.isArray(raw.statusCheckRollup)
|
|
19727
|
+
? raw.statusCheckRollup.map((entry) => ({
|
|
19728
|
+
name: String(entry.name || entry.context || 'check'),
|
|
19729
|
+
status: typeof entry.status === 'string' ? entry.status : undefined,
|
|
19730
|
+
conclusion: typeof entry.conclusion === 'string' ? entry.conclusion : undefined,
|
|
19731
|
+
}))
|
|
19732
|
+
: undefined,
|
|
19733
|
+
reviews: Array.isArray(raw.reviews)
|
|
19734
|
+
? raw.reviews.map((entry) => {
|
|
19735
|
+
const author = entry.author && typeof entry.author === 'object' && 'login' in entry.author
|
|
19736
|
+
? String(entry.author.login)
|
|
19737
|
+
: '';
|
|
19738
|
+
return {
|
|
19739
|
+
author,
|
|
19740
|
+
state: typeof entry.state === 'string' ? entry.state : '',
|
|
19741
|
+
};
|
|
19742
|
+
}).filter((review) => review.author)
|
|
19743
|
+
: undefined,
|
|
19744
|
+
};
|
|
18522
19745
|
}
|
|
19746
|
+
/**
|
|
19747
|
+
* `gh pr view --json` field list. Centralized so the data fetcher and
|
|
19748
|
+
* any future re-fetch (e.g., refresh after a merge action) request the
|
|
19749
|
+
* same shape — the parser depends on every field being present, even
|
|
19750
|
+
* if optional, so they're safe to deserialize.
|
|
19751
|
+
*/
|
|
19752
|
+
const PULL_REQUEST_VIEW_JSON_FIELDS = [
|
|
19753
|
+
'number',
|
|
19754
|
+
'title',
|
|
19755
|
+
'url',
|
|
19756
|
+
'state',
|
|
19757
|
+
'isDraft',
|
|
19758
|
+
'headRefName',
|
|
19759
|
+
'baseRefName',
|
|
19760
|
+
'body',
|
|
19761
|
+
'author',
|
|
19762
|
+
'reviewDecision',
|
|
19763
|
+
'mergeable',
|
|
19764
|
+
'mergeStateStatus',
|
|
19765
|
+
'statusCheckRollup',
|
|
19766
|
+
'reviews',
|
|
19767
|
+
].join(',');
|
|
18523
19768
|
async function getPullRequestOverview(git, runner = defaultGhRunner) {
|
|
18524
19769
|
const [repository, currentBranchOutput] = await Promise.all([
|
|
18525
19770
|
getGitHubRepository(git),
|
|
@@ -18551,7 +19796,7 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
|
|
|
18551
19796
|
'pr',
|
|
18552
19797
|
'view',
|
|
18553
19798
|
'--json',
|
|
18554
|
-
|
|
19799
|
+
PULL_REQUEST_VIEW_JSON_FIELDS,
|
|
18555
19800
|
]);
|
|
18556
19801
|
return {
|
|
18557
19802
|
available: true,
|
|
@@ -18718,7 +19963,7 @@ function providerBranchName(branch) {
|
|
|
18718
19963
|
return branch.shortName;
|
|
18719
19964
|
}
|
|
18720
19965
|
|
|
18721
|
-
function compactOutputLines$
|
|
19966
|
+
function compactOutputLines$2(output) {
|
|
18722
19967
|
return output
|
|
18723
19968
|
.split('\n')
|
|
18724
19969
|
.map((line) => line.trim())
|
|
@@ -18733,7 +19978,7 @@ async function runAction$5(action, successMessage) {
|
|
|
18733
19978
|
};
|
|
18734
19979
|
}
|
|
18735
19980
|
catch (error) {
|
|
18736
|
-
const lines = compactOutputLines$
|
|
19981
|
+
const lines = compactOutputLines$2(error.message);
|
|
18737
19982
|
return {
|
|
18738
19983
|
ok: false,
|
|
18739
19984
|
message: lines[0] || 'History action failed.',
|
|
@@ -18875,7 +20120,7 @@ async function compareCommits(git, from, to) {
|
|
|
18875
20120
|
}
|
|
18876
20121
|
try {
|
|
18877
20122
|
const output = await git.raw(['diff', '--stat', '--color=never', `${from.hash}..${to.hash}`]);
|
|
18878
|
-
const lines = compactOutputLines$
|
|
20123
|
+
const lines = compactOutputLines$2(output);
|
|
18879
20124
|
return {
|
|
18880
20125
|
ok: true,
|
|
18881
20126
|
message: `Compared ${from.shortHash}..${to.shortHash}`,
|
|
@@ -19042,6 +20287,61 @@ function createPullRequest(input, runner = defaultGhRunner) {
|
|
|
19042
20287
|
};
|
|
19043
20288
|
});
|
|
19044
20289
|
}
|
|
20290
|
+
function isPullRequestMergeStrategy(value) {
|
|
20291
|
+
return value === 'merge' || value === 'squash' || value === 'rebase';
|
|
20292
|
+
}
|
|
20293
|
+
function buildMergePullRequestArgs(strategy) {
|
|
20294
|
+
// `--auto` and `--admin` are intentionally omitted — they're rarely
|
|
20295
|
+
// what a user wants from a TUI and require explicit gh auth scopes.
|
|
20296
|
+
// `--delete-branch` is opt-in via a future flag; default leaves the
|
|
20297
|
+
// branch in place so the user can verify before cleanup.
|
|
20298
|
+
return ['pr', 'merge', `--${strategy}`];
|
|
20299
|
+
}
|
|
20300
|
+
function mergePullRequest(strategy, runner = defaultGhRunner) {
|
|
20301
|
+
return runGhAction(runner, buildMergePullRequestArgs(strategy), (output) => ({
|
|
20302
|
+
ok: true,
|
|
20303
|
+
message: output.trim() || `Merged pull request with ${strategy}`,
|
|
20304
|
+
}));
|
|
20305
|
+
}
|
|
20306
|
+
function closePullRequest(runner = defaultGhRunner) {
|
|
20307
|
+
return runGhAction(runner, ['pr', 'close'], (output) => ({
|
|
20308
|
+
ok: true,
|
|
20309
|
+
message: output.trim() || 'Closed pull request',
|
|
20310
|
+
}));
|
|
20311
|
+
}
|
|
20312
|
+
/**
|
|
20313
|
+
* `gh pr review --approve` requires the user's gh auth to have scope
|
|
20314
|
+
* to write reviews — same scope that the in-browser approve button
|
|
20315
|
+
* uses. The runner surfaces auth failures via the standard error path.
|
|
20316
|
+
*/
|
|
20317
|
+
function approvePullRequest(runner = defaultGhRunner) {
|
|
20318
|
+
return runGhAction(runner, ['pr', 'review', '--approve'], (output) => ({
|
|
20319
|
+
ok: true,
|
|
20320
|
+
message: output.trim() || 'Approved pull request',
|
|
20321
|
+
}));
|
|
20322
|
+
}
|
|
20323
|
+
/**
|
|
20324
|
+
* Request changes — `gh pr review` requires a body with this verb so
|
|
20325
|
+
* the empty-body case is rejected upstream by the input prompt.
|
|
20326
|
+
*/
|
|
20327
|
+
function requestChangesPullRequest(body, runner = defaultGhRunner) {
|
|
20328
|
+
if (!body.trim()) {
|
|
20329
|
+
return Promise.resolve({ ok: false, message: 'Review body required for change-request' });
|
|
20330
|
+
}
|
|
20331
|
+
return runGhAction(runner, ['pr', 'review', '--request-changes', '--body', body], (output) => ({
|
|
20332
|
+
ok: true,
|
|
20333
|
+
message: output.trim() || 'Requested changes',
|
|
20334
|
+
}));
|
|
20335
|
+
}
|
|
20336
|
+
function commentPullRequest(body, runner = defaultGhRunner) {
|
|
20337
|
+
if (!body.trim()) {
|
|
20338
|
+
return Promise.resolve({ ok: false, message: 'Comment body required' });
|
|
20339
|
+
}
|
|
20340
|
+
return runGhAction(runner, ['pr', 'comment', '--body', body], (output) => ({
|
|
20341
|
+
ok: true,
|
|
20342
|
+
message: output.trim() || 'Comment added',
|
|
20343
|
+
}));
|
|
20344
|
+
}
|
|
19045
20345
|
|
|
19046
20346
|
async function runAction$4(action, successMessage) {
|
|
19047
20347
|
try {
|
|
@@ -19666,7 +20966,7 @@ function applyLogTuiAction(state, action) {
|
|
|
19666
20966
|
}
|
|
19667
20967
|
}
|
|
19668
20968
|
|
|
19669
|
-
function compactOutputLines(output) {
|
|
20969
|
+
function compactOutputLines$1(output) {
|
|
19670
20970
|
return output
|
|
19671
20971
|
.split('\n')
|
|
19672
20972
|
.map((line) => line.trim())
|
|
@@ -19681,7 +20981,7 @@ async function runAction(action, successMessage) {
|
|
|
19681
20981
|
};
|
|
19682
20982
|
}
|
|
19683
20983
|
catch (error) {
|
|
19684
|
-
const details = compactOutputLines(error.message);
|
|
20984
|
+
const details = compactOutputLines$1(error.message);
|
|
19685
20985
|
return {
|
|
19686
20986
|
ok: false,
|
|
19687
20987
|
message: details[0] || 'Git operation action failed.',
|
|
@@ -21330,35 +22630,303 @@ async function startInteractiveLog(git, rows, streams = {}) {
|
|
|
21330
22630
|
applyAndRender(applyLogTuiAction(state, { type: 'clearFilter' }));
|
|
21331
22631
|
}
|
|
21332
22632
|
};
|
|
21333
|
-
input.on('keypress', onKeypress);
|
|
21334
|
-
void Promise.all([
|
|
21335
|
-
refreshBranches(),
|
|
21336
|
-
refreshPullRequest(),
|
|
21337
|
-
refreshTags(),
|
|
21338
|
-
refreshWorktree(),
|
|
21339
|
-
refreshStashes(),
|
|
21340
|
-
refreshWorktreeList(),
|
|
21341
|
-
refreshOperationOverview(),
|
|
21342
|
-
refreshProviderOverview(),
|
|
21343
|
-
])
|
|
21344
|
-
.then(async () => {
|
|
21345
|
-
await refreshStatusHunks();
|
|
21346
|
-
await render();
|
|
21347
|
-
})
|
|
21348
|
-
.catch(() => {
|
|
21349
|
-
branches = undefined;
|
|
21350
|
-
pullRequest = undefined;
|
|
21351
|
-
tags = undefined;
|
|
21352
|
-
worktree = undefined;
|
|
21353
|
-
statusHunks = undefined;
|
|
21354
|
-
stashes = undefined;
|
|
21355
|
-
worktreeList = undefined;
|
|
21356
|
-
operationOverview = undefined;
|
|
21357
|
-
providerOverview = undefined;
|
|
21358
|
-
});
|
|
21359
|
-
void render();
|
|
22633
|
+
input.on('keypress', onKeypress);
|
|
22634
|
+
void Promise.all([
|
|
22635
|
+
refreshBranches(),
|
|
22636
|
+
refreshPullRequest(),
|
|
22637
|
+
refreshTags(),
|
|
22638
|
+
refreshWorktree(),
|
|
22639
|
+
refreshStashes(),
|
|
22640
|
+
refreshWorktreeList(),
|
|
22641
|
+
refreshOperationOverview(),
|
|
22642
|
+
refreshProviderOverview(),
|
|
22643
|
+
])
|
|
22644
|
+
.then(async () => {
|
|
22645
|
+
await refreshStatusHunks();
|
|
22646
|
+
await render();
|
|
22647
|
+
})
|
|
22648
|
+
.catch(() => {
|
|
22649
|
+
branches = undefined;
|
|
22650
|
+
pullRequest = undefined;
|
|
22651
|
+
tags = undefined;
|
|
22652
|
+
worktree = undefined;
|
|
22653
|
+
statusHunks = undefined;
|
|
22654
|
+
stashes = undefined;
|
|
22655
|
+
worktreeList = undefined;
|
|
22656
|
+
operationOverview = undefined;
|
|
22657
|
+
providerOverview = undefined;
|
|
22658
|
+
});
|
|
22659
|
+
void render();
|
|
22660
|
+
});
|
|
22661
|
+
}
|
|
22662
|
+
|
|
22663
|
+
function compactOutputLines(output) {
|
|
22664
|
+
return output
|
|
22665
|
+
.split('\n')
|
|
22666
|
+
.map((line) => line.trim())
|
|
22667
|
+
.filter(Boolean);
|
|
22668
|
+
}
|
|
22669
|
+
async function safeUnlink(path) {
|
|
22670
|
+
try {
|
|
22671
|
+
await promises.unlink(path);
|
|
22672
|
+
}
|
|
22673
|
+
catch (error) {
|
|
22674
|
+
// ENOENT is fine — the temp file was never created or already
|
|
22675
|
+
// cleaned up. Anything else we silently swallow because the
|
|
22676
|
+
// worst-case impact is a single ~1KB file in $TMPDIR.
|
|
22677
|
+
error.code;
|
|
22678
|
+
}
|
|
22679
|
+
}
|
|
22680
|
+
/**
|
|
22681
|
+
* Write a unified-diff patch to a temp file and feed it to
|
|
22682
|
+
* `git apply` (or `git apply --cached` when target === 'index').
|
|
22683
|
+
*
|
|
22684
|
+
* This is the runner behind the `apply-hunk-worktree` /
|
|
22685
|
+
* `apply-hunk-index` workflow actions — the input handler builds
|
|
22686
|
+
* `patchText` from the cursored hunk via `extractDiffHunk` and the
|
|
22687
|
+
* runtime hands it here.
|
|
22688
|
+
*
|
|
22689
|
+
* `--whitespace=nowarn` keeps `git apply` quiet about trailing
|
|
22690
|
+
* whitespace differences (the most common false positive when the
|
|
22691
|
+
* patch comes from a stash made on a different platform). Real
|
|
22692
|
+
* conflicts still surface via the non-zero exit code.
|
|
22693
|
+
*
|
|
22694
|
+
* The patch is written to a temp file rather than piped on stdin
|
|
22695
|
+
* because some `simple-git` adapters don't expose a clean stdin
|
|
22696
|
+
* channel for `git.raw`; the tempfile path keeps the runner
|
|
22697
|
+
* portable across environments.
|
|
22698
|
+
*/
|
|
22699
|
+
async function applyHunkPatch(git, patchText, options) {
|
|
22700
|
+
if (!patchText.trim()) {
|
|
22701
|
+
return {
|
|
22702
|
+
ok: false,
|
|
22703
|
+
message: 'No hunk under cursor to apply.',
|
|
22704
|
+
};
|
|
22705
|
+
}
|
|
22706
|
+
const targetLabel = options.target === 'index' ? 'index' : 'worktree';
|
|
22707
|
+
const tempPath = join(tmpdir(), `coco-hunk-${randomUUID()}.patch`);
|
|
22708
|
+
try {
|
|
22709
|
+
await promises.writeFile(tempPath, patchText, 'utf8');
|
|
22710
|
+
const args = ['apply'];
|
|
22711
|
+
if (options.target === 'index') {
|
|
22712
|
+
args.push('--cached');
|
|
22713
|
+
}
|
|
22714
|
+
args.push('--whitespace=nowarn');
|
|
22715
|
+
args.push(tempPath);
|
|
22716
|
+
try {
|
|
22717
|
+
await git.raw(args);
|
|
22718
|
+
return {
|
|
22719
|
+
ok: true,
|
|
22720
|
+
message: `Applied hunk to ${targetLabel}`,
|
|
22721
|
+
};
|
|
22722
|
+
}
|
|
22723
|
+
catch (error) {
|
|
22724
|
+
const lines = compactOutputLines(error.message);
|
|
22725
|
+
return {
|
|
22726
|
+
ok: false,
|
|
22727
|
+
message: lines[0] || `Failed to apply hunk to ${targetLabel}`,
|
|
22728
|
+
details: lines.slice(1, 6),
|
|
22729
|
+
};
|
|
22730
|
+
}
|
|
22731
|
+
}
|
|
22732
|
+
catch (error) {
|
|
22733
|
+
return {
|
|
22734
|
+
ok: false,
|
|
22735
|
+
message: `Could not stage hunk for apply: ${error.message}`,
|
|
22736
|
+
};
|
|
22737
|
+
}
|
|
22738
|
+
finally {
|
|
22739
|
+
await safeUnlink(tempPath);
|
|
22740
|
+
}
|
|
22741
|
+
}
|
|
22742
|
+
|
|
22743
|
+
function formatStashHeaderIdentity(ref, stashes) {
|
|
22744
|
+
if (!ref) {
|
|
22745
|
+
return { subtitle: 'no stash', bodyLine: 'Stash:' };
|
|
22746
|
+
}
|
|
22747
|
+
const index = stashes?.findIndex((entry) => entry.ref === ref) ?? -1;
|
|
22748
|
+
const entry = index >= 0 ? stashes[index] : undefined;
|
|
22749
|
+
if (!entry) {
|
|
22750
|
+
return {
|
|
22751
|
+
subtitle: ref,
|
|
22752
|
+
bodyLine: `Stash: ${ref}`,
|
|
22753
|
+
};
|
|
22754
|
+
}
|
|
22755
|
+
const onBranch = entry.branch && entry.branch !== '<unknown>' ? ` on ${entry.branch}` : '';
|
|
22756
|
+
const message = entry.message?.trim() || '(no message)';
|
|
22757
|
+
return {
|
|
22758
|
+
subtitle: `@{${index}} ${message}${onBranch}`,
|
|
22759
|
+
bodyLine: `Stash: ${ref}${onBranch} — ${message}`,
|
|
22760
|
+
};
|
|
22761
|
+
}
|
|
22762
|
+
|
|
22763
|
+
/**
|
|
22764
|
+
* Normalize gh's two parallel signals (`status` for in-flight check
|
|
22765
|
+
* runs, `conclusion` for completed runs and status contexts) into a
|
|
22766
|
+
* single status enum the renderer can map to a glyph + color.
|
|
22767
|
+
*/
|
|
22768
|
+
function normalizePullRequestCheckStatus(check) {
|
|
22769
|
+
const status = (check.status || '').toUpperCase();
|
|
22770
|
+
const conclusion = (check.conclusion || '').toUpperCase();
|
|
22771
|
+
// In-flight check runs: gh emits `status: IN_PROGRESS|QUEUED` with
|
|
22772
|
+
// no conclusion yet. `PENDING` covers status-context runs that are
|
|
22773
|
+
// still waiting on a reporter.
|
|
22774
|
+
if (!conclusion && (status === 'IN_PROGRESS' || status === 'QUEUED' || status === 'PENDING')) {
|
|
22775
|
+
return 'pending';
|
|
22776
|
+
}
|
|
22777
|
+
switch (conclusion || status) {
|
|
22778
|
+
case 'SUCCESS':
|
|
22779
|
+
return 'success';
|
|
22780
|
+
case 'FAILURE':
|
|
22781
|
+
case 'ERROR':
|
|
22782
|
+
case 'TIMED_OUT':
|
|
22783
|
+
case 'ACTION_REQUIRED':
|
|
22784
|
+
return 'failure';
|
|
22785
|
+
case 'NEUTRAL':
|
|
22786
|
+
return 'neutral';
|
|
22787
|
+
case 'SKIPPED':
|
|
22788
|
+
case 'CANCELLED':
|
|
22789
|
+
return 'skipped';
|
|
22790
|
+
default:
|
|
22791
|
+
return 'pending';
|
|
22792
|
+
}
|
|
22793
|
+
}
|
|
22794
|
+
/**
|
|
22795
|
+
* Glyph for a normalized check status. ASCII fallbacks keep the panel
|
|
22796
|
+
* usable on legacy terminals where the geometric shapes block isn't
|
|
22797
|
+
* rendered.
|
|
22798
|
+
*/
|
|
22799
|
+
function pullRequestCheckGlyph(status, options = {}) {
|
|
22800
|
+
if (options.ascii) {
|
|
22801
|
+
switch (status) {
|
|
22802
|
+
case 'success': return '+';
|
|
22803
|
+
case 'failure': return 'x';
|
|
22804
|
+
case 'pending': return '.';
|
|
22805
|
+
case 'neutral': return '-';
|
|
22806
|
+
case 'skipped': return '/';
|
|
22807
|
+
}
|
|
22808
|
+
}
|
|
22809
|
+
switch (status) {
|
|
22810
|
+
case 'success': return '✓';
|
|
22811
|
+
case 'failure': return '✗';
|
|
22812
|
+
case 'pending': return '◌';
|
|
22813
|
+
case 'neutral': return '○';
|
|
22814
|
+
case 'skipped': return '∼';
|
|
22815
|
+
}
|
|
22816
|
+
}
|
|
22817
|
+
function summarizePullRequestChecks(checks) {
|
|
22818
|
+
const summary = {
|
|
22819
|
+
total: 0, success: 0, failure: 0, pending: 0, neutral: 0, skipped: 0,
|
|
22820
|
+
};
|
|
22821
|
+
if (!checks)
|
|
22822
|
+
return summary;
|
|
22823
|
+
for (const check of checks) {
|
|
22824
|
+
summary.total += 1;
|
|
22825
|
+
summary[normalizePullRequestCheckStatus(check)] += 1;
|
|
22826
|
+
}
|
|
22827
|
+
return summary;
|
|
22828
|
+
}
|
|
22829
|
+
/**
|
|
22830
|
+
* One-line summary like `5 checks · 4 ✓ · 1 ◌` for the panel header.
|
|
22831
|
+
* Hides zero-count categories so the line stays scannable.
|
|
22832
|
+
*/
|
|
22833
|
+
function formatPullRequestChecksSummary(summary, options = {}) {
|
|
22834
|
+
if (summary.total === 0) {
|
|
22835
|
+
return 'No status checks reported';
|
|
22836
|
+
}
|
|
22837
|
+
const parts = [`${summary.total} ${summary.total === 1 ? 'check' : 'checks'}`];
|
|
22838
|
+
const push = (count, status) => {
|
|
22839
|
+
if (count > 0)
|
|
22840
|
+
parts.push(`${count} ${pullRequestCheckGlyph(status, options)}`);
|
|
22841
|
+
};
|
|
22842
|
+
push(summary.success, 'success');
|
|
22843
|
+
push(summary.failure, 'failure');
|
|
22844
|
+
push(summary.pending, 'pending');
|
|
22845
|
+
push(summary.neutral, 'neutral');
|
|
22846
|
+
push(summary.skipped, 'skipped');
|
|
22847
|
+
return parts.join(' · ');
|
|
22848
|
+
}
|
|
22849
|
+
function buildPullRequestCheckRows(checks, options = {}) {
|
|
22850
|
+
if (!checks)
|
|
22851
|
+
return [];
|
|
22852
|
+
return checks.map((check) => {
|
|
22853
|
+
const status = normalizePullRequestCheckStatus(check);
|
|
22854
|
+
return {
|
|
22855
|
+
glyph: pullRequestCheckGlyph(status, options),
|
|
22856
|
+
name: check.name,
|
|
22857
|
+
status,
|
|
22858
|
+
detail: (check.conclusion || check.status || '').toLowerCase(),
|
|
22859
|
+
};
|
|
21360
22860
|
});
|
|
21361
22861
|
}
|
|
22862
|
+
function summarizePullRequestReviews(reviews, reviewDecision) {
|
|
22863
|
+
const summary = {
|
|
22864
|
+
total: 0, approved: 0, changesRequested: 0, commented: 0, dismissed: 0, pending: 0,
|
|
22865
|
+
decisionLabel: reviewDecision || undefined,
|
|
22866
|
+
};
|
|
22867
|
+
if (!reviews)
|
|
22868
|
+
return summary;
|
|
22869
|
+
for (const review of reviews) {
|
|
22870
|
+
summary.total += 1;
|
|
22871
|
+
switch (review.state.toUpperCase()) {
|
|
22872
|
+
case 'APPROVED':
|
|
22873
|
+
summary.approved += 1;
|
|
22874
|
+
break;
|
|
22875
|
+
case 'CHANGES_REQUESTED':
|
|
22876
|
+
summary.changesRequested += 1;
|
|
22877
|
+
break;
|
|
22878
|
+
case 'COMMENTED':
|
|
22879
|
+
summary.commented += 1;
|
|
22880
|
+
break;
|
|
22881
|
+
case 'DISMISSED':
|
|
22882
|
+
summary.dismissed += 1;
|
|
22883
|
+
break;
|
|
22884
|
+
case 'PENDING':
|
|
22885
|
+
summary.pending += 1;
|
|
22886
|
+
break;
|
|
22887
|
+
}
|
|
22888
|
+
}
|
|
22889
|
+
return summary;
|
|
22890
|
+
}
|
|
22891
|
+
function formatPullRequestReviewsSummary(summary) {
|
|
22892
|
+
const decision = summary.decisionLabel
|
|
22893
|
+
? summary.decisionLabel.replace(/_/g, ' ').toLowerCase()
|
|
22894
|
+
: undefined;
|
|
22895
|
+
if (summary.total === 0) {
|
|
22896
|
+
return decision ? `No reviews · ${decision}` : 'No reviews submitted';
|
|
22897
|
+
}
|
|
22898
|
+
const parts = [`${summary.total} ${summary.total === 1 ? 'review' : 'reviews'}`];
|
|
22899
|
+
if (summary.approved > 0)
|
|
22900
|
+
parts.push(`${summary.approved} approved`);
|
|
22901
|
+
if (summary.changesRequested > 0)
|
|
22902
|
+
parts.push(`${summary.changesRequested} changes requested`);
|
|
22903
|
+
if (summary.commented > 0)
|
|
22904
|
+
parts.push(`${summary.commented} commented`);
|
|
22905
|
+
if (summary.pending > 0)
|
|
22906
|
+
parts.push(`${summary.pending} pending`);
|
|
22907
|
+
if (summary.dismissed > 0)
|
|
22908
|
+
parts.push(`${summary.dismissed} dismissed`);
|
|
22909
|
+
if (decision)
|
|
22910
|
+
parts.push(`decision: ${decision}`);
|
|
22911
|
+
return parts.join(' · ');
|
|
22912
|
+
}
|
|
22913
|
+
/**
|
|
22914
|
+
* One-line state badge for the header, e.g. `OPEN · draft` or `MERGED`.
|
|
22915
|
+
* Mergeable / merge-state is appended as a secondary chip when the PR
|
|
22916
|
+
* is open so the user sees `MERGEABLE` / `CONFLICTING` at a glance.
|
|
22917
|
+
*/
|
|
22918
|
+
function formatPullRequestStateLine(pr) {
|
|
22919
|
+
const parts = [pr.state];
|
|
22920
|
+
if (pr.isDraft)
|
|
22921
|
+
parts.push('draft');
|
|
22922
|
+
if (pr.state === 'OPEN' && pr.mergeable) {
|
|
22923
|
+
parts.push(pr.mergeable.toLowerCase());
|
|
22924
|
+
}
|
|
22925
|
+
if (pr.state === 'OPEN' && pr.mergeStateStatus && pr.mergeStateStatus !== 'CLEAN') {
|
|
22926
|
+
parts.push(pr.mergeStateStatus.toLowerCase());
|
|
22927
|
+
}
|
|
22928
|
+
return parts.join(' · ');
|
|
22929
|
+
}
|
|
21362
22930
|
|
|
21363
22931
|
function sectionLines(title, diff) {
|
|
21364
22932
|
const lines = diff.split('\n').map((line) => line.trimEnd());
|
|
@@ -21575,6 +23143,89 @@ function diffLineProps(line, theme) {
|
|
|
21575
23143
|
}
|
|
21576
23144
|
return {};
|
|
21577
23145
|
}
|
|
23146
|
+
/**
|
|
23147
|
+
* Minimum terminal width below which the split diff falls back to
|
|
23148
|
+
* unified rendering (#785). Each column needs ~50 columns for code to
|
|
23149
|
+
* read comfortably plus border + padding overhead, so anything narrower
|
|
23150
|
+
* than ~120 columns gets the unified view regardless of the user's
|
|
23151
|
+
* preference. The preference is preserved — switching back to a wide
|
|
23152
|
+
* terminal restores split mode automatically.
|
|
23153
|
+
*/
|
|
23154
|
+
const MIN_SPLIT_DIFF_WIDTH = 120;
|
|
23155
|
+
function isSplitDiffViable(state, width) {
|
|
23156
|
+
return state.diffViewMode === 'split' && width >= MIN_SPLIT_DIFF_WIDTH;
|
|
23157
|
+
}
|
|
23158
|
+
/**
|
|
23159
|
+
* Style props for one side of a split-diff row, derived from the row's
|
|
23160
|
+
* `kind` rather than the leading character (because the helper has
|
|
23161
|
+
* already stripped the leading +/-/space). Keeps the colors aligned with
|
|
23162
|
+
* `diffLineProps`.
|
|
23163
|
+
*/
|
|
23164
|
+
function splitDiffSideProps(kind, theme) {
|
|
23165
|
+
if (kind === 'header') {
|
|
23166
|
+
if (theme.noColor)
|
|
23167
|
+
return { dimColor: true };
|
|
23168
|
+
return { color: theme.colors.accent };
|
|
23169
|
+
}
|
|
23170
|
+
if (kind === 'empty') {
|
|
23171
|
+
return { dimColor: true };
|
|
23172
|
+
}
|
|
23173
|
+
if (theme.noColor) {
|
|
23174
|
+
return { dimColor: kind === 'context' };
|
|
23175
|
+
}
|
|
23176
|
+
if (kind === 'add')
|
|
23177
|
+
return { color: theme.colors.gitAdded };
|
|
23178
|
+
if (kind === 'remove')
|
|
23179
|
+
return { color: theme.colors.gitDeleted };
|
|
23180
|
+
return {};
|
|
23181
|
+
}
|
|
23182
|
+
/**
|
|
23183
|
+
* Format one column of a split-diff row: an optional 4-digit line
|
|
23184
|
+
* number prefix + the line text, padded/truncated to the column width.
|
|
23185
|
+
* Empty rows render a faint `·` placeholder so the alignment gap is
|
|
23186
|
+
* visible at a glance.
|
|
23187
|
+
*/
|
|
23188
|
+
function formatSplitDiffCell(side, columnWidth) {
|
|
23189
|
+
if (side.kind === 'empty') {
|
|
23190
|
+
const placeholder = ' · ';
|
|
23191
|
+
return placeholder.padEnd(columnWidth);
|
|
23192
|
+
}
|
|
23193
|
+
if (side.kind === 'header') {
|
|
23194
|
+
return truncate$1(side.text, columnWidth).padEnd(columnWidth);
|
|
23195
|
+
}
|
|
23196
|
+
const lineNo = side.lineNumber !== undefined ? String(side.lineNumber).padStart(4) : ' ';
|
|
23197
|
+
// Strip the trailing newline that some diffs include. Keeps column
|
|
23198
|
+
// widths predictable.
|
|
23199
|
+
const text = side.text.replace(/\n$/, '');
|
|
23200
|
+
// 4 digits + 1 space gutter = 5 chars; reserve that off the column
|
|
23201
|
+
// before truncating the text.
|
|
23202
|
+
const textRoom = Math.max(1, columnWidth - 5);
|
|
23203
|
+
return `${lineNo} ${truncate$1(text, textRoom)}`.padEnd(columnWidth);
|
|
23204
|
+
}
|
|
23205
|
+
/**
|
|
23206
|
+
* Render the split-diff body as a list of two-column rows. The caller
|
|
23207
|
+
* is responsible for slicing the unified-line array to the visible
|
|
23208
|
+
* window — the helper just transforms that slice into Ink nodes.
|
|
23209
|
+
*/
|
|
23210
|
+
function renderSplitDiffBody(h, components, unifiedSlice, startOffset, width, theme, keyPrefix) {
|
|
23211
|
+
const { Box, Text } = components;
|
|
23212
|
+
const rows = buildSplitDiffRows(unifiedSlice);
|
|
23213
|
+
// Reserve 3 columns of gutter (1 left padding from the Box + 1 column
|
|
23214
|
+
// separator + 1 right padding) so neither side touches the border.
|
|
23215
|
+
const usable = Math.max(20, width - 4);
|
|
23216
|
+
const gutter = 1;
|
|
23217
|
+
const half = Math.max(10, Math.floor((usable - gutter) / 2));
|
|
23218
|
+
return rows.map((row, index) => {
|
|
23219
|
+
const leftProps = splitDiffSideProps(row.left.kind, theme);
|
|
23220
|
+
const rightProps = splitDiffSideProps(row.right.kind, theme);
|
|
23221
|
+
const leftText = formatSplitDiffCell(row.left, half);
|
|
23222
|
+
const rightText = formatSplitDiffCell(row.right, half);
|
|
23223
|
+
return h(Box, {
|
|
23224
|
+
key: `${keyPrefix}-${startOffset + index}`,
|
|
23225
|
+
flexDirection: 'row',
|
|
23226
|
+
}, 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)));
|
|
23227
|
+
});
|
|
23228
|
+
}
|
|
21578
23229
|
/**
|
|
21579
23230
|
* Pick a theme color for a single name-status code (`A`, `M`, `D`,
|
|
21580
23231
|
* `R100`, etc.) so the inspector and commit-diff file list render with
|
|
@@ -21628,68 +23279,6 @@ function sidebarTabLabel(tab) {
|
|
|
21628
23279
|
return tab;
|
|
21629
23280
|
}
|
|
21630
23281
|
}
|
|
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
23282
|
async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
21694
23283
|
const input = streams.input || process.stdin;
|
|
21695
23284
|
const output = streams.output || process.stdout;
|
|
@@ -22031,6 +23620,13 @@ function LogInkApp(deps) {
|
|
|
22031
23620
|
if (saved && saved !== state.userSidebarTab) {
|
|
22032
23621
|
dispatch({ type: 'restoreSidebarTab', value: saved });
|
|
22033
23622
|
}
|
|
23623
|
+
// Diff view mode persistence (#785). Same per-repo cache pattern
|
|
23624
|
+
// as the sidebar tab — restore the user's last preference if
|
|
23625
|
+
// they had one. New repos / fresh installs default to unified.
|
|
23626
|
+
const savedDiffMode = getSavedDiffViewMode(repoRoot);
|
|
23627
|
+
if (savedDiffMode && savedDiffMode !== state.diffViewMode) {
|
|
23628
|
+
dispatch({ type: 'setDiffViewMode', value: savedDiffMode });
|
|
23629
|
+
}
|
|
22034
23630
|
}
|
|
22035
23631
|
catch {
|
|
22036
23632
|
// Not in a worktree, or revparse failed; nothing to restore.
|
|
@@ -22044,6 +23640,12 @@ function LogInkApp(deps) {
|
|
|
22044
23640
|
return;
|
|
22045
23641
|
saveSidebarTab(repoRoot, state.userSidebarTab);
|
|
22046
23642
|
}, [state.userSidebarTab]);
|
|
23643
|
+
React.useEffect(() => {
|
|
23644
|
+
const repoRoot = repoRootRef.current;
|
|
23645
|
+
if (!repoRoot)
|
|
23646
|
+
return;
|
|
23647
|
+
saveDiffViewMode(repoRoot, state.diffViewMode);
|
|
23648
|
+
}, [state.diffViewMode]);
|
|
22047
23649
|
// P-stash-explorer: load `git stash show -p <ref>` once the diff view
|
|
22048
23650
|
// becomes active with diffSource='stash'. Best-effort — empty stashes
|
|
22049
23651
|
// or read errors fall through to a "no diff" hint at the render site.
|
|
@@ -22336,6 +23938,27 @@ function LogInkApp(deps) {
|
|
|
22336
23938
|
// disappears. Called from the y-confirm path for delete-branch / delete-
|
|
22337
23939
|
// tag / drop-stash / remove-worktree / abort-operation.
|
|
22338
23940
|
const runWorkflowAction = React.useCallback(async (id, payload) => {
|
|
23941
|
+
// Hunk-apply payload format: `<target>\n<patchText>` — the input
|
|
23942
|
+
// handler synthesizes both pieces (target from the keystroke,
|
|
23943
|
+
// patch text from extractDiffHunk against the live diff lines)
|
|
23944
|
+
// and packs them into the single `payload` field. Splitting on
|
|
23945
|
+
// the first newline keeps the patch body intact.
|
|
23946
|
+
const runApplyHunk = (expectedTarget, raw) => {
|
|
23947
|
+
if (!raw) {
|
|
23948
|
+
return Promise.resolve({ ok: false, message: 'No hunk under cursor to apply.' });
|
|
23949
|
+
}
|
|
23950
|
+
const newlineIndex = raw.indexOf('\n');
|
|
23951
|
+
if (newlineIndex < 0) {
|
|
23952
|
+
return Promise.resolve({ ok: false, message: 'Malformed hunk-apply payload.' });
|
|
23953
|
+
}
|
|
23954
|
+
const target = raw.slice(0, newlineIndex) === 'index' ? 'index' : 'worktree';
|
|
23955
|
+
const patchText = raw.slice(newlineIndex + 1);
|
|
23956
|
+
// The input handler is the source of truth for target — but if a
|
|
23957
|
+
// palette-injected payload mismatches the workflow id, prefer
|
|
23958
|
+
// the workflow id so the user sees the action they asked for.
|
|
23959
|
+
const effectiveTarget = expectedTarget || target;
|
|
23960
|
+
return applyHunkPatch(git, patchText, { target: effectiveTarget });
|
|
23961
|
+
};
|
|
22339
23962
|
const handlers = {
|
|
22340
23963
|
'create-branch': async () => {
|
|
22341
23964
|
const name = payload?.trim();
|
|
@@ -22441,6 +24064,44 @@ function LogInkApp(deps) {
|
|
|
22441
24064
|
message: commit.message,
|
|
22442
24065
|
});
|
|
22443
24066
|
},
|
|
24067
|
+
'revert-commit': async () => {
|
|
24068
|
+
const commit = getSelectedInkCommit(state);
|
|
24069
|
+
if (!commit)
|
|
24070
|
+
return { ok: false, message: 'No commit selected' };
|
|
24071
|
+
return revertCommit(git, {
|
|
24072
|
+
hash: commit.hash,
|
|
24073
|
+
shortHash: commit.shortHash,
|
|
24074
|
+
message: commit.message,
|
|
24075
|
+
});
|
|
24076
|
+
},
|
|
24077
|
+
'reset-to-commit': async () => {
|
|
24078
|
+
const commit = getSelectedInkCommit(state);
|
|
24079
|
+
if (!commit)
|
|
24080
|
+
return { ok: false, message: 'No commit selected' };
|
|
24081
|
+
// Mode arrives via the action's `payload` field — the input
|
|
24082
|
+
// handler runs the reset-mode prompt (kind: 'reset-mode') and
|
|
24083
|
+
// routes the typed value here. Default to `mixed` (git's own
|
|
24084
|
+
// default) when the user submitted an empty value.
|
|
24085
|
+
const raw = payload?.trim().toLowerCase() || 'mixed';
|
|
24086
|
+
if (!isResetMode(raw)) {
|
|
24087
|
+
return { ok: false, message: `Unknown reset mode: ${raw}. Use soft, mixed, or hard.` };
|
|
24088
|
+
}
|
|
24089
|
+
return resetToCommit(git, {
|
|
24090
|
+
hash: commit.hash,
|
|
24091
|
+
shortHash: commit.shortHash,
|
|
24092
|
+
message: commit.message,
|
|
24093
|
+
}, raw);
|
|
24094
|
+
},
|
|
24095
|
+
'interactive-rebase': async () => {
|
|
24096
|
+
const commit = getSelectedInkCommit(state);
|
|
24097
|
+
if (!commit)
|
|
24098
|
+
return { ok: false, message: 'No commit selected' };
|
|
24099
|
+
return startInteractiveRebase(git, {
|
|
24100
|
+
hash: commit.hash,
|
|
24101
|
+
shortHash: commit.shortHash,
|
|
24102
|
+
message: commit.message,
|
|
24103
|
+
});
|
|
24104
|
+
},
|
|
22444
24105
|
'checkout-file-from-commit': async () => {
|
|
22445
24106
|
// payload is "<sha> <path>" so we pass both through a single
|
|
22446
24107
|
// string field on the action.
|
|
@@ -22456,6 +24117,8 @@ function LogInkApp(deps) {
|
|
|
22456
24117
|
return { ok: false, message: 'No commit file under cursor' };
|
|
22457
24118
|
return checkoutFileFromCommit(git, sha, path);
|
|
22458
24119
|
},
|
|
24120
|
+
'apply-hunk-worktree': async () => runApplyHunk('worktree', payload),
|
|
24121
|
+
'apply-hunk-index': async () => runApplyHunk('index', payload),
|
|
22459
24122
|
'remove-worktree': async () => {
|
|
22460
24123
|
const all = context.worktreeList?.worktrees || [];
|
|
22461
24124
|
// Resolve the target from the visible (filtered) list so a
|
|
@@ -22544,6 +24207,32 @@ function LogInkApp(deps) {
|
|
|
22544
24207
|
return { ok: false, message: 'Stash message required' };
|
|
22545
24208
|
return createStash(git, message);
|
|
22546
24209
|
},
|
|
24210
|
+
// #783 — full PR action panel handlers. Each wraps the matching
|
|
24211
|
+
// pullRequestActions verb. Strategy / body arrives via `payload`
|
|
24212
|
+
// — input prompts validate before they reach here, but the
|
|
24213
|
+
// strategy guard stays as a defensive belt-and-suspenders since
|
|
24214
|
+
// a future palette path could call us with a raw value.
|
|
24215
|
+
'merge-pr': async () => {
|
|
24216
|
+
const strategy = (payload || 'merge').toLowerCase();
|
|
24217
|
+
if (!isPullRequestMergeStrategy(strategy)) {
|
|
24218
|
+
return { ok: false, message: `Unknown merge strategy: ${strategy}. Use merge, squash, or rebase.` };
|
|
24219
|
+
}
|
|
24220
|
+
return mergePullRequest(strategy);
|
|
24221
|
+
},
|
|
24222
|
+
'close-pr': async () => closePullRequest(),
|
|
24223
|
+
'approve-pr': async () => approvePullRequest(),
|
|
24224
|
+
'request-changes-pr': async () => {
|
|
24225
|
+
const body = payload?.trim();
|
|
24226
|
+
if (!body)
|
|
24227
|
+
return { ok: false, message: 'Review body required for change-request' };
|
|
24228
|
+
return requestChangesPullRequest(body);
|
|
24229
|
+
},
|
|
24230
|
+
'comment-pr': async () => {
|
|
24231
|
+
const body = payload?.trim();
|
|
24232
|
+
if (!body)
|
|
24233
|
+
return { ok: false, message: 'Comment body required' };
|
|
24234
|
+
return commentPullRequest(body);
|
|
24235
|
+
},
|
|
22547
24236
|
};
|
|
22548
24237
|
const handler = handlers[id];
|
|
22549
24238
|
if (!handler) {
|
|
@@ -22834,6 +24523,51 @@ function LogInkApp(deps) {
|
|
|
22834
24523
|
});
|
|
22835
24524
|
})();
|
|
22836
24525
|
}, [dispatch, git, logArgv, state.historyFetchArgs]);
|
|
24526
|
+
// Graph mode toggle (`g` key, #791 follow-up). The header label flips
|
|
24527
|
+
// between "compact graph" and "full graph", but unless we re-fetch with
|
|
24528
|
+
// the right `view`, the underlying rows still come from the user's
|
|
24529
|
+
// initial argv (default `--first-parent --no-merges`) and the renderer
|
|
24530
|
+
// has no topology to draw — defeating the per-lane / junction work.
|
|
24531
|
+
// Mirrors the historyFetchArgs effect: skip first run, request-id ref
|
|
24532
|
+
// for stale-completion guard, swap rows in place via replaceRows.
|
|
24533
|
+
const toggleGraphEffectInitialized = React.useRef(false);
|
|
24534
|
+
const toggleGraphRequestRef = React.useRef(0);
|
|
24535
|
+
React.useEffect(() => {
|
|
24536
|
+
if (!logArgv)
|
|
24537
|
+
return;
|
|
24538
|
+
if (!toggleGraphEffectInitialized.current) {
|
|
24539
|
+
toggleGraphEffectInitialized.current = true;
|
|
24540
|
+
return;
|
|
24541
|
+
}
|
|
24542
|
+
const requestId = toggleGraphRequestRef.current + 1;
|
|
24543
|
+
toggleGraphRequestRef.current = requestId;
|
|
24544
|
+
const merged = buildToggleGraphArgs(logArgv, state.fullGraph);
|
|
24545
|
+
dispatch({
|
|
24546
|
+
type: 'setStatus',
|
|
24547
|
+
value: state.fullGraph
|
|
24548
|
+
? 'Loading full topology…'
|
|
24549
|
+
: 'Loading compact history…',
|
|
24550
|
+
});
|
|
24551
|
+
void (async () => {
|
|
24552
|
+
const nextRows = await safe(getLogRows(git, merged, { limit: LOG_INTERACTIVE_DEFAULT_LIMIT }));
|
|
24553
|
+
if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
|
|
24554
|
+
return;
|
|
24555
|
+
}
|
|
24556
|
+
if (!nextRows) {
|
|
24557
|
+
dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows' });
|
|
24558
|
+
return;
|
|
24559
|
+
}
|
|
24560
|
+
dispatch({ type: 'replaceRows', rows: nextRows });
|
|
24561
|
+
const matched = getCommitRows(nextRows).length;
|
|
24562
|
+
setHasMoreCommits(matched >= LOG_INTERACTIVE_DEFAULT_LIMIT);
|
|
24563
|
+
dispatch({
|
|
24564
|
+
type: 'setStatus',
|
|
24565
|
+
value: state.fullGraph
|
|
24566
|
+
? `Showing ${matched} commits across all branches`
|
|
24567
|
+
: `Showing ${matched} commits (compact)`,
|
|
24568
|
+
});
|
|
24569
|
+
})();
|
|
24570
|
+
}, [dispatch, git, logArgv, state.fullGraph]);
|
|
22837
24571
|
const commitDiffHunkOffsets = React.useMemo(() => (filePreview?.hunks
|
|
22838
24572
|
.map((line, index) => (line.startsWith('@@') ? index : -1))
|
|
22839
24573
|
.filter((index) => index >= 0)), [filePreview]);
|
|
@@ -22928,6 +24662,17 @@ function LogInkApp(deps) {
|
|
|
22928
24662
|
? selected?.hash
|
|
22929
24663
|
: undefined,
|
|
22930
24664
|
worktreeDirty,
|
|
24665
|
+
// H / gH need the actual diff text (not just hunk offsets) to
|
|
24666
|
+
// slice the cursored hunk into a `git apply` patch. Stash uses
|
|
24667
|
+
// the full `git stash show -p` output; commit-diff uses the
|
|
24668
|
+
// per-file `filePreview.hunks` array. Either way, extractDiffHunk
|
|
24669
|
+
// walks `@@` headers and synthesizes a fresh diff --git / --- /
|
|
24670
|
+
// +++ header set using the path the caller already resolved.
|
|
24671
|
+
diffLinesForHunkApply: state.diffSource === 'stash'
|
|
24672
|
+
? stashDiffLines
|
|
24673
|
+
: state.diffSource === 'commit'
|
|
24674
|
+
? filePreview?.hunks
|
|
24675
|
+
: undefined,
|
|
22931
24676
|
}).forEach((event) => {
|
|
22932
24677
|
if (event.type === 'exit') {
|
|
22933
24678
|
exit();
|
|
@@ -22994,7 +24739,7 @@ function LogInkApp(deps) {
|
|
|
22994
24739
|
if (showOnboarding) {
|
|
22995
24740
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
22996
24741
|
}
|
|
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));
|
|
24742
|
+
return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
|
|
22998
24743
|
}
|
|
22999
24744
|
function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
|
|
23000
24745
|
const { Box, Text } = components;
|
|
@@ -23094,11 +24839,101 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
23094
24839
|
if (tab === 'status') {
|
|
23095
24840
|
return renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme);
|
|
23096
24841
|
}
|
|
23097
|
-
|
|
23098
|
-
|
|
23099
|
-
|
|
23100
|
-
|
|
23101
|
-
|
|
24842
|
+
// Branches / tags / stashes / worktrees: render selectable rows so
|
|
24843
|
+
// ↑/↓ navigates within the sidebar list and Enter / per-entity keys
|
|
24844
|
+
// act on the cursored item without needing to drill into the
|
|
24845
|
+
// dedicated view (#791 follow-up — in-sidebar selection).
|
|
24846
|
+
const focused = state.focus === 'sidebar' && state.sidebarTab === tab;
|
|
24847
|
+
if (tab === 'branches') {
|
|
24848
|
+
if (isLogInkContextKeyLoading(contextStatus, 'branches')) {
|
|
24849
|
+
return [h(Text, { key: 'tab-branches-loading', dimColor: true }, ' Loading branches…')];
|
|
24850
|
+
}
|
|
24851
|
+
const branches = context.branches;
|
|
24852
|
+
if (!branches) {
|
|
24853
|
+
return [h(Text, { key: 'tab-branches-empty', dimColor: true }, ' Branches unavailable')];
|
|
24854
|
+
}
|
|
24855
|
+
const sortedBranches = sortBranches(branches.localBranches, state.branchSort);
|
|
24856
|
+
const headerRows = [
|
|
24857
|
+
h(Text, { key: 'tab-branches-current', dimColor: true }, truncate$1(` Current: ${branches.currentBranch || '<detached>'}`, width - 4)),
|
|
24858
|
+
h(Text, { key: 'tab-branches-state', dimColor: true }, ` Worktree: ${branches.dirty ? 'dirty' : 'clean'}`),
|
|
24859
|
+
h(Text, { key: 'tab-branches-spacer' }, ''),
|
|
24860
|
+
];
|
|
24861
|
+
return [
|
|
24862
|
+
...headerRows,
|
|
24863
|
+
...renderSelectableSidebarRows(h, Text, sortedBranches, state.selectedBranchIndex, focused, width, theme, (branch) => `${branchRowMarker(branch, { ascii: theme.ascii })} ${branch.shortName}`, 'tab-branches'),
|
|
24864
|
+
];
|
|
24865
|
+
}
|
|
24866
|
+
if (tab === 'tags') {
|
|
24867
|
+
if (isLogInkContextKeyLoading(contextStatus, 'tags')) {
|
|
24868
|
+
return [h(Text, { key: 'tab-tags-loading', dimColor: true }, ' Loading tags…')];
|
|
24869
|
+
}
|
|
24870
|
+
const tags = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24871
|
+
if (tags.length === 0) {
|
|
24872
|
+
return [h(Text, { key: 'tab-tags-empty', dimColor: true }, ' No tags found')];
|
|
24873
|
+
}
|
|
24874
|
+
return renderSelectableSidebarRows(h, Text, tags, state.selectedTagIndex, focused, width, theme, (tag) => `${truncate$1(tag.name, 16)} ${tag.subject}`, 'tab-tags');
|
|
24875
|
+
}
|
|
24876
|
+
if (tab === 'stashes') {
|
|
24877
|
+
if (isLogInkContextKeyLoading(contextStatus, 'stashes')) {
|
|
24878
|
+
return [h(Text, { key: 'tab-stashes-loading', dimColor: true }, ' Loading stashes…')];
|
|
24879
|
+
}
|
|
24880
|
+
const stashes = context.stashes?.stashes || [];
|
|
24881
|
+
if (stashes.length === 0) {
|
|
24882
|
+
return [h(Text, { key: 'tab-stashes-empty', dimColor: true }, ' No stashes found')];
|
|
24883
|
+
}
|
|
24884
|
+
return renderSelectableSidebarRows(h, Text, stashes, state.selectedStashIndex, focused, width, theme, (stash, index) => `@{${index}} ${stash.message || '(no message)'}`, 'tab-stashes');
|
|
24885
|
+
}
|
|
24886
|
+
// worktrees
|
|
24887
|
+
if (isLogInkContextKeyLoading(contextStatus, 'worktreeList')) {
|
|
24888
|
+
return [h(Text, { key: 'tab-worktrees-loading', dimColor: true }, ' Loading worktrees…')];
|
|
24889
|
+
}
|
|
24890
|
+
const worktrees = context.worktreeList?.worktrees || [];
|
|
24891
|
+
if (worktrees.length === 0) {
|
|
24892
|
+
return [h(Text, { key: 'tab-worktrees-empty', dimColor: true }, ' No linked worktrees')];
|
|
24893
|
+
}
|
|
24894
|
+
return renderSelectableSidebarRows(h, Text, worktrees, state.selectedWorktreeListIndex, focused, width, theme, (worktree) => {
|
|
24895
|
+
const marker = worktree.current ? '*' : ' ';
|
|
24896
|
+
const wstate = worktree.dirty ? 'dirty' : 'clean';
|
|
24897
|
+
return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
|
|
24898
|
+
}, 'tab-worktrees');
|
|
24899
|
+
}
|
|
24900
|
+
/**
|
|
24901
|
+
* Render a sliding-window list of selectable sidebar rows. The cursor
|
|
24902
|
+
* highlights the row at `selectedIndex` only when `focused` is true so
|
|
24903
|
+
* an unfocused sidebar doesn't compete visually with the active panel.
|
|
24904
|
+
* Sliding window keeps the cursor in view as the user navigates a long
|
|
24905
|
+
* list; truncation hints surface the count of hidden rows.
|
|
24906
|
+
*/
|
|
24907
|
+
function renderSelectableSidebarRows(h, Text, items, selectedIndex, focused, width, theme, toRowText, keyPrefix) {
|
|
24908
|
+
if (items.length === 0)
|
|
24909
|
+
return [];
|
|
24910
|
+
const window = getSidebarVisibleWindow(items.length, selectedIndex);
|
|
24911
|
+
const elements = [];
|
|
24912
|
+
if (window.truncatedAbove > 0) {
|
|
24913
|
+
elements.push(h(Text, {
|
|
24914
|
+
key: `${keyPrefix}-trunc-above`,
|
|
24915
|
+
dimColor: true,
|
|
24916
|
+
}, truncate$1(` … ${window.truncatedAbove} more above`, width - 4)));
|
|
24917
|
+
}
|
|
24918
|
+
for (let offset = 0; offset < window.size; offset += 1) {
|
|
24919
|
+
const index = window.start + offset;
|
|
24920
|
+
if (index >= items.length)
|
|
24921
|
+
break;
|
|
24922
|
+
const isSelected = focused && index === selectedIndex;
|
|
24923
|
+
const text = toRowText(items[index], index);
|
|
24924
|
+
elements.push(h(Text, {
|
|
24925
|
+
key: `${keyPrefix}-row-${index}`,
|
|
24926
|
+
backgroundColor: isSelected && !theme.noColor ? theme.colors.selection : undefined,
|
|
24927
|
+
inverse: isSelected,
|
|
24928
|
+
}, truncate$1(` ${text}`, width - 4)));
|
|
24929
|
+
}
|
|
24930
|
+
if (window.truncatedBelow > 0) {
|
|
24931
|
+
elements.push(h(Text, {
|
|
24932
|
+
key: `${keyPrefix}-trunc-below`,
|
|
24933
|
+
dimColor: true,
|
|
24934
|
+
}, truncate$1(` … ${window.truncatedBelow} more below`, width - 4)));
|
|
24935
|
+
}
|
|
24936
|
+
return elements;
|
|
23102
24937
|
}
|
|
23103
24938
|
function renderActiveStatusTabContent(h, Text, context, contextStatus, width, theme) {
|
|
23104
24939
|
if (isLogInkContextKeyLoading(contextStatus, 'worktree')) {
|
|
@@ -23156,6 +24991,9 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
23156
24991
|
if (state.activeView === 'worktrees') {
|
|
23157
24992
|
return renderWorktreesSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
23158
24993
|
}
|
|
24994
|
+
if (state.activeView === 'pull-request') {
|
|
24995
|
+
return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
24996
|
+
}
|
|
23159
24997
|
return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
|
|
23160
24998
|
}
|
|
23161
24999
|
function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
|
|
@@ -23205,15 +25043,46 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
23205
25043
|
}))
|
|
23206
25044
|
: visible.items.map((item, index) => {
|
|
23207
25045
|
if (item.type === 'graph') {
|
|
25046
|
+
if (item.laneSegments && !theme.ascii) {
|
|
25047
|
+
return h(Text, { key: `graph-${index}-${item.graph}` }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`));
|
|
25048
|
+
}
|
|
23208
25049
|
return h(Text, {
|
|
23209
25050
|
key: `graph-${index}-${item.graph}`,
|
|
23210
25051
|
color: theme.noColor ? undefined : theme.colors.muted,
|
|
23211
25052
|
dimColor: theme.noColor,
|
|
23212
25053
|
}, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), 140));
|
|
23213
25054
|
}
|
|
23214
|
-
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index);
|
|
25055
|
+
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, item.laneSegments);
|
|
23215
25056
|
}));
|
|
23216
25057
|
}
|
|
25058
|
+
/**
|
|
25059
|
+
* Render `LaneSegment[]` as a flat list of Text spans, one per lane
|
|
25060
|
+
* (#791 stage 2). Each segment paints in its lane's palette color so
|
|
25061
|
+
* the eye can follow a branch column-by-column; segments without a
|
|
25062
|
+
* lane id (spaces, padding, decorations) fall back to the muted graph
|
|
25063
|
+
* color so they visually recede.
|
|
25064
|
+
*
|
|
25065
|
+
* Final padding is appended as its own span so callers do not need to
|
|
25066
|
+
* pre-pad the graph string before computing lane segments.
|
|
25067
|
+
*/
|
|
25068
|
+
function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
25069
|
+
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25070
|
+
const elements = [];
|
|
25071
|
+
let totalLen = 0;
|
|
25072
|
+
segments.forEach((seg, idx) => {
|
|
25073
|
+
const laneColor = getLaneColor(seg.laneId, theme);
|
|
25074
|
+
elements.push(h(Text, {
|
|
25075
|
+
key: `${keyPrefix}-${idx}`,
|
|
25076
|
+
color: laneColor ?? muted,
|
|
25077
|
+
dimColor: theme.noColor && seg.laneId === undefined,
|
|
25078
|
+
}, seg.text));
|
|
25079
|
+
totalLen += seg.text.length;
|
|
25080
|
+
});
|
|
25081
|
+
if (padTo > totalLen) {
|
|
25082
|
+
elements.push(h(Text, { key: `${keyPrefix}-pad` }, ' '.repeat(padTo - totalLen)));
|
|
25083
|
+
}
|
|
25084
|
+
return elements;
|
|
25085
|
+
}
|
|
23217
25086
|
/**
|
|
23218
25087
|
* Render a single commit row with each segment in its own colored span.
|
|
23219
25088
|
* Graph chars render in `theme.colors.muted` so the topology visually
|
|
@@ -23226,8 +25095,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
23226
25095
|
* Truncation is per-segment so the variable-length message field gets
|
|
23227
25096
|
* the leftover budget after fixed segments are accounted for.
|
|
23228
25097
|
*/
|
|
23229
|
-
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index) {
|
|
23230
|
-
const renderedGraph = substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii });
|
|
25098
|
+
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
|
|
23231
25099
|
const refs = formatInkRefLabels(commit.refs);
|
|
23232
25100
|
const totalWidth = 140;
|
|
23233
25101
|
const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
|
|
@@ -23236,11 +25104,17 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
|
|
|
23236
25104
|
const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
|
|
23237
25105
|
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
23238
25106
|
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25107
|
+
// Lane-colored graph spans when full graph mode + non-ASCII rendering
|
|
25108
|
+
// is in play; otherwise fall back to the legacy single-muted span so
|
|
25109
|
+
// compact mode and legacy terminals stay visually unchanged.
|
|
25110
|
+
const graphChildren = laneSegments && !theme.ascii
|
|
25111
|
+
? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `c${index}`)
|
|
25112
|
+
: [h(Text, { color: muted, dimColor: theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
|
|
23239
25113
|
return h(Text, {
|
|
23240
25114
|
key: `${commit.hash}-${index}`,
|
|
23241
25115
|
backgroundColor: selectedBg,
|
|
23242
25116
|
inverse: selected,
|
|
23243
|
-
},
|
|
25117
|
+
}, ...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
25118
|
}
|
|
23245
25119
|
/**
|
|
23246
25120
|
* Render the synthetic "(+) new commit" affordance shown above the real
|
|
@@ -23473,11 +25347,35 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
23473
25347
|
const cursor = isSelected ? '>' : ' ';
|
|
23474
25348
|
const marker = branchRowMarker(branch, { ascii: theme.ascii });
|
|
23475
25349
|
const divergence = formatBranchDivergence(branch, { ascii: theme.ascii });
|
|
25350
|
+
const lastTouched = formatBranchLastTouched(branch.date, new Date());
|
|
25351
|
+
// Split the row into spans so the timestamp stays dim even on the
|
|
25352
|
+
// currently-selected (bold) row. The leading marker + name keep
|
|
25353
|
+
// their original column widths; the timestamp is right-padded so
|
|
25354
|
+
// the divergence column stays aligned across rows.
|
|
25355
|
+
const namePadded = branch.shortName.padEnd(28);
|
|
25356
|
+
const timestampPadded = lastTouched.padEnd(8);
|
|
25357
|
+
const lineDim = !isSelected && !branch.current;
|
|
25358
|
+
const head = `${cursor} ${marker} ${namePadded} `;
|
|
25359
|
+
const trailingDivergence = divergence ? ` ${divergence}` : '';
|
|
25360
|
+
// Truncate the assembled line cooperatively so we never overflow
|
|
25361
|
+
// the panel; the timestamp is short and the divergence is the
|
|
25362
|
+
// most expendable, but the existing 140 cap is ample.
|
|
25363
|
+
const fullText = `${head}${timestampPadded}${trailingDivergence}`;
|
|
25364
|
+
const truncated = truncate$1(fullText, 140);
|
|
25365
|
+
// If truncation chopped into the timestamp/divergence portion,
|
|
25366
|
+
// fall back to a single Text to keep the visible width honest.
|
|
25367
|
+
if (truncated !== fullText) {
|
|
25368
|
+
return h(Text, {
|
|
25369
|
+
key: `branch-${index}`,
|
|
25370
|
+
bold: isSelected,
|
|
25371
|
+
dimColor: lineDim,
|
|
25372
|
+
}, truncated);
|
|
25373
|
+
}
|
|
23476
25374
|
return h(Text, {
|
|
23477
25375
|
key: `branch-${index}`,
|
|
23478
25376
|
bold: isSelected,
|
|
23479
|
-
dimColor:
|
|
23480
|
-
},
|
|
25377
|
+
dimColor: lineDim,
|
|
25378
|
+
}, head, h(Text, { dimColor: true }, timestampPadded), trailingDivergence);
|
|
23481
25379
|
});
|
|
23482
25380
|
return h(Box, {
|
|
23483
25381
|
borderColor: focusBorderColor(theme, focused),
|
|
@@ -23630,6 +25528,98 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
23630
25528
|
width,
|
|
23631
25529
|
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Worktrees', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
|
|
23632
25530
|
}
|
|
25531
|
+
/**
|
|
25532
|
+
* Pull-request action panel (#783) — renders the current branch's PR
|
|
25533
|
+
* with header, checks table, reviews summary, and a body preview.
|
|
25534
|
+
* Action keys (m / x / a / R / c / O) are wired in inkInput.ts and
|
|
25535
|
+
* surfaced via the footer; this renderer is read-only.
|
|
25536
|
+
*
|
|
25537
|
+
* Three loading / fallback states matter:
|
|
25538
|
+
* - Provider data still loading → "Loading pull request..."
|
|
25539
|
+
* - GitHub remote present but no PR for the current branch → empty
|
|
25540
|
+
* state hint pointing the user at `C` to create one.
|
|
25541
|
+
* - GitHub CLI missing / unauthenticated → unavailable hint.
|
|
25542
|
+
*/
|
|
25543
|
+
function renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
25544
|
+
const { Box, Text } = components;
|
|
25545
|
+
const focused = state.focus === 'commits';
|
|
25546
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequest');
|
|
25547
|
+
const pullRequestOverview = context.pullRequest;
|
|
25548
|
+
// Use the dedicated `pullRequest` overview only — the `provider`
|
|
25549
|
+
// shape carries a slimmer ProviderPullRequestStatus that lacks
|
|
25550
|
+
// url / headRefName / body / mergeable / reviews. The dedicated
|
|
25551
|
+
// overview hits `gh pr view --json` with the full enriched field
|
|
25552
|
+
// list (PULL_REQUEST_VIEW_JSON_FIELDS) so the panel has everything.
|
|
25553
|
+
const pr = pullRequestOverview?.currentPullRequest;
|
|
25554
|
+
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25555
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
25556
|
+
const containerProps = {
|
|
25557
|
+
borderColor: focusBorderColor(theme, focused),
|
|
25558
|
+
borderStyle: theme.borderStyle,
|
|
25559
|
+
flexDirection: 'column',
|
|
25560
|
+
flexShrink: 0,
|
|
25561
|
+
paddingX: 1,
|
|
25562
|
+
width,
|
|
25563
|
+
};
|
|
25564
|
+
if (loading && !pr) {
|
|
25565
|
+
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' })));
|
|
25566
|
+
}
|
|
25567
|
+
if (!pr) {
|
|
25568
|
+
const hint = pullRequestOverview?.message
|
|
25569
|
+
|| 'No pull request detected for this branch. Press `C` (or `:create-pr`) to create one.';
|
|
25570
|
+
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)));
|
|
25571
|
+
}
|
|
25572
|
+
const checks = summarizePullRequestChecks(pr.statusCheckRollup);
|
|
25573
|
+
const reviews = summarizePullRequestReviews(pr.reviews, pr.reviewDecision);
|
|
25574
|
+
const checkRows = buildPullRequestCheckRows(pr.statusCheckRollup, { ascii: theme.ascii });
|
|
25575
|
+
const checkColor = (s) => {
|
|
25576
|
+
if (theme.noColor)
|
|
25577
|
+
return undefined;
|
|
25578
|
+
if (s === 'success')
|
|
25579
|
+
return theme.colors.success;
|
|
25580
|
+
if (s === 'failure')
|
|
25581
|
+
return theme.colors.danger;
|
|
25582
|
+
if (s === 'pending')
|
|
25583
|
+
return theme.colors.warning;
|
|
25584
|
+
return theme.colors.muted;
|
|
25585
|
+
};
|
|
25586
|
+
// Reserve a few rows for the header/section labels; the rest go to
|
|
25587
|
+
// the checks table. Body preview gets the leftover rows so the
|
|
25588
|
+
// surface stays vertically balanced even on tall terminals.
|
|
25589
|
+
const checkBudget = Math.max(3, Math.min(checkRows.length, Math.floor(bodyRows / 2)));
|
|
25590
|
+
const visibleChecks = checkRows.slice(0, checkBudget);
|
|
25591
|
+
const truncatedChecks = checkRows.length - visibleChecks.length;
|
|
25592
|
+
const bodyPreviewBudget = Math.max(2, bodyRows - 8 - visibleChecks.length);
|
|
25593
|
+
const bodyLines = (pr.body || '').split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
25594
|
+
const visibleBodyLines = bodyLines.slice(0, bodyPreviewBudget);
|
|
25595
|
+
const truncatedBodyLines = bodyLines.length - visibleBodyLines.length;
|
|
25596
|
+
const headerRight = `#${pr.number} · ${pr.headRefName} → ${pr.baseRefName}`;
|
|
25597
|
+
const stateLine = formatPullRequestStateLine(pr);
|
|
25598
|
+
const author = pr.author ? `by @${pr.author}` : '';
|
|
25599
|
+
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, ''),
|
|
25600
|
+
// Checks section
|
|
25601
|
+
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, {
|
|
25602
|
+
key: `pr-check-${index}`,
|
|
25603
|
+
color: checkColor(row.status),
|
|
25604
|
+
}, truncate$1(` ${row.glyph} ${row.name.padEnd(28)} ${row.detail}`, width - 4))), ...(truncatedChecks > 0
|
|
25605
|
+
? [h(Text, { key: 'pr-checks-trunc', dimColor: true }, truncate$1(` … ${truncatedChecks} more`, width - 4))]
|
|
25606
|
+
: []), h(Text, undefined, ''),
|
|
25607
|
+
// Reviews section
|
|
25608
|
+
h(Text, { bold: true, color: accent }, 'Reviews'), h(Text, { dimColor: true }, truncate$1(` ${formatPullRequestReviewsSummary(reviews)}`, width - 4)), h(Text, undefined, ''),
|
|
25609
|
+
// Body preview
|
|
25610
|
+
...(visibleBodyLines.length > 0
|
|
25611
|
+
? [
|
|
25612
|
+
h(Text, { key: 'pr-body-label', bold: true, color: accent }, 'Description'),
|
|
25613
|
+
...visibleBodyLines.map((line, index) => h(Text, {
|
|
25614
|
+
key: `pr-body-${index}`,
|
|
25615
|
+
color: muted,
|
|
25616
|
+
}, truncate$1(` ${line}`, width - 4))),
|
|
25617
|
+
...(truncatedBodyLines > 0
|
|
25618
|
+
? [h(Text, { key: 'pr-body-trunc', dimColor: true }, truncate$1(` … ${truncatedBodyLines} more lines`, width - 4))]
|
|
25619
|
+
: []),
|
|
25620
|
+
]
|
|
25621
|
+
: []));
|
|
25622
|
+
}
|
|
23633
25623
|
/**
|
|
23634
25624
|
* Filter input cursor for the promoted views (branches/tags/stash).
|
|
23635
25625
|
* History already shows the same `filter: foo_` affordance in its header
|
|
@@ -23660,6 +25650,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23660
25650
|
// cherry-picks the file at the cursor.
|
|
23661
25651
|
if (state.diffSource === 'stash') {
|
|
23662
25652
|
const lines = stashDiffLines || [];
|
|
25653
|
+
const splitActive = isSplitDiffViable(state, width);
|
|
25654
|
+
const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
|
|
23663
25655
|
const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
23664
25656
|
const stashFiles = parseStashDiffFiles(lines);
|
|
23665
25657
|
const fileCount = stashFiles.length;
|
|
@@ -23680,11 +25672,19 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23680
25672
|
const currentFileIndex = currentFile
|
|
23681
25673
|
? Math.max(0, stashFiles.findIndex((file) => file.startLine === currentFile.startLine))
|
|
23682
25674
|
: -1;
|
|
23683
|
-
|
|
23684
|
-
|
|
25675
|
+
// Look up the active stash entry so the panel header can show a
|
|
25676
|
+
// human-identifier instead of the raw `stash@{<iso-date>}` ref.
|
|
25677
|
+
// The git ref is the timestamp form (we fetch with --date=iso for
|
|
25678
|
+
// stable parsing) which reads as noise in the title bar; the
|
|
25679
|
+
// message + branch + index combination is what the user wrote down
|
|
25680
|
+
// when they ran `git stash`. Body still shows the full ref so it
|
|
25681
|
+
// stays unambiguous.
|
|
25682
|
+
const stashIdentity = formatStashHeaderIdentity(state.stashDiffRef, context.stashes?.stashes);
|
|
25683
|
+
const baseHeaderLines = stashDiffLoading
|
|
25684
|
+
? [`Loading diff for ${stashIdentity.subtitle}...`]
|
|
23685
25685
|
: lines.length
|
|
23686
25686
|
? [
|
|
23687
|
-
|
|
25687
|
+
stashIdentity.bodyLine,
|
|
23688
25688
|
fileCount > 0 && currentFile
|
|
23689
25689
|
? `File ${currentFileIndex + 1}/${fileCount}: ${currentFile.path}`
|
|
23690
25690
|
: 'No files in this stash.',
|
|
@@ -23692,6 +25692,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23692
25692
|
'',
|
|
23693
25693
|
]
|
|
23694
25694
|
: ['No diff to display for this stash.'];
|
|
25695
|
+
const headerLines = splitRequestedButTooNarrow
|
|
25696
|
+
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
25697
|
+
: baseHeaderLines;
|
|
25698
|
+
const stashBodyNodes = stashDiffLoading || !lines.length
|
|
25699
|
+
? []
|
|
25700
|
+
: splitActive
|
|
25701
|
+
? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
|
|
25702
|
+
: visibleLines.map((line, index) => h(Text, {
|
|
25703
|
+
key: `stash-diff-line-${state.diffPreviewOffset + index}`,
|
|
25704
|
+
...diffLineProps(line, theme),
|
|
25705
|
+
}, truncate$1(line, width - 4)));
|
|
23695
25706
|
return h(Box, {
|
|
23696
25707
|
borderColor: focusBorderColor(theme, focused),
|
|
23697
25708
|
borderStyle: theme.borderStyle,
|
|
@@ -23699,15 +25710,10 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23699
25710
|
flexShrink: 0,
|
|
23700
25711
|
paddingX: 1,
|
|
23701
25712
|
width,
|
|
23702
|
-
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Stash diff', focused)), h(Text, { dimColor: true },
|
|
25713
|
+
}, 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
25714
|
key: `stash-diff-header-${index}`,
|
|
23704
25715
|
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)))));
|
|
25716
|
+
}, truncate$1(line, width - 4))), ...stashBodyNodes);
|
|
23711
25717
|
}
|
|
23712
25718
|
// diffSource disambiguates: 'commit' was set when the user opened the
|
|
23713
25719
|
// diff via history → Enter (read-only commit-diff explore), 'worktree'
|
|
@@ -23718,6 +25724,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23718
25724
|
(state.diffSource === undefined && !worktreeFile && Boolean(selectedDetailFile));
|
|
23719
25725
|
if (useCommitDiff) {
|
|
23720
25726
|
const previewHunks = filePreview?.hunks || [];
|
|
25727
|
+
const splitActive = isSplitDiffViable(state, width);
|
|
25728
|
+
const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
|
|
23721
25729
|
const visiblePreviewHunks = previewHunks.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
23722
25730
|
const hunkCount = commitDiffHunkOffsets?.length || 0;
|
|
23723
25731
|
const currentHunkIndex = hunkCount > 0
|
|
@@ -23728,7 +25736,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23728
25736
|
const currentHunkLabel = hunkCount > 0
|
|
23729
25737
|
? `Hunk ${Math.min(hunkCount - currentHunkIndex, hunkCount)}/${hunkCount}`
|
|
23730
25738
|
: 'No hunks for this file.';
|
|
23731
|
-
const
|
|
25739
|
+
const baseHeaderLines = filePreviewLoading
|
|
23732
25740
|
? [`Loading diff for ${selectedDetailFile?.path || 'selected file'}...`]
|
|
23733
25741
|
: previewHunks.length
|
|
23734
25742
|
? [
|
|
@@ -23738,6 +25746,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23738
25746
|
'',
|
|
23739
25747
|
]
|
|
23740
25748
|
: ['No diff preview available for this file.'];
|
|
25749
|
+
const headerLines = splitRequestedButTooNarrow
|
|
25750
|
+
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
25751
|
+
: baseHeaderLines;
|
|
25752
|
+
const commitBodyNodes = filePreviewLoading || !previewHunks.length
|
|
25753
|
+
? []
|
|
25754
|
+
: splitActive
|
|
25755
|
+
? renderSplitDiffBody(h, components, visiblePreviewHunks, state.diffPreviewOffset, width, theme, 'commit-diff-split')
|
|
25756
|
+
: visiblePreviewHunks.map((line, index) => h(Text, {
|
|
25757
|
+
key: `diff-surface-line-${state.diffPreviewOffset + index}`,
|
|
25758
|
+
...diffLineProps(line, theme),
|
|
25759
|
+
}, truncate$1(line, 140)));
|
|
23741
25760
|
return h(Box, {
|
|
23742
25761
|
borderColor: focusBorderColor(theme, focused),
|
|
23743
25762
|
borderStyle: theme.borderStyle,
|
|
@@ -23745,15 +25764,10 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
23745
25764
|
flexShrink: 0,
|
|
23746
25765
|
paddingX: 1,
|
|
23747
25766
|
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, {
|
|
25767
|
+
}, 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
25768
|
key: `diff-surface-header-${index}`,
|
|
23750
25769
|
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)))));
|
|
25770
|
+
}, truncate$1(line, 140))), ...commitBodyNodes);
|
|
23757
25771
|
}
|
|
23758
25772
|
const diffLines = worktreeDiff?.lines || [];
|
|
23759
25773
|
const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
|
|
@@ -24228,16 +26242,34 @@ function renderInputPromptPanel(h, components, state, width, theme, focused) {
|
|
|
24228
26242
|
if (!prompt) {
|
|
24229
26243
|
return h(Box, { width });
|
|
24230
26244
|
}
|
|
26245
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
26246
|
+
// Multi-line prompts (#806) split on newline and render one Text
|
|
26247
|
+
// row per buffer line — the cursor sits at the end of the last
|
|
26248
|
+
// line via the trailing `_`. Single-line prompts collapse to the
|
|
26249
|
+
// original one-row layout for muscle-memory continuity.
|
|
26250
|
+
const promptLines = prompt.multiline ? prompt.value.split('\n') : [prompt.value];
|
|
26251
|
+
if (promptLines.length === 0) {
|
|
26252
|
+
promptLines.push('');
|
|
26253
|
+
}
|
|
26254
|
+
const valueRows = promptLines.map((line, index) => {
|
|
26255
|
+
const isLast = index === promptLines.length - 1;
|
|
26256
|
+
const display = isLast ? `${line}_` : line;
|
|
26257
|
+
return h(Text, {
|
|
26258
|
+
key: `prompt-line-${index}`,
|
|
26259
|
+
bold: true,
|
|
26260
|
+
color: accent,
|
|
26261
|
+
}, truncate$1(display, width - 4));
|
|
26262
|
+
});
|
|
26263
|
+
const hint = prompt.multiline
|
|
26264
|
+
? 'Enter newline · Ctrl+d submit · Esc cancel · Ctrl+u clear'
|
|
26265
|
+
: 'Enter submit · Esc cancel · Ctrl+u clear';
|
|
24231
26266
|
return h(Box, {
|
|
24232
26267
|
borderColor: focusBorderColor(theme, focused),
|
|
24233
26268
|
borderStyle: theme.borderStyle,
|
|
24234
26269
|
flexDirection: 'column',
|
|
24235
26270
|
width,
|
|
24236
26271
|
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'));
|
|
26272
|
+
}, 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
26273
|
}
|
|
24242
26274
|
function renderConfirmationPanel(h, components, state, width, theme, focused) {
|
|
24243
26275
|
const { Box, Text } = components;
|
|
@@ -24411,16 +26443,31 @@ function renderCommandPalette(h, components, state, width, theme, focused) {
|
|
|
24411
26443
|
? [h(Text, { key: 'palette-recent-hint', dimColor: true }, '· marks recently-used')]
|
|
24412
26444
|
: []), ...itemLines);
|
|
24413
26445
|
}
|
|
24414
|
-
function renderFooter(h, components, state, theme, idleTip) {
|
|
26446
|
+
function renderFooter(h, components, state, context, theme, idleTip) {
|
|
24415
26447
|
const { Box, Text } = components;
|
|
26448
|
+
// Sidebar item count drives the per-tab footer hints — when items are
|
|
26449
|
+
// present the footer surfaces in-sidebar ops (checkout / apply / pop /
|
|
26450
|
+
// drop), otherwise it falls back to the generic "enter open" hint.
|
|
26451
|
+
const sidebarItemCount = (() => {
|
|
26452
|
+
switch (state.sidebarTab) {
|
|
26453
|
+
case 'branches': return context.branches?.localBranches.length;
|
|
26454
|
+
case 'tags': return context.tags?.tags.length;
|
|
26455
|
+
case 'stashes': return context.stashes?.stashes.length;
|
|
26456
|
+
case 'worktrees': return context.worktreeList?.worktrees.length;
|
|
26457
|
+
default: return undefined;
|
|
26458
|
+
}
|
|
26459
|
+
})();
|
|
24416
26460
|
const hints = getLogInkFooterHints({
|
|
24417
26461
|
activeView: state.activeView,
|
|
24418
26462
|
diffSource: state.diffSource,
|
|
26463
|
+
diffViewMode: state.diffViewMode,
|
|
24419
26464
|
filterMode: state.filterMode,
|
|
24420
26465
|
focus: state.focus,
|
|
24421
26466
|
pendingKey: state.pendingKey,
|
|
24422
26467
|
showCommandPalette: state.showCommandPalette,
|
|
24423
26468
|
showHelp: state.showHelp,
|
|
26469
|
+
sidebarTab: state.sidebarTab,
|
|
26470
|
+
sidebarItemCount,
|
|
24424
26471
|
});
|
|
24425
26472
|
// Real status messages always win; idle tips only fill the slot when it
|
|
24426
26473
|
// would otherwise be empty.
|