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.esm.mjs
CHANGED
|
@@ -54,7 +54,7 @@ import { pathToFileURL } from 'url';
|
|
|
54
54
|
/**
|
|
55
55
|
* Current build version from package.json
|
|
56
56
|
*/
|
|
57
|
-
const BUILD_VERSION = "0.
|
|
57
|
+
const BUILD_VERSION = "0.46.0";
|
|
58
58
|
|
|
59
59
|
const isInteractive = (config) => {
|
|
60
60
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -1228,6 +1228,18 @@ const schema$1 = {
|
|
|
1228
1228
|
"$ref": "#/definitions/DynamicModelPreference",
|
|
1229
1229
|
"description": "Default dynamic routing preference when model is set to \"dynamic\".",
|
|
1230
1230
|
"default": "balanced"
|
|
1231
|
+
},
|
|
1232
|
+
"fastPath": {
|
|
1233
|
+
"type": "object",
|
|
1234
|
+
"properties": {
|
|
1235
|
+
"markdown": {
|
|
1236
|
+
"type": "boolean",
|
|
1237
|
+
"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.",
|
|
1238
|
+
"default": false
|
|
1239
|
+
}
|
|
1240
|
+
},
|
|
1241
|
+
"additionalProperties": false,
|
|
1242
|
+
"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."
|
|
1231
1243
|
}
|
|
1232
1244
|
},
|
|
1233
1245
|
"required": [
|
|
@@ -1641,6 +1653,18 @@ const schema$1 = {
|
|
|
1641
1653
|
"$ref": "#/definitions/DynamicModelPreference",
|
|
1642
1654
|
"description": "Default dynamic routing preference when model is set to \"dynamic\".",
|
|
1643
1655
|
"default": "balanced"
|
|
1656
|
+
},
|
|
1657
|
+
"fastPath": {
|
|
1658
|
+
"type": "object",
|
|
1659
|
+
"properties": {
|
|
1660
|
+
"markdown": {
|
|
1661
|
+
"type": "boolean",
|
|
1662
|
+
"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.",
|
|
1663
|
+
"default": false
|
|
1664
|
+
}
|
|
1665
|
+
},
|
|
1666
|
+
"additionalProperties": false,
|
|
1667
|
+
"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."
|
|
1644
1668
|
}
|
|
1645
1669
|
},
|
|
1646
1670
|
"required": [
|
|
@@ -1797,6 +1821,18 @@ const schema$1 = {
|
|
|
1797
1821
|
"$ref": "#/definitions/DynamicModelPreference",
|
|
1798
1822
|
"description": "Default dynamic routing preference when model is set to \"dynamic\".",
|
|
1799
1823
|
"default": "balanced"
|
|
1824
|
+
},
|
|
1825
|
+
"fastPath": {
|
|
1826
|
+
"type": "object",
|
|
1827
|
+
"properties": {
|
|
1828
|
+
"markdown": {
|
|
1829
|
+
"type": "boolean",
|
|
1830
|
+
"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.",
|
|
1831
|
+
"default": false
|
|
1832
|
+
}
|
|
1833
|
+
},
|
|
1834
|
+
"additionalProperties": false,
|
|
1835
|
+
"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."
|
|
1800
1836
|
}
|
|
1801
1837
|
},
|
|
1802
1838
|
"required": [
|
|
@@ -7890,6 +7926,109 @@ async function summarize(documents, { chain, textSplitter, options, logger, toke
|
|
|
7890
7926
|
return res.text && res.text.trim();
|
|
7891
7927
|
}
|
|
7892
7928
|
|
|
7929
|
+
/**
|
|
7930
|
+
* Markdown-aware fast path (#861, angle 5). For modification diffs to
|
|
7931
|
+
* `.md` / `.mdx` / `.markdown` files, build a templated summary from
|
|
7932
|
+
* the changed structure (added / removed / updated headings) instead
|
|
7933
|
+
* of paying for an LLM call. Mirrors `trivialDiff` from #845: a deterministic
|
|
7934
|
+
* skip when the diff's meaning is captured by its shape.
|
|
7935
|
+
*
|
|
7936
|
+
* Quality / cost trade-off, on purpose: LLM summaries of markdown edits
|
|
7937
|
+
* are wordier ("expanded the configuration section with new examples,
|
|
7938
|
+
* fixed typos in troubleshooting") but most of that detail isn't load-
|
|
7939
|
+
* bearing for a commit message. The templated summary names the
|
|
7940
|
+
* structural changes (which sections moved) plus a +/- line count, and
|
|
7941
|
+
* defers to the LLM only when the diff has no clear structural signals
|
|
7942
|
+
* (paragraph-only edits, where a templated summary would actually drop
|
|
7943
|
+
* useful context).
|
|
7944
|
+
*/
|
|
7945
|
+
const MARKDOWN_EXTENSIONS = ['.md', '.markdown', '.mdx'];
|
|
7946
|
+
const MAX_HEADINGS_PER_BUCKET = 6;
|
|
7947
|
+
function isMarkdownFile(path) {
|
|
7948
|
+
const lower = path.toLowerCase();
|
|
7949
|
+
return MARKDOWN_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
|
7950
|
+
}
|
|
7951
|
+
function summarizeMarkdownDiff(fileDiff) {
|
|
7952
|
+
if (!isMarkdownFile(fileDiff.file))
|
|
7953
|
+
return undefined;
|
|
7954
|
+
const addedHeadings = new Set();
|
|
7955
|
+
const removedHeadings = new Set();
|
|
7956
|
+
let addedLines = 0;
|
|
7957
|
+
let removedLines = 0;
|
|
7958
|
+
for (const line of fileDiff.diff.split('\n')) {
|
|
7959
|
+
if (isHeaderLine$1(line))
|
|
7960
|
+
continue;
|
|
7961
|
+
if (line.startsWith('+')) {
|
|
7962
|
+
addedLines++;
|
|
7963
|
+
const heading = parseHeading(line.slice(1));
|
|
7964
|
+
if (heading)
|
|
7965
|
+
addedHeadings.add(heading);
|
|
7966
|
+
}
|
|
7967
|
+
else if (line.startsWith('-')) {
|
|
7968
|
+
removedLines++;
|
|
7969
|
+
const heading = parseHeading(line.slice(1));
|
|
7970
|
+
if (heading)
|
|
7971
|
+
removedHeadings.add(heading);
|
|
7972
|
+
}
|
|
7973
|
+
}
|
|
7974
|
+
// No content change → nothing to summarize. Caller falls through.
|
|
7975
|
+
if (addedLines === 0 && removedLines === 0)
|
|
7976
|
+
return undefined;
|
|
7977
|
+
// No structural signal → fall through to LLM. We only fast-path
|
|
7978
|
+
// when the diff has heading-level changes; pure paragraph edits go
|
|
7979
|
+
// to the LLM so the summary keeps its detail.
|
|
7980
|
+
if (addedHeadings.size === 0 && removedHeadings.size === 0) {
|
|
7981
|
+
return undefined;
|
|
7982
|
+
}
|
|
7983
|
+
// A heading that appears in both buckets is likely an update (kept
|
|
7984
|
+
// around but its body changed) rather than two distinct events.
|
|
7985
|
+
// The naive split-by-bucket diff format used by git emits the old
|
|
7986
|
+
// text under `-` and the new text under `+`; an unchanged heading
|
|
7987
|
+
// line shouldn't show up in either bucket via the standard hunk
|
|
7988
|
+
// path, but defensively de-dupe in case the diff producer emits
|
|
7989
|
+
// surrounding context as +/-.
|
|
7990
|
+
const updated = new Set([...addedHeadings].filter((h) => removedHeadings.has(h)));
|
|
7991
|
+
const purelyAdded = [...addedHeadings].filter((h) => !updated.has(h));
|
|
7992
|
+
const purelyRemoved = [...removedHeadings].filter((h) => !updated.has(h));
|
|
7993
|
+
const parts = [`Updated markdown \`${fileDiff.file}\``];
|
|
7994
|
+
if (purelyAdded.length) {
|
|
7995
|
+
parts.push(`new sections: ${formatHeadingList(purelyAdded)}`);
|
|
7996
|
+
}
|
|
7997
|
+
if (purelyRemoved.length) {
|
|
7998
|
+
parts.push(`removed sections: ${formatHeadingList(purelyRemoved)}`);
|
|
7999
|
+
}
|
|
8000
|
+
if (updated.size) {
|
|
8001
|
+
parts.push(`updated sections: ${formatHeadingList([...updated])}`);
|
|
8002
|
+
}
|
|
8003
|
+
parts.push(`+${addedLines}/-${removedLines} lines`);
|
|
8004
|
+
return `${parts.join('. ')}.`;
|
|
8005
|
+
}
|
|
8006
|
+
function formatHeadingList(headings) {
|
|
8007
|
+
if (headings.length <= MAX_HEADINGS_PER_BUCKET) {
|
|
8008
|
+
return headings.join(', ');
|
|
8009
|
+
}
|
|
8010
|
+
const shown = headings.slice(0, MAX_HEADINGS_PER_BUCKET);
|
|
8011
|
+
const remainder = headings.length - shown.length;
|
|
8012
|
+
return `${shown.join(', ')} (+${remainder} more)`;
|
|
8013
|
+
}
|
|
8014
|
+
function isHeaderLine$1(line) {
|
|
8015
|
+
return (line.startsWith('diff --git') ||
|
|
8016
|
+
line.startsWith('index ') ||
|
|
8017
|
+
line.startsWith('--- ') ||
|
|
8018
|
+
line.startsWith('+++ ') ||
|
|
8019
|
+
line.startsWith('@@') ||
|
|
8020
|
+
line.startsWith('new file mode') ||
|
|
8021
|
+
line.startsWith('deleted file mode') ||
|
|
8022
|
+
line.startsWith('similarity index') ||
|
|
8023
|
+
line.startsWith('rename from ') ||
|
|
8024
|
+
line.startsWith('rename to ') ||
|
|
8025
|
+
line.startsWith('Binary files '));
|
|
8026
|
+
}
|
|
8027
|
+
function parseHeading(line) {
|
|
8028
|
+
const match = line.match(/^#{1,6}\s+(.+?)\s*$/);
|
|
8029
|
+
return match ? match[1].trim() : undefined;
|
|
8030
|
+
}
|
|
8031
|
+
|
|
7893
8032
|
/**
|
|
7894
8033
|
* Inspect a unified-diff string and report its shape, or undefined
|
|
7895
8034
|
* if the diff isn't trivial (mixed +/- lines, weird headers, etc.).
|
|
@@ -8027,7 +8166,7 @@ function isCacheEnabled$1() {
|
|
|
8027
8166
|
* synthetic summaries usually drop the directory token totals under
|
|
8028
8167
|
* budget so wave consolidation skips too.
|
|
8029
8168
|
*/
|
|
8030
|
-
async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, logger, metadata, }) {
|
|
8169
|
+
async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, logger, metadata, fastPath, }) {
|
|
8031
8170
|
const trivialSummary = summarizeTrivialDiff(fileDiff);
|
|
8032
8171
|
if (trivialSummary !== undefined) {
|
|
8033
8172
|
logger.verbose(` - ${fileDiff.file}: trivial-shape skip (no LLM call)`, { color: 'gray' });
|
|
@@ -8037,6 +8176,25 @@ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, log
|
|
|
8037
8176
|
tokenCount: tokenizer(trivialSummary),
|
|
8038
8177
|
};
|
|
8039
8178
|
}
|
|
8179
|
+
// Markdown fast path (#861, angle 5). Opt-in via `fastPath.markdown`
|
|
8180
|
+
// because it's a lossy optimization: the templated summary names
|
|
8181
|
+
// structural changes only and drops body-text detail that an LLM
|
|
8182
|
+
// summary would carry. Off by default; users who prefer summary
|
|
8183
|
+
// fidelity over speed (which is the safer default for commit-message
|
|
8184
|
+
// generation downstream) keep the LLM path. When the flag IS on, the
|
|
8185
|
+
// fast path still falls through to the LLM for paragraph-only edits
|
|
8186
|
+
// where a templated summary would lose useful context.
|
|
8187
|
+
if (fastPath?.markdown) {
|
|
8188
|
+
const markdownSummary = summarizeMarkdownDiff(fileDiff);
|
|
8189
|
+
if (markdownSummary !== undefined) {
|
|
8190
|
+
logger.verbose(` - ${fileDiff.file}: markdown fast-path skip (no LLM call)`, { color: 'gray' });
|
|
8191
|
+
return {
|
|
8192
|
+
...fileDiff,
|
|
8193
|
+
diff: markdownSummary,
|
|
8194
|
+
tokenCount: tokenizer(markdownSummary),
|
|
8195
|
+
};
|
|
8196
|
+
}
|
|
8197
|
+
}
|
|
8040
8198
|
// Cache lookup (#845, PR 5). Keyed on the file's literal diff
|
|
8041
8199
|
// content + the active model + the summarization prompt hash.
|
|
8042
8200
|
// A hit returns the prior summary instantly; on iterative
|
|
@@ -8148,7 +8306,7 @@ function createLimit$2(maxConcurrent) {
|
|
|
8148
8306
|
* @returns Array of file diffs with large files summarized
|
|
8149
8307
|
*/
|
|
8150
8308
|
async function summarizeLargeFiles(diffs, options) {
|
|
8151
|
-
const { maxFileTokens, minTokensForSummary, maxConcurrent, tokenizer, logger, chain, textSplitter, metadata } = options;
|
|
8309
|
+
const { maxFileTokens, minTokensForSummary, maxConcurrent, maxTokens, fastPath, tokenizer, logger, chain, textSplitter, metadata, } = options;
|
|
8152
8310
|
// Identify files that need summarization
|
|
8153
8311
|
const filesToSummarize = [];
|
|
8154
8312
|
const results = [...diffs];
|
|
@@ -8160,17 +8318,57 @@ async function summarizeLargeFiles(diffs, options) {
|
|
|
8160
8318
|
if (filesToSummarize.length === 0) {
|
|
8161
8319
|
return results;
|
|
8162
8320
|
}
|
|
8163
|
-
|
|
8164
|
-
//
|
|
8165
|
-
|
|
8166
|
-
//
|
|
8167
|
-
|
|
8321
|
+
// Incremental termination (#861, PR 1). When the caller supplies a
|
|
8322
|
+
// budget, dispatch biggest-first and re-check the running total per
|
|
8323
|
+
// dispatch — once earlier completions drop the total under maxTokens,
|
|
8324
|
+
// the remaining queued files skip the LLM and keep their raw diffs.
|
|
8325
|
+
// Mirrors the Phase 3 pattern in `summarizeDiffs.ts`. Without a
|
|
8326
|
+
// budget (undefined), behavior matches the prior path: every
|
|
8327
|
+
// eligible file is summarized regardless.
|
|
8328
|
+
filesToSummarize.sort((a, b) => b.diff.tokenCount - a.diff.tokenCount);
|
|
8329
|
+
const incrementalTermination = maxTokens !== undefined;
|
|
8330
|
+
let runningTotal = diffs.reduce((sum, diff) => sum + diff.tokenCount, 0);
|
|
8331
|
+
let summarizedCount = 0;
|
|
8332
|
+
let skippedCount = 0;
|
|
8333
|
+
logger.verbose(`Pre-summarizing up to ${filesToSummarize.length} large file(s)...`, { color: 'blue' });
|
|
8334
|
+
const processed = await processInWaves$1(filesToSummarize, async ({ diff }) => {
|
|
8335
|
+
// Re-check the budget at dispatch time when the caller supplied
|
|
8336
|
+
// one. Earlier completions may have already dropped the total
|
|
8337
|
+
// under the cap; in that case skip the LLM call entirely and
|
|
8338
|
+
// keep the raw diff. Without a budget, every eligible file is
|
|
8339
|
+
// summarized (preserves the prior behavior).
|
|
8340
|
+
if (incrementalTermination && runningTotal <= maxTokens) {
|
|
8341
|
+
return { diff, summarized: false };
|
|
8342
|
+
}
|
|
8343
|
+
const summarized = await summarizeFileDiff(diff, {
|
|
8344
|
+
chain,
|
|
8345
|
+
textSplitter,
|
|
8346
|
+
tokenizer,
|
|
8347
|
+
logger,
|
|
8348
|
+
metadata,
|
|
8349
|
+
fastPath,
|
|
8350
|
+
});
|
|
8351
|
+
const delta = diff.tokenCount - summarized.tokenCount;
|
|
8352
|
+
if (delta > 0) {
|
|
8353
|
+
runningTotal -= delta;
|
|
8354
|
+
}
|
|
8355
|
+
return { diff: summarized, summarized: true };
|
|
8356
|
+
}, maxConcurrent);
|
|
8357
|
+
processed.forEach((entry, i) => {
|
|
8168
8358
|
const originalIndex = filesToSummarize[i].index;
|
|
8359
|
+
if (!entry.summarized) {
|
|
8360
|
+
skippedCount++;
|
|
8361
|
+
return;
|
|
8362
|
+
}
|
|
8363
|
+
summarizedCount++;
|
|
8169
8364
|
const originalTokens = results[originalIndex].tokenCount;
|
|
8170
|
-
const newTokens =
|
|
8171
|
-
logger.verbose(` - ${
|
|
8172
|
-
results[originalIndex] =
|
|
8365
|
+
const newTokens = entry.diff.tokenCount;
|
|
8366
|
+
logger.verbose(` - ${entry.diff.file}: ${originalTokens} -> ${newTokens} tokens`, { color: 'magenta' });
|
|
8367
|
+
results[originalIndex] = entry.diff;
|
|
8173
8368
|
});
|
|
8369
|
+
if (skippedCount > 0) {
|
|
8370
|
+
logger.verbose(`Skipped ${skippedCount} pre-summary call(s) — token budget already met after ${summarizedCount} earlier file(s)`, { color: 'cyan' });
|
|
8371
|
+
}
|
|
8174
8372
|
return results;
|
|
8175
8373
|
}
|
|
8176
8374
|
/**
|
|
@@ -8436,7 +8634,7 @@ async function summarizeDiffs(rootDiffNode, { tokenizer, logger,
|
|
|
8436
8634
|
// with the service defaults means a caller that omits
|
|
8437
8635
|
// `maxTokens` doesn't accidentally fall into a tighter budget
|
|
8438
8636
|
// than the rest of the system assumes.
|
|
8439
|
-
maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, textSplitter, chain, metadata, handleOutput = defaultOutputCallback, }) {
|
|
8637
|
+
maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, fastPath, textSplitter, chain, metadata, handleOutput = defaultOutputCallback, }) {
|
|
8440
8638
|
// Calculate maxFileTokens as 25% of maxTokens if not specified
|
|
8441
8639
|
const effectiveMaxFileTokens = maxFileTokens ?? Math.floor(maxTokens * 0.25);
|
|
8442
8640
|
// PHASE 1: Directory grouping & assessment
|
|
@@ -8460,6 +8658,13 @@ maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, t
|
|
|
8460
8658
|
maxFileTokens: effectiveMaxFileTokens,
|
|
8461
8659
|
minTokensForSummary,
|
|
8462
8660
|
maxConcurrent,
|
|
8661
|
+
// #861, PR 1: pass the overall budget so Phase 2 can short-circuit
|
|
8662
|
+
// once earlier completions drop the running total under the cap.
|
|
8663
|
+
maxTokens,
|
|
8664
|
+
// #861, angle 5: opt-in markdown fast path. Off by default; when
|
|
8665
|
+
// enabled, markdown modification diffs with structural signals
|
|
8666
|
+
// resolve via a templated extract instead of an LLM call.
|
|
8667
|
+
fastPath,
|
|
8463
8668
|
tokenizer,
|
|
8464
8669
|
logger,
|
|
8465
8670
|
chain,
|
|
@@ -11437,7 +11642,7 @@ for (var i = 0; i < 256; i++) {
|
|
|
11437
11642
|
simpleEscapeMap[i] = simpleEscapeSequence(i);
|
|
11438
11643
|
}
|
|
11439
11644
|
|
|
11440
|
-
async function fileChangeParser({ changes, commit, options: { tokenizer, git, llm: model, logger, maxTokens, minTokensForSummary, maxFileTokens, maxConcurrent, metadata, }, }) {
|
|
11645
|
+
async function fileChangeParser({ changes, commit, options: { tokenizer, git, llm: model, logger, maxTokens, minTokensForSummary, maxFileTokens, maxConcurrent, fastPath, metadata, }, }) {
|
|
11441
11646
|
const textSplitter = new RecursiveCharacterTextSplitter({ chunkSize: 10000, chunkOverlap: 250 });
|
|
11442
11647
|
const summarizationChain = loadSummarizationChain(model, {
|
|
11443
11648
|
type: 'map_reduce',
|
|
@@ -11469,6 +11674,7 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
|
|
|
11469
11674
|
minTokensForSummary,
|
|
11470
11675
|
maxFileTokens,
|
|
11471
11676
|
maxConcurrent,
|
|
11677
|
+
fastPath,
|
|
11472
11678
|
textSplitter,
|
|
11473
11679
|
chain: summarizationChain,
|
|
11474
11680
|
logger,
|
|
@@ -11488,6 +11694,7 @@ function createFileChangeParserOptions({ command, git, llm, logger, model, provi
|
|
|
11488
11694
|
minTokensForSummary: service?.minTokensForSummary,
|
|
11489
11695
|
maxFileTokens: service?.maxFileTokens,
|
|
11490
11696
|
maxConcurrent: service?.maxConcurrent,
|
|
11697
|
+
fastPath: service?.fastPath,
|
|
11491
11698
|
metadata: {
|
|
11492
11699
|
command,
|
|
11493
11700
|
provider,
|
|
@@ -14314,7 +14521,7 @@ const builder$3 = (yargs) => {
|
|
|
14314
14521
|
return yargs.options(options$3).usage(getCommandUsageHeader(command$3));
|
|
14315
14522
|
};
|
|
14316
14523
|
|
|
14317
|
-
const FIELD_SEPARATOR$
|
|
14524
|
+
const FIELD_SEPARATOR$3 = '\x1f';
|
|
14318
14525
|
// `%P` (parent hashes, space-separated) lets the TUI distinguish
|
|
14319
14526
|
// merge commits (parents.length > 1) from regular commits without a
|
|
14320
14527
|
// second round-trip to git. See #791 stage 3 — merge glyph + HEAD ring.
|
|
@@ -14367,13 +14574,13 @@ function parseLogOutput(output) {
|
|
|
14367
14574
|
.map((line) => line.trimEnd())
|
|
14368
14575
|
.filter(Boolean)
|
|
14369
14576
|
.map((line) => {
|
|
14370
|
-
if (!line.includes(FIELD_SEPARATOR$
|
|
14577
|
+
if (!line.includes(FIELD_SEPARATOR$3)) {
|
|
14371
14578
|
return {
|
|
14372
14579
|
type: 'graph',
|
|
14373
14580
|
graph: line,
|
|
14374
14581
|
};
|
|
14375
14582
|
}
|
|
14376
|
-
const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$
|
|
14583
|
+
const [graph, shortHash, hash, parentsStr, date, author, refs, message] = line.split(FIELD_SEPARATOR$3);
|
|
14377
14584
|
return {
|
|
14378
14585
|
type: 'commit',
|
|
14379
14586
|
graph: graph.trimEnd(),
|
|
@@ -14448,7 +14655,7 @@ function parseNameStatus(output, numstat = []) {
|
|
|
14448
14655
|
function parseCommitDetail(metadata, files, numstatOutput = '') {
|
|
14449
14656
|
const [hash, shortHash, parentsStr, date, author, refs, message, body = ''] = metadata
|
|
14450
14657
|
.trimEnd()
|
|
14451
|
-
.split(FIELD_SEPARATOR$
|
|
14658
|
+
.split(FIELD_SEPARATOR$3);
|
|
14452
14659
|
const numstat = parseNumstat(numstatOutput);
|
|
14453
14660
|
return {
|
|
14454
14661
|
shortHash,
|
|
@@ -14588,14 +14795,14 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
|
|
|
14588
14795
|
};
|
|
14589
14796
|
}
|
|
14590
14797
|
|
|
14591
|
-
const FIELD_SEPARATOR$
|
|
14798
|
+
const FIELD_SEPARATOR$2 = '\x1f';
|
|
14592
14799
|
function parseBranchRefs(output) {
|
|
14593
14800
|
return output
|
|
14594
14801
|
.split('\n')
|
|
14595
14802
|
.map((line) => line.trimEnd())
|
|
14596
14803
|
.filter(Boolean)
|
|
14597
14804
|
.map((line) => {
|
|
14598
|
-
const [refName, shortName, hash, upstream, head, date, subject] = line.split(FIELD_SEPARATOR$
|
|
14805
|
+
const [refName, shortName, hash, upstream, head, date, subject] = line.split(FIELD_SEPARATOR$2);
|
|
14599
14806
|
if (!refName || !shortName) {
|
|
14600
14807
|
return undefined;
|
|
14601
14808
|
}
|
|
@@ -14635,7 +14842,7 @@ async function getBranchOverview(git) {
|
|
|
14635
14842
|
const [branchOutput, statusOutput, currentBranchOutput] = await Promise.all([
|
|
14636
14843
|
git.raw([
|
|
14637
14844
|
'for-each-ref',
|
|
14638
|
-
`--format=%(refname)${FIELD_SEPARATOR$
|
|
14845
|
+
`--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)`,
|
|
14639
14846
|
'refs/heads',
|
|
14640
14847
|
'refs/remotes',
|
|
14641
14848
|
]),
|
|
@@ -15185,10 +15392,12 @@ async function runCommitDraftWorkflow(input = {}) {
|
|
|
15185
15392
|
}
|
|
15186
15393
|
|
|
15187
15394
|
const LOG_INK_CONTEXT_KEYS = [
|
|
15395
|
+
'bisect',
|
|
15188
15396
|
'branches',
|
|
15189
15397
|
'operation',
|
|
15190
15398
|
'provider',
|
|
15191
15399
|
'pullRequest',
|
|
15400
|
+
'reflog',
|
|
15192
15401
|
'stashes',
|
|
15193
15402
|
'tags',
|
|
15194
15403
|
'worktree',
|
|
@@ -15908,6 +16117,45 @@ function getLogInkWorkflowActions() {
|
|
|
15908
16117
|
kind: 'normal',
|
|
15909
16118
|
requiresConfirmation: false,
|
|
15910
16119
|
},
|
|
16120
|
+
{
|
|
16121
|
+
// #784 — bisect workflow actions. All four are scoped per-view in
|
|
16122
|
+
// inkInput (active only when activeView === 'bisect') so the
|
|
16123
|
+
// single-letter keys stay free elsewhere. Empty `key` keeps them
|
|
16124
|
+
// palette-discoverable. Reset is the only destructive one — it
|
|
16125
|
+
// throws away the bisect state — so it routes through y-confirm;
|
|
16126
|
+
// good / bad / skip are recoverable via `git bisect log` and run
|
|
16127
|
+
// immediately.
|
|
16128
|
+
id: 'bisect-good',
|
|
16129
|
+
key: '',
|
|
16130
|
+
label: 'Bisect: mark good',
|
|
16131
|
+
description: 'Mark the current bisect candidate as good and advance to the next one.',
|
|
16132
|
+
kind: 'normal',
|
|
16133
|
+
requiresConfirmation: false,
|
|
16134
|
+
},
|
|
16135
|
+
{
|
|
16136
|
+
id: 'bisect-bad',
|
|
16137
|
+
key: '',
|
|
16138
|
+
label: 'Bisect: mark bad',
|
|
16139
|
+
description: 'Mark the current bisect candidate as bad and advance to the next one.',
|
|
16140
|
+
kind: 'normal',
|
|
16141
|
+
requiresConfirmation: false,
|
|
16142
|
+
},
|
|
16143
|
+
{
|
|
16144
|
+
id: 'bisect-skip',
|
|
16145
|
+
key: '',
|
|
16146
|
+
label: 'Bisect: skip candidate',
|
|
16147
|
+
description: 'Skip the current bisect candidate (e.g. it does not build) and advance.',
|
|
16148
|
+
kind: 'normal',
|
|
16149
|
+
requiresConfirmation: false,
|
|
16150
|
+
},
|
|
16151
|
+
{
|
|
16152
|
+
id: 'bisect-reset',
|
|
16153
|
+
key: '',
|
|
16154
|
+
label: 'Bisect: reset',
|
|
16155
|
+
description: 'End the bisect session and restore HEAD. Discards in-progress bisect state.',
|
|
16156
|
+
kind: 'destructive',
|
|
16157
|
+
requiresConfirmation: true,
|
|
16158
|
+
},
|
|
15911
16159
|
{
|
|
15912
16160
|
id: 'ai-commit-summary',
|
|
15913
16161
|
key: 'I',
|
|
@@ -16144,6 +16392,27 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
16144
16392
|
description: 'Push the conflict resolution helper view (available during merge/rebase/cherry-pick/revert).',
|
|
16145
16393
|
contexts: ['normal'],
|
|
16146
16394
|
},
|
|
16395
|
+
{
|
|
16396
|
+
id: 'navigateReflog',
|
|
16397
|
+
keys: ['gr'],
|
|
16398
|
+
label: 'reflog',
|
|
16399
|
+
description: 'Push the reflog browser view — chronological recovery log.',
|
|
16400
|
+
contexts: ['normal'],
|
|
16401
|
+
},
|
|
16402
|
+
{
|
|
16403
|
+
id: 'navigateBisect',
|
|
16404
|
+
keys: ['gB'],
|
|
16405
|
+
label: 'bisect',
|
|
16406
|
+
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.',
|
|
16407
|
+
contexts: ['normal'],
|
|
16408
|
+
},
|
|
16409
|
+
{
|
|
16410
|
+
id: 'markForCompare',
|
|
16411
|
+
keys: ['m'],
|
|
16412
|
+
label: 'mark compare',
|
|
16413
|
+
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.',
|
|
16414
|
+
contexts: ['commits'],
|
|
16415
|
+
},
|
|
16147
16416
|
{
|
|
16148
16417
|
id: 'navigateBack',
|
|
16149
16418
|
keys: ['<', 'esc'],
|
|
@@ -16274,6 +16543,8 @@ const GLOBAL_BINDING_IDS = [
|
|
|
16274
16543
|
'navigateWorktrees',
|
|
16275
16544
|
'navigatePullRequest',
|
|
16276
16545
|
'navigateConflicts',
|
|
16546
|
+
'navigateReflog',
|
|
16547
|
+
'navigateBisect',
|
|
16277
16548
|
'navigateBack',
|
|
16278
16549
|
];
|
|
16279
16550
|
const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
|
|
@@ -16401,6 +16672,15 @@ function getLogInkFooterHints(options) {
|
|
|
16401
16672
|
global: NORMAL_GLOBAL_HINTS,
|
|
16402
16673
|
};
|
|
16403
16674
|
}
|
|
16675
|
+
if (options.diffSource === 'compare') {
|
|
16676
|
+
// Compare-two-refs (#779): read-only diff with no per-file
|
|
16677
|
+
// cherry-pick or hunk apply (those don't make sense across
|
|
16678
|
+
// arbitrary refs). Just scroll + back out.
|
|
16679
|
+
return {
|
|
16680
|
+
contextual: ['j/k lines', splitToggleHint, 'esc back'],
|
|
16681
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16682
|
+
};
|
|
16683
|
+
}
|
|
16404
16684
|
return {
|
|
16405
16685
|
contextual: ['j/k hunks', 'space stage', 'z revert', 'o edit', 'e/c compose', 'y yank'],
|
|
16406
16686
|
global: NORMAL_GLOBAL_HINTS,
|
|
@@ -16413,14 +16693,26 @@ function getLogInkFooterHints(options) {
|
|
|
16413
16693
|
};
|
|
16414
16694
|
}
|
|
16415
16695
|
if (options.activeView === 'branches') {
|
|
16696
|
+
if (options.compareBaseSet) {
|
|
16697
|
+
return {
|
|
16698
|
+
contextual: ['↑/↓ branches', 'enter compare', 'm clear', 'esc back'],
|
|
16699
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16700
|
+
};
|
|
16701
|
+
}
|
|
16416
16702
|
return {
|
|
16417
|
-
contextual: ['↑/↓ branches', 'enter checkout', '+ new', 'D delete', 's sort', 'y yank'],
|
|
16703
|
+
contextual: ['↑/↓ branches', 'enter checkout', '+ new', 'D delete', 'm compare', 's sort', 'y yank'],
|
|
16418
16704
|
global: NORMAL_GLOBAL_HINTS,
|
|
16419
16705
|
};
|
|
16420
16706
|
}
|
|
16421
16707
|
if (options.activeView === 'tags') {
|
|
16708
|
+
if (options.compareBaseSet) {
|
|
16709
|
+
return {
|
|
16710
|
+
contextual: ['↑/↓ tags', 'enter compare', 'm clear', 'esc back'],
|
|
16711
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16712
|
+
};
|
|
16713
|
+
}
|
|
16422
16714
|
return {
|
|
16423
|
-
contextual: ['↑/↓ tags', '+ new', 'P push', 'T delete', 's sort', 'y yank'],
|
|
16715
|
+
contextual: ['↑/↓ tags', '+ new', 'P push', 'T delete', 'm compare', 's sort', 'y yank'],
|
|
16424
16716
|
global: NORMAL_GLOBAL_HINTS,
|
|
16425
16717
|
};
|
|
16426
16718
|
}
|
|
@@ -16452,6 +16744,28 @@ function getLogInkFooterHints(options) {
|
|
|
16452
16744
|
global: NORMAL_GLOBAL_HINTS,
|
|
16453
16745
|
};
|
|
16454
16746
|
}
|
|
16747
|
+
if (options.activeView === 'reflog') {
|
|
16748
|
+
return {
|
|
16749
|
+
contextual: ['↑/↓ entries', 'enter inspect', 'esc back'],
|
|
16750
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16751
|
+
};
|
|
16752
|
+
}
|
|
16753
|
+
if (options.activeView === 'bisect') {
|
|
16754
|
+
return {
|
|
16755
|
+
contextual: ['g good', 'b bad', 's skip', 'x reset', 'esc back'],
|
|
16756
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16757
|
+
};
|
|
16758
|
+
}
|
|
16759
|
+
if (options.compareBaseSet) {
|
|
16760
|
+
// History view with a compare base set — Enter is overridden to
|
|
16761
|
+
// open the compare diff; show the override + the bail-out key.
|
|
16762
|
+
// Mutate / new chips are dropped so the footer doesn't compete
|
|
16763
|
+
// with the active workflow.
|
|
16764
|
+
return {
|
|
16765
|
+
contextual: ['↑/↓ move', 'enter compare', 'm clear', 'esc back'],
|
|
16766
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
16767
|
+
};
|
|
16768
|
+
}
|
|
16455
16769
|
return {
|
|
16456
16770
|
// History view default hints. Mutating ops (`c` cherry-pick, `R`
|
|
16457
16771
|
// revert, `Z` reset, `i` interactive-rebase) all route through a
|
|
@@ -16461,7 +16775,7 @@ function getLogInkFooterHints(options) {
|
|
|
16461
16775
|
// Grouped into compact `c/R/Z/i mutate` and `B/gT new` chips so
|
|
16462
16776
|
// the footer stays scannable; full descriptions live in `?` help
|
|
16463
16777
|
// and the palette.
|
|
16464
|
-
contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', '
|
|
16778
|
+
contextual: ['↑/↓ move', 'enter diff', 'c/R/Z/i mutate', 'B/gT new', 'm compare', 'y/Y yank', '/ search'],
|
|
16465
16779
|
global: NORMAL_GLOBAL_HINTS,
|
|
16466
16780
|
};
|
|
16467
16781
|
}
|
|
@@ -17040,6 +17354,7 @@ function withPushedView(state, value) {
|
|
|
17040
17354
|
selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
|
|
17041
17355
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
17042
17356
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
17357
|
+
compareHead: value === 'diff' ? state.compareHead : undefined,
|
|
17043
17358
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
17044
17359
|
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
17045
17360
|
pendingKey: undefined,
|
|
@@ -17051,6 +17366,13 @@ function withPoppedView(state) {
|
|
|
17051
17366
|
}
|
|
17052
17367
|
const viewStack = state.viewStack.slice(0, -1);
|
|
17053
17368
|
const next = topOfStack(viewStack);
|
|
17369
|
+
// #779 — compareBase is "cleared when the diff view is popped." We
|
|
17370
|
+
// detect that case by checking if the *previous* top was 'diff'.
|
|
17371
|
+
// The compare workflow ends when the user backs out of the compare
|
|
17372
|
+
// diff; on the next mark they re-set the base. Other view pops
|
|
17373
|
+
// preserve compareBase so the user can move between branches / tags /
|
|
17374
|
+
// history while hunting for a head ref.
|
|
17375
|
+
const wasOnDiff = state.activeView === 'diff';
|
|
17054
17376
|
return {
|
|
17055
17377
|
...state,
|
|
17056
17378
|
activeView: next,
|
|
@@ -17063,6 +17385,8 @@ function withPoppedView(state) {
|
|
|
17063
17385
|
selectedWorktreeHunkIndex: next === 'diff' ? state.selectedWorktreeHunkIndex : 0,
|
|
17064
17386
|
diffSource: next === 'diff' ? state.diffSource : undefined,
|
|
17065
17387
|
stashDiffRef: next === 'diff' ? state.stashDiffRef : undefined,
|
|
17388
|
+
compareBase: wasOnDiff ? undefined : state.compareBase,
|
|
17389
|
+
compareHead: next === 'diff' ? state.compareHead : undefined,
|
|
17066
17390
|
pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
|
|
17067
17391
|
statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
|
|
17068
17392
|
pendingKey: undefined,
|
|
@@ -17081,6 +17405,7 @@ function withReplacedView(state, value) {
|
|
|
17081
17405
|
selectedWorktreeHunkIndex: value === 'diff' ? state.selectedWorktreeHunkIndex : 0,
|
|
17082
17406
|
diffSource: value === 'diff' ? state.diffSource : undefined,
|
|
17083
17407
|
stashDiffRef: value === 'diff' ? state.stashDiffRef : undefined,
|
|
17408
|
+
compareHead: value === 'diff' ? state.compareHead : undefined,
|
|
17084
17409
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
17085
17410
|
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
17086
17411
|
pendingKey: undefined,
|
|
@@ -17101,6 +17426,11 @@ function withFilter$1(state, filter, promotedSelections) {
|
|
|
17101
17426
|
(filterChanged ? 0 : state.selectedTagIndex);
|
|
17102
17427
|
const stashIndex = promotedSelections?.stashIndex ??
|
|
17103
17428
|
(filterChanged ? 0 : state.selectedStashIndex);
|
|
17429
|
+
// Reflog (#781) snaps to 0 on filter change rather than rectifying.
|
|
17430
|
+
// The list is chronological and the user is unlikely to be tracking
|
|
17431
|
+
// a specific entry through filter changes — the simpler reset
|
|
17432
|
+
// matches the "find recovery target by typing" interaction.
|
|
17433
|
+
const reflogIndex = filterChanged ? 0 : state.selectedReflogIndex;
|
|
17104
17434
|
return {
|
|
17105
17435
|
...state,
|
|
17106
17436
|
filter,
|
|
@@ -17110,6 +17440,7 @@ function withFilter$1(state, filter, promotedSelections) {
|
|
|
17110
17440
|
selectedBranchIndex: branchIndex,
|
|
17111
17441
|
selectedTagIndex: tagIndex,
|
|
17112
17442
|
selectedStashIndex: stashIndex,
|
|
17443
|
+
selectedReflogIndex: reflogIndex,
|
|
17113
17444
|
diffPreviewOffset: 0,
|
|
17114
17445
|
pendingKey: undefined,
|
|
17115
17446
|
};
|
|
@@ -17199,6 +17530,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
17199
17530
|
selectedStashIndex: 0,
|
|
17200
17531
|
selectedWorktreeListIndex: 0,
|
|
17201
17532
|
selectedConflictFileIndex: 0,
|
|
17533
|
+
selectedReflogIndex: 0,
|
|
17202
17534
|
branchSort: DEFAULT_BRANCH_SORT_MODE,
|
|
17203
17535
|
tagSort: DEFAULT_TAG_SORT_MODE,
|
|
17204
17536
|
paletteFilter: '',
|
|
@@ -17446,6 +17778,12 @@ function applyLogInkAction(state, action) {
|
|
|
17446
17778
|
selectedStashIndex: clampIndex$1(state.selectedStashIndex + action.delta, action.count),
|
|
17447
17779
|
pendingKey: undefined,
|
|
17448
17780
|
};
|
|
17781
|
+
case 'moveReflog':
|
|
17782
|
+
return {
|
|
17783
|
+
...state,
|
|
17784
|
+
selectedReflogIndex: clampIndex$1(state.selectedReflogIndex + action.delta, action.count),
|
|
17785
|
+
pendingKey: undefined,
|
|
17786
|
+
};
|
|
17449
17787
|
case 'moveWorktreeListEntry':
|
|
17450
17788
|
return {
|
|
17451
17789
|
...state,
|
|
@@ -17670,6 +18008,31 @@ function applyLogInkAction(state, action) {
|
|
|
17670
18008
|
worktreeDiffOffset: 0,
|
|
17671
18009
|
};
|
|
17672
18010
|
}
|
|
18011
|
+
case 'navigateOpenDiffForCompare': {
|
|
18012
|
+
const next = withPushedView(state, 'diff');
|
|
18013
|
+
return {
|
|
18014
|
+
...next,
|
|
18015
|
+
diffSource: 'compare',
|
|
18016
|
+
compareBase: action.base,
|
|
18017
|
+
compareHead: action.head,
|
|
18018
|
+
// Reset scroll offset so the compare patch always opens at
|
|
18019
|
+
// the top — same reasoning as the stash branch above.
|
|
18020
|
+
diffPreviewOffset: 0,
|
|
18021
|
+
worktreeDiffOffset: 0,
|
|
18022
|
+
};
|
|
18023
|
+
}
|
|
18024
|
+
case 'setCompareBase':
|
|
18025
|
+
return {
|
|
18026
|
+
...state,
|
|
18027
|
+
compareBase: action.value,
|
|
18028
|
+
pendingKey: undefined,
|
|
18029
|
+
};
|
|
18030
|
+
case 'clearCompareBase':
|
|
18031
|
+
return {
|
|
18032
|
+
...state,
|
|
18033
|
+
compareBase: undefined,
|
|
18034
|
+
pendingKey: undefined,
|
|
18035
|
+
};
|
|
17673
18036
|
case 'navigateOpenComposeForFile': {
|
|
17674
18037
|
const next = withPushedView(state, 'status');
|
|
17675
18038
|
return {
|
|
@@ -18025,10 +18388,66 @@ function isStashActionTarget(state) {
|
|
|
18025
18388
|
return (state.activeView === 'stash' && state.focus === 'commits') ||
|
|
18026
18389
|
(state.focus === 'sidebar' && state.sidebarTab === 'stashes');
|
|
18027
18390
|
}
|
|
18391
|
+
/**
|
|
18392
|
+
* Reflog has no sidebar tab — only the dedicated promoted view (#781).
|
|
18393
|
+
* The condition stays as a single helper anyway so navigation handlers
|
|
18394
|
+
* can read it the same way they do for the other promoted views.
|
|
18395
|
+
*/
|
|
18396
|
+
function isReflogActionTarget(state) {
|
|
18397
|
+
return state.activeView === 'reflog' && state.focus === 'commits';
|
|
18398
|
+
}
|
|
18028
18399
|
function isWorktreeActionTarget(state) {
|
|
18029
18400
|
return (state.activeView === 'worktrees' && state.focus === 'commits') ||
|
|
18030
18401
|
(state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
|
|
18031
18402
|
}
|
|
18403
|
+
/**
|
|
18404
|
+
* Compare-flow target views (#779). The `m` mark + Enter-as-compare
|
|
18405
|
+
* overrides only fire on rows that represent a single ref the user
|
|
18406
|
+
* could pass to `git diff <ref>..<ref>` — branches, tags, and history
|
|
18407
|
+
* commits. The reflog view is intentionally excluded because reflog
|
|
18408
|
+
* entries are *moves* of HEAD, not refs a user typically diffs against.
|
|
18409
|
+
*/
|
|
18410
|
+
function isCompareFlowTarget(state) {
|
|
18411
|
+
if (state.focus !== 'commits')
|
|
18412
|
+
return false;
|
|
18413
|
+
return state.activeView === 'branches' ||
|
|
18414
|
+
state.activeView === 'tags' ||
|
|
18415
|
+
state.activeView === 'history';
|
|
18416
|
+
}
|
|
18417
|
+
/**
|
|
18418
|
+
* Resolve the cursored ref for the compare flow (#779). Pulls the
|
|
18419
|
+
* concrete ref + label off context for branches / tags, and reads the
|
|
18420
|
+
* commit row from state for history. Returns undefined when no usable
|
|
18421
|
+
* ref is under the cursor (e.g., the views are empty, or the focus is
|
|
18422
|
+
* on the synthetic "(+) new commit" row).
|
|
18423
|
+
*/
|
|
18424
|
+
function getCursoredCompareRef(state, context) {
|
|
18425
|
+
if (state.activeView === 'branches' && context.branchSelectedShortName) {
|
|
18426
|
+
return {
|
|
18427
|
+
kind: 'branch',
|
|
18428
|
+
ref: context.branchSelectedShortName,
|
|
18429
|
+
label: context.branchSelectedShortName,
|
|
18430
|
+
};
|
|
18431
|
+
}
|
|
18432
|
+
if (state.activeView === 'tags' && context.tagSelectedName) {
|
|
18433
|
+
return {
|
|
18434
|
+
kind: 'tag',
|
|
18435
|
+
ref: context.tagSelectedName,
|
|
18436
|
+
label: context.tagSelectedName,
|
|
18437
|
+
};
|
|
18438
|
+
}
|
|
18439
|
+
if (state.activeView === 'history' && !state.pendingCommitFocused) {
|
|
18440
|
+
const commit = state.filteredCommits[state.selectedIndex];
|
|
18441
|
+
if (commit) {
|
|
18442
|
+
return {
|
|
18443
|
+
kind: 'commit',
|
|
18444
|
+
ref: commit.hash,
|
|
18445
|
+
label: `${commit.shortHash} ${commit.message}`.trim(),
|
|
18446
|
+
};
|
|
18447
|
+
}
|
|
18448
|
+
}
|
|
18449
|
+
return undefined;
|
|
18450
|
+
}
|
|
18032
18451
|
/**
|
|
18033
18452
|
* Item count for the active sidebar tab — used by the generic
|
|
18034
18453
|
* sidebar-Enter handler to decide whether to defer to the per-entity
|
|
@@ -18128,6 +18547,20 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
18128
18547
|
return [action({ type: 'pushView', value: 'pull-request' })];
|
|
18129
18548
|
case 'navigateConflicts':
|
|
18130
18549
|
return [action({ type: 'pushView', value: 'conflicts' })];
|
|
18550
|
+
case 'navigateReflog':
|
|
18551
|
+
return [action({ type: 'pushView', value: 'reflog' })];
|
|
18552
|
+
case 'navigateBisect':
|
|
18553
|
+
return [action({ type: 'pushView', value: 'bisect' })];
|
|
18554
|
+
case 'markForCompare':
|
|
18555
|
+
// Palette context can't reach the cursored ref (filtered branch /
|
|
18556
|
+
// tag lists live in runtime state, not the reducer). Surface a
|
|
18557
|
+
// hint and let the user press `m` directly on the row. The
|
|
18558
|
+
// inline keypress handler further down in this file does the
|
|
18559
|
+
// actual work and has access to the necessary context.
|
|
18560
|
+
return [action({
|
|
18561
|
+
type: 'setStatus',
|
|
18562
|
+
value: 'open branches / tags / history and press m on the cursored ref',
|
|
18563
|
+
})];
|
|
18131
18564
|
case 'navigateBack':
|
|
18132
18565
|
return [action({ type: 'popView' })];
|
|
18133
18566
|
case 'openSelected': {
|
|
@@ -18605,6 +19038,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18605
19038
|
action({ type: 'setStatus', value: 'jumped to conflicts' }),
|
|
18606
19039
|
];
|
|
18607
19040
|
}
|
|
19041
|
+
// `gr` chord: jump to the reflog browser (#781). Recovery view —
|
|
19042
|
+
// chronological list of reflog entries with Enter to drill into the
|
|
19043
|
+
// commit-diff for the entry's hash. Loaded lazily by the runtime.
|
|
19044
|
+
if (state.pendingKey === 'g' && inputValue === 'r') {
|
|
19045
|
+
return [
|
|
19046
|
+
action({ type: 'pushView', value: 'reflog' }),
|
|
19047
|
+
action({ type: 'setStatus', value: 'jumped to reflog' }),
|
|
19048
|
+
];
|
|
19049
|
+
}
|
|
19050
|
+
// `gB` chord: jump to the bisect workflow view (#784). Capital B
|
|
19051
|
+
// disambiguates from `gb` (branches). Always navigates — even when
|
|
19052
|
+
// bisect is inactive — so the user can see the empty-state hint and
|
|
19053
|
+
// know how to start one. The view's surface tells them the next step.
|
|
19054
|
+
if (state.pendingKey === 'g' && inputValue === 'B') {
|
|
19055
|
+
return [
|
|
19056
|
+
action({ type: 'pushView', value: 'bisect' }),
|
|
19057
|
+
action({ type: 'setStatus', value: 'jumped to bisect' }),
|
|
19058
|
+
];
|
|
19059
|
+
}
|
|
18608
19060
|
// `gH` chord: apply the cursored hunk to the index (`git apply
|
|
18609
19061
|
// --cached`). Sibling of bare `H` which targets the worktree.
|
|
18610
19062
|
// Discoverable via the footer hint on diff views and the help
|
|
@@ -18644,6 +19096,29 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18644
19096
|
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
|
|
18645
19097
|
];
|
|
18646
19098
|
}
|
|
19099
|
+
// #784 — bisect view action keys. Scoped to `state.activeView ===
|
|
19100
|
+
// 'bisect' && state.focus === 'commits'` so the single-letter keys
|
|
19101
|
+
// stay free everywhere else. `g` and `b` collide with the global
|
|
19102
|
+
// chord prefix and the `gb` continuation respectively — placed
|
|
19103
|
+
// BEFORE the bare-`g` chord trigger below so a `g` keystroke on
|
|
19104
|
+
// the bisect view marks good rather than entering chord mode. The
|
|
19105
|
+
// user's path back out of bisect is `<` / `esc`, never a chord;
|
|
19106
|
+
// the in-bisect view itself can't navigate elsewhere via `g`-prefix
|
|
19107
|
+
// chords until the user exits with `esc` first.
|
|
19108
|
+
if (state.activeView === 'bisect' && state.focus === 'commits') {
|
|
19109
|
+
if (inputValue === 'g' && state.pendingKey !== 'g') {
|
|
19110
|
+
return [{ type: 'runWorkflowAction', id: 'bisect-good' }];
|
|
19111
|
+
}
|
|
19112
|
+
if (inputValue === 'b' && state.pendingKey !== 'g') {
|
|
19113
|
+
return [{ type: 'runWorkflowAction', id: 'bisect-bad' }];
|
|
19114
|
+
}
|
|
19115
|
+
if (inputValue === 's') {
|
|
19116
|
+
return [{ type: 'runWorkflowAction', id: 'bisect-skip' }];
|
|
19117
|
+
}
|
|
19118
|
+
if (inputValue === 'x') {
|
|
19119
|
+
return [action({ type: 'setPendingConfirmation', value: 'bisect-reset' })];
|
|
19120
|
+
}
|
|
19121
|
+
}
|
|
18647
19122
|
if (inputValue === 'g') {
|
|
18648
19123
|
if (state.pendingKey === 'g') {
|
|
18649
19124
|
return [
|
|
@@ -18911,6 +19386,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18911
19386
|
if (isStashActionTarget(state) && context.stashCount) {
|
|
18912
19387
|
return [action({ type: 'moveStash', delta: -1, count: context.stashCount })];
|
|
18913
19388
|
}
|
|
19389
|
+
if (isReflogActionTarget(state) && context.reflogCount) {
|
|
19390
|
+
return [action({ type: 'moveReflog', delta: -1, count: context.reflogCount })];
|
|
19391
|
+
}
|
|
18914
19392
|
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
18915
19393
|
return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
|
|
18916
19394
|
}
|
|
@@ -18992,6 +19470,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18992
19470
|
if (isStashActionTarget(state) && context.stashCount) {
|
|
18993
19471
|
return [action({ type: 'moveStash', delta: 1, count: context.stashCount })];
|
|
18994
19472
|
}
|
|
19473
|
+
if (isReflogActionTarget(state) && context.reflogCount) {
|
|
19474
|
+
return [action({ type: 'moveReflog', delta: 1, count: context.reflogCount })];
|
|
19475
|
+
}
|
|
18995
19476
|
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
18996
19477
|
return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
|
|
18997
19478
|
}
|
|
@@ -19063,6 +19544,30 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19063
19544
|
action({ type: 'setStatus', value: 'staging worktree changes' }),
|
|
19064
19545
|
];
|
|
19065
19546
|
}
|
|
19547
|
+
// Compare-flow Enter override (#779). When `compareBase` is set and
|
|
19548
|
+
// the user presses Enter on a branch / tag / history commit row, we
|
|
19549
|
+
// open the compare diff (base..head) instead of the row's normal
|
|
19550
|
+
// action (checkout / drill-in / diff). Scoped to compare-flow
|
|
19551
|
+
// targets so non-flow views keep their Enter intact. Runs BEFORE
|
|
19552
|
+
// the per-row Enter handlers below so the override wins, including
|
|
19553
|
+
// before the history-row drill-in.
|
|
19554
|
+
if (key.return && state.compareBase && isCompareFlowTarget(state)) {
|
|
19555
|
+
const head = getCursoredCompareRef(state, context);
|
|
19556
|
+
if (!head) {
|
|
19557
|
+
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
|
|
19558
|
+
}
|
|
19559
|
+
if (head.ref === state.compareBase.ref && head.kind === state.compareBase.kind) {
|
|
19560
|
+
return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one' })];
|
|
19561
|
+
}
|
|
19562
|
+
return [
|
|
19563
|
+
action({
|
|
19564
|
+
type: 'navigateOpenDiffForCompare',
|
|
19565
|
+
base: state.compareBase,
|
|
19566
|
+
head,
|
|
19567
|
+
}),
|
|
19568
|
+
action({ type: 'setStatus', value: `Comparing ${state.compareBase.label} → ${head.label}` }),
|
|
19569
|
+
];
|
|
19570
|
+
}
|
|
19066
19571
|
if (key.return &&
|
|
19067
19572
|
state.activeView === 'history' &&
|
|
19068
19573
|
state.focus === 'commits' &&
|
|
@@ -19079,6 +19584,28 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19079
19584
|
];
|
|
19080
19585
|
}
|
|
19081
19586
|
}
|
|
19587
|
+
// Enter on a reflog row drills into the diff for that entry's hash
|
|
19588
|
+
// (#781). Reuses `navigateOpenDiffForCommit`, which finds the commit
|
|
19589
|
+
// by hash in `state.filteredCommits` first and falls back to
|
|
19590
|
+
// `commitIndex` only when the hash isn't present. Reflog hashes that
|
|
19591
|
+
// exist in the loaded history (the common case) drill in cleanly;
|
|
19592
|
+
// dangling-commit hashes fall back to the index. The `commitIndex`
|
|
19593
|
+
// we pass is best-effort — index in `state.commits` if found, else
|
|
19594
|
+
// `state.selectedIndex` so the cursor stays sane on the diff view.
|
|
19595
|
+
if (key.return &&
|
|
19596
|
+
isReflogActionTarget(state) &&
|
|
19597
|
+
context.reflogSelectedHash) {
|
|
19598
|
+
const sha = context.reflogSelectedHash;
|
|
19599
|
+
const fallbackIndex = state.commits.findIndex((commit) => commit.hash === sha);
|
|
19600
|
+
return [
|
|
19601
|
+
action({
|
|
19602
|
+
type: 'navigateOpenDiffForCommit',
|
|
19603
|
+
sha,
|
|
19604
|
+
commitIndex: fallbackIndex >= 0 ? fallbackIndex : state.selectedIndex,
|
|
19605
|
+
}),
|
|
19606
|
+
action({ type: 'setStatus', value: `viewing diff for ${sha.slice(0, 7)}` }),
|
|
19607
|
+
];
|
|
19608
|
+
}
|
|
19082
19609
|
// Inspector Actions tab: Enter on the cursored action fires its
|
|
19083
19610
|
// associated event (cherry-pick / revert / yank / etc.). Wins over
|
|
19084
19611
|
// the file-list Enter below when the user has [/]-toggled to the
|
|
@@ -19258,6 +19785,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
19258
19785
|
if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
|
|
19259
19786
|
return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
|
|
19260
19787
|
}
|
|
19788
|
+
// `m` marks (or un-marks) the cursored ref as the compare base
|
|
19789
|
+
// (#779). Scoped to compare-flow targets so it doesn't collide with
|
|
19790
|
+
// the `m` PR-merge handler further down. The toggle behavior — `m`
|
|
19791
|
+
// again on the same ref clears the base — gives the user a way to
|
|
19792
|
+
// bail out without remembering a separate cancel key.
|
|
19793
|
+
if (inputValue === 'm' && isCompareFlowTarget(state)) {
|
|
19794
|
+
const ref = getCursoredCompareRef(state, context);
|
|
19795
|
+
if (!ref) {
|
|
19796
|
+
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
|
|
19797
|
+
}
|
|
19798
|
+
if (state.compareBase && state.compareBase.ref === ref.ref && state.compareBase.kind === ref.kind) {
|
|
19799
|
+
return [
|
|
19800
|
+
action({ type: 'clearCompareBase' }),
|
|
19801
|
+
action({ type: 'setStatus', value: `Cleared compare base ${ref.label}` }),
|
|
19802
|
+
];
|
|
19803
|
+
}
|
|
19804
|
+
return [
|
|
19805
|
+
action({ type: 'setCompareBase', value: ref }),
|
|
19806
|
+
action({ type: 'setStatus', value: `Compare base: ${ref.label} — press enter on another ref to diff` }),
|
|
19807
|
+
];
|
|
19808
|
+
}
|
|
19261
19809
|
// Per-view worktree action: `D` removes the worktree AND deletes
|
|
19262
19810
|
// the branch it was tracking (#838). Scoped to the worktrees
|
|
19263
19811
|
// surface so it intercepts BEFORE the global workflow-by-key
|
|
@@ -20636,6 +21184,12 @@ function formatLogInkStatusEmpty({ hasChanges }) {
|
|
|
20636
21184
|
}
|
|
20637
21185
|
return 'Worktree clean. Press gh for history, gb for branches, gz for stash.';
|
|
20638
21186
|
}
|
|
21187
|
+
function formatLogInkReflogEmpty({ filter }) {
|
|
21188
|
+
if (filter.trim()) {
|
|
21189
|
+
return `No reflog entries match filter '${filter}'. Press ctrl+u to clear.`;
|
|
21190
|
+
}
|
|
21191
|
+
return 'No reflog entries. Activity in this repo will appear here over time.';
|
|
21192
|
+
}
|
|
20639
21193
|
function formatLogInkComposeEmpty({ hasStaged }) {
|
|
20640
21194
|
if (hasStaged) {
|
|
20641
21195
|
return undefined;
|
|
@@ -22265,14 +22819,14 @@ function deleteRemoteTag(git, tagName) {
|
|
|
22265
22819
|
return runAction$2(() => git.raw(['push', 'origin', `:${tagName}`]), `Deleted remote tag ${tagName}`);
|
|
22266
22820
|
}
|
|
22267
22821
|
|
|
22268
|
-
const FIELD_SEPARATOR = '\x1f';
|
|
22822
|
+
const FIELD_SEPARATOR$1 = '\x1f';
|
|
22269
22823
|
function parseTagRefs(output) {
|
|
22270
22824
|
return output
|
|
22271
22825
|
.split('\n')
|
|
22272
22826
|
.map((line) => line.trimEnd())
|
|
22273
22827
|
.filter(Boolean)
|
|
22274
22828
|
.map((line) => {
|
|
22275
|
-
const [name, hash, date, subject] = line.split(FIELD_SEPARATOR);
|
|
22829
|
+
const [name, hash, date, subject] = line.split(FIELD_SEPARATOR$1);
|
|
22276
22830
|
return {
|
|
22277
22831
|
name,
|
|
22278
22832
|
hash,
|
|
@@ -22284,7 +22838,7 @@ function parseTagRefs(output) {
|
|
|
22284
22838
|
async function getTagOverview(git) {
|
|
22285
22839
|
const output = await git.raw([
|
|
22286
22840
|
'for-each-ref',
|
|
22287
|
-
`--format=%(refname:short)${FIELD_SEPARATOR}%(objectname:short)${FIELD_SEPARATOR}%(creatordate:short)${FIELD_SEPARATOR}%(subject)`,
|
|
22841
|
+
`--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
|
|
22288
22842
|
'--sort=-creatordate',
|
|
22289
22843
|
'refs/tags',
|
|
22290
22844
|
]);
|
|
@@ -24543,6 +25097,231 @@ function formatPullRequestStateLine(pr) {
|
|
|
24543
25097
|
return parts.join(' · ');
|
|
24544
25098
|
}
|
|
24545
25099
|
|
|
25100
|
+
const EMPTY_STATUS = {
|
|
25101
|
+
active: false,
|
|
25102
|
+
currentSha: '',
|
|
25103
|
+
log: [],
|
|
25104
|
+
};
|
|
25105
|
+
async function bisectIsActive(git) {
|
|
25106
|
+
try {
|
|
25107
|
+
const path = (await git.revparse(['--git-path', 'BISECT_LOG'])).trim();
|
|
25108
|
+
return path.length > 0 && existsSync(path);
|
|
25109
|
+
}
|
|
25110
|
+
catch {
|
|
25111
|
+
return false;
|
|
25112
|
+
}
|
|
25113
|
+
}
|
|
25114
|
+
/**
|
|
25115
|
+
* Parse the output of `git bisect log` into structured entries. Each
|
|
25116
|
+
* entry corresponds to one user decision (start / good / bad / skip)
|
|
25117
|
+
* or the "# bad: [<sha>] <subject>" comment lines git emits for
|
|
25118
|
+
* traceability. Comment lines without a recognized prefix are dropped
|
|
25119
|
+
* — they're informational headers ("# status: ..."), not actions
|
|
25120
|
+
* the user took.
|
|
25121
|
+
*/
|
|
25122
|
+
function parseBisectLog(output) {
|
|
25123
|
+
const entries = [];
|
|
25124
|
+
for (const rawLine of output.split('\n')) {
|
|
25125
|
+
const line = rawLine.trimEnd();
|
|
25126
|
+
if (!line)
|
|
25127
|
+
continue;
|
|
25128
|
+
// Comment rows: "# good: [sha] subject" / "# bad: [sha] subject" /
|
|
25129
|
+
// "# first bad commit: ..." / "# status: ...". The first two carry
|
|
25130
|
+
// the most user-relevant info (which commits were marked) so we
|
|
25131
|
+
// promote them to typed entries; the rest fall through as raw
|
|
25132
|
+
// lines tagged 'unknown' so the renderer can dim them or hide
|
|
25133
|
+
// entirely.
|
|
25134
|
+
if (line.startsWith('#')) {
|
|
25135
|
+
const commentMatch = line.match(/^#\s+(good|bad|skip):\s+\[([^\]]+)\]\s*(.*)$/);
|
|
25136
|
+
if (commentMatch) {
|
|
25137
|
+
entries.push({
|
|
25138
|
+
kind: commentMatch[1],
|
|
25139
|
+
sha: commentMatch[2],
|
|
25140
|
+
subject: commentMatch[3] || undefined,
|
|
25141
|
+
raw: line,
|
|
25142
|
+
});
|
|
25143
|
+
continue;
|
|
25144
|
+
}
|
|
25145
|
+
entries.push({ kind: 'unknown', raw: line });
|
|
25146
|
+
continue;
|
|
25147
|
+
}
|
|
25148
|
+
// Command rows: "git bisect start", "git bisect good <sha>",
|
|
25149
|
+
// "git bisect bad <sha>", "git bisect skip <sha>".
|
|
25150
|
+
const commandMatch = line.match(/^git\s+bisect\s+(start|good|bad|skip)\s*(.*)$/);
|
|
25151
|
+
if (commandMatch) {
|
|
25152
|
+
const sha = commandMatch[2]?.trim().split(/\s+/)[0] || undefined;
|
|
25153
|
+
entries.push({
|
|
25154
|
+
kind: commandMatch[1],
|
|
25155
|
+
sha: sha || undefined,
|
|
25156
|
+
raw: line,
|
|
25157
|
+
});
|
|
25158
|
+
continue;
|
|
25159
|
+
}
|
|
25160
|
+
entries.push({ kind: 'unknown', raw: line });
|
|
25161
|
+
}
|
|
25162
|
+
return entries;
|
|
25163
|
+
}
|
|
25164
|
+
/**
|
|
25165
|
+
* Load the live bisect status. Best-effort — when bisect isn't
|
|
25166
|
+
* active the empty-status sentinel returns immediately so callers
|
|
25167
|
+
* don't pay for a `git bisect log` round-trip on every refresh.
|
|
25168
|
+
*/
|
|
25169
|
+
async function getBisectStatus(git) {
|
|
25170
|
+
if (!(await bisectIsActive(git))) {
|
|
25171
|
+
return EMPTY_STATUS;
|
|
25172
|
+
}
|
|
25173
|
+
let log = [];
|
|
25174
|
+
try {
|
|
25175
|
+
const output = await git.raw(['bisect', 'log']);
|
|
25176
|
+
log = parseBisectLog(output);
|
|
25177
|
+
}
|
|
25178
|
+
catch {
|
|
25179
|
+
// bisect log can fail on a freshly-started bisect with no decisions.
|
|
25180
|
+
// Treat the absence of a parseable log as "active but empty" rather
|
|
25181
|
+
// than non-active, so the surface still routes to the bisect view
|
|
25182
|
+
// and the user can see the badge.
|
|
25183
|
+
log = [];
|
|
25184
|
+
}
|
|
25185
|
+
let currentSha = '';
|
|
25186
|
+
try {
|
|
25187
|
+
currentSha = (await git.revparse(['HEAD'])).trim();
|
|
25188
|
+
}
|
|
25189
|
+
catch {
|
|
25190
|
+
currentSha = '';
|
|
25191
|
+
}
|
|
25192
|
+
return { active: true, currentSha, log };
|
|
25193
|
+
}
|
|
25194
|
+
|
|
25195
|
+
/**
|
|
25196
|
+
* Thin wrappers around `git bisect <verb>` for the TUI's in-bisect
|
|
25197
|
+
* action keys (#784). Each returns the raw stdout so the surface can
|
|
25198
|
+
* surface git's own "Bisecting: N revisions left to test after this
|
|
25199
|
+
* (roughly K steps)" hint as a status message — that wording is the
|
|
25200
|
+
* single most useful piece of feedback git emits during bisect, and
|
|
25201
|
+
* mirroring it keeps the TUI's status line authoritative.
|
|
25202
|
+
*
|
|
25203
|
+
* No try/catch here — git itself returns non-zero on user errors
|
|
25204
|
+
* (already-bisecting, no good ref, etc.) and `simple-git` surfaces
|
|
25205
|
+
* those as rejections. The runtime catches them and routes to the
|
|
25206
|
+
* status line.
|
|
25207
|
+
*/
|
|
25208
|
+
async function bisectGood(git, ref) {
|
|
25209
|
+
const args = ['bisect', 'good'];
|
|
25210
|
+
return git.raw(args);
|
|
25211
|
+
}
|
|
25212
|
+
async function bisectBad(git, ref) {
|
|
25213
|
+
const args = ['bisect', 'bad'];
|
|
25214
|
+
return git.raw(args);
|
|
25215
|
+
}
|
|
25216
|
+
async function bisectSkip(git, ref) {
|
|
25217
|
+
const args = ['bisect', 'skip'];
|
|
25218
|
+
return git.raw(args);
|
|
25219
|
+
}
|
|
25220
|
+
async function bisectReset(git) {
|
|
25221
|
+
return git.raw(['bisect', 'reset']);
|
|
25222
|
+
}
|
|
25223
|
+
/**
|
|
25224
|
+
* Pull the user-facing remaining-revisions hint out of `git bisect`
|
|
25225
|
+
* stdout. Looks for the canonical line:
|
|
25226
|
+
*
|
|
25227
|
+
* `Bisecting: N revisions left to test after this (roughly K steps)`
|
|
25228
|
+
*
|
|
25229
|
+
* Returns undefined when the line isn't present (e.g. the run
|
|
25230
|
+
* finished and git emitted a "<sha> is the first bad commit" line
|
|
25231
|
+
* instead). Callers fall back to an empty status update in that case.
|
|
25232
|
+
*/
|
|
25233
|
+
function extractBisectRemainingHint(stdout) {
|
|
25234
|
+
for (const line of stdout.split('\n').reverse()) {
|
|
25235
|
+
const trimmed = line.trim();
|
|
25236
|
+
if (trimmed.startsWith('Bisecting:'))
|
|
25237
|
+
return trimmed;
|
|
25238
|
+
if (trimmed.includes('is the first bad commit'))
|
|
25239
|
+
return trimmed;
|
|
25240
|
+
}
|
|
25241
|
+
return undefined;
|
|
25242
|
+
}
|
|
25243
|
+
|
|
25244
|
+
/**
|
|
25245
|
+
* Compare two refs (branches / tags / commits) and return the unified
|
|
25246
|
+
* patch as line-split string output (#779).
|
|
25247
|
+
*
|
|
25248
|
+
* Mirrors the stash-diff loader's contract — emits `string[]` so the
|
|
25249
|
+
* existing diff surface can render the lines through its standard
|
|
25250
|
+
* +/-/@@ coloring path. Two-dot syntax (`base..head`) gives the
|
|
25251
|
+
* "what changed on head, relative to base" view that's natural for
|
|
25252
|
+
* branch reviews and pre-merge sanity checks.
|
|
25253
|
+
*
|
|
25254
|
+
* Defensive about input — both refs are passed as-is to git, so the
|
|
25255
|
+
* caller is responsible for providing a git-resolvable form
|
|
25256
|
+
* (branch shortName, tag name, or commit hash). On any git error
|
|
25257
|
+
* (unknown ref, etc.) the runtime's `safe()` wrapper at the call
|
|
25258
|
+
* site catches the throw and the surface falls back to a "no diff"
|
|
25259
|
+
* hint.
|
|
25260
|
+
*/
|
|
25261
|
+
async function getCompareDiff(git, base, head) {
|
|
25262
|
+
return (await git.raw(['diff', `${base}..${head}`]))
|
|
25263
|
+
.split('\n')
|
|
25264
|
+
.map((line) => line.replace(/\r$/, ''));
|
|
25265
|
+
}
|
|
25266
|
+
|
|
25267
|
+
const FIELD_SEPARATOR = '\x1f';
|
|
25268
|
+
/**
|
|
25269
|
+
* Default fetch limit. 200 entries is enough to span weeks of normal
|
|
25270
|
+
* activity for an active repo while keeping the load fast — `git reflog`
|
|
25271
|
+
* is local-only so even 1000+ entries is sub-second, but 200 keeps the
|
|
25272
|
+
* rendered list bounded for terminals.
|
|
25273
|
+
*/
|
|
25274
|
+
const DEFAULT_REFLOG_LIMIT = 200;
|
|
25275
|
+
function parseReflogOverview(output) {
|
|
25276
|
+
return output
|
|
25277
|
+
.split('\n')
|
|
25278
|
+
.map((line) => line.trimEnd())
|
|
25279
|
+
.filter(Boolean)
|
|
25280
|
+
.map((line) => {
|
|
25281
|
+
const [selector, hash, relativeDate, subject] = line.split(FIELD_SEPARATOR);
|
|
25282
|
+
return {
|
|
25283
|
+
selector: selector || '',
|
|
25284
|
+
hash: hash || '',
|
|
25285
|
+
relativeDate: relativeDate || '',
|
|
25286
|
+
subject: subject || '',
|
|
25287
|
+
};
|
|
25288
|
+
});
|
|
25289
|
+
}
|
|
25290
|
+
async function getReflogOverview(git, limit = DEFAULT_REFLOG_LIMIT) {
|
|
25291
|
+
const output = await git.raw([
|
|
25292
|
+
'reflog',
|
|
25293
|
+
`--max-count=${limit}`,
|
|
25294
|
+
`--pretty=format:%gd${FIELD_SEPARATOR}%h${FIELD_SEPARATOR}%cr${FIELD_SEPARATOR}%gs`,
|
|
25295
|
+
]);
|
|
25296
|
+
return {
|
|
25297
|
+
entries: parseReflogOverview(output),
|
|
25298
|
+
};
|
|
25299
|
+
}
|
|
25300
|
+
/**
|
|
25301
|
+
* Pull the action prefix off a reflog subject. Reflog subjects follow
|
|
25302
|
+
* a `<verb>[ qualifier]: <message>` pattern emitted by git itself —
|
|
25303
|
+
* "commit: ...", "commit (amend): ...", "checkout: moving from main
|
|
25304
|
+
* to feature", "merge feature: ...", "reset: moving to HEAD~1", etc.
|
|
25305
|
+
*
|
|
25306
|
+
* For display we want the verb (and any parenthetical qualifier) on
|
|
25307
|
+
* its own so the view can render a fixed-width `action` column and
|
|
25308
|
+
* keep the rest of the message left-aligned.
|
|
25309
|
+
*
|
|
25310
|
+
* Defensive: if the subject has no colon, the whole string is treated
|
|
25311
|
+
* as the action and the message is empty. This keeps the renderer
|
|
25312
|
+
* from crashing on a malformed entry.
|
|
25313
|
+
*/
|
|
25314
|
+
function splitReflogSubject(subject) {
|
|
25315
|
+
const colonIndex = subject.indexOf(':');
|
|
25316
|
+
if (colonIndex === -1) {
|
|
25317
|
+
return { action: subject.trim(), message: '' };
|
|
25318
|
+
}
|
|
25319
|
+
return {
|
|
25320
|
+
action: subject.slice(0, colonIndex).trim(),
|
|
25321
|
+
message: subject.slice(colonIndex + 1).trim(),
|
|
25322
|
+
};
|
|
25323
|
+
}
|
|
25324
|
+
|
|
24546
25325
|
function sectionLines(title, diff) {
|
|
24547
25326
|
const lines = diff.split('\n').map((line) => line.trimEnd());
|
|
24548
25327
|
return [
|
|
@@ -24655,7 +25434,7 @@ async function safe(promise) {
|
|
|
24655
25434
|
}
|
|
24656
25435
|
}
|
|
24657
25436
|
async function loadLogInkContext(git) {
|
|
24658
|
-
const [branches, pullRequest, tags, worktree, stashes, worktreeList, operation, provider] = await Promise.all([
|
|
25437
|
+
const [branches, pullRequest, tags, worktree, stashes, worktreeList, operation, provider, reflog, bisect] = await Promise.all([
|
|
24659
25438
|
safe(getBranchOverview(git)),
|
|
24660
25439
|
safe(getPullRequestOverview(git)),
|
|
24661
25440
|
safe(getTagOverview(git)),
|
|
@@ -24664,12 +25443,16 @@ async function loadLogInkContext(git) {
|
|
|
24664
25443
|
safe(getWorktreeListOverview(git)),
|
|
24665
25444
|
safe(getGitOperationOverview(git)),
|
|
24666
25445
|
safe(getProviderOverview(git)),
|
|
25446
|
+
safe(getReflogOverview(git)),
|
|
25447
|
+
safe(getBisectStatus(git)),
|
|
24667
25448
|
]);
|
|
24668
25449
|
return {
|
|
25450
|
+
bisect,
|
|
24669
25451
|
branches,
|
|
24670
25452
|
operation,
|
|
24671
25453
|
provider,
|
|
24672
25454
|
pullRequest,
|
|
25455
|
+
reflog,
|
|
24673
25456
|
stashes,
|
|
24674
25457
|
tags,
|
|
24675
25458
|
worktree,
|
|
@@ -24695,6 +25478,14 @@ function loadLogInkContextEntries(git) {
|
|
|
24695
25478
|
key: 'tags',
|
|
24696
25479
|
load: () => safe(getTagOverview(git)),
|
|
24697
25480
|
},
|
|
25481
|
+
{
|
|
25482
|
+
key: 'reflog',
|
|
25483
|
+
load: () => safe(getReflogOverview(git)),
|
|
25484
|
+
},
|
|
25485
|
+
{
|
|
25486
|
+
key: 'bisect',
|
|
25487
|
+
load: () => safe(getBisectStatus(git)),
|
|
25488
|
+
},
|
|
24698
25489
|
{
|
|
24699
25490
|
key: 'worktree',
|
|
24700
25491
|
load: () => safe(getWorktreeOverview(git)),
|
|
@@ -25083,6 +25874,10 @@ function LogInkApp(deps) {
|
|
|
25083
25874
|
// colors match the commit-diff path.
|
|
25084
25875
|
const [stashDiffLines, setStashDiffLines] = React.useState(undefined);
|
|
25085
25876
|
const [stashDiffLoading, setStashDiffLoading] = React.useState(false);
|
|
25877
|
+
// #779 — compare-two-refs diff state. Loaded lazily when the diff
|
|
25878
|
+
// view becomes active with `diffSource === 'compare'`.
|
|
25879
|
+
const [compareDiffLines, setCompareDiffLines] = React.useState(undefined);
|
|
25880
|
+
const [compareDiffLoading, setCompareDiffLoading] = React.useState(false);
|
|
25086
25881
|
const [hasMoreCommits, setHasMoreCommits] = React.useState(() => (Boolean(logArgv?.interactive && !logArgv.limit) &&
|
|
25087
25882
|
getCommitRows(rows).length >= LOG_INTERACTIVE_DEFAULT_LIMIT));
|
|
25088
25883
|
const [loadingMoreCommits, setLoadingMoreCommits] = React.useState(false);
|
|
@@ -25177,6 +25972,12 @@ function LogInkApp(deps) {
|
|
|
25177
25972
|
return all;
|
|
25178
25973
|
return all.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || ''], state.filter));
|
|
25179
25974
|
}, [context.worktreeList?.worktrees, state.filter]);
|
|
25975
|
+
const filteredReflogList = React.useMemo(() => {
|
|
25976
|
+
const all = context.reflog?.entries || [];
|
|
25977
|
+
if (!state.filter)
|
|
25978
|
+
return all;
|
|
25979
|
+
return all.filter((entry) => matchesPromotedFilter([entry.selector, entry.hash, entry.relativeDate, entry.subject], state.filter));
|
|
25980
|
+
}, [context.reflog?.entries, state.filter]);
|
|
25180
25981
|
const dispatch = React.useCallback((action) => {
|
|
25181
25982
|
setState((current) => applyLogInkAction(current, action));
|
|
25182
25983
|
}, []);
|
|
@@ -25387,6 +26188,41 @@ function LogInkApp(deps) {
|
|
|
25387
26188
|
})();
|
|
25388
26189
|
return () => { active = false; };
|
|
25389
26190
|
}, [git, state.activeView, state.diffSource, state.stashDiffRef]);
|
|
26191
|
+
// #779 — load `git diff <base>..<head>` once the diff view becomes
|
|
26192
|
+
// active with diffSource='compare'. Mirrors the stash loader's
|
|
26193
|
+
// shape; the surface renders the lines via the same +/-/@@ coloring
|
|
26194
|
+
// path. On unknown ref / git error, `safe()` swallows and the
|
|
26195
|
+
// surface falls back to a "no diff" hint.
|
|
26196
|
+
const compareBaseRef = state.compareBase?.ref;
|
|
26197
|
+
const compareHeadRef = state.compareHead?.ref;
|
|
26198
|
+
React.useEffect(() => {
|
|
26199
|
+
if (state.activeView !== 'diff' ||
|
|
26200
|
+
state.diffSource !== 'compare' ||
|
|
26201
|
+
!compareBaseRef ||
|
|
26202
|
+
!compareHeadRef) {
|
|
26203
|
+
return;
|
|
26204
|
+
}
|
|
26205
|
+
let active = true;
|
|
26206
|
+
setCompareDiffLoading(true);
|
|
26207
|
+
void (async () => {
|
|
26208
|
+
const lines = await safe(getCompareDiff(git, compareBaseRef, compareHeadRef));
|
|
26209
|
+
if (active) {
|
|
26210
|
+
setCompareDiffLines(lines || []);
|
|
26211
|
+
setCompareDiffLoading(false);
|
|
26212
|
+
}
|
|
26213
|
+
})();
|
|
26214
|
+
return () => { active = false; };
|
|
26215
|
+
}, [git, state.activeView, state.diffSource, compareBaseRef, compareHeadRef]);
|
|
26216
|
+
// Reset compare-diff state whenever the diff view exits. Without
|
|
26217
|
+
// this, opening a new compare immediately after closing one would
|
|
26218
|
+
// briefly show the previous comparison's lines while the new
|
|
26219
|
+
// loader runs.
|
|
26220
|
+
React.useEffect(() => {
|
|
26221
|
+
if (state.diffSource !== 'compare') {
|
|
26222
|
+
setCompareDiffLines(undefined);
|
|
26223
|
+
setCompareDiffLoading(false);
|
|
26224
|
+
}
|
|
26225
|
+
}, [state.diffSource]);
|
|
25390
26226
|
React.useEffect(() => {
|
|
25391
26227
|
let active = true;
|
|
25392
26228
|
async function loadWorktreeHunks() {
|
|
@@ -25897,6 +26733,50 @@ function LogInkApp(deps) {
|
|
|
25897
26733
|
return { ok: false, message: 'No stash selected' };
|
|
25898
26734
|
return popStash(git, stash);
|
|
25899
26735
|
},
|
|
26736
|
+
'bisect-good': async () => {
|
|
26737
|
+
if (!context.bisect?.active)
|
|
26738
|
+
return { ok: false, message: 'No bisect in progress' };
|
|
26739
|
+
try {
|
|
26740
|
+
const stdout = await bisectGood(git);
|
|
26741
|
+
return { ok: true, message: extractBisectRemainingHint(stdout) || 'Marked good' };
|
|
26742
|
+
}
|
|
26743
|
+
catch (error) {
|
|
26744
|
+
return { ok: false, message: `Bisect good failed: ${error.message}` };
|
|
26745
|
+
}
|
|
26746
|
+
},
|
|
26747
|
+
'bisect-bad': async () => {
|
|
26748
|
+
if (!context.bisect?.active)
|
|
26749
|
+
return { ok: false, message: 'No bisect in progress' };
|
|
26750
|
+
try {
|
|
26751
|
+
const stdout = await bisectBad(git);
|
|
26752
|
+
return { ok: true, message: extractBisectRemainingHint(stdout) || 'Marked bad' };
|
|
26753
|
+
}
|
|
26754
|
+
catch (error) {
|
|
26755
|
+
return { ok: false, message: `Bisect bad failed: ${error.message}` };
|
|
26756
|
+
}
|
|
26757
|
+
},
|
|
26758
|
+
'bisect-skip': async () => {
|
|
26759
|
+
if (!context.bisect?.active)
|
|
26760
|
+
return { ok: false, message: 'No bisect in progress' };
|
|
26761
|
+
try {
|
|
26762
|
+
const stdout = await bisectSkip(git);
|
|
26763
|
+
return { ok: true, message: extractBisectRemainingHint(stdout) || 'Skipped' };
|
|
26764
|
+
}
|
|
26765
|
+
catch (error) {
|
|
26766
|
+
return { ok: false, message: `Bisect skip failed: ${error.message}` };
|
|
26767
|
+
}
|
|
26768
|
+
},
|
|
26769
|
+
'bisect-reset': async () => {
|
|
26770
|
+
if (!context.bisect?.active)
|
|
26771
|
+
return { ok: false, message: 'No bisect in progress' };
|
|
26772
|
+
try {
|
|
26773
|
+
await bisectReset(git);
|
|
26774
|
+
return { ok: true, message: 'Bisect reset' };
|
|
26775
|
+
}
|
|
26776
|
+
catch (error) {
|
|
26777
|
+
return { ok: false, message: `Bisect reset failed: ${error.message}` };
|
|
26778
|
+
}
|
|
26779
|
+
},
|
|
25900
26780
|
'checkout-file-from-stash': async () => {
|
|
25901
26781
|
const path = payload?.trim();
|
|
25902
26782
|
const ref = state.stashDiffRef;
|
|
@@ -26569,9 +27449,13 @@ function LogInkApp(deps) {
|
|
|
26569
27449
|
// perf pass) — reading them here is O(1) instead of O(branches +
|
|
26570
27450
|
// tags + stashes + worktrees) per keystroke.
|
|
26571
27451
|
const branchVisibleCount = filteredBranchList.length;
|
|
27452
|
+
const branchSelectedShortName = filteredBranchList[Math.min(state.selectedBranchIndex, Math.max(0, filteredBranchList.length - 1))]?.shortName;
|
|
26572
27453
|
const tagVisibleCount = filteredTagList.length;
|
|
27454
|
+
const tagSelectedName = filteredTagList[Math.min(state.selectedTagIndex, Math.max(0, filteredTagList.length - 1))]?.name;
|
|
26573
27455
|
const stashVisibleCount = filteredStashList.length;
|
|
26574
27456
|
const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
|
|
27457
|
+
const reflogVisibleCount = filteredReflogList.length;
|
|
27458
|
+
const reflogSelectedHash = filteredReflogList[Math.min(state.selectedReflogIndex, Math.max(0, filteredReflogList.length - 1))]?.hash;
|
|
26575
27459
|
const worktreeVisibleCount = filteredWorktreeList.length;
|
|
26576
27460
|
// When the diff view is showing a stash patch, swap the previewLineCount
|
|
26577
27461
|
// to the stash diff length so the existing pageDetailPreview path
|
|
@@ -26596,8 +27480,12 @@ function LogInkApp(deps) {
|
|
|
26596
27480
|
worktreeHunkOffsets: worktreeDiff?.hunkOffsets,
|
|
26597
27481
|
commitDiffHunkOffsets,
|
|
26598
27482
|
branchCount: branchVisibleCount,
|
|
27483
|
+
branchSelectedShortName,
|
|
26599
27484
|
tagCount: tagVisibleCount,
|
|
27485
|
+
tagSelectedName,
|
|
26600
27486
|
stashCount: stashVisibleCount,
|
|
27487
|
+
reflogCount: reflogVisibleCount,
|
|
27488
|
+
reflogSelectedHash,
|
|
26601
27489
|
stashSelectedRef,
|
|
26602
27490
|
stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
|
|
26603
27491
|
stashDiffSelectedPath,
|
|
@@ -26703,12 +27591,15 @@ function LogInkApp(deps) {
|
|
|
26703
27591
|
if (showOnboarding) {
|
|
26704
27592
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
26705
27593
|
}
|
|
26706
|
-
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));
|
|
27594
|
+
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));
|
|
26707
27595
|
}
|
|
26708
27596
|
function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
|
|
26709
27597
|
const { Box, Text } = components;
|
|
26710
27598
|
const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
|
|
26711
|
-
|
|
27599
|
+
// #784 — surface bisect-in-progress in the title bar so users entering
|
|
27600
|
+
// the TUI mid-bisect see it immediately, before they navigate to gB.
|
|
27601
|
+
const dirtyBase = context.branches?.dirty ? 'dirty' : 'clean';
|
|
27602
|
+
const dirty = context.bisect?.active ? `${dirtyBase} · BISECTING` : dirtyBase;
|
|
26712
27603
|
const repo = context.provider?.repository.owner && context.provider.repository.name
|
|
26713
27604
|
? `${context.provider.repository.owner}/${context.provider.repository.name}`
|
|
26714
27605
|
: 'local repository';
|
|
@@ -26963,12 +27854,12 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
|
|
|
26963
27854
|
: []),
|
|
26964
27855
|
];
|
|
26965
27856
|
}
|
|
26966
|
-
function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
|
|
27857
|
+
function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
|
|
26967
27858
|
if (state.activeView === 'status') {
|
|
26968
27859
|
return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
26969
27860
|
}
|
|
26970
27861
|
if (state.activeView === 'diff') {
|
|
26971
|
-
return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme);
|
|
27862
|
+
return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme);
|
|
26972
27863
|
}
|
|
26973
27864
|
if (state.activeView === 'compose') {
|
|
26974
27865
|
return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
@@ -26979,6 +27870,12 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
26979
27870
|
if (state.activeView === 'tags') {
|
|
26980
27871
|
return renderTagsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
26981
27872
|
}
|
|
27873
|
+
if (state.activeView === 'reflog') {
|
|
27874
|
+
return renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
27875
|
+
}
|
|
27876
|
+
if (state.activeView === 'bisect') {
|
|
27877
|
+
return renderBisectSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
27878
|
+
}
|
|
26982
27879
|
if (state.activeView === 'stash') {
|
|
26983
27880
|
return renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
26984
27881
|
}
|
|
@@ -27606,6 +28503,150 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
27606
28503
|
width,
|
|
27607
28504
|
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Tags', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
|
|
27608
28505
|
}
|
|
28506
|
+
/**
|
|
28507
|
+
* Promoted reflog browser (#781). Mirrors `renderTagsSurface` visually
|
|
28508
|
+
* — same header / filter affordance / footer hint conventions — but
|
|
28509
|
+
* lays out four columns per row: relative date, action prefix, short
|
|
28510
|
+
* hash, and message. Filtering matches against all four (so typing
|
|
28511
|
+
* "checkout" narrows to checkout entries, "abc" narrows to a hash).
|
|
28512
|
+
*
|
|
28513
|
+
* Per-row layout uses fixed column widths derived from the visible
|
|
28514
|
+
* window so short-action rows don't leave a wide gutter and long
|
|
28515
|
+
* actions don't push the message off-screen. The cap mirrors the
|
|
28516
|
+
* tags surface's name-column treatment.
|
|
28517
|
+
*/
|
|
28518
|
+
function renderReflogSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
28519
|
+
const { Box, Text } = components;
|
|
28520
|
+
const focused = state.focus === 'commits';
|
|
28521
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'reflog');
|
|
28522
|
+
const allEntries = context.reflog?.entries || [];
|
|
28523
|
+
const entries = state.filter
|
|
28524
|
+
? allEntries.filter((entry) => matchesPromotedFilter([entry.selector, entry.hash, entry.relativeDate, entry.subject], state.filter))
|
|
28525
|
+
: allEntries;
|
|
28526
|
+
const selected = Math.max(0, Math.min(state.selectedReflogIndex, Math.max(0, entries.length - 1)));
|
|
28527
|
+
const listRows = Math.max(4, bodyRows - 4);
|
|
28528
|
+
const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
|
|
28529
|
+
const visible = entries.slice(startIndex, startIndex + listRows);
|
|
28530
|
+
const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
|
|
28531
|
+
const headerRight = loading
|
|
28532
|
+
? 'loading reflog'
|
|
28533
|
+
: `${entries.length}/${allEntries.length} entries${filterLabel}`;
|
|
28534
|
+
const emptyLabel = formatLogInkReflogEmpty({ filter: state.filter });
|
|
28535
|
+
const loadingLabel = formatLogInkLoading({ resource: 'reflog' });
|
|
28536
|
+
// Column widths derived from the visible window. The hash column is
|
|
28537
|
+
// fixed (short SHA is always 7 chars) and the date column caps so
|
|
28538
|
+
// "X minutes ago" / "Y hours ago" stays readable without dominating
|
|
28539
|
+
// the row. Action column scales to the longest visible action so
|
|
28540
|
+
// commit / checkout / merge align cleanly.
|
|
28541
|
+
const splitVisible = visible.map((entry) => ({
|
|
28542
|
+
entry,
|
|
28543
|
+
parts: splitReflogSubject(entry.subject),
|
|
28544
|
+
}));
|
|
28545
|
+
const dateColWidth = splitVisible.length === 0
|
|
28546
|
+
? 16
|
|
28547
|
+
: Math.min(20, Math.max(6, ...splitVisible.map(({ entry }) => entry.relativeDate.length)));
|
|
28548
|
+
const actionColWidth = splitVisible.length === 0
|
|
28549
|
+
? 12
|
|
28550
|
+
: Math.min(24, Math.max(6, ...splitVisible.map(({ parts }) => parts.action.length)));
|
|
28551
|
+
const hashColWidth = 8;
|
|
28552
|
+
const lines = loading
|
|
28553
|
+
? [h(Text, { key: 'reflog-loading', dimColor: true }, loadingLabel)]
|
|
28554
|
+
: entries.length === 0
|
|
28555
|
+
? [h(Text, { key: 'reflog-empty', dimColor: true }, emptyLabel)]
|
|
28556
|
+
: splitVisible.map(({ entry, parts }, offset) => {
|
|
28557
|
+
const index = startIndex + offset;
|
|
28558
|
+
const isSelected = index === selected;
|
|
28559
|
+
const cursor = isSelected ? '>' : ' ';
|
|
28560
|
+
const datePadded = truncate$1(entry.relativeDate, dateColWidth).padEnd(dateColWidth);
|
|
28561
|
+
const actionPadded = truncate$1(parts.action, actionColWidth).padEnd(actionColWidth);
|
|
28562
|
+
const hashPadded = truncate$1(entry.hash, hashColWidth).padEnd(hashColWidth);
|
|
28563
|
+
const message = parts.message || entry.subject;
|
|
28564
|
+
const lineText = truncate$1(`${cursor} ${datePadded} ${actionPadded} ${hashPadded} ${message}`, Math.max(20, width - 4));
|
|
28565
|
+
return h(Text, {
|
|
28566
|
+
key: `reflog-${index}`,
|
|
28567
|
+
bold: isSelected,
|
|
28568
|
+
dimColor: !isSelected,
|
|
28569
|
+
}, lineText);
|
|
28570
|
+
});
|
|
28571
|
+
return h(Box, {
|
|
28572
|
+
borderColor: focusBorderColor(theme, focused),
|
|
28573
|
+
borderStyle: theme.borderStyle,
|
|
28574
|
+
flexDirection: 'column',
|
|
28575
|
+
flexShrink: 0,
|
|
28576
|
+
paddingX: 1,
|
|
28577
|
+
width,
|
|
28578
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Reflog', focused)), h(Text, { dimColor: true }, headerRight)), ...renderPromotedFilterAffordance(h, Text, state, theme), ...lines);
|
|
28579
|
+
}
|
|
28580
|
+
/**
|
|
28581
|
+
* Bisect workflow surface (#784). Shows the current candidate commit
|
|
28582
|
+
* (HEAD), a parsed view of recent decisions from `git bisect log`, and
|
|
28583
|
+
* the four action keys (g good, b bad, s skip, x reset).
|
|
28584
|
+
*
|
|
28585
|
+
* When bisect is inactive, the surface renders an empty-state hint
|
|
28586
|
+
* pointing the user at the CLI to start one. The view stays
|
|
28587
|
+
* navigable so the user can read the documentation before starting
|
|
28588
|
+
* — they can't break anything from here.
|
|
28589
|
+
*/
|
|
28590
|
+
function renderBisectSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
28591
|
+
const { Box, Text } = components;
|
|
28592
|
+
const focused = state.focus === 'commits';
|
|
28593
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
|
|
28594
|
+
const bisect = context.bisect;
|
|
28595
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
28596
|
+
const lines = [];
|
|
28597
|
+
if (loading) {
|
|
28598
|
+
lines.push(h(Text, { key: 'bisect-loading', dimColor: true }, truncate$1('· Loading bisect status…', width - 4)));
|
|
28599
|
+
}
|
|
28600
|
+
else if (!bisect?.active) {
|
|
28601
|
+
// No bisect active. Surface the CLI on-ramp — starting from the
|
|
28602
|
+
// TUI is intentionally out of scope for this PR (#784 follow-up).
|
|
28603
|
+
// The user is expected to enter via `git bisect start <bad> <good>`
|
|
28604
|
+
// and re-open `coco ui`; once bisect is active this view drives
|
|
28605
|
+
// the rest.
|
|
28606
|
+
lines.push(h(Text, { key: 'bisect-empty-1', bold: true }, truncate$1('No bisect in progress.', width - 4)));
|
|
28607
|
+
lines.push(h(Text, { key: 'bisect-empty-2' }, ''));
|
|
28608
|
+
lines.push(h(Text, { key: 'bisect-empty-3' }, truncate$1('Start one from the shell with:', width - 4)));
|
|
28609
|
+
lines.push(h(Text, { key: 'bisect-empty-4', color: accent }, truncate$1(' git bisect start <bad-ref> <good-ref>', width - 4)));
|
|
28610
|
+
lines.push(h(Text, { key: 'bisect-empty-5' }, ''));
|
|
28611
|
+
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)));
|
|
28612
|
+
}
|
|
28613
|
+
else {
|
|
28614
|
+
// Active bisect. Two-section body: current candidate, recent
|
|
28615
|
+
// decisions. Action keys live in the footer.
|
|
28616
|
+
const headerSha = bisect.currentSha ? bisect.currentSha.slice(0, 8) : '<unknown>';
|
|
28617
|
+
lines.push(h(Text, { key: 'bisect-active-title', bold: true }, truncate$1(`Bisecting · current candidate ${headerSha}`, width - 4)));
|
|
28618
|
+
lines.push(h(Text, { key: 'bisect-active-spacer' }, ''));
|
|
28619
|
+
const decisions = bisect.log.filter((entry) => entry.kind === 'good' || entry.kind === 'bad' || entry.kind === 'skip');
|
|
28620
|
+
if (decisions.length === 0) {
|
|
28621
|
+
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)));
|
|
28622
|
+
}
|
|
28623
|
+
else {
|
|
28624
|
+
lines.push(h(Text, { key: 'bisect-decisions-header', bold: true }, truncate$1(`Decisions (${decisions.length}):`, width - 4)));
|
|
28625
|
+
const recent = decisions.slice(-Math.max(4, bodyRows - 8));
|
|
28626
|
+
for (const entry of recent) {
|
|
28627
|
+
const kindLabel = entry.kind.toUpperCase().padEnd(5);
|
|
28628
|
+
const sha = (entry.sha || '<unknown>').padEnd(8);
|
|
28629
|
+
const subject = entry.subject || '';
|
|
28630
|
+
const text = ` ${kindLabel} ${sha} ${subject}`;
|
|
28631
|
+
lines.push(h(Text, {
|
|
28632
|
+
key: `bisect-entry-${entry.raw}`,
|
|
28633
|
+
dimColor: entry.kind === 'skip',
|
|
28634
|
+
bold: entry.kind === 'bad',
|
|
28635
|
+
}, truncate$1(text, width - 4)));
|
|
28636
|
+
}
|
|
28637
|
+
}
|
|
28638
|
+
lines.push(h(Text, { key: 'bisect-action-spacer' }, ''));
|
|
28639
|
+
lines.push(h(Text, { key: 'bisect-action-hint', dimColor: true }, truncate$1('Actions: g good · b bad · s skip · x reset', width - 4)));
|
|
28640
|
+
}
|
|
28641
|
+
return h(Box, {
|
|
28642
|
+
borderColor: focusBorderColor(theme, focused),
|
|
28643
|
+
borderStyle: theme.borderStyle,
|
|
28644
|
+
flexDirection: 'column',
|
|
28645
|
+
flexShrink: 0,
|
|
28646
|
+
paddingX: 1,
|
|
28647
|
+
width,
|
|
28648
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Bisect', focused)), h(Text, { dimColor: true }, bisect?.active ? 'BISECTING' : 'inactive')), ...lines);
|
|
28649
|
+
}
|
|
27609
28650
|
function renderStashSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
27610
28651
|
const { Box, Text } = components;
|
|
27611
28652
|
const focused = state.focus === 'commits';
|
|
@@ -27811,7 +28852,7 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
|
|
|
27811
28852
|
h(Text, { key: 'promoted-filter-input', color: accent }, `filter: ${state.filter}_`),
|
|
27812
28853
|
];
|
|
27813
28854
|
}
|
|
27814
|
-
function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, bodyRows, width, theme) {
|
|
28855
|
+
function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme) {
|
|
27815
28856
|
const { Box, Text } = components;
|
|
27816
28857
|
const focused = state.focus === 'commits';
|
|
27817
28858
|
const worktree = context.worktree;
|
|
@@ -27902,6 +28943,50 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
27902
28943
|
dimColor: index > 0,
|
|
27903
28944
|
}, truncate$1(line, width - 4))), ...stashBodyNodes);
|
|
27904
28945
|
}
|
|
28946
|
+
// Compare-two-refs branch (#779). Mirrors the stash diff above but
|
|
28947
|
+
// sourced from `git diff <base>..<head>`. No per-file cherry-pick or
|
|
28948
|
+
// hunk apply — comparing arbitrary refs doesn't have a sensible
|
|
28949
|
+
// mutate-from-here flow, so the surface is read-only navigation.
|
|
28950
|
+
if (state.diffSource === 'compare') {
|
|
28951
|
+
const lines = compareDiffLines || [];
|
|
28952
|
+
const splitActive = isSplitDiffViable(state, width);
|
|
28953
|
+
const splitRequestedButTooNarrow = state.diffViewMode === 'split' && !splitActive;
|
|
28954
|
+
const visibleLines = lines.slice(state.diffPreviewOffset, state.diffPreviewOffset + visibleRows);
|
|
28955
|
+
const baseLabel = state.compareBase?.label || state.compareBase?.ref || '<base>';
|
|
28956
|
+
const headLabel = state.compareHead?.label || state.compareHead?.ref || '<head>';
|
|
28957
|
+
const compareTitle = `${baseLabel} → ${headLabel}`;
|
|
28958
|
+
const baseHeaderLines = compareDiffLoading
|
|
28959
|
+
? [`Loading diff for ${compareTitle}...`]
|
|
28960
|
+
: lines.length && (lines.length > 1 || lines[0])
|
|
28961
|
+
? [
|
|
28962
|
+
compareTitle,
|
|
28963
|
+
`Lines ${Math.min(state.diffPreviewOffset + 1, lines.length)}-${Math.min(state.diffPreviewOffset + visibleLines.length, lines.length)}/${lines.length}`,
|
|
28964
|
+
'',
|
|
28965
|
+
]
|
|
28966
|
+
: ['No diff to display — refs may resolve to the same tree.'];
|
|
28967
|
+
const headerLines = splitRequestedButTooNarrow
|
|
28968
|
+
? [...baseHeaderLines.slice(0, -1), 'Terminal too narrow for side-by-side; showing unified.', '']
|
|
28969
|
+
: baseHeaderLines;
|
|
28970
|
+
const compareBodyNodes = compareDiffLoading || !lines.length || (lines.length === 1 && !lines[0])
|
|
28971
|
+
? []
|
|
28972
|
+
: splitActive
|
|
28973
|
+
? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'compare-diff-split')
|
|
28974
|
+
: visibleLines.map((line, index) => h(Text, {
|
|
28975
|
+
key: `compare-diff-line-${state.diffPreviewOffset + index}`,
|
|
28976
|
+
...diffLineProps(line, theme),
|
|
28977
|
+
}, truncate$1(line, width - 4)));
|
|
28978
|
+
return h(Box, {
|
|
28979
|
+
borderColor: focusBorderColor(theme, focused),
|
|
28980
|
+
borderStyle: theme.borderStyle,
|
|
28981
|
+
flexDirection: 'column',
|
|
28982
|
+
flexShrink: 0,
|
|
28983
|
+
paddingX: 1,
|
|
28984
|
+
width,
|
|
28985
|
+
}, 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, {
|
|
28986
|
+
key: `compare-diff-header-${index}`,
|
|
28987
|
+
dimColor: index > 0,
|
|
28988
|
+
}, truncate$1(line, width - 4))), ...compareBodyNodes);
|
|
28989
|
+
}
|
|
27905
28990
|
// diffSource disambiguates: 'commit' was set when the user opened the
|
|
27906
28991
|
// diff via history → Enter (read-only commit-diff explore), 'worktree'
|
|
27907
28992
|
// was set when they came from status → Enter (stage / hunk / revert).
|
|
@@ -28760,6 +29845,7 @@ function renderFooter(h, components, state, context, theme, idleTip) {
|
|
|
28760
29845
|
showHelp: state.showHelp,
|
|
28761
29846
|
sidebarTab: state.sidebarTab,
|
|
28762
29847
|
sidebarItemCount,
|
|
29848
|
+
compareBaseSet: Boolean(state.compareBase),
|
|
28763
29849
|
});
|
|
28764
29850
|
// Real status messages always win; idle tips only fill the slot when it
|
|
28765
29851
|
// would otherwise be empty.
|