git-coco 0.44.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/dist/index.d.ts +42 -0
- package/dist/index.esm.mjs +1118 -32
- package/dist/index.js +1118 -32
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
|
|
|
78
78
|
/**
|
|
79
79
|
* Current build version from package.json
|
|
80
80
|
*/
|
|
81
|
-
const BUILD_VERSION = "0.
|
|
81
|
+
const BUILD_VERSION = "0.46.0";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -1252,6 +1252,18 @@ const schema$1 = {
|
|
|
1252
1252
|
"$ref": "#/definitions/DynamicModelPreference",
|
|
1253
1253
|
"description": "Default dynamic routing preference when model is set to \"dynamic\".",
|
|
1254
1254
|
"default": "balanced"
|
|
1255
|
+
},
|
|
1256
|
+
"fastPath": {
|
|
1257
|
+
"type": "object",
|
|
1258
|
+
"properties": {
|
|
1259
|
+
"markdown": {
|
|
1260
|
+
"type": "boolean",
|
|
1261
|
+
"description": "Replace the LLM summary with a templated heading extract for `.md` / `.mdx` / `.markdown` modification diffs that have clear heading-level structural changes. Diffs without structural signals (paragraph-only edits) still go to the LLM regardless of this flag.\n\nBench impact (synthetic): collapses docs-update-shaped commits from ~24s cold to ~3ms (no LLM calls fire for the markdown files). Real-world wall-clock savings depend on per-call LLM latency.",
|
|
1262
|
+
"default": false
|
|
1263
|
+
}
|
|
1264
|
+
},
|
|
1265
|
+
"additionalProperties": false,
|
|
1266
|
+
"description": "Opt-in fast paths that trade summary detail for speed. Each flag here replaces an LLM summary call with a deterministic templated extract for a specific file shape. Off by default — when enabled, you accept that final commit messages on those file shapes may be blander than LLM-generated summaries (the templated extract names structural changes only).\n\nLossless optimizations (cache, trivial-shape skip on pure additions / deletions / renames / binary, sort discipline) ship default-on and are not configured here."
|
|
1255
1267
|
}
|
|
1256
1268
|
},
|
|
1257
1269
|
"required": [
|
|
@@ -1665,6 +1677,18 @@ const schema$1 = {
|
|
|
1665
1677
|
"$ref": "#/definitions/DynamicModelPreference",
|
|
1666
1678
|
"description": "Default dynamic routing preference when model is set to \"dynamic\".",
|
|
1667
1679
|
"default": "balanced"
|
|
1680
|
+
},
|
|
1681
|
+
"fastPath": {
|
|
1682
|
+
"type": "object",
|
|
1683
|
+
"properties": {
|
|
1684
|
+
"markdown": {
|
|
1685
|
+
"type": "boolean",
|
|
1686
|
+
"description": "Replace the LLM summary with a templated heading extract for `.md` / `.mdx` / `.markdown` modification diffs that have clear heading-level structural changes. Diffs without structural signals (paragraph-only edits) still go to the LLM regardless of this flag.\n\nBench impact (synthetic): collapses docs-update-shaped commits from ~24s cold to ~3ms (no LLM calls fire for the markdown files). Real-world wall-clock savings depend on per-call LLM latency.",
|
|
1687
|
+
"default": false
|
|
1688
|
+
}
|
|
1689
|
+
},
|
|
1690
|
+
"additionalProperties": false,
|
|
1691
|
+
"description": "Opt-in fast paths that trade summary detail for speed. Each flag here replaces an LLM summary call with a deterministic templated extract for a specific file shape. Off by default — when enabled, you accept that final commit messages on those file shapes may be blander than LLM-generated summaries (the templated extract names structural changes only).\n\nLossless optimizations (cache, trivial-shape skip on pure additions / deletions / renames / binary, sort discipline) ship default-on and are not configured here."
|
|
1668
1692
|
}
|
|
1669
1693
|
},
|
|
1670
1694
|
"required": [
|
|
@@ -1821,6 +1845,18 @@ const schema$1 = {
|
|
|
1821
1845
|
"$ref": "#/definitions/DynamicModelPreference",
|
|
1822
1846
|
"description": "Default dynamic routing preference when model is set to \"dynamic\".",
|
|
1823
1847
|
"default": "balanced"
|
|
1848
|
+
},
|
|
1849
|
+
"fastPath": {
|
|
1850
|
+
"type": "object",
|
|
1851
|
+
"properties": {
|
|
1852
|
+
"markdown": {
|
|
1853
|
+
"type": "boolean",
|
|
1854
|
+
"description": "Replace the LLM summary with a templated heading extract for `.md` / `.mdx` / `.markdown` modification diffs that have clear heading-level structural changes. Diffs without structural signals (paragraph-only edits) still go to the LLM regardless of this flag.\n\nBench impact (synthetic): collapses docs-update-shaped commits from ~24s cold to ~3ms (no LLM calls fire for the markdown files). Real-world wall-clock savings depend on per-call LLM latency.",
|
|
1855
|
+
"default": false
|
|
1856
|
+
}
|
|
1857
|
+
},
|
|
1858
|
+
"additionalProperties": false,
|
|
1859
|
+
"description": "Opt-in fast paths that trade summary detail for speed. Each flag here replaces an LLM summary call with a deterministic templated extract for a specific file shape. Off by default — when enabled, you accept that final commit messages on those file shapes may be blander than LLM-generated summaries (the templated extract names structural changes only).\n\nLossless optimizations (cache, trivial-shape skip on pure additions / deletions / renames / binary, sort discipline) ship default-on and are not configured here."
|
|
1824
1860
|
}
|
|
1825
1861
|
},
|
|
1826
1862
|
"required": [
|
|
@@ -7914,6 +7950,109 @@ async function summarize(documents$1, { chain, textSplitter, options, logger, to
|
|
|
7914
7950
|
return res.text && res.text.trim();
|
|
7915
7951
|
}
|
|
7916
7952
|
|
|
7953
|
+
/**
|
|
7954
|
+
* Markdown-aware fast path (#861, angle 5). For modification diffs to
|
|
7955
|
+
* `.md` / `.mdx` / `.markdown` files, build a templated summary from
|
|
7956
|
+
* the changed structure (added / removed / updated headings) instead
|
|
7957
|
+
* of paying for an LLM call. Mirrors `trivialDiff` from #845: a deterministic
|
|
7958
|
+
* skip when the diff's meaning is captured by its shape.
|
|
7959
|
+
*
|
|
7960
|
+
* Quality / cost trade-off, on purpose: LLM summaries of markdown edits
|
|
7961
|
+
* are wordier ("expanded the configuration section with new examples,
|
|
7962
|
+
* fixed typos in troubleshooting") but most of that detail isn't load-
|
|
7963
|
+
* bearing for a commit message. The templated summary names the
|
|
7964
|
+
* structural changes (which sections moved) plus a +/- line count, and
|
|
7965
|
+
* defers to the LLM only when the diff has no clear structural signals
|
|
7966
|
+
* (paragraph-only edits, where a templated summary would actually drop
|
|
7967
|
+
* useful context).
|
|
7968
|
+
*/
|
|
7969
|
+
const MARKDOWN_EXTENSIONS = ['.md', '.markdown', '.mdx'];
|
|
7970
|
+
const MAX_HEADINGS_PER_BUCKET = 6;
|
|
7971
|
+
function isMarkdownFile(path) {
|
|
7972
|
+
const lower = path.toLowerCase();
|
|
7973
|
+
return MARKDOWN_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
|
7974
|
+
}
|
|
7975
|
+
function summarizeMarkdownDiff(fileDiff) {
|
|
7976
|
+
if (!isMarkdownFile(fileDiff.file))
|
|
7977
|
+
return undefined;
|
|
7978
|
+
const addedHeadings = new Set();
|
|
7979
|
+
const removedHeadings = new Set();
|
|
7980
|
+
let addedLines = 0;
|
|
7981
|
+
let removedLines = 0;
|
|
7982
|
+
for (const line of fileDiff.diff.split('\n')) {
|
|
7983
|
+
if (isHeaderLine$1(line))
|
|
7984
|
+
continue;
|
|
7985
|
+
if (line.startsWith('+')) {
|
|
7986
|
+
addedLines++;
|
|
7987
|
+
const heading = parseHeading(line.slice(1));
|
|
7988
|
+
if (heading)
|
|
7989
|
+
addedHeadings.add(heading);
|
|
7990
|
+
}
|
|
7991
|
+
else if (line.startsWith('-')) {
|
|
7992
|
+
removedLines++;
|
|
7993
|
+
const heading = parseHeading(line.slice(1));
|
|
7994
|
+
if (heading)
|
|
7995
|
+
removedHeadings.add(heading);
|
|
7996
|
+
}
|
|
7997
|
+
}
|
|
7998
|
+
// No content change → nothing to summarize. Caller falls through.
|
|
7999
|
+
if (addedLines === 0 && removedLines === 0)
|
|
8000
|
+
return undefined;
|
|
8001
|
+
// No structural signal → fall through to LLM. We only fast-path
|
|
8002
|
+
// when the diff has heading-level changes; pure paragraph edits go
|
|
8003
|
+
// to the LLM so the summary keeps its detail.
|
|
8004
|
+
if (addedHeadings.size === 0 && removedHeadings.size === 0) {
|
|
8005
|
+
return undefined;
|
|
8006
|
+
}
|
|
8007
|
+
// A heading that appears in both buckets is likely an update (kept
|
|
8008
|
+
// around but its body changed) rather than two distinct events.
|
|
8009
|
+
// The naive split-by-bucket diff format used by git emits the old
|
|
8010
|
+
// text under `-` and the new text under `+`; an unchanged heading
|
|
8011
|
+
// line shouldn't show up in either bucket via the standard hunk
|
|
8012
|
+
// path, but defensively de-dupe in case the diff producer emits
|
|
8013
|
+
// surrounding context as +/-.
|
|
8014
|
+
const updated = new Set([...addedHeadings].filter((h) => removedHeadings.has(h)));
|
|
8015
|
+
const purelyAdded = [...addedHeadings].filter((h) => !updated.has(h));
|
|
8016
|
+
const purelyRemoved = [...removedHeadings].filter((h) => !updated.has(h));
|
|
8017
|
+
const parts = [`Updated markdown \`${fileDiff.file}\``];
|
|
8018
|
+
if (purelyAdded.length) {
|
|
8019
|
+
parts.push(`new sections: ${formatHeadingList(purelyAdded)}`);
|
|
8020
|
+
}
|
|
8021
|
+
if (purelyRemoved.length) {
|
|
8022
|
+
parts.push(`removed sections: ${formatHeadingList(purelyRemoved)}`);
|
|
8023
|
+
}
|
|
8024
|
+
if (updated.size) {
|
|
8025
|
+
parts.push(`updated sections: ${formatHeadingList([...updated])}`);
|
|
8026
|
+
}
|
|
8027
|
+
parts.push(`+${addedLines}/-${removedLines} lines`);
|
|
8028
|
+
return `${parts.join('. ')}.`;
|
|
8029
|
+
}
|
|
8030
|
+
function formatHeadingList(headings) {
|
|
8031
|
+
if (headings.length <= MAX_HEADINGS_PER_BUCKET) {
|
|
8032
|
+
return headings.join(', ');
|
|
8033
|
+
}
|
|
8034
|
+
const shown = headings.slice(0, MAX_HEADINGS_PER_BUCKET);
|
|
8035
|
+
const remainder = headings.length - shown.length;
|
|
8036
|
+
return `${shown.join(', ')} (+${remainder} more)`;
|
|
8037
|
+
}
|
|
8038
|
+
function isHeaderLine$1(line) {
|
|
8039
|
+
return (line.startsWith('diff --git') ||
|
|
8040
|
+
line.startsWith('index ') ||
|
|
8041
|
+
line.startsWith('--- ') ||
|
|
8042
|
+
line.startsWith('+++ ') ||
|
|
8043
|
+
line.startsWith('@@') ||
|
|
8044
|
+
line.startsWith('new file mode') ||
|
|
8045
|
+
line.startsWith('deleted file mode') ||
|
|
8046
|
+
line.startsWith('similarity index') ||
|
|
8047
|
+
line.startsWith('rename from ') ||
|
|
8048
|
+
line.startsWith('rename to ') ||
|
|
8049
|
+
line.startsWith('Binary files '));
|
|
8050
|
+
}
|
|
8051
|
+
function parseHeading(line) {
|
|
8052
|
+
const match = line.match(/^#{1,6}\s+(.+?)\s*$/);
|
|
8053
|
+
return match ? match[1].trim() : undefined;
|
|
8054
|
+
}
|
|
8055
|
+
|
|
7917
8056
|
/**
|
|
7918
8057
|
* Inspect a unified-diff string and report its shape, or undefined
|
|
7919
8058
|
* if the diff isn't trivial (mixed +/- lines, weird headers, etc.).
|
|
@@ -8051,7 +8190,7 @@ function isCacheEnabled$1() {
|
|
|
8051
8190
|
* synthetic summaries usually drop the directory token totals under
|
|
8052
8191
|
* budget so wave consolidation skips too.
|
|
8053
8192
|
*/
|
|
8054
|
-
async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, logger, metadata, }) {
|
|
8193
|
+
async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, logger, metadata, fastPath, }) {
|
|
8055
8194
|
const trivialSummary = summarizeTrivialDiff(fileDiff);
|
|
8056
8195
|
if (trivialSummary !== undefined) {
|
|
8057
8196
|
logger.verbose(` - ${fileDiff.file}: trivial-shape skip (no LLM call)`, { color: 'gray' });
|
|
@@ -8061,6 +8200,25 @@ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, log
|
|
|
8061
8200
|
tokenCount: tokenizer(trivialSummary),
|
|
8062
8201
|
};
|
|
8063
8202
|
}
|
|
8203
|
+
// Markdown fast path (#861, angle 5). Opt-in via `fastPath.markdown`
|
|
8204
|
+
// because it's a lossy optimization: the templated summary names
|
|
8205
|
+
// structural changes only and drops body-text detail that an LLM
|
|
8206
|
+
// summary would carry. Off by default; users who prefer summary
|
|
8207
|
+
// fidelity over speed (which is the safer default for commit-message
|
|
8208
|
+
// generation downstream) keep the LLM path. When the flag IS on, the
|
|
8209
|
+
// fast path still falls through to the LLM for paragraph-only edits
|
|
8210
|
+
// where a templated summary would lose useful context.
|
|
8211
|
+
if (fastPath?.markdown) {
|
|
8212
|
+
const markdownSummary = summarizeMarkdownDiff(fileDiff);
|
|
8213
|
+
if (markdownSummary !== undefined) {
|
|
8214
|
+
logger.verbose(` - ${fileDiff.file}: markdown fast-path skip (no LLM call)`, { color: 'gray' });
|
|
8215
|
+
return {
|
|
8216
|
+
...fileDiff,
|
|
8217
|
+
diff: markdownSummary,
|
|
8218
|
+
tokenCount: tokenizer(markdownSummary),
|
|
8219
|
+
};
|
|
8220
|
+
}
|
|
8221
|
+
}
|
|
8064
8222
|
// Cache lookup (#845, PR 5). Keyed on the file's literal diff
|
|
8065
8223
|
// content + the active model + the summarization prompt hash.
|
|
8066
8224
|
// A hit returns the prior summary instantly; on iterative
|
|
@@ -8172,7 +8330,7 @@ function createLimit$2(maxConcurrent) {
|
|
|
8172
8330
|
* @returns Array of file diffs with large files summarized
|
|
8173
8331
|
*/
|
|
8174
8332
|
async function summarizeLargeFiles(diffs, options) {
|
|
8175
|
-
const { maxFileTokens, minTokensForSummary, maxConcurrent, tokenizer, logger, chain, textSplitter, metadata } = options;
|
|
8333
|
+
const { maxFileTokens, minTokensForSummary, maxConcurrent, maxTokens, fastPath, tokenizer, logger, chain, textSplitter, metadata, } = options;
|
|
8176
8334
|
// Identify files that need summarization
|
|
8177
8335
|
const filesToSummarize = [];
|
|
8178
8336
|
const results = [...diffs];
|
|
@@ -8184,17 +8342,57 @@ async function summarizeLargeFiles(diffs, options) {
|
|
|
8184
8342
|
if (filesToSummarize.length === 0) {
|
|
8185
8343
|
return results;
|
|
8186
8344
|
}
|
|
8187
|
-
|
|
8188
|
-
//
|
|
8189
|
-
|
|
8190
|
-
//
|
|
8191
|
-
|
|
8345
|
+
// Incremental termination (#861, PR 1). When the caller supplies a
|
|
8346
|
+
// budget, dispatch biggest-first and re-check the running total per
|
|
8347
|
+
// dispatch — once earlier completions drop the total under maxTokens,
|
|
8348
|
+
// the remaining queued files skip the LLM and keep their raw diffs.
|
|
8349
|
+
// Mirrors the Phase 3 pattern in `summarizeDiffs.ts`. Without a
|
|
8350
|
+
// budget (undefined), behavior matches the prior path: every
|
|
8351
|
+
// eligible file is summarized regardless.
|
|
8352
|
+
filesToSummarize.sort((a, b) => b.diff.tokenCount - a.diff.tokenCount);
|
|
8353
|
+
const incrementalTermination = maxTokens !== undefined;
|
|
8354
|
+
let runningTotal = diffs.reduce((sum, diff) => sum + diff.tokenCount, 0);
|
|
8355
|
+
let summarizedCount = 0;
|
|
8356
|
+
let skippedCount = 0;
|
|
8357
|
+
logger.verbose(`Pre-summarizing up to ${filesToSummarize.length} large file(s)...`, { color: 'blue' });
|
|
8358
|
+
const processed = await processInWaves$1(filesToSummarize, async ({ diff }) => {
|
|
8359
|
+
// Re-check the budget at dispatch time when the caller supplied
|
|
8360
|
+
// one. Earlier completions may have already dropped the total
|
|
8361
|
+
// under the cap; in that case skip the LLM call entirely and
|
|
8362
|
+
// keep the raw diff. Without a budget, every eligible file is
|
|
8363
|
+
// summarized (preserves the prior behavior).
|
|
8364
|
+
if (incrementalTermination && runningTotal <= maxTokens) {
|
|
8365
|
+
return { diff, summarized: false };
|
|
8366
|
+
}
|
|
8367
|
+
const summarized = await summarizeFileDiff(diff, {
|
|
8368
|
+
chain,
|
|
8369
|
+
textSplitter,
|
|
8370
|
+
tokenizer,
|
|
8371
|
+
logger,
|
|
8372
|
+
metadata,
|
|
8373
|
+
fastPath,
|
|
8374
|
+
});
|
|
8375
|
+
const delta = diff.tokenCount - summarized.tokenCount;
|
|
8376
|
+
if (delta > 0) {
|
|
8377
|
+
runningTotal -= delta;
|
|
8378
|
+
}
|
|
8379
|
+
return { diff: summarized, summarized: true };
|
|
8380
|
+
}, maxConcurrent);
|
|
8381
|
+
processed.forEach((entry, i) => {
|
|
8192
8382
|
const originalIndex = filesToSummarize[i].index;
|
|
8383
|
+
if (!entry.summarized) {
|
|
8384
|
+
skippedCount++;
|
|
8385
|
+
return;
|
|
8386
|
+
}
|
|
8387
|
+
summarizedCount++;
|
|
8193
8388
|
const originalTokens = results[originalIndex].tokenCount;
|
|
8194
|
-
const newTokens =
|
|
8195
|
-
logger.verbose(` - ${
|
|
8196
|
-
results[originalIndex] =
|
|
8389
|
+
const newTokens = entry.diff.tokenCount;
|
|
8390
|
+
logger.verbose(` - ${entry.diff.file}: ${originalTokens} -> ${newTokens} tokens`, { color: 'magenta' });
|
|
8391
|
+
results[originalIndex] = entry.diff;
|
|
8197
8392
|
});
|
|
8393
|
+
if (skippedCount > 0) {
|
|
8394
|
+
logger.verbose(`Skipped ${skippedCount} pre-summary call(s) — token budget already met after ${summarizedCount} earlier file(s)`, { color: 'cyan' });
|
|
8395
|
+
}
|
|
8198
8396
|
return results;
|
|
8199
8397
|
}
|
|
8200
8398
|
/**
|
|
@@ -8460,7 +8658,7 @@ async function summarizeDiffs(rootDiffNode, { tokenizer, logger,
|
|
|
8460
8658
|
// with the service defaults means a caller that omits
|
|
8461
8659
|
// `maxTokens` doesn't accidentally fall into a tighter budget
|
|
8462
8660
|
// than the rest of the system assumes.
|
|
8463
|
-
maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, textSplitter, chain, metadata, handleOutput = defaultOutputCallback, }) {
|
|
8661
|
+
maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, fastPath, textSplitter, chain, metadata, handleOutput = defaultOutputCallback, }) {
|
|
8464
8662
|
// Calculate maxFileTokens as 25% of maxTokens if not specified
|
|
8465
8663
|
const effectiveMaxFileTokens = maxFileTokens ?? Math.floor(maxTokens * 0.25);
|
|
8466
8664
|
// PHASE 1: Directory grouping & assessment
|
|
@@ -8484,6 +8682,13 @@ maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, t
|
|
|
8484
8682
|
maxFileTokens: effectiveMaxFileTokens,
|
|
8485
8683
|
minTokensForSummary,
|
|
8486
8684
|
maxConcurrent,
|
|
8685
|
+
// #861, PR 1: pass the overall budget so Phase 2 can short-circuit
|
|
8686
|
+
// once earlier completions drop the running total under the cap.
|
|
8687
|
+
maxTokens,
|
|
8688
|
+
// #861, angle 5: opt-in markdown fast path. Off by default; when
|
|
8689
|
+
// enabled, markdown modification diffs with structural signals
|
|
8690
|
+
// resolve via a templated extract instead of an LLM call.
|
|
8691
|
+
fastPath,
|
|
8487
8692
|
tokenizer,
|
|
8488
8693
|
logger,
|
|
8489
8694
|
chain,
|
|
@@ -11461,7 +11666,7 @@ for (var i = 0; i < 256; i++) {
|
|
|
11461
11666
|
simpleEscapeMap[i] = simpleEscapeSequence(i);
|
|
11462
11667
|
}
|
|
11463
11668
|
|
|
11464
|
-
async function fileChangeParser({ changes, commit, options: { tokenizer, git, llm: model, logger, maxTokens, minTokensForSummary, maxFileTokens, maxConcurrent, metadata, }, }) {
|
|
11669
|
+
async function fileChangeParser({ changes, commit, options: { tokenizer, git, llm: model, logger, maxTokens, minTokensForSummary, maxFileTokens, maxConcurrent, fastPath, metadata, }, }) {
|
|
11465
11670
|
const textSplitter = new RecursiveCharacterTextSplitter({ chunkSize: 10000, chunkOverlap: 250 });
|
|
11466
11671
|
const summarizationChain = loadSummarizationChain(model, {
|
|
11467
11672
|
type: 'map_reduce',
|
|
@@ -11493,6 +11698,7 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
|
|
|
11493
11698
|
minTokensForSummary,
|
|
11494
11699
|
maxFileTokens,
|
|
11495
11700
|
maxConcurrent,
|
|
11701
|
+
fastPath,
|
|
11496
11702
|
textSplitter,
|
|
11497
11703
|
chain: summarizationChain,
|
|
11498
11704
|
logger,
|
|
@@ -11512,6 +11718,7 @@ function createFileChangeParserOptions({ command, git, llm, logger, model, provi
|
|
|
11512
11718
|
minTokensForSummary: service?.minTokensForSummary,
|
|
11513
11719
|
maxFileTokens: service?.maxFileTokens,
|
|
11514
11720
|
maxConcurrent: service?.maxConcurrent,
|
|
11721
|
+
fastPath: service?.fastPath,
|
|
11515
11722
|
metadata: {
|
|
11516
11723
|
command,
|
|
11517
11724
|
provider,
|
|
@@ -14338,7 +14545,7 @@ const builder$3 = (yargs) => {
|
|
|
14338
14545
|
return yargs.options(options$3).usage(getCommandUsageHeader(command$3));
|
|
14339
14546
|
};
|
|
14340
14547
|
|
|
14341
|
-
const FIELD_SEPARATOR$
|
|
14548
|
+
const FIELD_SEPARATOR$3 = '\x1f';
|
|
14342
14549
|
// `%P` (parent hashes, space-separated) lets the TUI distinguish
|
|
14343
14550
|
// merge commits (parents.length > 1) from regular commits without a
|
|
14344
14551
|
// second round-trip to git. See #791 stage 3 — merge glyph + HEAD ring.
|
|
@@ -14391,13 +14598,13 @@ function parseLogOutput(output) {
|
|
|
14391
14598
|
.map((line) => line.trimEnd())
|
|
14392
14599
|
.filter(Boolean)
|
|
14393
14600
|
.map((line) => {
|
|
14394
|
-
if (!line.includes(FIELD_SEPARATOR$
|
|
14601
|
+
if (!line.includes(FIELD_SEPARATOR$3)) {
|
|
14395
14602
|
return {
|
|
14396
14603
|
type: 'graph',
|
|
14397
14604
|
graph: line,
|
|
14398
14605
|
};
|
|
14399
14606
|
}
|
|
14400
|
-
const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$
|
|
14607
|
+
const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$3);
|
|
14401
14608
|
return {
|
|
14402
14609
|
type: 'commit',
|
|
14403
14610
|
graph: graph.trimEnd(),
|
|
@@ -14472,7 +14679,7 @@ function parseNameStatus(output, numstat = []) {
|
|
|
14472
14679
|
function parseCommitDetail(metadata, files, numstatOutput = '') {
|
|
14473
14680
|
const [hash, shortHash, parentsStr, date, author, refs, message, body = ''] = metadata
|
|
14474
14681
|
.trimEnd()
|
|
14475
|
-
.split(FIELD_SEPARATOR$
|
|
14682
|
+
.split(FIELD_SEPARATOR$3);
|
|
14476
14683
|
const numstat = parseNumstat(numstatOutput);
|
|
14477
14684
|
return {
|
|
14478
14685
|
shortHash,
|
|
@@ -14612,14 +14819,14 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
|
|
|
14612
14819
|
};
|
|
14613
14820
|
}
|
|
14614
14821
|
|
|
14615
|
-
const FIELD_SEPARATOR$
|
|
14822
|
+
const FIELD_SEPARATOR$2 = '\x1f';
|
|
14616
14823
|
function parseBranchRefs(output) {
|
|
14617
14824
|
return output
|
|
14618
14825
|
.split('\n')
|
|
14619
14826
|
.map((line) => line.trimEnd())
|
|
14620
14827
|
.filter(Boolean)
|
|
14621
14828
|
.map((line) => {
|
|
14622
|
-
const [refName, shortName, hash, upstream, head, date, subject] = line.split(FIELD_SEPARATOR$
|
|
14829
|
+
const [refName, shortName, hash, upstream, head, date, subject] = line.split(FIELD_SEPARATOR$2);
|
|
14623
14830
|
if (!refName || !shortName) {
|
|
14624
14831
|
return undefined;
|
|
14625
14832
|
}
|
|
@@ -14659,7 +14866,7 @@ async function getBranchOverview(git) {
|
|
|
14659
14866
|
const [branchOutput, statusOutput, currentBranchOutput] = await Promise.all([
|
|
14660
14867
|
git.raw([
|
|
14661
14868
|
'for-each-ref',
|
|
14662
|
-
`--format=%(refname)${FIELD_SEPARATOR$
|
|
14869
|
+
`--format=%(refname)${FIELD_SEPARATOR$2}%(refname:short)${FIELD_SEPARATOR$2}%(objectname:short)${FIELD_SEPARATOR$2}%(upstream:short)${FIELD_SEPARATOR$2}%(HEAD)${FIELD_SEPARATOR$2}%(committerdate:short)${FIELD_SEPARATOR$2}%(contents:subject)`,
|
|
14663
14870
|
'refs/heads',
|
|
14664
14871
|
'refs/remotes',
|
|
14665
14872
|
]),
|
|
@@ -15209,10 +15416,12 @@ async function runCommitDraftWorkflow(input = {}) {
|
|
|
15209
15416
|
}
|
|
15210
15417
|
|
|
15211
15418
|
const LOG_INK_CONTEXT_KEYS = [
|
|
15419
|
+
'bisect',
|
|
15212
15420
|
'branches',
|
|
15213
15421
|
'operation',
|
|
15214
15422
|
'provider',
|
|
15215
15423
|
'pullRequest',
|
|
15424
|
+
'reflog',
|
|
15216
15425
|
'stashes',
|
|
15217
15426
|
'tags',
|
|
15218
15427
|
'worktree',
|
|
@@ -15932,6 +16141,45 @@ function getLogInkWorkflowActions() {
|
|
|
15932
16141
|
kind: 'normal',
|
|
15933
16142
|
requiresConfirmation: false,
|
|
15934
16143
|
},
|
|
16144
|
+
{
|
|
16145
|
+
// #784 — bisect workflow actions. All four are scoped per-view in
|
|
16146
|
+
// inkInput (active only when activeView === 'bisect') so the
|
|
16147
|
+
// single-letter keys stay free elsewhere. Empty `key` keeps them
|
|
16148
|
+
// palette-discoverable. Reset is the only destructive one — it
|
|
16149
|
+
// throws away the bisect state — so it routes through y-confirm;
|
|
16150
|
+
// good / bad / skip are recoverable via `git bisect log` and run
|
|
16151
|
+
// immediately.
|
|
16152
|
+
id: 'bisect-good',
|
|
16153
|
+
key: '',
|
|
16154
|
+
label: 'Bisect: mark good',
|
|
16155
|
+
description: 'Mark the current bisect candidate as good and advance to the next one.',
|
|
16156
|
+
kind: 'normal',
|
|
16157
|
+
requiresConfirmation: false,
|
|
16158
|
+
},
|
|
16159
|
+
{
|
|
16160
|
+
id: 'bisect-bad',
|
|
16161
|
+
key: '',
|
|
16162
|
+
label: 'Bisect: mark bad',
|
|
16163
|
+
description: 'Mark the current bisect candidate as bad and advance to the next one.',
|
|
16164
|
+
kind: 'normal',
|
|
16165
|
+
requiresConfirmation: false,
|
|
16166
|
+
},
|
|
16167
|
+
{
|
|
16168
|
+
id: 'bisect-skip',
|
|
16169
|
+
key: '',
|
|
16170
|
+
label: 'Bisect: skip candidate',
|
|
16171
|
+
description: 'Skip the current bisect candidate (e.g. it does not build) and advance.',
|
|
16172
|
+
kind: 'normal',
|
|
16173
|
+
requiresConfirmation: false,
|
|
16174
|
+
},
|
|
16175
|
+
{
|
|
16176
|
+
id: 'bisect-reset',
|
|
16177
|
+
key: '',
|
|
16178
|
+
label: 'Bisect: reset',
|
|
16179
|
+
description: 'End the bisect session and restore HEAD. Discards in-progress bisect state.',
|
|
16180
|
+
kind: 'destructive',
|
|
16181
|
+
requiresConfirmation: true,
|
|
16182
|
+
},
|
|
15935
16183
|
{
|
|
15936
16184
|
id: 'ai-commit-summary',
|
|
15937
16185
|
key: 'I',
|
|
@@ -16168,6 +16416,27 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
16168
16416
|
description: 'Push the conflict resolution helper view (available during merge/rebase/cherry-pick/revert).',
|
|
16169
16417
|
contexts: ['normal'],
|
|
16170
16418
|
},
|
|
16419
|
+
{
|
|
16420
|
+
id: 'navigateReflog',
|
|
16421
|
+
keys: ['gr'],
|
|
16422
|
+
label: 'reflog',
|
|
16423
|
+
description: 'Push the reflog browser view — chronological recovery log.',
|
|
16424
|
+
contexts: ['normal'],
|
|
16425
|
+
},
|
|
16426
|
+
{
|
|
16427
|
+
id: 'navigateBisect',
|
|
16428
|
+
keys: ['gB'],
|
|
16429
|
+
label: 'bisect',
|
|
16430
|
+
description: 'Push the bisect workflow view (#784). Capital B disambiguates from gb (branches). Available whenever a bisect is in progress; surfaces the current candidate and the good / bad / skip / reset action keys.',
|
|
16431
|
+
contexts: ['normal'],
|
|
16432
|
+
},
|
|
16433
|
+
{
|
|
16434
|
+
id: 'markForCompare',
|
|
16435
|
+
keys: ['m'],
|
|
16436
|
+
label: 'mark compare',
|
|
16437
|
+
description: 'Mark the cursored ref (branch / tag / commit) as the base for a compare-two-refs diff (#779). Press again on the same ref to clear; with a base set, Enter on another ref opens the compare diff.',
|
|
16438
|
+
contexts: ['commits'],
|
|
16439
|
+
},
|
|
16171
16440
|
{
|
|
16172
16441
|
id: 'navigateBack',
|
|
16173
16442
|
keys: ['<', 'esc'],
|
|
@@ -16298,6 +16567,8 @@ const GLOBAL_BINDING_IDS = [
|
|
|
16298
16567
|
'navigateWorktrees',
|
|
16299
16568
|
'navigatePullRequest',
|
|
16300
16569
|
'navigateConflicts',
|
|
16570
|
+
'navigateReflog',
|
|
16571
|
+
'navigateBisect',
|
|
16301
16572
|
'navigateBack',
|
|
16302
16573
|
];
|
|
16303
16574
|
const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
|
|
@@ -16425,6 +16696,15 @@ function getLogInkFooterHints(options) {
|
|
|
16425
16696
|
global: NORMAL_GLOBAL_HINTS,
|
|
16426
16697
|
};
|
|
16427
16698
|
}
|
|
16699
|
+
if (options.diffSource === 'compare') {
|
|
16700
|
+
// Compare-two-refs (#779): read-only diff with no per-file
|
|
16701
|
+
// cherry-pick or hunk apply (those don't make sense across
|
|
16702
|
+
// arbitrary refs). Just scroll + back out.
|
|
16703
|
+
return {
|
|
16704
|
+
contextual: ['j/k lines', splitToggleHint, 'esc back'],
|
|
16705
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16706
|
+
};
|
|
16707
|
+
}
|
|
16428
16708
|
return {
|
|
16429
16709
|
contextual: ['j/k hunks', 'space stage', 'z revert', 'o edit', 'e/c compose', 'y yank'],
|
|
16430
16710
|
global: NORMAL_GLOBAL_HINTS,
|
|
@@ -16437,14 +16717,26 @@ function getLogInkFooterHints(options) {
|
|
|
16437
16717
|
};
|
|
16438
16718
|
}
|
|
16439
16719
|
if (options.activeView === 'branches') {
|
|
16720
|
+
if (options.compareBaseSet) {
|
|
16721
|
+
return {
|
|
16722
|
+
contextual: ['↑/↓ branches', 'enter compare', 'm clear', 'esc back'],
|
|
16723
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16724
|
+
};
|
|
16725
|
+
}
|
|
16440
16726
|
return {
|
|
16441
|
-
contextual: ['↑/↓ branches', 'enter checkout', '+ new', 'D delete', 's sort', 'y yank'],
|
|
16727
|
+
contextual: ['↑/↓ branches', 'enter checkout', '+ new', 'D delete', 'm compare', 's sort', 'y yank'],
|
|
16442
16728
|
global: NORMAL_GLOBAL_HINTS,
|
|
16443
16729
|
};
|
|
16444
16730
|
}
|
|
16445
16731
|
if (options.activeView === 'tags') {
|
|
16732
|
+
if (options.compareBaseSet) {
|
|
16733
|
+
return {
|
|
16734
|
+
contextual: ['↑/↓ tags', 'enter compare', 'm clear', 'esc back'],
|
|
16735
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16736
|
+
};
|
|
16737
|
+
}
|
|
16446
16738
|
return {
|
|
16447
|
-
contextual: ['↑/↓ tags', '+ new', 'P push', 'T delete', 's sort', 'y yank'],
|
|
16739
|
+
contextual: ['↑/↓ tags', '+ new', 'P push', 'T delete', 'm compare', 's sort', 'y yank'],
|
|
16448
16740
|
global: NORMAL_GLOBAL_HINTS,
|
|
16449
16741
|
};
|
|
16450
16742
|
}
|
|
@@ -16476,6 +16768,28 @@ function getLogInkFooterHints(options) {
|
|
|
16476
16768
|
global: NORMAL_GLOBAL_HINTS,
|
|
16477
16769
|
};
|
|
16478
16770
|
}
|
|
16771
|
+
if (options.activeView === 'reflog') {
|
|
16772
|
+
return {
|
|
16773
|
+
contextual: ['↑/↓ entries', 'enter inspect', 'esc back'],
|
|
16774
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16775
|
+
};
|
|
16776
|
+
}
|
|
16777
|
+
if (options.activeView === 'bisect') {
|
|
16778
|
+
return {
|
|
16779
|
+
contextual: ['g good', 'b bad', 's skip', 'x reset', 'esc back'],
|
|
16780
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16781
|
+
};
|
|
16782
|
+
}
|
|
16783
|
+
if (options.compareBaseSet) {
|
|
16784
|
+
// History view with a compare base set — Enter is overridden to
|
|
16785
|
+
// open the compare diff; show the override + the bail-out key.
|
|
16786
|
+
// Mutate / new chips are dropped so the footer doesn't compete
|
|
16787
|
+
// with the active workflow.
|
|
16788
|
+
return {
|
|
16789
|
+
contextual: ['↑/↓ move', 'enter compare', 'm clear', 'esc back'],
|
|
16790
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16791
|
+
};
|
|
16792
|
+
}
|
|
16479
16793
|
return {
|
|
16480
16794
|
// History view default hints. Mutating ops (`c` cherry-pick, `R`
|
|
16481
16795
|
// revert, `Z` reset, `i` interactive-rebase) all route through a
|
|
@@ -16485,7 +16799,7 @@ function getLogInkFooterHints(options) {
|
|
|
16485
16799
|
// Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
|
|
16486
16800
|
// the footer stays scannable; full descriptions live in `?` help
|
|
16487
16801
|
// and the palette.
|
|
16488
|
-
contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', '
|
|
16802
|
+
contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'm compare', 'y/Y yank', '/ search'],
|
|
16489
16803
|
global: NORMAL_GLOBAL_HINTS,
|
|
16490
16804
|
};
|
|
16491
16805
|
}
|
|
@@ -17064,6 +17378,7 @@ function withPushedView(state, value) {
|
|
|
17064
17378
|
selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
|
|
17065
17379
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
17066
17380
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
17381
|
+
compareHead: value === 'diff' ? state.compareHead : undefined,
|
|
17067
17382
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
17068
17383
|
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
17069
17384
|
pendingKey: undefined,
|
|
@@ -17075,6 +17390,13 @@ function withPoppedView(state) {
|
|
|
17075
17390
|
}
|
|
17076
17391
|
const viewStack = state.viewStack.slice(0, -1);
|
|
17077
17392
|
const next = topOfStack(viewStack);
|
|
17393
|
+
// #779 — compareBase is "cleared when the diff view is popped." We
|
|
17394
|
+
// detect that case by checking if the *previous* top was 'diff'.
|
|
17395
|
+
// The compare workflow ends when the user backs out of the compare
|
|
17396
|
+
// diff; on the next mark they re-set the base. Other view pops
|
|
17397
|
+
// preserve compareBase so the user can move between branches / tags /
|
|
17398
|
+
// history while hunting for a head ref.
|
|
17399
|
+
const wasOnDiff = state.activeView === 'diff';
|
|
17078
17400
|
return {
|
|
17079
17401
|
...state,
|
|
17080
17402
|
activeView: next,
|
|
@@ -17087,6 +17409,8 @@ function withPoppedView(state) {
|
|
|
17087
17409
|
selectedWorktreeHunkIndex: next === 'diff' ? state.selectedWorktreeHunkIndex : 0,
|
|
17088
17410
|
diffSource: next === 'diff' ? state.diffSource : undefined,
|
|
17089
17411
|
stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
|
|
17412
|
+
compareBase: wasOnDiff ? undefined : state.compareBase,
|
|
17413
|
+
compareHead: next === 'diff' ? state.compareHead : undefined,
|
|
17090
17414
|
pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
|
|
17091
17415
|
statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
|
|
17092
17416
|
pendingKey: undefined,
|
|
@@ -17105,6 +17429,7 @@ function withReplacedView(state, value) {
|
|
|
17105
17429
|
selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
|
|
17106
17430
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
17107
17431
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
17432
|
+
compareHead: value === 'diff' ? state.compareHead : undefined,
|
|
17108
17433
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
17109
17434
|
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
17110
17435
|
pendingKey: undefined,
|
|
@@ -17125,6 +17450,11 @@ function withFilter$1(state, filter, promotedSelections) {
|
|
|
17125
17450
|
(filterChanged ? 0 : state.selectedTagIndex);
|
|
17126
17451
|
const stashIndex = promotedSelections?.stashIndex ??
|
|
17127
17452
|
(filterChanged ? 0 : state.selectedStashIndex);
|
|
17453
|
+
// Reflog (#781) snaps to 0 on filter change rather than rectifying.
|
|
17454
|
+
// The list is chronological and the user is unlikely to be tracking
|
|
17455
|
+
// a specific entry through filter changes — the simpler reset
|
|
17456
|
+
// matches the "find recovery target by typing" interaction.
|
|
17457
|
+
const reflogIndex = filterChanged ? 0 : state.selectedReflogIndex;
|
|
17128
17458
|
return {
|
|
17129
17459
|
...state,
|
|
17130
17460
|
filter,
|
|
@@ -17134,6 +17464,7 @@ function withFilter$1(state, filter, promotedSelections) {
|
|
|
17134
17464
|
selectedBranchIndex: branchIndex,
|
|
17135
17465
|
selectedTagIndex: tagIndex,
|
|
17136
17466
|
selectedStashIndex: stashIndex,
|
|
17467
|
+
selectedReflogIndex: reflogIndex,
|
|
17137
17468
|
diffPreviewOffset: 0,
|
|
17138
17469
|
pendingKey: undefined,
|
|
17139
17470
|
};
|
|
@@ -17223,6 +17554,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
17223
17554
|
selectedStashIndex: 0,
|
|
17224
17555
|
selectedWorktreeListIndex: 0,
|
|
17225
17556
|
selectedConflictFileIndex: 0,
|
|
17557
|
+
selectedReflogIndex: 0,
|
|
17226
17558
|
branchSort: DEFAULT_BRANCH_SORT_MODE,
|
|
17227
17559
|
tagSort: DEFAULT_TAG_SORT_MODE,
|
|
17228
17560
|
paletteFilter: '',
|
|
@@ -17470,6 +17802,12 @@ function applyLogInkAction(state, action) {
|
|
|
17470
17802
|
selectedStashIndex: clampIndex$1(state.selectedStashIndex + action.delta, action.count),
|
|
17471
17803
|
pendingKey: undefined,
|
|
17472
17804
|
};
|
|
17805
|
+
case 'moveReflog':
|
|
17806
|
+
return {
|
|
17807
|
+
...state,
|
|
17808
|
+
selectedReflogIndex: clampIndex$1(state.selectedReflogIndex + action.delta, action.count),
|
|
17809
|
+
pendingKey: undefined,
|
|
17810
|
+
};
|
|
17473
17811
|
case 'moveWorktreeListEntry':
|
|
17474
17812
|
return {
|
|
17475
17813
|
...state,
|
|
@@ -17694,6 +18032,31 @@ function applyLogInkAction(state, action) {
|
|
|
17694
18032
|
worktreeDiffOffset: 0,
|
|
17695
18033
|
};
|
|
17696
18034
|
}
|
|
18035
|
+
case 'navigateOpenDiffForCompare': {
|
|
18036
|
+
const next = withPushedView(state, 'diff');
|
|
18037
|
+
return {
|
|
18038
|
+
...next,
|
|
18039
|
+
diffSource: 'compare',
|
|
18040
|
+
compareBase: action.base,
|
|
18041
|
+
compareHead: action.head,
|
|
18042
|
+
// Reset scroll offset so the compare patch always opens at
|
|
18043
|
+
// the top — same reasoning as the stash branch above.
|
|
18044
|
+
diffPreviewOffset: 0,
|
|
18045
|
+
worktreeDiffOffset: 0,
|
|
18046
|
+
};
|
|
18047
|
+
}
|
|
18048
|
+
case 'setCompareBase':
|
|
18049
|
+
return {
|
|
18050
|
+
...state,
|
|
18051
|
+
compareBase: action.value,
|
|
18052
|
+
pendingKey: undefined,
|
|
18053
|
+
};
|
|
18054
|
+
case 'clearCompareBase':
|
|
18055
|
+
return {
|
|
18056
|
+
...state,
|
|
18057
|
+
compareBase: undefined,
|
|
18058
|
+
pendingKey: undefined,
|
|
18059
|
+
};
|
|
17697
18060
|
case 'navigateOpenComposeForFile': {
|
|
17698
18061
|
const next = withPushedView(state, 'status');
|
|
17699
18062
|
return {
|
|
@@ -18049,10 +18412,66 @@ function isStashActionTarget(state) {
|
|
|
18049
18412
|
return (state.activeView === 'stash' && state.focus === 'commits') ||
|
|
18050
18413
|
(state.focus === 'sidebar' && state.sidebarTab === 'stashes');
|
|
18051
18414
|
}
|
|
18415
|
+
/**
|
|
18416
|
+
* Reflog has no sidebar tab — only the dedicated promoted view (#781).
|
|
18417
|
+
* The condition stays as a single helper anyway so navigation handlers
|
|
18418
|
+
* can read it the same way they do for the other promoted views.
|
|
18419
|
+
*/
|
|
18420
|
+
function isReflogActionTarget(state) {
|
|
18421
|
+
return state.activeView === 'reflog' && state.focus === 'commits';
|
|
18422
|
+
}
|
|
18052
18423
|
function isWorktreeActionTarget(state) {
|
|
18053
18424
|
return (state.activeView === 'worktrees' && state.focus === 'commits') ||
|
|
18054
18425
|
(state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
|
|
18055
18426
|
}
|
|
18427
|
+
/**
|
|
18428
|
+
* Compare-flow target views (#779). The `m` mark + Enter-as-compare
|
|
18429
|
+
* overrides only fire on rows that represent a single ref the user
|
|
18430
|
+
* could pass to `git diff <ref>..<ref>` — branches, tags, and history
|
|
18431
|
+
* commits. The reflog view is intentionally excluded because reflog
|
|
18432
|
+
* entries are *moves* of HEAD, not refs a user typically diffs against.
|
|
18433
|
+
*/
|
|
18434
|
+
function isCompareFlowTarget(state) {
|
|
18435
|
+
if (state.focus !== 'commits')
|
|
18436
|
+
return false;
|
|
18437
|
+
return state.activeView === 'branches' ||
|
|
18438
|
+
state.activeView === 'tags' ||
|
|
18439
|
+
state.activeView === 'history';
|
|
18440
|
+
}
|
|
18441
|
+
/**
|
|
18442
|
+
* Resolve the cursored ref for the compare flow (#779). Pulls the
|
|
18443
|
+
* concrete ref + label off context for branches / tags, and reads the
|
|
18444
|
+
* commit row from state for history. Returns undefined when no usable
|
|
18445
|
+
* ref is under the cursor (e.g., the views are empty, or the focus is
|
|
18446
|
+
* on the synthetic "(+) new commit" row).
|
|
18447
|
+
*/
|
|
18448
|
+
function getCursoredCompareRef(state, context) {
|
|
18449
|
+
if (state.activeView === 'branches' && context.branchSelectedShortName) {
|
|
18450
|
+
return {
|
|
18451
|
+
kind: 'branch',
|
|
18452
|
+
ref: context.branchSelectedShortName,
|
|
18453
|
+
label: context.branchSelectedShortName,
|
|
18454
|
+
};
|
|
18455
|
+
}
|
|
18456
|
+
if (state.activeView === 'tags' && context.tagSelectedName) {
|
|
18457
|
+
return {
|
|
18458
|
+
kind: 'tag',
|
|
18459
|
+
ref: context.tagSelectedName,
|
|
18460
|
+
label: context.tagSelectedName,
|
|
18461
|
+
};
|
|
18462
|
+
}
|
|
18463
|
+
if (state.activeView === 'history' && !state.pendingCommitFocused) {
|
|
18464
|
+
const commit = state.filteredCommits[state.selectedIndex];
|
|
18465
|
+
if (commit) {
|
|
18466
|
+
return {
|
|
18467
|
+
kind: 'commit',
|
|
18468
|
+
ref: commit.hash,
|
|
18469
|
+
label: `${commit.shortHash} ${commit.message}`.trim(),
|
|
18470
|
+
};
|
|
18471
|
+
}
|
|
18472
|
+
}
|
|
18473
|
+
return undefined;
|
|
18474
|
+
}
|
|
18056
18475
|
/**
|
|
18057
18476
|
* Item count for the active sidebar tab — used by the generic
|
|
18058
18477
|
* sidebar-Enter handler to decide whether to defer to the per-entity
|
|
@@ -18152,6 +18571,20 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
18152
18571
|
return [action({ type: 'pushView', value: 'pull-request' })];
|
|
18153
18572
|
case 'navigateConflicts':
|
|
18154
18573
|
return [action({ type: 'pushView', value: 'conflicts' })];
|
|
18574
|
+
case 'navigateReflog':
|
|
18575
|
+
return [action({ type: 'pushView', value: 'reflog' })];
|
|
18576
|
+
case 'navigateBisect':
|
|
18577
|
+
return [action({ type: 'pushView', value: 'bisect' })];
|
|
18578
|
+
case 'markForCompare':
|
|
18579
|
+
// Palette context can't reach the cursored ref (filtered branch /
|
|
18580
|
+
// tag lists live in runtime state, not the reducer). Surface a
|
|
18581
|
+
// hint and let the user press `m` directly on the row. The
|
|
18582
|
+
// inline keypress handler further down in this file does the
|
|
18583
|
+
// actual work and has access to the necessary context.
|
|
18584
|
+
return [action({
|
|
18585
|
+
type: 'setStatus',
|
|
18586
|
+
value: 'open branches / tags / history and press m on the cursored ref',
|
|
18587
|
+
})];
|
|
18155
18588
|
case 'navigateBack':
|
|
18156
18589
|
return [action({ type: 'popView' })];
|
|
18157
18590
|
case 'openSelected': {
|
|
@@ -18629,6 +19062,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18629
19062
|
action({ type: 'setStatus', value: 'jumped to conflicts' }),
|
|
18630
19063
|
];
|
|
18631
19064
|
}
|
|
19065
|
+
// `gr` chord: jump to the reflog browser (#781). Recovery view —
|
|
19066
|
+
// chronological list of reflog entries with Enter to drill into the
|
|
19067
|
+
// commit-diff for the entry's hash. Loaded lazily by the runtime.
|
|
19068
|
+
if (state.pendingKey === 'g' && inputValue === 'r') {
|
|
19069
|
+
return [
|
|
19070
|
+
action({ type: 'pushView', value: 'reflog' }),
|
|
19071
|
+
action({ type: 'setStatus', value: 'jumped to reflog' }),
|
|
19072
|
+
];
|
|
19073
|
+
}
|
|
19074
|
+
// `gB` chord: jump to the bisect workflow view (#784). Capital B
|
|
19075
|
+
// disambiguates from `gb` (branches). Always navigates — even when
|
|
19076
|
+
// bisect is inactive — so the user can see the empty-state hint and
|
|
19077
|
+
// know how to start one. The view's surface tells them the next step.
|
|
19078
|
+
if (state.pendingKey === 'g' && inputValue === 'B') {
|
|
19079
|
+
return [
|
|
19080
|
+
action({ type: 'pushView', value: 'bisect' }),
|
|
19081
|
+
action({ type: 'setStatus', value: 'jumped to bisect' }),
|
|
19082
|
+
];
|
|
19083
|
+
}
|
|
18632
19084
|
// `gH` chord: apply the cursored hunk to the index (`git apply
|
|
18633
19085
|
// --cached`). Sibling of bare `H` which targets the worktree.
|
|
18634
19086
|
// Discoverable via the footer hint on diff views and the help
|
|
@@ -18668,6 +19120,29 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18668
19120
|
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
|
|
18669
19121
|
];
|
|
18670
19122
|
}
|
|
19123
|
+
// #784 — bisect view action keys. Scoped to `state.activeView ===
|
|
19124
|
+
// 'bisect' && state.focus === 'commits'` so the single-letter keys
|
|
19125
|
+
// stay free everywhere else. `g` and `b` collide with the global
|
|
19126
|
+
// chord prefix and the `gb` continuation respectively — placed
|
|
19127
|
+
// BEFORE the bare-`g` chord trigger below so a `g` keystroke on
|
|
19128
|
+
// the bisect view marks good rather than entering chord mode. The
|
|
19129
|
+
// user's path back out of bisect is `<` / `esc`, never a chord;
|
|
19130
|
+
// the in-bisect view itself can't navigate elsewhere via `g`-prefix
|
|
19131
|
+
// chords until the user exits with `esc` first.
|
|
19132
|
+
if (state.activeView === 'bisect' && state.focus === 'commits') {
|
|
19133
|
+
if (inputValue === 'g' && state.pendingKey !== 'g') {
|
|
19134
|
+
return [{ type: 'runWorkflowAction', id: 'bisect-good' }];
|
|
19135
|
+
}
|
|
19136
|
+
if (inputValue === 'b' && state.pendingKey !== 'g') {
|
|
19137
|
+
return [{ type: 'runWorkflowAction', id: 'bisect-bad' }];
|
|
19138
|
+
}
|
|
19139
|
+
if (inputValue === 's') {
|
|
19140
|
+
return [{ type: 'runWorkflowAction', id: 'bisect-skip' }];
|
|
19141
|
+
}
|
|
19142
|
+
if (inputValue === 'x') {
|
|
19143
|
+
return [action({ type: 'setPendingConfirmation', value: 'bisect-reset' })];
|
|
19144
|
+
}
|
|
19145
|
+
}
|
|
18671
19146
|
if (inputValue === 'g') {
|
|
18672
19147
|
if (state.pendingKey === 'g') {
|
|
18673
19148
|
return [
|
|
@@ -18935,6 +19410,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18935
19410
|
if (isStashActionTarget(state) && context.stashCount) {
|
|
18936
19411
|
return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
|
|
18937
19412
|
}
|
|
19413
|
+
if (isReflogActionTarget(state) && context.reflogCount) {
|
|
19414
|
+
return [action({ type: 'moveReflog', delta: -1, count: context.reflogCount })];
|
|
19415
|
+
}
|
|
18938
19416
|
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
18939
19417
|
return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
|
|
18940
19418
|
}
|
|
@@ -19016,6 +19494,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19016
19494
|
if (isStashActionTarget(state) && context.stashCount) {
|
|
19017
19495
|
return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
|
|
19018
19496
|
}
|
|
19497
|
+
if (isReflogActionTarget(state) && context.reflogCount) {
|
|
19498
|
+
return [action({ type: 'moveReflog', delta: 1, count: context.reflogCount })];
|
|
19499
|
+
}
|
|
19019
19500
|
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
19020
19501
|
return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
|
|
19021
19502
|
}
|
|
@@ -19087,6 +19568,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19087
19568
|
action({ type: 'setStatus', value: 'staging worktree changes' }),
|
|
19088
19569
|
];
|
|
19089
19570
|
}
|
|
19571
|
+
// Compare-flow Enter override (#779). When `compareBase` is set and
|
|
19572
|
+
// the user presses Enter on a branch / tag / history commit row, we
|
|
19573
|
+
// open the compare diff (base..head) instead of the row's normal
|
|
19574
|
+
// action (checkout / drill-in / diff). Scoped to compare-flow
|
|
19575
|
+
// targets so non-flow views keep their Enter intact. Runs BEFORE
|
|
19576
|
+
// the per-row Enter handlers below so the override wins, including
|
|
19577
|
+
// before the history-row drill-in.
|
|
19578
|
+
if (key.return && state.compareBase && isCompareFlowTarget(state)) {
|
|
19579
|
+
const head = getCursoredCompareRef(state, context);
|
|
19580
|
+
if (!head) {
|
|
19581
|
+
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
|
|
19582
|
+
}
|
|
19583
|
+
if (head.ref === state.compareBase.ref && head.kind === state.compareBase.kind) {
|
|
19584
|
+
return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one' })];
|
|
19585
|
+
}
|
|
19586
|
+
return [
|
|
19587
|
+
action({
|
|
19588
|
+
type: 'navigateOpenDiffForCompare',
|
|
19589
|
+
base: state.compareBase,
|
|
19590
|
+
head,
|
|
19591
|
+
}),
|
|
19592
|
+
action({ type: 'setStatus', value: `Comparing ${state.compareBase.label} → ${head.label}` }),
|
|
19593
|
+
];
|
|
19594
|
+
}
|
|
19090
19595
|
if (key.return &&
|
|
19091
19596
|
state.activeView === 'history' &&
|
|
19092
19597
|
state.focus === 'commits' &&
|
|
@@ -19103,6 +19608,28 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19103
19608
|
];
|
|
19104
19609
|
}
|
|
19105
19610
|
}
|
|
19611
|
+
// Enter on a reflog row drills into the diff for that entry's hash
|
|
19612
|
+
// (#781). Reuses `navigateOpenDiffForCommit`, which finds the commit
|
|
19613
|
+
// by hash in `state.filteredCommits` first and falls back to
|
|
19614
|
+
// `commitIndex` only when the hash isn't present. Reflog hashes that
|
|
19615
|
+
// exist in the loaded history (the common case) drill in cleanly;
|
|
19616
|
+
// dangling-commit hashes fall back to the index. The `commitIndex`
|
|
19617
|
+
// we pass is best-effort — index in `state.commits` if found, else
|
|
19618
|
+
// `state.selectedIndex` so the cursor stays sane on the diff view.
|
|
19619
|
+
if (key.return &&
|
|
19620
|
+
isReflogActionTarget(state) &&
|
|
19621
|
+
context.reflogSelectedHash) {
|
|
19622
|
+
const sha = context.reflogSelectedHash;
|
|
19623
|
+
const fallbackIndex = state.commits.findIndex((commit) => commit.hash === sha);
|
|
19624
|
+
return [
|
|
19625
|
+
action({
|
|
19626
|
+
type: 'navigateOpenDiffForCommit',
|
|
19627
|
+
sha,
|
|
19628
|
+
commitIndex: fallbackIndex >= 0 ? fallbackIndex : state.selectedIndex,
|
|
19629
|
+
}),
|
|
19630
|
+
action({ type: 'setStatus', value: `viewing diff for ${sha.slice(0, 7)}` }),
|
|
19631
|
+
];
|
|
19632
|
+
}
|
|
19106
19633
|
// Inspector Actions tab: Enter on the cursored action fires its
|
|
19107
19634
|
// associated event (cherry-pick / revert / yank / etc.). Wins over
|
|
19108
19635
|
// the file-list Enter below when the user has [/]-toggled to the
|
|
@@ -19282,6 +19809,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19282
19809
|
if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
|
|
19283
19810
|
return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
|
|
19284
19811
|
}
|
|
19812
|
+
// `m` marks (or un-marks) the cursored ref as the compare base
|
|
19813
|
+
// (#779). Scoped to compare-flow targets so it doesn't collide with
|
|
19814
|
+
// the `m` PR-merge handler further down. The toggle behavior — `m`
|
|
19815
|
+
// again on the same ref clears the base — gives the user a way to
|
|
19816
|
+
// bail out without remembering a separate cancel key.
|
|
19817
|
+
if (inputValue === 'm' && isCompareFlowTarget(state)) {
|
|
19818
|
+
const ref = getCursoredCompareRef(state, context);
|
|
19819
|
+
if (!ref) {
|
|
19820
|
+
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
|
|
19821
|
+
}
|
|
19822
|
+
if (state.compareBase && state.compareBase.ref === ref.ref && state.compareBase.kind === ref.kind) {
|
|
19823
|
+
return [
|
|
19824
|
+
action({ type: 'clearCompareBase' }),
|
|
19825
|
+
action({ type: 'setStatus', value: `Cleared compare base ${ref.label}` }),
|
|
19826
|
+
];
|
|
19827
|
+
}
|
|
19828
|
+
return [
|
|
19829
|
+
action({ type: 'setCompareBase', value: ref }),
|
|
19830
|
+
action({ type: 'setStatus', value: `Compare base: ${ref.label} — press enter on another ref to diff` }),
|
|
19831
|
+
];
|
|
19832
|
+
}
|
|
19285
19833
|
// Per-view worktree action: `D` removes the worktree AND deletes
|
|
19286
19834
|
// the branch it was tracking (#838). Scoped to the worktrees
|
|
19287
19835
|
// surface so it intercepts BEFORE the global workflow-by-key
|
|
@@ -20660,6 +21208,12 @@ function formatLogInkStatusEmpty({ hasChanges }) {
|
|
|
20660
21208
|
}
|
|
20661
21209
|
return 'Worktree clean. Press gh for history, gb for branches, gz for stash.';
|
|
20662
21210
|
}
|
|
21211
|
+
function formatLogInkReflogEmpty({ filter }) {
|
|
21212
|
+
if (filter.trim()) {
|
|
21213
|
+
return `No reflog entries match filter '${filter}'. Press ctrl+u to clear.`;
|
|
21214
|
+
}
|
|
21215
|
+
return 'No reflog entries. Activity in this repo will appear here over time.';
|
|
21216
|
+
}
|
|
20663
21217
|
function formatLogInkComposeEmpty({ hasStaged }) {
|
|
20664
21218
|
if (hasStaged) {
|
|
20665
21219
|
return undefined;
|
|
@@ -22289,14 +22843,14 @@ function deleteRemoteTag(git, tagName) {
|
|
|
22289
22843
|
return runAction$2(() => git.raw(['push', 'origin', `:${tagName}`]), `Deleted remote tag ${tagName}`);
|
|
22290
22844
|
}
|
|
22291
22845
|
|
|
22292
|
-
const FIELD_SEPARATOR = '\x1f';
|
|
22846
|
+
const FIELD_SEPARATOR$1 = '\x1f';
|
|
22293
22847
|
function parseTagRefs(output) {
|
|
22294
22848
|
return output
|
|
22295
22849
|
.split('\n')
|
|
22296
22850
|
.map((line) => line.trimEnd())
|
|
22297
22851
|
.filter(Boolean)
|
|
22298
22852
|
.map((line) => {
|
|
22299
|
-
const [name, hash, date, subject] = line.split(FIELD_SEPARATOR);
|
|
22853
|
+
const [name, hash, date, subject] = line.split(FIELD_SEPARATOR$1);
|
|
22300
22854
|
return {
|
|
22301
22855
|
name,
|
|
22302
22856
|
hash,
|
|
@@ -22308,7 +22862,7 @@ function parseTagRefs(output) {
|
|
|
22308
22862
|
async function getTagOverview(git) {
|
|
22309
22863
|
const output = await git.raw([
|
|
22310
22864
|
'for-each-ref',
|
|
22311
|
-
`--format=%(refname:short)${FIELD_SEPARATOR}%(objectname:short)${FIELD_SEPARATOR}%(creatordate:short)${FIELD_SEPARATOR}%(subject)`,
|
|
22865
|
+
`--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
|
|
22312
22866
|
'--sort=-creatordate',
|
|
22313
22867
|
'refs/tags',
|
|
22314
22868
|
]);
|
|
@@ -24567,6 +25121,231 @@ function formatPullRequestStateLine(pr) {
|
|
|
24567
25121
|
return parts.join(' · ');
|
|
24568
25122
|
}
|
|
24569
25123
|
|
|
25124
|
+
const EMPTY_STATUS = {
|
|
25125
|
+
active: false,
|
|
25126
|
+
currentSha: '',
|
|
25127
|
+
log: [],
|
|
25128
|
+
};
|
|
25129
|
+
async function bisectIsActive(git) {
|
|
25130
|
+
try {
|
|
25131
|
+
const path = (await git.revparse(['--git-path', 'BISECT_LOG'])).trim();
|
|
25132
|
+
return path.length > 0 && fs.existsSync(path);
|
|
25133
|
+
}
|
|
25134
|
+
catch {
|
|
25135
|
+
return false;
|
|
25136
|
+
}
|
|
25137
|
+
}
|
|
25138
|
+
/**
|
|
25139
|
+
* Parse the output of `git bisect log` into structured entries. Each
|
|
25140
|
+
* entry corresponds to one user decision (start / good / bad / skip)
|
|
25141
|
+
* or the "# bad: [<sha>] <subject>" comment lines git emits for
|
|
25142
|
+
* traceability. Comment lines without a recognized prefix are dropped
|
|
25143
|
+
* — they're informational headers ("# status: ..."), not actions
|
|
25144
|
+
* the user took.
|
|
25145
|
+
*/
|
|
25146
|
+
function parseBisectLog(output) {
|
|
25147
|
+
const entries = [];
|
|
25148
|
+
for (const rawLine of output.split('\n')) {
|
|
25149
|
+
const line = rawLine.trimEnd();
|
|
25150
|
+
if (!line)
|
|
25151
|
+
continue;
|
|
25152
|
+
// Comment rows: "# good: [sha] subject" / "# bad: [sha] subject" /
|
|
25153
|
+
// "# first bad commit: ..." / "# status: ...". The first two carry
|
|
25154
|
+
// the most user-relevant info (which commits were marked) so we
|
|
25155
|
+
// promote them to typed entries; the rest fall through as raw
|
|
25156
|
+
// lines tagged 'unknown' so the renderer can dim them or hide
|
|
25157
|
+
// entirely.
|
|
25158
|
+
if (line.startsWith('#')) {
|
|
25159
|
+
const commentMatch = line.match(/^#\s+(good|bad|skip):\s+\[([^\]]+)\]\s*(.*)$/);
|
|
25160
|
+
if (commentMatch) {
|
|
25161
|
+
entries.push({
|
|
25162
|
+
kind: commentMatch[1],
|
|
25163
|
+
sha: commentMatch[2],
|
|
25164
|
+
subject: commentMatch[3] || undefined,
|
|
25165
|
+
raw: line,
|
|
25166
|
+
});
|
|
25167
|
+
continue;
|
|
25168
|
+
}
|
|
25169
|
+
entries.push({ kind: 'unknown', raw: line });
|
|
25170
|
+
continue;
|
|
25171
|
+
}
|
|
25172
|
+
// Command rows: "git bisect start", "git bisect good <sha>",
|
|
25173
|
+
// "git bisect bad <sha>", "git bisect skip <sha>".
|
|
25174
|
+
const commandMatch = line.match(/^git\s+bisect\s+(start|good|bad|skip)\s*(.*)$/);
|
|
25175
|
+
if (commandMatch) {
|
|
25176
|
+
const sha = commandMatch[2]?.trim().split(/\s+/)[0] || undefined;
|
|
25177
|
+
entries.push({
|
|
25178
|
+
kind: commandMatch[1],
|
|
25179
|
+
sha: sha || undefined,
|
|
25180
|
+
raw: line,
|
|
25181
|
+
});
|
|
25182
|
+
continue;
|
|
25183
|
+
}
|
|
25184
|
+
entries.push({ kind: 'unknown', raw: line });
|
|
25185
|
+
}
|
|
25186
|
+
return entries;
|
|
25187
|
+
}
|
|
25188
|
+
/**
|
|
25189
|
+
* Load the live bisect status. Best-effort — when bisect isn't
|
|
25190
|
+
* active the empty-status sentinel returns immediately so callers
|
|
25191
|
+
* don't pay for a `git bisect log` round-trip on every refresh.
|
|
25192
|
+
*/
|
|
25193
|
+
async function getBisectStatus(git) {
|
|
25194
|
+
if (!(await bisectIsActive(git))) {
|
|
25195
|
+
return EMPTY_STATUS;
|
|
25196
|
+
}
|
|
25197
|
+
let log = [];
|
|
25198
|
+
try {
|
|
25199
|
+
const output = await git.raw(['bisect', 'log']);
|
|
25200
|
+
log = parseBisectLog(output);
|
|
25201
|
+
}
|
|
25202
|
+
catch {
|
|
25203
|
+
// bisect log can fail on a freshly-started bisect with no decisions.
|
|
25204
|
+
// Treat the absence of a parseable log as "active but empty" rather
|
|
25205
|
+
// than non-active, so the surface still routes to the bisect view
|
|
25206
|
+
// and the user can see the badge.
|
|
25207
|
+
log = [];
|
|
25208
|
+
}
|
|
25209
|
+
let currentSha = '';
|
|
25210
|
+
try {
|
|
25211
|
+
currentSha = (await git.revparse(['HEAD'])).trim();
|
|
25212
|
+
}
|
|
25213
|
+
catch {
|
|
25214
|
+
currentSha = '';
|
|
25215
|
+
}
|
|
25216
|
+
return { active: true, currentSha, log };
|
|
25217
|
+
}
|
|
25218
|
+
|
|
25219
|
+
/**
|
|
25220
|
+
* Thin wrappers around `git bisect <verb>` for the TUI's in-bisect
|
|
25221
|
+
* action keys (#784). Each returns the raw stdout so the surface can
|
|
25222
|
+
* surface git's own "Bisecting: N revisions left to test after this
|
|
25223
|
+
* (roughly K steps)" hint as a status message — that wording is the
|
|
25224
|
+
* single most useful piece of feedback git emits during bisect, and
|
|
25225
|
+
* mirroring it keeps the TUI's status line authoritative.
|
|
25226
|
+
*
|
|
25227
|
+
* No try/catch here — git itself returns non-zero on user errors
|
|
25228
|
+
* (already-bisecting, no good ref, etc.) and `simple-git` surfaces
|
|
25229
|
+
* those as rejections. The runtime catches them and routes to the
|
|
25230
|
+
* status line.
|
|
25231
|
+
*/
|
|
25232
|
+
async function bisectGood(git, ref) {
|
|
25233
|
+
const args = ['bisect', 'good'];
|
|
25234
|
+
return git.raw(args);
|
|
25235
|
+
}
|
|
25236
|
+
async function bisectBad(git, ref) {
|
|
25237
|
+
const args = ['bisect', 'bad'];
|
|
25238
|
+
return git.raw(args);
|
|
25239
|
+
}
|
|
25240
|
+
async function bisectSkip(git, ref) {
|
|
25241
|
+
const args = ['bisect', 'skip'];
|
|
25242
|
+
return git.raw(args);
|
|
25243
|
+
}
|
|
25244
|
+
async function bisectReset(git) {
|
|
25245
|
+
return git.raw(['bisect', 'reset']);
|
|
25246
|
+
}
|
|
25247
|
+
/**
|
|
25248
|
+
* Pull the user-facing remaining-revisions hint out of `git bisect`
|
|
25249
|
+
* stdout. Looks for the canonical line:
|
|
25250
|
+
*
|
|
25251
|
+
* `Bisecting: N revisions left to test after this (roughly K steps)`
|
|
25252
|
+
*
|
|
25253
|
+
* Returns undefined when the line isn't present (e.g. the run
|
|
25254
|
+
* finished and git emitted a "<sha> is the first bad commit" line
|
|
25255
|
+
* instead). Callers fall back to an empty status update in that case.
|
|
25256
|
+
*/
|
|
25257
|
+
function extractBisectRemainingHint(stdout) {
|
|
25258
|
+
for (const line of stdout.split('\n').reverse()) {
|
|
25259
|
+
const trimmed = line.trim();
|
|
25260
|
+
if (trimmed.startsWith('Bisecting:'))
|
|
25261
|
+
return trimmed;
|
|
25262
|
+
if (trimmed.includes('is the first bad commit'))
|
|
25263
|
+
return trimmed;
|
|
25264
|
+
}
|
|
25265
|
+
return undefined;
|
|
25266
|
+
}
|
|
25267
|
+
|
|
25268
|
+
/**
|
|
25269
|
+
* Compare two refs (branches / tags / commits) and return the unified
|
|
25270
|
+
* patch as line-split string output (#779).
|
|
25271
|
+
*
|
|
25272
|
+
* Mirrors the stash-diff loader's contract — emits `string[]` so the
|
|
25273
|
+
* existing diff surface can render the lines through its standard
|
|
25274
|
+
* +/-/@@ coloring path. Two-dot syntax (`base..head`) gives the
|
|
25275
|
+
* "what changed on head, relative to base" view that's natural for
|
|
25276
|
+
* branch reviews and pre-merge sanity checks.
|
|
25277
|
+
*
|
|
25278
|
+
* Defensive about input — both refs are passed as-is to git, so the
|
|
25279
|
+
* caller is responsible for providing a git-resolvable form
|
|
25280
|
+
* (branch shortName, tag name, or commit hash). On any git error
|
|
25281
|
+
* (unknown ref, etc.) the runtime's `safe()` wrapper at the call
|
|
25282
|
+
* site catches the throw and the surface falls back to a "no diff"
|
|
25283
|
+
* hint.
|
|
25284
|
+
*/
|
|
25285
|
+
async function getCompareDiff(git, base, head) {
|
|
25286
|
+
return (await git.raw(['diff', `${base}..${head}`]))
|
|
25287
|
+
.split('\n')
|
|
25288
|
+
.map((line) => line.replace(/\r$/, ''));
|
|
25289
|
+
}
|
|
25290
|
+
|
|
25291
|
+
const FIELD_SEPARATOR = '\x1f';
|
|
25292
|
+
/**
|
|
25293
|
+
* Default fetch limit. 200 entries is enough to span weeks of normal
|
|
25294
|
+
* activity for an active repo while keeping the load fast — `git reflog`
|
|
25295
|
+
* is local-only so even 1000+ entries is sub-second, but 200 keeps the
|
|
25296
|
+
* rendered list bounded for terminals.
|
|
25297
|
+
*/
|
|
25298
|
+
const DEFAULT_REFLOG_LIMIT = 200;
|
|
25299
|
+
function parseReflogOverview(output) {
|
|
25300
|
+
return output
|
|
25301
|
+
.split('\n')
|
|
25302
|
+
.map((line) => line.trimEnd())
|
|
25303
|
+
.filter(Boolean)
|
|
25304
|
+
.map((line) => {
|
|
25305
|
+
const [selector, hash, relativeDate, subject] = line.split(FIELD_SEPARATOR);
|
|
25306
|
+
return {
|
|
25307
|
+
selector: selector || '',
|
|
25308
|
+
hash: hash || '',
|
|
25309
|
+
relativeDate: relativeDate || '',
|
|
25310
|
+
subject: subject || '',
|
|
25311
|
+
};
|
|
25312
|
+
});
|
|
25313
|
+
}
|
|
25314
|
+
async function getReflogOverview(git, limit = DEFAULT_REFLOG_LIMIT) {
|
|
25315
|
+
const output = await git.raw([
|
|
25316
|
+
'reflog',
|
|
25317
|
+
`--max-count=${limit}`,
|
|
25318
|
+
`--pretty=format:%gd${FIELD_SEPARATOR}%h${FIELD_SEPARATOR}%cr${FIELD_SEPARATOR}%gs`,
|
|
25319
|
+
]);
|
|
25320
|
+
return {
|
|
25321
|
+
entries: parseReflogOverview(output),
|
|
25322
|
+
};
|
|
25323
|
+
}
|
|
25324
|
+
/**
|
|
25325
|
+
* Pull the action prefix off a reflog subject. Reflog subjects follow
|
|
25326
|
+
* a `<verb>[ qualifier]: <message>` pattern emitted by git itself —
|
|
25327
|
+
* "commit: ...", "commit (amend): ...", "checkout: moving from main
|
|
25328
|
+
* to feature", "merge feature: ...", "reset: moving to HEAD~1", etc.
|
|
25329
|
+
*
|
|
25330
|
+
* For display we want the verb (and any parenthetical qualifier) on
|
|
25331
|
+
* its own so the view can render a fixed-width `action` column and
|
|
25332
|
+
* keep the rest of the message left-aligned.
|
|
25333
|
+
*
|
|
25334
|
+
* Defensive: if the subject has no colon, the whole string is treated
|
|
25335
|
+
* as the action and the message is empty. This keeps the renderer
|
|
25336
|
+
* from crashing on a malformed entry.
|
|
25337
|
+
*/
|
|
25338
|
+
function splitReflogSubject(subject) {
|
|
25339
|
+
const colonIndex = subject.indexOf(':');
|
|
25340
|
+
if (colonIndex === -1) {
|
|
25341
|
+
return { action: subject.trim(), message: '' };
|
|
25342
|
+
}
|
|
25343
|
+
return {
|
|
25344
|
+
action: subject.slice(0, colonIndex).trim(),
|
|
25345
|
+
message: subject.slice(colonIndex + 1).trim(),
|
|
25346
|
+
};
|
|
25347
|
+
}
|
|
25348
|
+
|
|
24570
25349
|
function sectionLines(title, diff) {
|
|
24571
25350
|
const lines = diff.split('\n').map((line) => line.trimEnd());
|
|
24572
25351
|
return [
|
|
@@ -24679,7 +25458,7 @@ async function safe(promise) {
|
|
|
24679
25458
|
}
|
|
24680
25459
|
}
|
|
24681
25460
|
async function loadLogInkContext(git) {
|
|
24682
|
-
const [branches, pullRequest, tags, worktree, stashes, worktreeList, operation, provider] = await Promise.all([
|
|
25461
|
+
const [branches, pullRequest, tags, worktree, stashes, worktreeList, operation, provider, reflog, bisect] = await Promise.all([
|
|
24683
25462
|
safe(getBranchOverview(git)),
|
|
24684
25463
|
safe(getPullRequestOverview(git)),
|
|
24685
25464
|
safe(getTagOverview(git)),
|
|
@@ -24688,12 +25467,16 @@ async function loadLogInkContext(git) {
|
|
|
24688
25467
|
safe(getWorktreeListOverview(git)),
|
|
24689
25468
|
safe(getGitOperationOverview(git)),
|
|
24690
25469
|
safe(getProviderOverview(git)),
|
|
25470
|
+
safe(getReflogOverview(git)),
|
|
25471
|
+
safe(getBisectStatus(git)),
|
|
24691
25472
|
]);
|
|
24692
25473
|
return {
|
|
25474
|
+
bisect,
|
|
24693
25475
|
branches,
|
|
24694
25476
|
operation,
|
|
24695
25477
|
provider,
|
|
24696
25478
|
pullRequest,
|
|
25479
|
+
reflog,
|
|
24697
25480
|
stashes,
|
|
24698
25481
|
tags,
|
|
24699
25482
|
worktree,
|
|
@@ -24719,6 +25502,14 @@ function loadLogInkContextEntries(git) {
|
|
|
24719
25502
|
key: 'tags',
|
|
24720
25503
|
load: () => safe(getTagOverview(git)),
|
|
24721
25504
|
},
|
|
25505
|
+
{
|
|
25506
|
+
key: 'reflog',
|
|
25507
|
+
load: () => safe(getReflogOverview(git)),
|
|
25508
|
+
},
|
|
25509
|
+
{
|
|
25510
|
+
key: 'bisect',
|
|
25511
|
+
load: () => safe(getBisectStatus(git)),
|
|
25512
|
+
},
|
|
24722
25513
|
{
|
|
24723
25514
|
key: 'worktree',
|
|
24724
25515
|
load: () => safe(getWorktreeOverview(git)),
|
|
@@ -25107,6 +25898,10 @@ function LogInkApp(deps) {
|
|
|
25107
25898
|
// colors match the commit-diff path.
|
|
25108
25899
|
const [stashDiffLines, setStashDiffLines] = React.useState(undefined);
|
|
25109
25900
|
const [stashDiffLoading, setStashDiffLoading] = React.useState(false);
|
|
25901
|
+
// #779 — compare-two-refs diff state. Loaded lazily when the diff
|
|
25902
|
+
// view becomes active with `diffSource === 'compare'`.
|
|
25903
|
+
const [compareDiffLines, setCompareDiffLines] = React.useState(undefined);
|
|
25904
|
+
const [compareDiffLoading, setCompareDiffLoading] = React.useState(false);
|
|
25110
25905
|
const [hasMoreCommits, setHasMoreCommits] = React.useState(() => (Boolean(logArgv?.interactive && !logArgv.limit) &&
|
|
25111
25906
|
getCommitRows(rows).length >= LOG_INTERACTIVE_DEFAULT_LIMIT));
|
|
25112
25907
|
const [loadingMoreCommits, setLoadingMoreCommits] = React.useState(false);
|
|
@@ -25201,6 +25996,12 @@ function LogInkApp(deps) {
|
|
|
25201
25996
|
return all;
|
|
25202
25997
|
return all.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || ''], state.filter));
|
|
25203
25998
|
}, [context.worktreeList?.worktrees, state.filter]);
|
|
25999
|
+
const filteredReflogList = React.useMemo(() => {
|
|
26000
|
+
const all = context.reflog?.entries || [];
|
|
26001
|
+
if (!state.filter)
|
|
26002
|
+
return all;
|
|
26003
|
+
return all.filter((entry) => matchesPromotedFilter([entry.selector, entry.hash, entry.relativeDate, entry.subject], state.filter));
|
|
26004
|
+
}, [context.reflog?.entries, state.filter]);
|
|
25204
26005
|
const dispatch = React.useCallback((action) => {
|
|
25205
26006
|
setState((current) => applyLogInkAction(current, action));
|
|
25206
26007
|
}, []);
|
|
@@ -25411,6 +26212,41 @@ function LogInkApp(deps) {
|
|
|
25411
26212
|
})();
|
|
25412
26213
|
return () => { active = false; };
|
|
25413
26214
|
}, [git, state.activeView, state.diffSource, state.stashDiffRef]);
|
|
26215
|
+
// #779 — load `git diff <base>..<head>` once the diff view becomes
|
|
26216
|
+
// active with diffSource='compare'. Mirrors the stash loader's
|
|
26217
|
+
// shape; the surface renders the lines via the same +/-/@@ coloring
|
|
26218
|
+
// path. On unknown ref / git error, `safe()` swallows and the
|
|
26219
|
+
// surface falls back to a "no diff" hint.
|
|
26220
|
+
const compareBaseRef = state.compareBase?.ref;
|
|
26221
|
+
const compareHeadRef = state.compareHead?.ref;
|
|
26222
|
+
React.useEffect(() => {
|
|
26223
|
+
if (state.activeView !== 'diff' ||
|
|
26224
|
+
state.diffSource !== 'compare' ||
|
|
26225
|
+
!compareBaseRef ||
|
|
26226
|
+
!compareHeadRef) {
|
|
26227
|
+
return;
|
|
26228
|
+
}
|
|
26229
|
+
let active = true;
|
|
26230
|
+
setCompareDiffLoading(true);
|
|
26231
|
+
void (async () => {
|
|
26232
|
+
const lines = await safe(getCompareDiff(git, compareBaseRef, compareHeadRef));
|
|
26233
|
+
if (active) {
|
|
26234
|
+
setCompareDiffLines(lines || []);
|
|
26235
|
+
setCompareDiffLoading(false);
|
|
26236
|
+
}
|
|
26237
|
+
})();
|
|
26238
|
+
return () => { active = false; };
|
|
26239
|
+
}, [git, state.activeView, state.diffSource, compareBaseRef, compareHeadRef]);
|
|
26240
|
+
// Reset compare-diff state whenever the diff view exits. Without
|
|
26241
|
+
// this, opening a new compare immediately after closing one would
|
|
26242
|
+
// briefly show the previous comparison's lines while the new
|
|
26243
|
+
// loader runs.
|
|
26244
|
+
React.useEffect(() => {
|
|
26245
|
+
if (state.diffSource !== 'compare') {
|
|
26246
|
+
setCompareDiffLines(undefined);
|
|
26247
|
+
setCompareDiffLoading(false);
|
|
26248
|
+
}
|
|
26249
|
+
}, [state.diffSource]);
|
|
25414
26250
|
React.useEffect(() => {
|
|
25415
26251
|
let active = true;
|
|
25416
26252
|
async function loadWorktreeHunks() {
|
|
@@ -25921,6 +26757,50 @@ function LogInkApp(deps) {
|
|
|
25921
26757
|
return { ok: false, message: 'No stash selected' };
|
|
25922
26758
|
return popStash(git, stash);
|
|
25923
26759
|
},
|
|
26760
|
+
'bisect-good': async () => {
|
|
26761
|
+
if (!context.bisect?.active)
|
|
26762
|
+
return { ok: false, message: 'No bisect in progress' };
|
|
26763
|
+
try {
|
|
26764
|
+
const stdout = await bisectGood(git);
|
|
26765
|
+
return { ok: true, message: extractBisectRemainingHint(stdout) || 'Marked good' };
|
|
26766
|
+
}
|
|
26767
|
+
catch (error) {
|
|
26768
|
+
return { ok: false, message: `Bisect good failed: ${error.message}` };
|
|
26769
|
+
}
|
|
26770
|
+
},
|
|
26771
|
+
'bisect-bad': async () => {
|
|
26772
|
+
if (!context.bisect?.active)
|
|
26773
|
+
return { ok: false, message: 'No bisect in progress' };
|
|
26774
|
+
try {
|
|
26775
|
+
const stdout = await bisectBad(git);
|
|
26776
|
+
return { ok: true, message: extractBisectRemainingHint(stdout) || 'Marked bad' };
|
|
26777
|
+
}
|
|
26778
|
+
catch (error) {
|
|
26779
|
+
return { ok: false, message: `Bisect bad failed: ${error.message}` };
|
|
26780
|
+
}
|
|
26781
|
+
},
|
|
26782
|
+
'bisect-skip': async () => {
|
|
26783
|
+
if (!context.bisect?.active)
|
|
26784
|
+
return { ok: false, message: 'No bisect in progress' };
|
|
26785
|
+
try {
|
|
26786
|
+
const stdout = await bisectSkip(git);
|
|
26787
|
+
return { ok: true, message: extractBisectRemainingHint(stdout) || 'Skipped' };
|
|
26788
|
+
}
|
|
26789
|
+
catch (error) {
|
|
26790
|
+
return { ok: false, message: `Bisect skip failed: ${error.message}` };
|
|
26791
|
+
}
|
|
26792
|
+
},
|
|
26793
|
+
'bisect-reset': async () => {
|
|
26794
|
+
if (!context.bisect?.active)
|
|
26795
|
+
return { ok: false, message: 'No bisect in progress' };
|
|
26796
|
+
try {
|
|
26797
|
+
await bisectReset(git);
|
|
26798
|
+
return { ok: true, message: 'Bisect reset' };
|
|
26799
|
+
}
|
|
26800
|
+
catch (error) {
|
|
26801
|
+
return { ok: false, message: `Bisect reset failed: ${error.message}` };
|
|
26802
|
+
}
|
|
26803
|
+
},
|
|
25924
26804
|
'checkout-file-from-stash': async () => {
|
|
25925
26805
|
const path = payload?.trim();
|
|
25926
26806
|
const ref = state.stashDiffRef;
|
|
@@ -26593,9 +27473,13 @@ function LogInkApp(deps) {
|
|
|
26593
27473
|
// perf pass) — reading them here is O(1) instead of O(branches +
|
|
26594
27474
|
// tags + stashes + worktrees) per keystroke.
|
|
26595
27475
|
const branchVisibleCount = filteredBranchList.length;
|
|
27476
|
+
const branchSelectedShortName = filteredBranchList[Math.min(state.selectedBranchIndex, Math.max(0, filteredBranchList.length - 1))]?.shortName;
|
|
26596
27477
|
const tagVisibleCount = filteredTagList.length;
|
|
27478
|
+
const tagSelectedName = filteredTagList[Math.min(state.selectedTagIndex, Math.max(0, filteredTagList.length - 1))]?.name;
|
|
26597
27479
|
const stashVisibleCount = filteredStashList.length;
|
|
26598
27480
|
const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
|
|
27481
|
+
const reflogVisibleCount = filteredReflogList.length;
|
|
27482
|
+
const reflogSelectedHash = filteredReflogList[Math.min(state.selectedReflogIndex, Math.max(0, filteredReflogList.length - 1))]?.hash;
|
|
26599
27483
|
const worktreeVisibleCount = filteredWorktreeList.length;
|
|
26600
27484
|
// When the diff view is showing a stash patch, swap the previewLineCount
|
|
26601
27485
|
// to the stash diff length so the existing pageDetailPreview path
|
|
@@ -26620,8 +27504,12 @@ function LogInkApp(deps) {
|
|
|
26620
27504
|
worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
|
|
26621
27505
|
commitDiffHunkOffsets,
|
|
26622
27506
|
branchCount: branchVisibleCount,
|
|
27507
|
+
branchSelectedShortName,
|
|
26623
27508
|
tagCount: tagVisibleCount,
|
|
27509
|
+
tagSelectedName,
|
|
26624
27510
|
stashCount: stashVisibleCount,
|
|
27511
|
+
reflogCount: reflogVisibleCount,
|
|
27512
|
+
reflogSelectedHash,
|
|
26625
27513
|
stashSelectedRef,
|
|
26626
27514
|
stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
|
|
26627
27515
|
stashDiffSelectedPath,
|
|
@@ -26727,12 +27615,15 @@ function LogInkApp(deps) {
|
|
|
26727
27615
|
if (showOnboarding) {
|
|
26728
27616
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
26729
27617
|
}
|
|
26730
|
-
return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
|
|
27618
|
+
return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip));
|
|
26731
27619
|
}
|
|
26732
27620
|
function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
|
|
26733
27621
|
const { Box, Text } = components;
|
|
26734
27622
|
const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
|
|
26735
|
-
|
|
27623
|
+
// #784 — surface bisect-in-progress in the title bar so users entering
|
|
27624
|
+
// the TUI mid-bisect see it immediately, before they navigate to gB.
|
|
27625
|
+
const dirtyBase = context.branches?.dirty ? 'dirty' : 'clean';
|
|
27626
|
+
const dirty = context.bisect?.active ? `${dirtyBase} · BISECTING` : dirtyBase;
|
|
26736
27627
|
const repo = context.provider?.repository.owner && context.provider.repository.name
|
|
26737
27628
|
? `${context.provider.repository.owner}/${context.provider.repository.name}`
|
|
26738
27629
|
: 'local repository';
|
|
@@ -26987,12 +27878,12 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
|
|
|
26987
27878
|
: []),
|
|
26988
27879
|
];
|
|
26989
27880
|
}
|
|
26990
|
-
function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
|
|
27881
|
+
function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
|
|
26991
27882
|
if (state.activeView === 'status') {
|
|
26992
27883
|
return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
26993
27884
|
}
|
|
26994
27885
|
if (state.activeView === 'diff') {
|
|
26995
|
-
return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme);
|
|
27886
|
+
return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme);
|
|
26996
27887
|
}
|
|
26997
27888
|
if (state.activeView === 'compose') {
|
|
26998
27889
|
return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
@@ -27003,6 +27894,12 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
27003
27894
|
if (state.activeView === 'tags') {
|
|
27004
27895
|
return renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
27005
27896
|
}
|
|
27897
|
+
if (state.activeView === 'reflog') {
|
|
27898
|
+
return renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
27899
|
+
}
|
|
27900
|
+
if (state.activeView === 'bisect') {
|
|
27901
|
+
return renderBisectSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
27902
|
+
}
|
|
27006
27903
|
if (state.activeView === 'stash') {
|
|
27007
27904
|
return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
27008
27905
|
}
|
|
@@ -27630,6 +28527,150 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
27630
28527
|
width,
|
|
27631
28528
|
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
|
|
27632
28529
|
}
|
|
28530
|
+
/**
|
|
28531
|
+
* Promoted reflog browser (#781). Mirrors `renderTagsSurface` visually
|
|
28532
|
+
* — same header / filter affordance / footer hint conventions — but
|
|
28533
|
+
* lays out four columns per row: relative date, action prefix, short
|
|
28534
|
+
* hash, and message. Filtering matches against all four (so typing
|
|
28535
|
+
* "checkout" narrows to checkout entries, "abc" narrows to a hash).
|
|
28536
|
+
*
|
|
28537
|
+
* Per-row layout uses fixed column widths derived from the visible
|
|
28538
|
+
* window so short-action rows don't leave a wide gutter and long
|
|
28539
|
+
* actions don't push the message off-screen. The cap mirrors the
|
|
28540
|
+
* tags surface's name-column treatment.
|
|
28541
|
+
*/
|
|
28542
|
+
function renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
28543
|
+
const { Box, Text } = components;
|
|
28544
|
+
const focused = state.focus === 'commits';
|
|
28545
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'reflog');
|
|
28546
|
+
const allEntries = context.reflog?.entries || [];
|
|
28547
|
+
const entries = state.filter
|
|
28548
|
+
? allEntries.filter((entry) => matchesPromotedFilter([entry.selector, entry.hash, entry.relativeDate, entry.subject], state.filter))
|
|
28549
|
+
: allEntries;
|
|
28550
|
+
const selected = Math.max(0, Math.min(state.selectedReflogIndex, Math.max(0, entries.length - 1)));
|
|
28551
|
+
const listRows = Math.max(4, bodyRows - 4);
|
|
28552
|
+
const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
|
|
28553
|
+
const visible = entries.slice(startIndex, startIndex + listRows);
|
|
28554
|
+
const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
|
|
28555
|
+
const headerRight = loading
|
|
28556
|
+
? 'loading reflog'
|
|
28557
|
+
: `${entries.length}/${allEntries.length} entries${filterLabel}`;
|
|
28558
|
+
const emptyLabel = formatLogInkReflogEmpty({ filter: state.filter });
|
|
28559
|
+
const loadingLabel = formatLogInkLoading({ resource: 'reflog' });
|
|
28560
|
+
// Column widths derived from the visible window. The hash column is
|
|
28561
|
+
// fixed (short SHA is always 7 chars) and the date column caps so
|
|
28562
|
+
// "X minutes ago" / "Y hours ago" stays readable without dominating
|
|
28563
|
+
// the row. Action column scales to the longest visible action so
|
|
28564
|
+
// commit / checkout / merge align cleanly.
|
|
28565
|
+
const splitVisible = visible.map((entry) => ({
|
|
28566
|
+
entry,
|
|
28567
|
+
parts: splitReflogSubject(entry.subject),
|
|
28568
|
+
}));
|
|
28569
|
+
const dateColWidth = splitVisible.length === 0
|
|
28570
|
+
? 16
|
|
28571
|
+
: Math.min(20, Math.max(6, ...splitVisible.map(({ entry }) => entry.relativeDate.length)));
|
|
28572
|
+
const actionColWidth = splitVisible.length === 0
|
|
28573
|
+
? 12
|
|
28574
|
+
: Math.min(24, Math.max(6, ...splitVisible.map(({ parts }) => parts.action.length)));
|
|
28575
|
+
const hashColWidth = 8;
|
|
28576
|
+
const lines = loading
|
|
28577
|
+
? [h(Text, { key: 'reflog-loading', dimColor: true }, loadingLabel)]
|
|
28578
|
+
: entries.length === 0
|
|
28579
|
+
? [h(Text, { key: 'reflog-empty', dimColor: true }, emptyLabel)]
|
|
28580
|
+
: splitVisible.map(({ entry, parts }, offset) => {
|
|
28581
|
+
const index = startIndex + offset;
|
|
28582
|
+
const isSelected = index === selected;
|
|
28583
|
+
const cursor = isSelected ? '>' : ' ';
|
|
28584
|
+
const datePadded = truncate$1(entry.relativeDate, dateColWidth).padEnd(dateColWidth);
|
|
28585
|
+
const actionPadded = truncate$1(parts.action, actionColWidth).padEnd(actionColWidth);
|
|
28586
|
+
const hashPadded = truncate$1(entry.hash, hashColWidth).padEnd(hashColWidth);
|
|
28587
|
+
const message = parts.message || entry.subject;
|
|
28588
|
+
const lineText = truncate$1(`${cursor} ${datePadded} ${actionPadded} ${hashPadded} ${message}`, Math.max(20, width - 4));
|
|
28589
|
+
return h(Text, {
|
|
28590
|
+
key: `reflog-${index}`,
|
|
28591
|
+
bold: isSelected,
|
|
28592
|
+
dimColor: !isSelected,
|
|
28593
|
+
}, lineText);
|
|
28594
|
+
});
|
|
28595
|
+
return h(Box, {
|
|
28596
|
+
borderColor: focusBorderColor(theme, focused),
|
|
28597
|
+
borderStyle: theme.borderStyle,
|
|
28598
|
+
flexDirection: 'column',
|
|
28599
|
+
flexShrink: 0,
|
|
28600
|
+
paddingX: 1,
|
|
28601
|
+
width,
|
|
28602
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Reflog', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
|
|
28603
|
+
}
|
|
28604
|
+
/**
|
|
28605
|
+
* Bisect workflow surface (#784). Shows the current candidate commit
|
|
28606
|
+
* (HEAD), a parsed view of recent decisions from `git bisect log`, and
|
|
28607
|
+
* the four action keys (g good, b bad, s skip, x reset).
|
|
28608
|
+
*
|
|
28609
|
+
* When bisect is inactive, the surface renders an empty-state hint
|
|
28610
|
+
* pointing the user at the CLI to start one. The view stays
|
|
28611
|
+
* navigable so the user can read the documentation before starting
|
|
28612
|
+
* — they can't break anything from here.
|
|
28613
|
+
*/
|
|
28614
|
+
function renderBisectSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
28615
|
+
const { Box, Text } = components;
|
|
28616
|
+
const focused = state.focus === 'commits';
|
|
28617
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
|
|
28618
|
+
const bisect = context.bisect;
|
|
28619
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
28620
|
+
const lines = [];
|
|
28621
|
+
if (loading) {
|
|
28622
|
+
lines.push(h(Text, { key: 'bisect-loading', dimColor: true }, truncate$1('· Loading bisect status…', width - 4)));
|
|
28623
|
+
}
|
|
28624
|
+
else if (!bisect?.active) {
|
|
28625
|
+
// No bisect active. Surface the CLI on-ramp — starting from the
|
|
28626
|
+
// TUI is intentionally out of scope for this PR (#784 follow-up).
|
|
28627
|
+
// The user is expected to enter via `git bisect start <bad> <good>`
|
|
28628
|
+
// and re-open `coco ui`; once bisect is active this view drives
|
|
28629
|
+
// the rest.
|
|
28630
|
+
lines.push(h(Text, { key: 'bisect-empty-1', bold: true }, truncate$1('No bisect in progress.', width - 4)));
|
|
28631
|
+
lines.push(h(Text, { key: 'bisect-empty-2' }, ''));
|
|
28632
|
+
lines.push(h(Text, { key: 'bisect-empty-3' }, truncate$1('Start one from the shell with:', width - 4)));
|
|
28633
|
+
lines.push(h(Text, { key: 'bisect-empty-4', color: accent }, truncate$1(' git bisect start <bad-ref> <good-ref>', width - 4)));
|
|
28634
|
+
lines.push(h(Text, { key: 'bisect-empty-5' }, ''));
|
|
28635
|
+
lines.push(h(Text, { key: 'bisect-empty-6', dimColor: true }, truncate$1('coco will pick up the active bisect on the next refresh — actions will become available here.', width - 4)));
|
|
28636
|
+
}
|
|
28637
|
+
else {
|
|
28638
|
+
// Active bisect. Two-section body: current candidate, recent
|
|
28639
|
+
// decisions. Action keys live in the footer.
|
|
28640
|
+
const headerSha = bisect.currentSha ? bisect.currentSha.slice(0, 8) : '<unknown>';
|
|
28641
|
+
lines.push(h(Text, { key: 'bisect-active-title', bold: true }, truncate$1(`Bisecting · current candidate ${headerSha}`, width - 4)));
|
|
28642
|
+
lines.push(h(Text, { key: 'bisect-active-spacer' }, ''));
|
|
28643
|
+
const decisions = bisect.log.filter((entry) => entry.kind === 'good' || entry.kind === 'bad' || entry.kind === 'skip');
|
|
28644
|
+
if (decisions.length === 0) {
|
|
28645
|
+
lines.push(h(Text, { key: 'bisect-no-decisions', dimColor: true }, truncate$1('No decisions logged yet — press g (good) or b (bad) to record one.', width - 4)));
|
|
28646
|
+
}
|
|
28647
|
+
else {
|
|
28648
|
+
lines.push(h(Text, { key: 'bisect-decisions-header', bold: true }, truncate$1(`Decisions (${decisions.length}):`, width - 4)));
|
|
28649
|
+
const recent = decisions.slice(-Math.max(4, bodyRows - 8));
|
|
28650
|
+
for (const entry of recent) {
|
|
28651
|
+
const kindLabel = entry.kind.toUpperCase().padEnd(5);
|
|
28652
|
+
const sha = (entry.sha || '<unknown>').padEnd(8);
|
|
28653
|
+
const subject = entry.subject || '';
|
|
28654
|
+
const text = ` ${kindLabel} ${sha} ${subject}`;
|
|
28655
|
+
lines.push(h(Text, {
|
|
28656
|
+
key: `bisect-entry-${entry.raw}`,
|
|
28657
|
+
dimColor: entry.kind === 'skip',
|
|
28658
|
+
bold: entry.kind === 'bad',
|
|
28659
|
+
}, truncate$1(text, width - 4)));
|
|
28660
|
+
}
|
|
28661
|
+
}
|
|
28662
|
+
lines.push(h(Text, { key: 'bisect-action-spacer' }, ''));
|
|
28663
|
+
lines.push(h(Text, { key: 'bisect-action-hint', dimColor: true }, truncate$1('Actions: g good · b bad · s skip · x reset', width - 4)));
|
|
28664
|
+
}
|
|
28665
|
+
return h(Box, {
|
|
28666
|
+
borderColor: focusBorderColor(theme, focused),
|
|
28667
|
+
borderStyle: theme.borderStyle,
|
|
28668
|
+
flexDirection: 'column',
|
|
28669
|
+
flexShrink: 0,
|
|
28670
|
+
paddingX: 1,
|
|
28671
|
+
width,
|
|
28672
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Bisect', focused)), h(Text, { dimColor: true }, bisect?.active ? 'BISECTING' : 'inactive')), ...lines);
|
|
28673
|
+
}
|
|
27633
28674
|
function renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
27634
28675
|
const { Box, Text } = components;
|
|
27635
28676
|
const focused = state.focus === 'commits';
|
|
@@ -27835,7 +28876,7 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
|
|
|
27835
28876
|
h(Text, { key: 'promoted-filter-input', color: accent }, `filter: ${state.filter}_`),
|
|
27836
28877
|
];
|
|
27837
28878
|
}
|
|
27838
|
-
function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme) {
|
|
28879
|
+
function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme) {
|
|
27839
28880
|
const { Box, Text } = components;
|
|
27840
28881
|
const focused = state.focus === 'commits';
|
|
27841
28882
|
const worktree = context.worktree;
|
|
@@ -27926,6 +28967,50 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
27926
28967
|
dimColor: index > 0,
|
|
27927
28968
|
}, truncate$1(line, width - 4))), ...stashBodyNodes);
|
|
27928
28969
|
}
|
|
28970
|
+
// Compare-two-refs branch (#779). Mirrors the stash diff above but
|
|
28971
|
+
// sourced from `git diff <base>..<head>`. No per-file cherry-pick or
|
|
28972
|
+
// hunk apply — comparing arbitrary refs doesn't have a sensible
|
|
28973
|
+
// mutate-from-here flow, so the surface is read-only navigation.
|
|
28974
|
+
if (state.diffSource === 'compare') {
|
|
28975
|
+
const lines = compareDiffLines || [];
|
|
28976
|
+
const splitActive = isSplitDiffViable(state, width);
|
|
28977
|
+
const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
|
|
28978
|
+
const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
28979
|
+
const baseLabel = state.compareBase?.label || state.compareBase?.ref || '<base>';
|
|
28980
|
+
const headLabel = state.compareHead?.label || state.compareHead?.ref || '<head>';
|
|
28981
|
+
const compareTitle = `${baseLabel} → ${headLabel}`;
|
|
28982
|
+
const baseHeaderLines = compareDiffLoading
|
|
28983
|
+
? [`Loading diff for ${compareTitle}...`]
|
|
28984
|
+
: lines.length && (lines.length > 1 || lines[0])
|
|
28985
|
+
? [
|
|
28986
|
+
compareTitle,
|
|
28987
|
+
`Lines ${Math.min(state.diffPreviewOffset + 1, lines.length)}-${Math.min(state.diffPreviewOffset + visibleLines.length, lines.length)}/${lines.length}`,
|
|
28988
|
+
'',
|
|
28989
|
+
]
|
|
28990
|
+
: ['No diff to display — refs may resolve to the same tree.'];
|
|
28991
|
+
const headerLines = splitRequestedButTooNarrow
|
|
28992
|
+
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
28993
|
+
: baseHeaderLines;
|
|
28994
|
+
const compareBodyNodes = compareDiffLoading || !lines.length || (lines.length === 1 && !lines[0])
|
|
28995
|
+
? []
|
|
28996
|
+
: splitActive
|
|
28997
|
+
? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'compare-diff-split')
|
|
28998
|
+
: visibleLines.map((line, index) => h(Text, {
|
|
28999
|
+
key: `compare-diff-line-${state.diffPreviewOffset + index}`,
|
|
29000
|
+
...diffLineProps(line, theme),
|
|
29001
|
+
}, truncate$1(line, width - 4)));
|
|
29002
|
+
return h(Box, {
|
|
29003
|
+
borderColor: focusBorderColor(theme, focused),
|
|
29004
|
+
borderStyle: theme.borderStyle,
|
|
29005
|
+
flexDirection: 'column',
|
|
29006
|
+
flexShrink: 0,
|
|
29007
|
+
paddingX: 1,
|
|
29008
|
+
width,
|
|
29009
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle(splitActive ? 'Compare (split)' : 'Compare', focused)), h(Text, { dimColor: true }, truncate$1(compareTitle, Math.max(20, Math.floor(width / 2))))), ...headerLines.map((line, index) => h(Text, {
|
|
29010
|
+
key: `compare-diff-header-${index}`,
|
|
29011
|
+
dimColor: index > 0,
|
|
29012
|
+
}, truncate$1(line, width - 4))), ...compareBodyNodes);
|
|
29013
|
+
}
|
|
27929
29014
|
// diffSource disambiguates: 'commit' was set when the user opened the
|
|
27930
29015
|
// diff via history → Enter (read-only commit-diff explore), 'worktree'
|
|
27931
29016
|
// was set when they came from status → Enter (stage / hunk / revert).
|
|
@@ -28784,6 +29869,7 @@ function renderFooter(h, components, state, context, theme, idleTip) {
|
|
|
28784
29869
|
showHelp: state.showHelp,
|
|
28785
29870
|
sidebarTab: state.sidebarTab,
|
|
28786
29871
|
sidebarItemCount,
|
|
29872
|
+
compareBaseSet: Boolean(state.compareBase),
|
|
28787
29873
|
});
|
|
28788
29874
|
// Real status messages always win; idle tips only fill the slot when it
|
|
28789
29875
|
// would otherwise be empty.
|