git-coco 0.50.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +88 -9
- package/dist/index.esm.mjs +3794 -156
- package/dist/index.js +3795 -155
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
|
|
|
78
78
|
/**
|
|
79
79
|
* Current build version from package.json
|
|
80
80
|
*/
|
|
81
|
-
const BUILD_VERSION = "0.
|
|
81
|
+
const BUILD_VERSION = "0.51.0";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -145,11 +145,11 @@ function removeUndefined(obj) {
|
|
|
145
145
|
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
const dynamicImport$
|
|
148
|
+
const dynamicImport$1 = new Function('specifier', 'return import(specifier)');
|
|
149
149
|
let promptsPromise;
|
|
150
150
|
function loadInquirerPrompts() {
|
|
151
151
|
if (!promptsPromise) {
|
|
152
|
-
promptsPromise = dynamicImport$
|
|
152
|
+
promptsPromise = dynamicImport$1('@inquirer/prompts');
|
|
153
153
|
}
|
|
154
154
|
return promptsPromise;
|
|
155
155
|
}
|
|
@@ -1140,6 +1140,11 @@ const schema$1 = {
|
|
|
1140
1140
|
"idleTips": {
|
|
1141
1141
|
"type": "boolean",
|
|
1142
1142
|
"description": "Rotate short usage tips through the status line when the TUI has been idle for >10s. Off by default so power users aren't distracted."
|
|
1143
|
+
},
|
|
1144
|
+
"dateBucketing": {
|
|
1145
|
+
"type": "boolean",
|
|
1146
|
+
"description": "Group adjacent commits in the history surface under shared section headers (`── Today ──`, `── Yesterday ──`, `── April 2026 ──`) and drop the per-row date column in favor of the headers. On by default because the bucketed view gives stronger temporal orientation at a glance and the freed cells go to the commit subject. Flip off if you prefer a date column on every row.\n\nBucketing automatically suppresses itself while a search filter is active (results aren't chronological), regardless of this setting.",
|
|
1147
|
+
"default": true
|
|
1143
1148
|
}
|
|
1144
1149
|
},
|
|
1145
1150
|
"additionalProperties": false,
|
|
@@ -1596,6 +1601,9 @@ const schema$1 = {
|
|
|
1596
1601
|
"commit": {
|
|
1597
1602
|
"$ref": "#/definitions/LLMModel"
|
|
1598
1603
|
},
|
|
1604
|
+
"commitSplit": {
|
|
1605
|
+
"$ref": "#/definitions/LLMModel"
|
|
1606
|
+
},
|
|
1599
1607
|
"changelog": {
|
|
1600
1608
|
"$ref": "#/definitions/LLMModel"
|
|
1601
1609
|
},
|
|
@@ -2505,9 +2513,10 @@ const CACHE_SUBCOMMANDS = [
|
|
|
2505
2513
|
'parsers',
|
|
2506
2514
|
'prefetch',
|
|
2507
2515
|
'clear-parsers',
|
|
2516
|
+
'clear-github',
|
|
2508
2517
|
];
|
|
2509
|
-
const command$
|
|
2510
|
-
const builder$
|
|
2518
|
+
const command$a = 'cache <subcommand> [languages..]';
|
|
2519
|
+
const builder$a = (yargs) => {
|
|
2511
2520
|
return yargs
|
|
2512
2521
|
.positional('subcommand', {
|
|
2513
2522
|
describe: 'Cache action to run',
|
|
@@ -2519,7 +2528,7 @@ const builder$8 = (yargs) => {
|
|
|
2519
2528
|
type: 'string',
|
|
2520
2529
|
array: true,
|
|
2521
2530
|
})
|
|
2522
|
-
.usage(getCommandUsageHeader(command$
|
|
2531
|
+
.usage(getCommandUsageHeader(command$a));
|
|
2523
2532
|
};
|
|
2524
2533
|
|
|
2525
2534
|
/**
|
|
@@ -2969,15 +2978,15 @@ async function runPrefetchFromEnv(options) {
|
|
|
2969
2978
|
* cache file under ~500 KB on a typical repo (each entry is a
|
|
2970
2979
|
* sha256 hash + 200-500-byte summary).
|
|
2971
2980
|
*/
|
|
2972
|
-
const CACHE_SCHEMA_VERSION$
|
|
2973
|
-
const CACHE_DIR_NAME$
|
|
2981
|
+
const CACHE_SCHEMA_VERSION$2 = 1;
|
|
2982
|
+
const CACHE_DIR_NAME$2 = 'diff-summaries';
|
|
2974
2983
|
const CACHE_ENTRY_HARD_CAP = 500;
|
|
2975
|
-
function resolveCacheDir$
|
|
2984
|
+
function resolveCacheDir$5() {
|
|
2976
2985
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
2977
2986
|
if (xdg && xdg.trim().length > 0) {
|
|
2978
|
-
return path__namespace$1.join(xdg, 'coco', CACHE_DIR_NAME$
|
|
2987
|
+
return path__namespace$1.join(xdg, 'coco', CACHE_DIR_NAME$2);
|
|
2979
2988
|
}
|
|
2980
|
-
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$
|
|
2989
|
+
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$2);
|
|
2981
2990
|
}
|
|
2982
2991
|
function repoKey$3(repoPath) {
|
|
2983
2992
|
// sha256 here is a non-security cache-key derivation — deterministic
|
|
@@ -2987,7 +2996,7 @@ function repoKey$3(repoPath) {
|
|
|
2987
2996
|
return crypto__namespace.createHash('sha256').update(repoPath).digest('hex').slice(0, 16);
|
|
2988
2997
|
}
|
|
2989
2998
|
function getDiffSummaryCachePath(repoPath) {
|
|
2990
|
-
return path__namespace$1.join(resolveCacheDir$
|
|
2999
|
+
return path__namespace$1.join(resolveCacheDir$5(), `summaries.${repoKey$3(repoPath)}.json`);
|
|
2991
3000
|
}
|
|
2992
3001
|
/**
|
|
2993
3002
|
* Build the cache key for a (diff, model, prompt) tuple. sha256
|
|
@@ -3004,7 +3013,7 @@ function readEnvelope(filePath) {
|
|
|
3004
3013
|
try {
|
|
3005
3014
|
const raw = fs__namespace$1.readFileSync(filePath, 'utf8');
|
|
3006
3015
|
const parsed = JSON.parse(raw);
|
|
3007
|
-
if (parsed.version !== CACHE_SCHEMA_VERSION$
|
|
3016
|
+
if (parsed.version !== CACHE_SCHEMA_VERSION$2)
|
|
3008
3017
|
return undefined;
|
|
3009
3018
|
if (!parsed.entries || typeof parsed.entries !== 'object')
|
|
3010
3019
|
return undefined;
|
|
@@ -3026,7 +3035,7 @@ function readDiffSummary(repoPath, key) {
|
|
|
3026
3035
|
function writeDiffSummary(repoPath, key, entry) {
|
|
3027
3036
|
const filePath = getDiffSummaryCachePath(repoPath);
|
|
3028
3037
|
const existing = readEnvelope(filePath) || {
|
|
3029
|
-
version: CACHE_SCHEMA_VERSION$
|
|
3038
|
+
version: CACHE_SCHEMA_VERSION$2,
|
|
3030
3039
|
savedAt: new Date().toISOString(),
|
|
3031
3040
|
entries: {},
|
|
3032
3041
|
};
|
|
@@ -3095,6 +3104,132 @@ function clearDiffSummaryCache(repoPath) {
|
|
|
3095
3104
|
}
|
|
3096
3105
|
}
|
|
3097
3106
|
|
|
3107
|
+
/**
|
|
3108
|
+
* Disk-backed cache for `coco issues` / `coco prs` list fetches
|
|
3109
|
+
* (#882 phase 2). Triage is bursty — a user runs `coco issues` a
|
|
3110
|
+
* dozen times in a few minutes, then doesn't touch it for hours —
|
|
3111
|
+
* so a short TTL (default 5 minutes) buys a lot of latency back
|
|
3112
|
+
* without serving stale data outside that window.
|
|
3113
|
+
*
|
|
3114
|
+
* Best-effort, same as `overviewCache.ts`: read failures fall back
|
|
3115
|
+
* to "no cache" (the fetcher does a fresh `gh` call), write failures
|
|
3116
|
+
* are swallowed silently (next call just re-fetches). The cache is
|
|
3117
|
+
* never load-bearing — `gh` is always the source of truth.
|
|
3118
|
+
*
|
|
3119
|
+
* Keying: `{kind}.{repoHash}.{filterHash}.json` where:
|
|
3120
|
+
* - `kind` is `'issues'` or `'prs'` so the two surfaces don't
|
|
3121
|
+
* collide.
|
|
3122
|
+
* - `repoHash` is a stable short hash of the absolute repo path
|
|
3123
|
+
* (same scheme as `overviewCache.ts`).
|
|
3124
|
+
* - `filterHash` is a stable short hash of the canonicalized
|
|
3125
|
+
* filter object so different `--state` / `--assignee` / `--label`
|
|
3126
|
+
* combinations cache independently.
|
|
3127
|
+
*
|
|
3128
|
+
* No PII in filenames; no auth context is hashed; no
|
|
3129
|
+
* collision-resistance against an adversary is required.
|
|
3130
|
+
*/
|
|
3131
|
+
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
3132
|
+
const CACHE_SCHEMA_VERSION$1 = 1;
|
|
3133
|
+
const CACHE_DIR_NAME$1 = 'github';
|
|
3134
|
+
function resolveCacheDir$4() {
|
|
3135
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
3136
|
+
if (xdg && xdg.trim().length > 0) {
|
|
3137
|
+
return path__namespace$1.join(xdg, 'coco', CACHE_DIR_NAME$1);
|
|
3138
|
+
}
|
|
3139
|
+
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$1);
|
|
3140
|
+
}
|
|
3141
|
+
function shortHash$1(input) {
|
|
3142
|
+
// sha1 here is a non-security cache-key derivation — we just need a
|
|
3143
|
+
// deterministic short identifier so two repos / filters at different
|
|
3144
|
+
// values never collide in the cache directory. No PII or auth
|
|
3145
|
+
// context is hashed and no collision-resistance against an adversary
|
|
3146
|
+
// is required.
|
|
3147
|
+
// DevSkim: ignore DS126858
|
|
3148
|
+
return crypto__namespace.createHash('sha1').update(input).digest('hex').slice(0, 16);
|
|
3149
|
+
}
|
|
3150
|
+
/**
|
|
3151
|
+
* Canonicalize the filter object into a stable string before hashing.
|
|
3152
|
+
* Sorts keys + drops undefined entries so equivalent filters
|
|
3153
|
+
* (`{state: 'open'}` and `{state: 'open', limit: undefined}`) hash to
|
|
3154
|
+
* the same key and share cached data.
|
|
3155
|
+
*/
|
|
3156
|
+
function canonicalizeFilter(filter) {
|
|
3157
|
+
const entries = Object.entries(filter)
|
|
3158
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
|
3159
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
3160
|
+
return JSON.stringify(entries);
|
|
3161
|
+
}
|
|
3162
|
+
function getCachePath(kind, repoPath, filter) {
|
|
3163
|
+
const repoHash = shortHash$1(repoPath);
|
|
3164
|
+
const filterHash = shortHash$1(canonicalizeFilter(filter));
|
|
3165
|
+
return path__namespace$1.join(resolveCacheDir$4(), `${kind}.${repoHash}.${filterHash}.json`);
|
|
3166
|
+
}
|
|
3167
|
+
function readCachedList(kind, repoPath, filter, ttlMs = DEFAULT_CACHE_TTL_MS) {
|
|
3168
|
+
try {
|
|
3169
|
+
const raw = fs__namespace$1.readFileSync(getCachePath(kind, repoPath, filter), 'utf8');
|
|
3170
|
+
const parsed = JSON.parse(raw);
|
|
3171
|
+
if (parsed.version !== CACHE_SCHEMA_VERSION$1)
|
|
3172
|
+
return undefined;
|
|
3173
|
+
if (!parsed.payload || parsed.payload.kind !== kind)
|
|
3174
|
+
return undefined;
|
|
3175
|
+
if (!Array.isArray(parsed.payload.items))
|
|
3176
|
+
return undefined;
|
|
3177
|
+
const savedAt = new Date(parsed.savedAt);
|
|
3178
|
+
if (Number.isNaN(savedAt.getTime()))
|
|
3179
|
+
return undefined;
|
|
3180
|
+
const ageMs = Date.now() - savedAt.getTime();
|
|
3181
|
+
return {
|
|
3182
|
+
payload: parsed.payload,
|
|
3183
|
+
savedAt,
|
|
3184
|
+
ageMs,
|
|
3185
|
+
fresh: ageMs < ttlMs,
|
|
3186
|
+
};
|
|
3187
|
+
}
|
|
3188
|
+
catch {
|
|
3189
|
+
return undefined;
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
function writeCachedList(repoPath, filter, payload) {
|
|
3193
|
+
const file = getCachePath(payload.kind, repoPath, filter);
|
|
3194
|
+
const envelope = {
|
|
3195
|
+
version: CACHE_SCHEMA_VERSION$1,
|
|
3196
|
+
savedAt: new Date().toISOString(),
|
|
3197
|
+
payload,
|
|
3198
|
+
};
|
|
3199
|
+
try {
|
|
3200
|
+
fs__namespace$1.mkdirSync(path__namespace$1.dirname(file), { recursive: true });
|
|
3201
|
+
fs__namespace$1.writeFileSync(file, JSON.stringify(envelope));
|
|
3202
|
+
}
|
|
3203
|
+
catch {
|
|
3204
|
+
// Best-effort persistence; swallow.
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
/**
|
|
3208
|
+
* Drop every cached file under the github cache directory. Used by
|
|
3209
|
+
* `--no-cache` / explicit purge commands. Best-effort: ENOENT on a
|
|
3210
|
+
* never-populated cache directory is treated as success.
|
|
3211
|
+
*/
|
|
3212
|
+
function clearGitHubListCache() {
|
|
3213
|
+
const dir = resolveCacheDir$4();
|
|
3214
|
+
let removed = 0;
|
|
3215
|
+
try {
|
|
3216
|
+
const entries = fs__namespace$1.readdirSync(dir);
|
|
3217
|
+
for (const entry of entries) {
|
|
3218
|
+
try {
|
|
3219
|
+
fs__namespace$1.unlinkSync(path__namespace$1.join(dir, entry));
|
|
3220
|
+
removed++;
|
|
3221
|
+
}
|
|
3222
|
+
catch {
|
|
3223
|
+
// Skip individual file failures; keep counting the rest.
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
catch {
|
|
3228
|
+
// Directory missing → nothing to clear, treat as success.
|
|
3229
|
+
}
|
|
3230
|
+
return { removed };
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3098
3233
|
/**
|
|
3099
3234
|
* Retrieves a SimpleGit instance for a repository.
|
|
3100
3235
|
*
|
|
@@ -3267,7 +3402,7 @@ async function promptLanguageSelection(logger) {
|
|
|
3267
3402
|
});
|
|
3268
3403
|
return picked;
|
|
3269
3404
|
}
|
|
3270
|
-
const handler$
|
|
3405
|
+
const handler$a = async (argv, logger) => {
|
|
3271
3406
|
const subcommand = argv.subcommand;
|
|
3272
3407
|
const positionalLanguages = (argv.languages || [])
|
|
3273
3408
|
.map((s) => s.trim())
|
|
@@ -3344,6 +3479,16 @@ const handler$8 = async (argv, logger) => {
|
|
|
3344
3479
|
}
|
|
3345
3480
|
return;
|
|
3346
3481
|
}
|
|
3482
|
+
if (subcommand === 'clear-github') {
|
|
3483
|
+
const result = clearGitHubListCache();
|
|
3484
|
+
if (result.removed === 0) {
|
|
3485
|
+
logger.log(chalk.dim('No GitHub triage cache to clear.'));
|
|
3486
|
+
return;
|
|
3487
|
+
}
|
|
3488
|
+
logger.log(chalk.green(`✓ cleared ${result.removed} cached GitHub triage list${result.removed === 1 ? '' : 's'}`));
|
|
3489
|
+
logger.log(chalk.dim('Cleared from ~/.cache/coco/github/'));
|
|
3490
|
+
return;
|
|
3491
|
+
}
|
|
3347
3492
|
if (subcommand === 'clear-parsers') {
|
|
3348
3493
|
const languages = listManifestLanguages();
|
|
3349
3494
|
let cleared = 0;
|
|
@@ -3362,15 +3507,15 @@ const handler$8 = async (argv, logger) => {
|
|
|
3362
3507
|
return;
|
|
3363
3508
|
}
|
|
3364
3509
|
logger.log(chalk.red(`Unknown cache subcommand: ${subcommand}`));
|
|
3365
|
-
logger.log(chalk.dim('Use one of: clear, info, parsers, prefetch, clear-parsers'));
|
|
3510
|
+
logger.log(chalk.dim('Use one of: clear, info, parsers, prefetch, clear-parsers, clear-github'));
|
|
3366
3511
|
process.exitCode = 1;
|
|
3367
3512
|
};
|
|
3368
3513
|
|
|
3369
3514
|
var cache = {
|
|
3370
|
-
command: command$
|
|
3515
|
+
command: command$a,
|
|
3371
3516
|
desc: 'Manage the diff-summary cache (clear, info)',
|
|
3372
|
-
builder: builder$
|
|
3373
|
-
handler: commandExecutor(handler$
|
|
3517
|
+
builder: builder$a,
|
|
3518
|
+
handler: commandExecutor(handler$a),
|
|
3374
3519
|
};
|
|
3375
3520
|
|
|
3376
3521
|
var util;
|
|
@@ -7159,11 +7304,11 @@ const ChangelogResponseSchema = objectType({
|
|
|
7159
7304
|
title: stringType(),
|
|
7160
7305
|
content: stringType(),
|
|
7161
7306
|
});
|
|
7162
|
-
const command$
|
|
7307
|
+
const command$9 = 'changelog';
|
|
7163
7308
|
/**
|
|
7164
7309
|
* Command line options via yargs
|
|
7165
7310
|
*/
|
|
7166
|
-
const options$
|
|
7311
|
+
const options$9 = {
|
|
7167
7312
|
range: {
|
|
7168
7313
|
type: 'string',
|
|
7169
7314
|
alias: 'r',
|
|
@@ -7210,8 +7355,8 @@ const options$7 = {
|
|
|
7210
7355
|
description: 'Toggle interactive mode',
|
|
7211
7356
|
},
|
|
7212
7357
|
};
|
|
7213
|
-
const builder$
|
|
7214
|
-
return yargs.options(options$
|
|
7358
|
+
const builder$9 = (yargs) => {
|
|
7359
|
+
return yargs.options(options$9).usage(getCommandUsageHeader(command$9));
|
|
7215
7360
|
};
|
|
7216
7361
|
|
|
7217
7362
|
/**
|
|
@@ -7415,6 +7560,12 @@ const OPENAI_DYNAMIC_DEFAULTS = {
|
|
|
7415
7560
|
cost: {
|
|
7416
7561
|
summarize: 'gpt-4.1-nano',
|
|
7417
7562
|
commit: 'gpt-4.1-mini',
|
|
7563
|
+
// `commitSplit` floors at mini even in cost mode. The split
|
|
7564
|
+
// planner emits structured JSON with strict cross-group
|
|
7565
|
+
// constraints (files appear exactly once, hunks fully cover or
|
|
7566
|
+
// not at all). Nano-class models fail those constraints often
|
|
7567
|
+
// enough that the cost win is eaten by the 3-retry budget.
|
|
7568
|
+
commitSplit: 'gpt-4.1-mini',
|
|
7418
7569
|
changelog: 'gpt-4.1-mini',
|
|
7419
7570
|
review: 'gpt-4.1-mini',
|
|
7420
7571
|
recap: 'gpt-4.1-nano',
|
|
@@ -7424,6 +7575,7 @@ const OPENAI_DYNAMIC_DEFAULTS = {
|
|
|
7424
7575
|
balanced: {
|
|
7425
7576
|
summarize: 'gpt-4.1-mini',
|
|
7426
7577
|
commit: 'gpt-4.1-mini',
|
|
7578
|
+
commitSplit: 'gpt-4.1',
|
|
7427
7579
|
changelog: 'gpt-4.1',
|
|
7428
7580
|
review: 'gpt-4.1',
|
|
7429
7581
|
recap: 'gpt-4.1-mini',
|
|
@@ -7433,6 +7585,7 @@ const OPENAI_DYNAMIC_DEFAULTS = {
|
|
|
7433
7585
|
quality: {
|
|
7434
7586
|
summarize: 'gpt-4.1-mini',
|
|
7435
7587
|
commit: 'gpt-4.1',
|
|
7588
|
+
commitSplit: 'gpt-4.1',
|
|
7436
7589
|
changelog: 'gpt-4.1',
|
|
7437
7590
|
review: 'gpt-4.1',
|
|
7438
7591
|
recap: 'gpt-4.1',
|
|
@@ -7444,6 +7597,8 @@ const ANTHROPIC_DYNAMIC_DEFAULTS = {
|
|
|
7444
7597
|
cost: {
|
|
7445
7598
|
summarize: 'claude-3-5-haiku-latest',
|
|
7446
7599
|
commit: 'claude-3-5-haiku-latest',
|
|
7600
|
+
// Floor at sonnet — see note on OpenAI commitSplit above.
|
|
7601
|
+
commitSplit: 'claude-3-5-sonnet-latest',
|
|
7447
7602
|
changelog: 'claude-3-5-sonnet-latest',
|
|
7448
7603
|
review: 'claude-3-5-sonnet-latest',
|
|
7449
7604
|
recap: 'claude-3-5-haiku-latest',
|
|
@@ -7453,6 +7608,7 @@ const ANTHROPIC_DYNAMIC_DEFAULTS = {
|
|
|
7453
7608
|
balanced: {
|
|
7454
7609
|
summarize: 'claude-3-5-haiku-latest',
|
|
7455
7610
|
commit: 'claude-3-5-sonnet-latest',
|
|
7611
|
+
commitSplit: 'claude-3-7-sonnet-latest',
|
|
7456
7612
|
changelog: 'claude-3-5-sonnet-latest',
|
|
7457
7613
|
review: 'claude-3-7-sonnet-latest',
|
|
7458
7614
|
recap: 'claude-3-5-sonnet-latest',
|
|
@@ -7462,6 +7618,7 @@ const ANTHROPIC_DYNAMIC_DEFAULTS = {
|
|
|
7462
7618
|
quality: {
|
|
7463
7619
|
summarize: 'claude-3-5-sonnet-latest',
|
|
7464
7620
|
commit: 'claude-3-7-sonnet-latest',
|
|
7621
|
+
commitSplit: 'claude-sonnet-4-0',
|
|
7465
7622
|
changelog: 'claude-3-7-sonnet-latest',
|
|
7466
7623
|
review: 'claude-sonnet-4-0',
|
|
7467
7624
|
recap: 'claude-3-7-sonnet-latest',
|
|
@@ -7473,6 +7630,8 @@ const OLLAMA_DYNAMIC_DEFAULTS = {
|
|
|
7473
7630
|
cost: {
|
|
7474
7631
|
summarize: 'llama3.2:3b',
|
|
7475
7632
|
commit: 'llama3.1:8b',
|
|
7633
|
+
// Floor at the coder-tuned 14b — see note on OpenAI commitSplit above.
|
|
7634
|
+
commitSplit: 'qwen2.5-coder:14b',
|
|
7476
7635
|
changelog: 'llama3.1:8b',
|
|
7477
7636
|
review: 'qwen2.5-coder:7b',
|
|
7478
7637
|
recap: 'llama3.2:3b',
|
|
@@ -7482,6 +7641,7 @@ const OLLAMA_DYNAMIC_DEFAULTS = {
|
|
|
7482
7641
|
balanced: {
|
|
7483
7642
|
summarize: 'llama3.1:8b',
|
|
7484
7643
|
commit: 'qwen2.5-coder:14b',
|
|
7644
|
+
commitSplit: 'qwen2.5-coder:32b',
|
|
7485
7645
|
changelog: 'qwen2.5-coder:14b',
|
|
7486
7646
|
review: 'qwen2.5-coder:32b',
|
|
7487
7647
|
recap: 'llama3.1:8b',
|
|
@@ -7491,6 +7651,7 @@ const OLLAMA_DYNAMIC_DEFAULTS = {
|
|
|
7491
7651
|
quality: {
|
|
7492
7652
|
summarize: 'qwen2.5-coder:14b',
|
|
7493
7653
|
commit: 'qwen2.5-coder:32b',
|
|
7654
|
+
commitSplit: 'qwen2.5-coder:32b',
|
|
7494
7655
|
changelog: 'qwen2.5-coder:32b',
|
|
7495
7656
|
review: 'qwen2.5-coder:32b',
|
|
7496
7657
|
recap: 'qwen2.5-coder:14b',
|
|
@@ -7506,6 +7667,7 @@ const DYNAMIC_DEFAULTS = {
|
|
|
7506
7667
|
const DYNAMIC_MODEL_TASKS = [
|
|
7507
7668
|
'summarize',
|
|
7508
7669
|
'commit',
|
|
7670
|
+
'commitSplit',
|
|
7509
7671
|
'changelog',
|
|
7510
7672
|
'review',
|
|
7511
7673
|
'recap',
|
|
@@ -9219,7 +9381,9 @@ function summarizeRustStructuralDiff(fileDiff) {
|
|
|
9219
9381
|
* on every diff — the regex fallback is the correct steady state
|
|
9220
9382
|
* in that case.
|
|
9221
9383
|
*/
|
|
9222
|
-
|
|
9384
|
+
async function loadTreeSitterModule() {
|
|
9385
|
+
return import('web-tree-sitter');
|
|
9386
|
+
}
|
|
9223
9387
|
/**
|
|
9224
9388
|
* Locate the bundled .wasm files. Tries the dist layout first (the
|
|
9225
9389
|
* common case for installed packages), then falls back to the
|
|
@@ -9300,7 +9464,7 @@ async function ensureRuntime() {
|
|
|
9300
9464
|
return undefined;
|
|
9301
9465
|
let mod;
|
|
9302
9466
|
try {
|
|
9303
|
-
mod = await
|
|
9467
|
+
mod = await loadTreeSitterModule();
|
|
9304
9468
|
}
|
|
9305
9469
|
catch {
|
|
9306
9470
|
return undefined;
|
|
@@ -14051,7 +14215,7 @@ async function processInWaves(items, processor, maxConcurrent = 6) {
|
|
|
14051
14215
|
}
|
|
14052
14216
|
return results;
|
|
14053
14217
|
}
|
|
14054
|
-
const handler$
|
|
14218
|
+
const handler$9 = async (argv, logger) => {
|
|
14055
14219
|
const git = applyRepoFlag(argv);
|
|
14056
14220
|
const config = loadConfig(argv);
|
|
14057
14221
|
const key = getApiKeyForModel(config);
|
|
@@ -14276,11 +14440,11 @@ const handler$7 = async (argv, logger) => {
|
|
|
14276
14440
|
};
|
|
14277
14441
|
|
|
14278
14442
|
var changelog = {
|
|
14279
|
-
command: command$
|
|
14443
|
+
command: command$9,
|
|
14280
14444
|
desc: 'Generate a changelog from current or target branch, provided commit range, or since the last tag.',
|
|
14281
|
-
builder: builder$
|
|
14282
|
-
handler: commandExecutor(handler$
|
|
14283
|
-
options: options$
|
|
14445
|
+
builder: builder$9,
|
|
14446
|
+
handler: commandExecutor(handler$9),
|
|
14447
|
+
options: options$9,
|
|
14284
14448
|
};
|
|
14285
14449
|
|
|
14286
14450
|
const conventionalTypeRegex = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:/;
|
|
@@ -14297,11 +14461,11 @@ const ConventionalCommitMessageResponseSchema = objectType({
|
|
|
14297
14461
|
body: stringType().describe("Body of the commit message")
|
|
14298
14462
|
// .max(280, "Body must be 280 characters or less"),
|
|
14299
14463
|
}).describe("Object with Conventional Commit message 'title' and 'body' adhering to Conventional Commits specification");
|
|
14300
|
-
const command$
|
|
14464
|
+
const command$8 = 'commit';
|
|
14301
14465
|
/**
|
|
14302
14466
|
* Command line options via yargs
|
|
14303
14467
|
*/
|
|
14304
|
-
const options$
|
|
14468
|
+
const options$8 = {
|
|
14305
14469
|
i: {
|
|
14306
14470
|
alias: 'interactive',
|
|
14307
14471
|
description: 'Toggle interactive mode',
|
|
@@ -14373,8 +14537,8 @@ const options$6 = {
|
|
|
14373
14537
|
default: false,
|
|
14374
14538
|
},
|
|
14375
14539
|
};
|
|
14376
|
-
const builder$
|
|
14377
|
-
return yargs.options(options$
|
|
14540
|
+
const builder$8 = (yargs) => {
|
|
14541
|
+
return yargs.options(options$8).usage(getCommandUsageHeader(command$8));
|
|
14378
14542
|
};
|
|
14379
14543
|
|
|
14380
14544
|
/**
|
|
@@ -14988,6 +15152,73 @@ function formatPlanValidationIssuesError(issues) {
|
|
|
14988
15152
|
.filter(Boolean)
|
|
14989
15153
|
.join('; ');
|
|
14990
15154
|
}
|
|
15155
|
+
/**
|
|
15156
|
+
* Salvage a plan that lists the same file in `files[]` of more than
|
|
15157
|
+
* one group. Weaker models (e.g. `gpt-4.1-nano`) hit this often when
|
|
15158
|
+
* the staged set has many files — they re-assert files across groups
|
|
15159
|
+
* even though the prompt forbids it.
|
|
15160
|
+
*
|
|
15161
|
+
* Recovery: walk groups in plan order, keep the FIRST occurrence of
|
|
15162
|
+
* each file path, drop subsequent occurrences. Plan-order is used
|
|
15163
|
+
* because the LLM tends to put the most thematically-correct
|
|
15164
|
+
* assignment in the first group it considered the file for; later
|
|
15165
|
+
* appearances are usually accidental re-emissions.
|
|
15166
|
+
*
|
|
15167
|
+
* If dropping a duplicate leaves a group with empty `files[]` AND
|
|
15168
|
+
* empty `hunks[]`, `dropEmptyGroups` (run last) filters it out so
|
|
15169
|
+
* the apply path never sees a group with nothing to commit.
|
|
15170
|
+
*
|
|
15171
|
+
* Returns a NEW plan object — original is not mutated.
|
|
15172
|
+
*/
|
|
15173
|
+
function rescueDuplicateFiles(plan) {
|
|
15174
|
+
const seen = new Set();
|
|
15175
|
+
let mutated = false;
|
|
15176
|
+
const rescuedGroups = plan.groups.map((group) => {
|
|
15177
|
+
const keptFiles = [];
|
|
15178
|
+
for (const file of group.files || []) {
|
|
15179
|
+
if (seen.has(file)) {
|
|
15180
|
+
mutated = true;
|
|
15181
|
+
continue;
|
|
15182
|
+
}
|
|
15183
|
+
seen.add(file);
|
|
15184
|
+
keptFiles.push(file);
|
|
15185
|
+
}
|
|
15186
|
+
return { ...group, files: keptFiles };
|
|
15187
|
+
});
|
|
15188
|
+
if (!mutated)
|
|
15189
|
+
return plan;
|
|
15190
|
+
return { ...plan, groups: rescuedGroups };
|
|
15191
|
+
}
|
|
15192
|
+
/**
|
|
15193
|
+
* Salvage a plan that lists the same hunk ID in `hunks[]` of more
|
|
15194
|
+
* than one group. Same failure mode as duplicate files but for the
|
|
15195
|
+
* hunk-level assignments.
|
|
15196
|
+
*
|
|
15197
|
+
* Recovery: keep the FIRST occurrence of each hunk ID across groups
|
|
15198
|
+
* (plan order), drop subsequent ones. `dropEmptyGroups` handles any
|
|
15199
|
+
* group left fully empty.
|
|
15200
|
+
*
|
|
15201
|
+
* Returns a NEW plan object — original is not mutated.
|
|
15202
|
+
*/
|
|
15203
|
+
function rescueDuplicateHunks(plan) {
|
|
15204
|
+
const seen = new Set();
|
|
15205
|
+
let mutated = false;
|
|
15206
|
+
const rescuedGroups = plan.groups.map((group) => {
|
|
15207
|
+
const keptHunks = [];
|
|
15208
|
+
for (const hunkId of group.hunks || []) {
|
|
15209
|
+
if (seen.has(hunkId)) {
|
|
15210
|
+
mutated = true;
|
|
15211
|
+
continue;
|
|
15212
|
+
}
|
|
15213
|
+
seen.add(hunkId);
|
|
15214
|
+
keptHunks.push(hunkId);
|
|
15215
|
+
}
|
|
15216
|
+
return { ...group, hunks: keptHunks };
|
|
15217
|
+
});
|
|
15218
|
+
if (!mutated)
|
|
15219
|
+
return plan;
|
|
15220
|
+
return { ...plan, groups: rescuedGroups };
|
|
15221
|
+
}
|
|
14991
15222
|
/**
|
|
14992
15223
|
* Salvage a plan that references hunk IDs not in the inventory by
|
|
14993
15224
|
* promoting those hunks to file-level assignments. The LLM commonly
|
|
@@ -15251,31 +15482,39 @@ async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged
|
|
|
15251
15482
|
});
|
|
15252
15483
|
// Rescue passes. Run in order — order matters:
|
|
15253
15484
|
//
|
|
15254
|
-
// 1.
|
|
15485
|
+
// 1. rescueDuplicateFiles / rescueDuplicateHunks: weak models
|
|
15486
|
+
// (e.g. gpt-4.1-nano) repeatedly re-assert the same file or
|
|
15487
|
+
// hunk across multiple groups. Keep the first occurrence,
|
|
15488
|
+
// drop the rest. Run FIRST so downstream rescues see a
|
|
15489
|
+
// deduplicated plan and don't re-process redundant entries.
|
|
15490
|
+
//
|
|
15491
|
+
// 2. rescuePhantomHunks (#918): LLM commonly emits "file::hunk-1"
|
|
15255
15492
|
// against an empty inventory (all staged files are new).
|
|
15256
15493
|
// Promote those to file-level assignments.
|
|
15257
15494
|
//
|
|
15258
|
-
//
|
|
15495
|
+
// 3. rescueMixedFiles (#919): LLM commonly puts a file in
|
|
15259
15496
|
// `files[]` of group A AND uses its hunks in `hunks[]` of
|
|
15260
15497
|
// group B. Drop the hunks (the file-level claim is more
|
|
15261
15498
|
// specific). Must run AFTER phantom-hunk rescue because the
|
|
15262
15499
|
// rescue itself can create mixed-files situations.
|
|
15263
15500
|
//
|
|
15264
|
-
//
|
|
15501
|
+
// 4. rescueMissingFiles (#921): LLM occasionally forgets a
|
|
15265
15502
|
// staged file across every group. Append a synthetic "misc"
|
|
15266
15503
|
// group so the plan covers every staged file.
|
|
15267
15504
|
//
|
|
15268
|
-
//
|
|
15269
|
-
// empty files[] AND empty hunks[] when
|
|
15270
|
-
//
|
|
15271
|
-
//
|
|
15272
|
-
//
|
|
15273
|
-
//
|
|
15274
|
-
//
|
|
15505
|
+
// 5. dropEmptyGroups: earlier rescues can leave a group with
|
|
15506
|
+
// empty files[] AND empty hunks[] when their only contents
|
|
15507
|
+
// got dropped. Apply-time, an empty group means `git commit`
|
|
15508
|
+
// with nothing staged, which throws and aborts mid-loop
|
|
15509
|
+
// after the up-front `git reset` has already wiped the
|
|
15510
|
+
// index. Filter the empty groups out LAST so the apply path
|
|
15511
|
+
// can't hit them.
|
|
15275
15512
|
//
|
|
15276
15513
|
// All rescues are no-ops when there's nothing to rescue, so
|
|
15277
15514
|
// running them unconditionally costs nothing on healthy plans.
|
|
15278
|
-
const
|
|
15515
|
+
const dedupedFiles = rescueDuplicateFiles(rawPlan);
|
|
15516
|
+
const dedupedHunks = rescueDuplicateHunks(dedupedFiles);
|
|
15517
|
+
const phantomRescued = rescuePhantomHunks(dedupedHunks, staged, hunkInventory);
|
|
15279
15518
|
const mixedRescued = rescueMixedFiles(phantomRescued, hunkInventory);
|
|
15280
15519
|
const missingRescued = rescueMissingFiles(mixedRescued, staged, hunkInventory);
|
|
15281
15520
|
const plan = dropEmptyGroups(missingRescued);
|
|
@@ -15624,7 +15863,7 @@ async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger,
|
|
|
15624
15863
|
* and apply would risk drift (small LLM nondeterminism, staged-state
|
|
15625
15864
|
* changes the user didn't intend, etc.).
|
|
15626
15865
|
*/
|
|
15627
|
-
async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, llm, }) {
|
|
15866
|
+
async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, llm, planLlm, planService, }) {
|
|
15628
15867
|
const changes = await getChanges({
|
|
15629
15868
|
git,
|
|
15630
15869
|
options: {
|
|
@@ -15697,8 +15936,10 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
|
|
|
15697
15936
|
// the conventional-commits ruleset (or generic style).
|
|
15698
15937
|
}
|
|
15699
15938
|
}
|
|
15939
|
+
const resolvedPlanLlm = planLlm ?? llm;
|
|
15940
|
+
const resolvedPlanModel = planService?.model ?? config.service.model;
|
|
15700
15941
|
const { plan } = await generateValidatedCommitSplitPlan({
|
|
15701
|
-
llm,
|
|
15942
|
+
llm: resolvedPlanLlm,
|
|
15702
15943
|
prompt: COMMIT_SPLIT_PROMPT,
|
|
15703
15944
|
variables: {
|
|
15704
15945
|
file_inventory: fileInventory,
|
|
@@ -15716,15 +15957,24 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
|
|
|
15716
15957
|
metadata: {
|
|
15717
15958
|
command: 'commit',
|
|
15718
15959
|
provider: config.service.provider,
|
|
15719
|
-
model: String(
|
|
15960
|
+
model: String(resolvedPlanModel),
|
|
15720
15961
|
conventional: useConventional,
|
|
15721
15962
|
},
|
|
15722
15963
|
maxAttempts: DEFAULT_MAX_PLAN_ATTEMPTS,
|
|
15723
15964
|
});
|
|
15724
15965
|
return { plan, context: { changes, hunkInventory } };
|
|
15725
15966
|
}
|
|
15726
|
-
async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, }) {
|
|
15727
|
-
const result = await prepareCommitSplitPlan({
|
|
15967
|
+
async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, planLlm, planService, }) {
|
|
15968
|
+
const result = await prepareCommitSplitPlan({
|
|
15969
|
+
argv,
|
|
15970
|
+
config,
|
|
15971
|
+
git,
|
|
15972
|
+
logger,
|
|
15973
|
+
tokenizer,
|
|
15974
|
+
llm,
|
|
15975
|
+
planLlm,
|
|
15976
|
+
planService,
|
|
15977
|
+
});
|
|
15728
15978
|
if ('empty' in result) {
|
|
15729
15979
|
return 'No staged changes found.';
|
|
15730
15980
|
}
|
|
@@ -15743,13 +15993,14 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, })
|
|
|
15743
15993
|
return formatCommitSplitPlan(plan);
|
|
15744
15994
|
}
|
|
15745
15995
|
|
|
15746
|
-
const handler$
|
|
15996
|
+
const handler$8 = async (argv, logger) => {
|
|
15747
15997
|
const git = applyRepoFlag(argv);
|
|
15748
15998
|
const config = loadConfig(argv);
|
|
15749
15999
|
const key = getApiKeyForModel(config);
|
|
15750
16000
|
const { provider } = getModelAndProviderFromConfig(config);
|
|
15751
16001
|
const commitService = resolveDynamicService(config, 'commit');
|
|
15752
16002
|
const summaryService = resolveDynamicService(config, 'summarize');
|
|
16003
|
+
const splitService = resolveDynamicService(config, 'commitSplit');
|
|
15753
16004
|
const model = commitService.model;
|
|
15754
16005
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
15755
16006
|
logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
|
|
@@ -15758,6 +16009,12 @@ const handler$6 = async (argv, logger) => {
|
|
|
15758
16009
|
const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
|
|
15759
16010
|
const llm = getLlm(provider, model, { ...config, service: commitService });
|
|
15760
16011
|
const summaryLlm = getLlm(provider, summaryService.model, { ...config, service: summaryService });
|
|
16012
|
+
// The split planner uses a dedicated LLM because its output schema
|
|
16013
|
+
// is far stricter than the regular commit-message path (every staged
|
|
16014
|
+
// file claimed exactly once, no cross-group duplication, hunk-vs-
|
|
16015
|
+
// file mode exclusivity). Weak models fail those constraints often
|
|
16016
|
+
// enough that the `cost` preference floors `commitSplit` at mini.
|
|
16017
|
+
const splitLlm = getLlm(provider, splitService.model, { ...config, service: splitService });
|
|
15761
16018
|
const INTERACTIVE = argv.interactive || isInteractive(config);
|
|
15762
16019
|
if (INTERACTIVE) {
|
|
15763
16020
|
if (!config.hideCocoBanner) {
|
|
@@ -15776,6 +16033,7 @@ const handler$6 = async (argv, logger) => {
|
|
|
15776
16033
|
color: 'green',
|
|
15777
16034
|
});
|
|
15778
16035
|
if (isCommitSplitCommand(argv)) {
|
|
16036
|
+
logger.verbose(`→ split planner: ${provider} (${splitService.model})`, { color: 'green' });
|
|
15779
16037
|
const splitResult = await handleCommitSplit({
|
|
15780
16038
|
argv,
|
|
15781
16039
|
config,
|
|
@@ -15783,6 +16041,8 @@ const handler$6 = async (argv, logger) => {
|
|
|
15783
16041
|
logger,
|
|
15784
16042
|
tokenizer,
|
|
15785
16043
|
llm,
|
|
16044
|
+
planLlm: splitLlm,
|
|
16045
|
+
planService: splitService,
|
|
15786
16046
|
});
|
|
15787
16047
|
const splitMode = INTERACTIVE ? 'interactive' : (config.mode || 'stdout');
|
|
15788
16048
|
await handleResult({
|
|
@@ -16208,23 +16468,23 @@ IMPORTANT RULES:
|
|
|
16208
16468
|
};
|
|
16209
16469
|
|
|
16210
16470
|
var commit = {
|
|
16211
|
-
command: command$
|
|
16471
|
+
command: command$8,
|
|
16212
16472
|
desc: 'Summarize the staged changes in a commit message.',
|
|
16213
|
-
builder: builder$
|
|
16214
|
-
handler: commandExecutor(handler$
|
|
16215
|
-
options: options$
|
|
16473
|
+
builder: builder$8,
|
|
16474
|
+
handler: commandExecutor(handler$8),
|
|
16475
|
+
options: options$8,
|
|
16216
16476
|
};
|
|
16217
16477
|
|
|
16218
|
-
const command$
|
|
16219
|
-
const options$
|
|
16478
|
+
const command$7 = 'doctor';
|
|
16479
|
+
const options$7 = {
|
|
16220
16480
|
fix: {
|
|
16221
16481
|
description: 'Attempt to auto-fix detected issues and write the updated config',
|
|
16222
16482
|
type: 'boolean',
|
|
16223
16483
|
default: false,
|
|
16224
16484
|
},
|
|
16225
16485
|
};
|
|
16226
|
-
const builder$
|
|
16227
|
-
return yargs.options(options$
|
|
16486
|
+
const builder$7 = (yargs) => {
|
|
16487
|
+
return yargs.options(options$7).usage(getCommandUsageHeader(command$7));
|
|
16228
16488
|
};
|
|
16229
16489
|
|
|
16230
16490
|
/**
|
|
@@ -16503,7 +16763,7 @@ function formatSourceInfo(sources) {
|
|
|
16503
16763
|
}
|
|
16504
16764
|
return lines;
|
|
16505
16765
|
}
|
|
16506
|
-
const handler$
|
|
16766
|
+
const handler$7 = async (argv, logger) => {
|
|
16507
16767
|
// Honor the global --repo flag so `coco doctor --repo <X>`
|
|
16508
16768
|
// inspects X's config sources, not the launcher's cwd. The chdir
|
|
16509
16769
|
// has to happen before loadConfig so `findUp` walks the targeted
|
|
@@ -16610,17 +16870,17 @@ const handler$5 = async (argv, logger) => {
|
|
|
16610
16870
|
};
|
|
16611
16871
|
|
|
16612
16872
|
var doctor = {
|
|
16613
|
-
command: command$
|
|
16873
|
+
command: command$7,
|
|
16614
16874
|
desc: 'Check your coco configuration for common issues and suggest fixes',
|
|
16615
|
-
builder: builder$
|
|
16616
|
-
handler: commandExecutor(handler$
|
|
16875
|
+
builder: builder$7,
|
|
16876
|
+
handler: commandExecutor(handler$7),
|
|
16617
16877
|
};
|
|
16618
16878
|
|
|
16619
|
-
const command$
|
|
16879
|
+
const command$6 = 'init';
|
|
16620
16880
|
/**
|
|
16621
16881
|
* Command line options via yargs
|
|
16622
16882
|
*/
|
|
16623
|
-
const options$
|
|
16883
|
+
const options$6 = {
|
|
16624
16884
|
scope: {
|
|
16625
16885
|
type: 'string',
|
|
16626
16886
|
description: 'configure coco for the current user or project?',
|
|
@@ -16632,8 +16892,8 @@ const options$4 = {
|
|
|
16632
16892
|
default: false,
|
|
16633
16893
|
},
|
|
16634
16894
|
};
|
|
16635
|
-
const builder$
|
|
16636
|
-
return yargs.options(options$
|
|
16895
|
+
const builder$6 = (yargs) => {
|
|
16896
|
+
return yargs.options(options$6).usage(getCommandUsageHeader(command$6));
|
|
16637
16897
|
};
|
|
16638
16898
|
|
|
16639
16899
|
/**
|
|
@@ -16988,7 +17248,7 @@ const questions = {
|
|
|
16988
17248
|
}),
|
|
16989
17249
|
};
|
|
16990
17250
|
|
|
16991
|
-
const handler$
|
|
17251
|
+
const handler$6 = async (argv, logger) => {
|
|
16992
17252
|
// Honor the global --repo flag so `coco init --repo <X> --scope project`
|
|
16993
17253
|
// writes the project config to X, not the launcher's cwd. The
|
|
16994
17254
|
// chdir has to happen before getProjectConfigFilePath resolves
|
|
@@ -17172,15 +17432,417 @@ async function installCommitlintPackages(scope, logger) {
|
|
|
17172
17432
|
}
|
|
17173
17433
|
|
|
17174
17434
|
var init = {
|
|
17175
|
-
command: command$
|
|
17435
|
+
command: command$6,
|
|
17176
17436
|
desc: 'install & configure coco globally or for the current project',
|
|
17177
|
-
builder: builder$
|
|
17178
|
-
handler: commandExecutor(handler$
|
|
17179
|
-
options: options$
|
|
17437
|
+
builder: builder$6,
|
|
17438
|
+
handler: commandExecutor(handler$6),
|
|
17439
|
+
options: options$6,
|
|
17180
17440
|
};
|
|
17181
17441
|
|
|
17182
|
-
const command$
|
|
17183
|
-
const options$
|
|
17442
|
+
const command$5 = 'issues';
|
|
17443
|
+
const options$5 = {
|
|
17444
|
+
state: {
|
|
17445
|
+
type: 'string',
|
|
17446
|
+
choices: ['open', 'closed', 'all'],
|
|
17447
|
+
description: 'Filter by issue state.',
|
|
17448
|
+
default: 'open',
|
|
17449
|
+
},
|
|
17450
|
+
assignee: {
|
|
17451
|
+
type: 'string',
|
|
17452
|
+
description: 'Filter by assignee GitHub login (or `@me`).',
|
|
17453
|
+
},
|
|
17454
|
+
author: {
|
|
17455
|
+
type: 'string',
|
|
17456
|
+
description: 'Filter by author GitHub login.',
|
|
17457
|
+
},
|
|
17458
|
+
label: {
|
|
17459
|
+
type: 'string',
|
|
17460
|
+
description: 'Filter by label name (comma-separated for AND).',
|
|
17461
|
+
},
|
|
17462
|
+
search: {
|
|
17463
|
+
type: 'string',
|
|
17464
|
+
description: 'Free-form GitHub issue search query.',
|
|
17465
|
+
},
|
|
17466
|
+
mine: {
|
|
17467
|
+
type: 'boolean',
|
|
17468
|
+
description: 'Shorthand for `--assignee @me`.',
|
|
17469
|
+
default: false,
|
|
17470
|
+
},
|
|
17471
|
+
limit: {
|
|
17472
|
+
type: 'number',
|
|
17473
|
+
description: 'Maximum rows to fetch. Defaults to `gh`\'s own default.',
|
|
17474
|
+
},
|
|
17475
|
+
json: {
|
|
17476
|
+
type: 'boolean',
|
|
17477
|
+
description: 'Print machine-readable JSON instead of a formatted table.',
|
|
17478
|
+
default: false,
|
|
17479
|
+
},
|
|
17480
|
+
refresh: {
|
|
17481
|
+
type: 'boolean',
|
|
17482
|
+
description: 'Force fresh `gh` call (writes through to cache).',
|
|
17483
|
+
default: false,
|
|
17484
|
+
},
|
|
17485
|
+
'no-cache': {
|
|
17486
|
+
type: 'boolean',
|
|
17487
|
+
description: 'Skip the disk cache entirely (no read, no write).',
|
|
17488
|
+
default: false,
|
|
17489
|
+
},
|
|
17490
|
+
};
|
|
17491
|
+
const builder$5 = (yargs) => {
|
|
17492
|
+
return yargs.options(options$5).usage(getCommandUsageHeader(command$5));
|
|
17493
|
+
};
|
|
17494
|
+
|
|
17495
|
+
const execFileAsync = util$1.promisify(child_process.execFile);
|
|
17496
|
+
function parseGitHubRemoteUrl$1(url) {
|
|
17497
|
+
const trimmed = url.trim().replace(/\.git$/, '');
|
|
17498
|
+
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/(.+)$/);
|
|
17499
|
+
const httpsMatch = trimmed.match(/^https:\/\/github\.com\/([^/]+)\/(.+)$/);
|
|
17500
|
+
const match = sshMatch || httpsMatch;
|
|
17501
|
+
if (!match) {
|
|
17502
|
+
return undefined;
|
|
17503
|
+
}
|
|
17504
|
+
return {
|
|
17505
|
+
owner: match[1],
|
|
17506
|
+
name: match[2],
|
|
17507
|
+
};
|
|
17508
|
+
}
|
|
17509
|
+
async function defaultGhRunner(args) {
|
|
17510
|
+
const result = await execFileAsync('gh', args);
|
|
17511
|
+
return result.stdout;
|
|
17512
|
+
}
|
|
17513
|
+
async function getGitHubRepository(git) {
|
|
17514
|
+
const remotes = await git.getRemotes(true);
|
|
17515
|
+
const remote = remotes.find((entry) => entry.name === 'origin') || remotes[0];
|
|
17516
|
+
const url = remote?.refs.push || remote?.refs.fetch;
|
|
17517
|
+
return url ? parseGitHubRemoteUrl$1(url) : undefined;
|
|
17518
|
+
}
|
|
17519
|
+
/**
|
|
17520
|
+
* Probe `gh auth status` and return whether the GitHub CLI is
|
|
17521
|
+
* installed AND authenticated. Used by every data fetcher to short-
|
|
17522
|
+
* circuit before issuing real API calls — keeps the failure-mode
|
|
17523
|
+
* messaging consistent ("CLI missing or not authenticated") instead
|
|
17524
|
+
* of leaking through as a generic spawn error.
|
|
17525
|
+
*/
|
|
17526
|
+
async function isGhAuthenticated(runner) {
|
|
17527
|
+
try {
|
|
17528
|
+
await runner(['auth', 'status', '--hostname', 'github.com']);
|
|
17529
|
+
return true;
|
|
17530
|
+
}
|
|
17531
|
+
catch {
|
|
17532
|
+
return false;
|
|
17533
|
+
}
|
|
17534
|
+
}
|
|
17535
|
+
|
|
17536
|
+
/**
|
|
17537
|
+
* Pad a (possibly already-colored) string to `width` visible columns.
|
|
17538
|
+
* We can't naively `String#padEnd` after coloring because ANSI escape
|
|
17539
|
+
* codes inflate `.length` past the visible width. The pattern here is
|
|
17540
|
+
* "measure the plain string, color last" — every formatter below
|
|
17541
|
+
* computes column widths from raw values and passes the visible
|
|
17542
|
+
* length explicitly so `padToVisible` only needs to add spaces.
|
|
17543
|
+
*/
|
|
17544
|
+
function padToVisible(colored, visibleLength, width) {
|
|
17545
|
+
if (visibleLength >= width)
|
|
17546
|
+
return colored;
|
|
17547
|
+
return colored + ' '.repeat(width - visibleLength);
|
|
17548
|
+
}
|
|
17549
|
+
const STATE_COLORS = {
|
|
17550
|
+
OPEN: chalk.green,
|
|
17551
|
+
CLOSED: chalk.red,
|
|
17552
|
+
MERGED: chalk.magenta,
|
|
17553
|
+
};
|
|
17554
|
+
function colorState(state) {
|
|
17555
|
+
const fn = STATE_COLORS[state.toUpperCase()] || chalk.dim;
|
|
17556
|
+
return fn(state.toLowerCase());
|
|
17557
|
+
}
|
|
17558
|
+
function formatLabels(labels) {
|
|
17559
|
+
if (!labels || labels.length === 0)
|
|
17560
|
+
return '';
|
|
17561
|
+
return chalk.dim(labels.map((l) => `[${l}]`).join(' '));
|
|
17562
|
+
}
|
|
17563
|
+
function formatReviewDecision(decision) {
|
|
17564
|
+
if (!decision)
|
|
17565
|
+
return ' ';
|
|
17566
|
+
switch (decision) {
|
|
17567
|
+
case 'APPROVED':
|
|
17568
|
+
return chalk.green('✓');
|
|
17569
|
+
case 'CHANGES_REQUESTED':
|
|
17570
|
+
return chalk.red('✗');
|
|
17571
|
+
case 'REVIEW_REQUIRED':
|
|
17572
|
+
return chalk.yellow('?');
|
|
17573
|
+
default:
|
|
17574
|
+
return chalk.dim(decision.slice(0, 1));
|
|
17575
|
+
}
|
|
17576
|
+
}
|
|
17577
|
+
function formatMergeable(mergeable, mergeStateStatus) {
|
|
17578
|
+
if (mergeStateStatus === 'CLEAN')
|
|
17579
|
+
return chalk.green('●');
|
|
17580
|
+
if (mergeStateStatus === 'BLOCKED')
|
|
17581
|
+
return chalk.yellow('●');
|
|
17582
|
+
if (mergeStateStatus === 'DIRTY' || mergeable === 'CONFLICTING')
|
|
17583
|
+
return chalk.red('●');
|
|
17584
|
+
if (mergeStateStatus === 'BEHIND')
|
|
17585
|
+
return chalk.cyan('●');
|
|
17586
|
+
if (mergeStateStatus === 'UNSTABLE')
|
|
17587
|
+
return chalk.yellow('●');
|
|
17588
|
+
return chalk.dim('●');
|
|
17589
|
+
}
|
|
17590
|
+
function formatIssueList(items) {
|
|
17591
|
+
if (items.length === 0) {
|
|
17592
|
+
return chalk.dim('No issues match the current filter.');
|
|
17593
|
+
}
|
|
17594
|
+
const numberWidth = Math.max(...items.map((i) => `#${i.number}`.length));
|
|
17595
|
+
const authorWidth = Math.max(...items.map((i) => (i.author ? i.author.length : 0)), 1);
|
|
17596
|
+
const stateWidth = 6;
|
|
17597
|
+
return items
|
|
17598
|
+
.map((issue) => {
|
|
17599
|
+
const numRaw = `#${issue.number}`;
|
|
17600
|
+
const num = padToVisible(chalk.dim(numRaw), numRaw.length, numberWidth);
|
|
17601
|
+
const stateRaw = issue.state.toLowerCase();
|
|
17602
|
+
const state = padToVisible(colorState(issue.state), stateRaw.length, stateWidth);
|
|
17603
|
+
const authorRaw = issue.author || '';
|
|
17604
|
+
const author = padToVisible(chalk.cyan(authorRaw), authorRaw.length, authorWidth);
|
|
17605
|
+
const comments = typeof issue.comments === 'number' && issue.comments > 0
|
|
17606
|
+
? chalk.dim(` ${issue.comments}c`)
|
|
17607
|
+
: '';
|
|
17608
|
+
const labels = formatLabels(issue.labels);
|
|
17609
|
+
const parts = [num, state, author, issue.title];
|
|
17610
|
+
if (labels)
|
|
17611
|
+
parts.push(labels);
|
|
17612
|
+
return parts.join(' ') + comments;
|
|
17613
|
+
})
|
|
17614
|
+
.join('\n');
|
|
17615
|
+
}
|
|
17616
|
+
function formatPullRequestList(items) {
|
|
17617
|
+
if (items.length === 0) {
|
|
17618
|
+
return chalk.dim('No pull requests match the current filter.');
|
|
17619
|
+
}
|
|
17620
|
+
const numberWidth = Math.max(...items.map((i) => `#${i.number}`.length));
|
|
17621
|
+
const authorWidth = Math.max(...items.map((i) => (i.author ? i.author.length : 0)), 1);
|
|
17622
|
+
const headWidth = Math.min(Math.max(...items.map((i) => i.headRefName.length), 1), 28);
|
|
17623
|
+
const stateWidth = 6;
|
|
17624
|
+
return items
|
|
17625
|
+
.map((pr) => {
|
|
17626
|
+
const numRaw = `#${pr.number}`;
|
|
17627
|
+
const num = padToVisible(chalk.dim(numRaw), numRaw.length, numberWidth);
|
|
17628
|
+
const stateRaw = pr.isDraft ? 'draft' : pr.state.toLowerCase();
|
|
17629
|
+
const stateColored = pr.isDraft ? chalk.dim('draft') : colorState(pr.state);
|
|
17630
|
+
const state = padToVisible(stateColored, stateRaw.length, stateWidth);
|
|
17631
|
+
const mergeable = formatMergeable(pr.mergeable, pr.mergeStateStatus);
|
|
17632
|
+
const review = formatReviewDecision(pr.reviewDecision);
|
|
17633
|
+
const authorRaw = pr.author || '';
|
|
17634
|
+
const author = padToVisible(chalk.cyan(authorRaw), authorRaw.length, authorWidth);
|
|
17635
|
+
const branchTruncated = pr.headRefName.length > headWidth
|
|
17636
|
+
? pr.headRefName.slice(0, headWidth - 1) + '…'
|
|
17637
|
+
: pr.headRefName;
|
|
17638
|
+
const branch = padToVisible(chalk.dim(branchTruncated), branchTruncated.length, headWidth);
|
|
17639
|
+
const labels = formatLabels(pr.labels);
|
|
17640
|
+
const parts = [num, state, mergeable, review, author, branch, pr.title];
|
|
17641
|
+
if (labels)
|
|
17642
|
+
parts.push(labels);
|
|
17643
|
+
return parts.join(' ');
|
|
17644
|
+
})
|
|
17645
|
+
.join('\n');
|
|
17646
|
+
}
|
|
17647
|
+
|
|
17648
|
+
/**
|
|
17649
|
+
* `gh issue list --json` field list. Centralized so any future
|
|
17650
|
+
* re-fetch (refresh, cache invalidation) requests the same shape and
|
|
17651
|
+
* the parser can rely on every field being present (even if optional).
|
|
17652
|
+
*/
|
|
17653
|
+
const ISSUE_LIST_JSON_FIELDS = [
|
|
17654
|
+
'number',
|
|
17655
|
+
'title',
|
|
17656
|
+
'url',
|
|
17657
|
+
'state',
|
|
17658
|
+
'author',
|
|
17659
|
+
'assignees',
|
|
17660
|
+
'labels',
|
|
17661
|
+
'comments',
|
|
17662
|
+
'createdAt',
|
|
17663
|
+
'updatedAt',
|
|
17664
|
+
].join(',');
|
|
17665
|
+
function parseIssueListItems(output) {
|
|
17666
|
+
const trimmed = output.trim();
|
|
17667
|
+
if (!trimmed)
|
|
17668
|
+
return [];
|
|
17669
|
+
const raw = JSON.parse(trimmed);
|
|
17670
|
+
return raw.map((entry) => {
|
|
17671
|
+
const author = entry.author && typeof entry.author === 'object' && 'login' in entry.author
|
|
17672
|
+
? String(entry.author.login)
|
|
17673
|
+
: undefined;
|
|
17674
|
+
const assignees = Array.isArray(entry.assignees)
|
|
17675
|
+
? entry.assignees
|
|
17676
|
+
.map((a) => (a && 'login' in a ? String(a.login) : ''))
|
|
17677
|
+
.filter(Boolean)
|
|
17678
|
+
: undefined;
|
|
17679
|
+
const labels = Array.isArray(entry.labels)
|
|
17680
|
+
? entry.labels
|
|
17681
|
+
.map((l) => (l && 'name' in l ? String(l.name) : ''))
|
|
17682
|
+
.filter(Boolean)
|
|
17683
|
+
: undefined;
|
|
17684
|
+
return {
|
|
17685
|
+
number: entry.number,
|
|
17686
|
+
title: String(entry.title || ''),
|
|
17687
|
+
url: String(entry.url || ''),
|
|
17688
|
+
state: String(entry.state || ''),
|
|
17689
|
+
author,
|
|
17690
|
+
assignees,
|
|
17691
|
+
labels,
|
|
17692
|
+
comments: typeof entry.comments === 'number' ? entry.comments : undefined,
|
|
17693
|
+
createdAt: String(entry.createdAt || ''),
|
|
17694
|
+
updatedAt: String(entry.updatedAt || ''),
|
|
17695
|
+
};
|
|
17696
|
+
});
|
|
17697
|
+
}
|
|
17698
|
+
function buildGhArgs$1(filter) {
|
|
17699
|
+
const args = ['issue', 'list', '--json', ISSUE_LIST_JSON_FIELDS];
|
|
17700
|
+
if (filter.state)
|
|
17701
|
+
args.push('--state', filter.state);
|
|
17702
|
+
if (filter.assignee)
|
|
17703
|
+
args.push('--assignee', filter.assignee);
|
|
17704
|
+
if (filter.author)
|
|
17705
|
+
args.push('--author', filter.author);
|
|
17706
|
+
if (filter.label)
|
|
17707
|
+
args.push('--label', filter.label);
|
|
17708
|
+
if (filter.search)
|
|
17709
|
+
args.push('--search', filter.search);
|
|
17710
|
+
if (typeof filter.limit === 'number')
|
|
17711
|
+
args.push('--limit', String(filter.limit));
|
|
17712
|
+
return args;
|
|
17713
|
+
}
|
|
17714
|
+
async function getIssueList(git, filter = {}, runner = defaultGhRunner) {
|
|
17715
|
+
const repository = await getGitHubRepository(git);
|
|
17716
|
+
if (!repository) {
|
|
17717
|
+
return {
|
|
17718
|
+
available: false,
|
|
17719
|
+
authenticated: false,
|
|
17720
|
+
filter,
|
|
17721
|
+
message: 'No GitHub remote detected.',
|
|
17722
|
+
};
|
|
17723
|
+
}
|
|
17724
|
+
if (!(await isGhAuthenticated(runner))) {
|
|
17725
|
+
return {
|
|
17726
|
+
available: true,
|
|
17727
|
+
authenticated: false,
|
|
17728
|
+
repository,
|
|
17729
|
+
filter,
|
|
17730
|
+
message: 'GitHub CLI is missing or not authenticated.',
|
|
17731
|
+
};
|
|
17732
|
+
}
|
|
17733
|
+
try {
|
|
17734
|
+
const output = await runner(buildGhArgs$1(filter));
|
|
17735
|
+
return {
|
|
17736
|
+
available: true,
|
|
17737
|
+
authenticated: true,
|
|
17738
|
+
repository,
|
|
17739
|
+
filter,
|
|
17740
|
+
issues: parseIssueListItems(output),
|
|
17741
|
+
};
|
|
17742
|
+
}
|
|
17743
|
+
catch (error) {
|
|
17744
|
+
return {
|
|
17745
|
+
available: true,
|
|
17746
|
+
authenticated: true,
|
|
17747
|
+
repository,
|
|
17748
|
+
filter,
|
|
17749
|
+
message: error instanceof Error ? error.message : 'Failed to fetch issue list.',
|
|
17750
|
+
};
|
|
17751
|
+
}
|
|
17752
|
+
}
|
|
17753
|
+
|
|
17754
|
+
const handler$5 = async (argv, logger) => {
|
|
17755
|
+
const git = applyRepoFlag(argv);
|
|
17756
|
+
// `applyRepoFlag` chdir'd to the repo path (or kept process.cwd
|
|
17757
|
+
// when --repo was omitted), so the cache key derives from a stable
|
|
17758
|
+
// absolute path either way.
|
|
17759
|
+
const repoPath = process.cwd();
|
|
17760
|
+
const filter = {
|
|
17761
|
+
state: argv.state,
|
|
17762
|
+
assignee: argv.mine ? '@me' : argv.assignee,
|
|
17763
|
+
author: argv.author,
|
|
17764
|
+
label: argv.label,
|
|
17765
|
+
search: argv.search,
|
|
17766
|
+
limit: argv.limit,
|
|
17767
|
+
};
|
|
17768
|
+
const cacheEnabled = !argv.noCache;
|
|
17769
|
+
let issues;
|
|
17770
|
+
let fromCache = false;
|
|
17771
|
+
let cacheAgeMs;
|
|
17772
|
+
// Repository metadata is needed for the header in both code paths
|
|
17773
|
+
// (cache hit and fresh fetch). The cache hit path skips
|
|
17774
|
+
// `getIssueList` entirely, so probe it directly here. Cheap — no
|
|
17775
|
+
// network, just a single `git remote` parse.
|
|
17776
|
+
const repository = await getGitHubRepository(git);
|
|
17777
|
+
if (cacheEnabled && !argv.refresh) {
|
|
17778
|
+
const cached = readCachedList('issues', repoPath, filter);
|
|
17779
|
+
if (cached?.fresh) {
|
|
17780
|
+
issues = cached.payload.items;
|
|
17781
|
+
fromCache = true;
|
|
17782
|
+
cacheAgeMs = cached.ageMs;
|
|
17783
|
+
}
|
|
17784
|
+
}
|
|
17785
|
+
if (!issues) {
|
|
17786
|
+
const overview = await getIssueList(git, filter);
|
|
17787
|
+
if (!overview.available) {
|
|
17788
|
+
logger.log(chalk.red(overview.message || 'No GitHub remote detected.'));
|
|
17789
|
+
commandExit(1);
|
|
17790
|
+
return;
|
|
17791
|
+
}
|
|
17792
|
+
if (!overview.authenticated) {
|
|
17793
|
+
logger.log(chalk.yellow(overview.message || 'GitHub CLI is missing or not authenticated.'));
|
|
17794
|
+
logger.log(chalk.dim('Install `gh` and run `gh auth login` to enable issue triage.'));
|
|
17795
|
+
commandExit(1);
|
|
17796
|
+
return;
|
|
17797
|
+
}
|
|
17798
|
+
if (overview.message) {
|
|
17799
|
+
logger.log(chalk.red(overview.message));
|
|
17800
|
+
commandExit(1);
|
|
17801
|
+
return;
|
|
17802
|
+
}
|
|
17803
|
+
issues = overview.issues || [];
|
|
17804
|
+
if (cacheEnabled) {
|
|
17805
|
+
writeCachedList(repoPath, filter, { kind: 'issues', items: issues });
|
|
17806
|
+
}
|
|
17807
|
+
}
|
|
17808
|
+
if (argv.json) {
|
|
17809
|
+
logger.log(JSON.stringify(issues, null, 2));
|
|
17810
|
+
return;
|
|
17811
|
+
}
|
|
17812
|
+
if (repository) {
|
|
17813
|
+
const filterParts = [];
|
|
17814
|
+
if (filter.state && filter.state !== 'open')
|
|
17815
|
+
filterParts.push(`state=${filter.state}`);
|
|
17816
|
+
if (filter.assignee)
|
|
17817
|
+
filterParts.push(`assignee=${filter.assignee}`);
|
|
17818
|
+
if (filter.author)
|
|
17819
|
+
filterParts.push(`author=${filter.author}`);
|
|
17820
|
+
if (filter.label)
|
|
17821
|
+
filterParts.push(`label=${filter.label}`);
|
|
17822
|
+
if (filter.search)
|
|
17823
|
+
filterParts.push(`search=${JSON.stringify(filter.search)}`);
|
|
17824
|
+
const suffix = filterParts.length ? chalk.dim(` (${filterParts.join(', ')})`) : '';
|
|
17825
|
+
const cacheTag = fromCache && typeof cacheAgeMs === 'number'
|
|
17826
|
+
? chalk.dim(` · cached ${Math.round(cacheAgeMs / 1000)}s ago`)
|
|
17827
|
+
: '';
|
|
17828
|
+
logger.log(chalk.bold(`${repository.owner}/${repository.name}`) +
|
|
17829
|
+
chalk.dim(` · ${issues.length} issue${issues.length === 1 ? '' : 's'}`) +
|
|
17830
|
+
suffix +
|
|
17831
|
+
cacheTag);
|
|
17832
|
+
logger.log('');
|
|
17833
|
+
}
|
|
17834
|
+
logger.log(formatIssueList(issues));
|
|
17835
|
+
};
|
|
17836
|
+
|
|
17837
|
+
var issues = {
|
|
17838
|
+
command: command$5,
|
|
17839
|
+
desc: 'List GitHub issues for the current repository (read-only triage)',
|
|
17840
|
+
builder: builder$5,
|
|
17841
|
+
handler: commandExecutor(handler$5),
|
|
17842
|
+
};
|
|
17843
|
+
|
|
17844
|
+
const command$4 = 'log';
|
|
17845
|
+
const options$4 = {
|
|
17184
17846
|
i: {
|
|
17185
17847
|
description: 'Open the interactive terminal log UI',
|
|
17186
17848
|
type: 'boolean',
|
|
@@ -17243,8 +17905,8 @@ const options$3 = {
|
|
|
17243
17905
|
default: 'compact',
|
|
17244
17906
|
},
|
|
17245
17907
|
};
|
|
17246
|
-
const builder$
|
|
17247
|
-
return yargs.options(options$
|
|
17908
|
+
const builder$4 = (yargs) => {
|
|
17909
|
+
return yargs.options(options$4).usage(getCommandUsageHeader(command$4));
|
|
17248
17910
|
};
|
|
17249
17911
|
|
|
17250
17912
|
/**
|
|
@@ -17818,30 +18480,6 @@ async function getBranchOverview(git) {
|
|
|
17818
18480
|
};
|
|
17819
18481
|
}
|
|
17820
18482
|
|
|
17821
|
-
const execFileAsync = util$1.promisify(child_process.execFile);
|
|
17822
|
-
function parseGitHubRemoteUrl$1(url) {
|
|
17823
|
-
const trimmed = url.trim().replace(/\.git$/, '');
|
|
17824
|
-
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/(.+)$/);
|
|
17825
|
-
const httpsMatch = trimmed.match(/^https:\/\/github\.com\/([^/]+)\/(.+)$/);
|
|
17826
|
-
const match = sshMatch || httpsMatch;
|
|
17827
|
-
if (!match) {
|
|
17828
|
-
return undefined;
|
|
17829
|
-
}
|
|
17830
|
-
return {
|
|
17831
|
-
owner: match[1],
|
|
17832
|
-
name: match[2],
|
|
17833
|
-
};
|
|
17834
|
-
}
|
|
17835
|
-
async function defaultGhRunner(args) {
|
|
17836
|
-
const result = await execFileAsync('gh', args);
|
|
17837
|
-
return result.stdout;
|
|
17838
|
-
}
|
|
17839
|
-
async function getGitHubRepository(git) {
|
|
17840
|
-
const remotes = await git.getRemotes(true);
|
|
17841
|
-
const remote = remotes.find((entry) => entry.name === 'origin') || remotes[0];
|
|
17842
|
-
const url = remote?.refs.push || remote?.refs.fetch;
|
|
17843
|
-
return url ? parseGitHubRemoteUrl$1(url) : undefined;
|
|
17844
|
-
}
|
|
17845
18483
|
function parsePullRequestInfo(output) {
|
|
17846
18484
|
const trimmed = output.trim();
|
|
17847
18485
|
if (!trimmed) {
|
|
@@ -17920,10 +18558,7 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
|
|
|
17920
18558
|
message: 'No GitHub remote detected.',
|
|
17921
18559
|
};
|
|
17922
18560
|
}
|
|
17923
|
-
|
|
17924
|
-
await runner(['auth', 'status', '--hostname', 'github.com']);
|
|
17925
|
-
}
|
|
17926
|
-
catch {
|
|
18561
|
+
if (!(await isGhAuthenticated(runner))) {
|
|
17927
18562
|
return {
|
|
17928
18563
|
available: true,
|
|
17929
18564
|
authenticated: false,
|
|
@@ -19800,6 +20435,7 @@ async function runCommitSplitPlanWorkflow(input = {}) {
|
|
|
19800
20435
|
const key = getApiKeyForModel(config);
|
|
19801
20436
|
const { provider } = getModelAndProviderFromConfig(config);
|
|
19802
20437
|
const commitService = resolveDynamicService(config, 'commit');
|
|
20438
|
+
const splitService = resolveDynamicService(config, 'commitSplit');
|
|
19803
20439
|
const model = commitService.model;
|
|
19804
20440
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
19805
20441
|
return {
|
|
@@ -19810,6 +20446,10 @@ async function runCommitSplitPlanWorkflow(input = {}) {
|
|
|
19810
20446
|
try {
|
|
19811
20447
|
const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
|
|
19812
20448
|
const llm = getLlm(provider, model, { ...config, service: commitService });
|
|
20449
|
+
const planLlm = getLlm(provider, splitService.model, {
|
|
20450
|
+
...config,
|
|
20451
|
+
service: splitService,
|
|
20452
|
+
});
|
|
19813
20453
|
const result = await prepareCommitSplitPlan({
|
|
19814
20454
|
argv,
|
|
19815
20455
|
config,
|
|
@@ -19817,6 +20457,8 @@ async function runCommitSplitPlanWorkflow(input = {}) {
|
|
|
19817
20457
|
logger,
|
|
19818
20458
|
tokenizer,
|
|
19819
20459
|
llm,
|
|
20460
|
+
planLlm,
|
|
20461
|
+
planService: splitService,
|
|
19820
20462
|
});
|
|
19821
20463
|
if ('empty' in result) {
|
|
19822
20464
|
return {
|
|
@@ -19958,7 +20600,7 @@ async function runPullRequestBodyWorkflow(input = {}) {
|
|
|
19958
20600
|
const argv = createChangelogArgv({ branch: baseBranch });
|
|
19959
20601
|
let raw = '';
|
|
19960
20602
|
try {
|
|
19961
|
-
raw = await captureStdout(() => handler$
|
|
20603
|
+
raw = await captureStdout(() => handler$9(argv, new Logger({
|
|
19962
20604
|
verbose: true,
|
|
19963
20605
|
silent: false,
|
|
19964
20606
|
})));
|
|
@@ -20021,7 +20663,7 @@ async function runChangelogTextWorkflow(input = {}) {
|
|
|
20021
20663
|
const argv = createChangelogArgv(input);
|
|
20022
20664
|
let raw = '';
|
|
20023
20665
|
try {
|
|
20024
|
-
raw = await captureStdout(() => handler$
|
|
20666
|
+
raw = await captureStdout(() => handler$9(argv, new Logger({
|
|
20025
20667
|
verbose: true,
|
|
20026
20668
|
silent: false,
|
|
20027
20669
|
})));
|
|
@@ -20043,10 +20685,12 @@ async function runChangelogTextWorkflow(input = {}) {
|
|
|
20043
20685
|
const LOG_INK_CONTEXT_KEYS = [
|
|
20044
20686
|
'bisect',
|
|
20045
20687
|
'branches',
|
|
20688
|
+
'issueList',
|
|
20046
20689
|
'lfs',
|
|
20047
20690
|
'operation',
|
|
20048
20691
|
'provider',
|
|
20049
20692
|
'pullRequest',
|
|
20693
|
+
'pullRequestList',
|
|
20050
20694
|
'reflog',
|
|
20051
20695
|
'stashes',
|
|
20052
20696
|
'submodules',
|
|
@@ -20492,6 +21136,60 @@ function getLogInkWorkflowActions() {
|
|
|
20492
21136
|
kind: 'normal',
|
|
20493
21137
|
requiresConfirmation: false,
|
|
20494
21138
|
},
|
|
21139
|
+
// #882 phase 5 — triage-view destructive verbs. Each routed
|
|
21140
|
+
// through the y-confirm path so single-keystroke `x` / `a` /
|
|
21141
|
+
// `R` / `m` never silently rewrites publicly-visible state.
|
|
21142
|
+
// The runner reads the cursored item from the filtered list
|
|
21143
|
+
// at confirm-time — the cursor can't move while the
|
|
21144
|
+
// confirmation overlay is up, so no stale-target risk.
|
|
21145
|
+
{
|
|
21146
|
+
id: 'triage-issue-close',
|
|
21147
|
+
key: '',
|
|
21148
|
+
label: 'Close issue',
|
|
21149
|
+
description: 'Close the cursored issue on the triage list view.',
|
|
21150
|
+
kind: 'destructive',
|
|
21151
|
+
requiresConfirmation: true,
|
|
21152
|
+
},
|
|
21153
|
+
{
|
|
21154
|
+
id: 'triage-issue-reopen',
|
|
21155
|
+
key: '',
|
|
21156
|
+
label: 'Reopen issue',
|
|
21157
|
+
description: 'Reopen the cursored issue on the triage list view.',
|
|
21158
|
+
kind: 'normal',
|
|
21159
|
+
requiresConfirmation: true,
|
|
21160
|
+
},
|
|
21161
|
+
{
|
|
21162
|
+
id: 'triage-pr-merge',
|
|
21163
|
+
key: '',
|
|
21164
|
+
label: 'Merge pull request',
|
|
21165
|
+
description: 'Merge the cursored pull request on the triage list view (prompts for merge / squash / rebase, then confirms).',
|
|
21166
|
+
kind: 'destructive',
|
|
21167
|
+
requiresConfirmation: true,
|
|
21168
|
+
},
|
|
21169
|
+
{
|
|
21170
|
+
id: 'triage-pr-close',
|
|
21171
|
+
key: '',
|
|
21172
|
+
label: 'Close pull request',
|
|
21173
|
+
description: 'Close the cursored pull request on the triage list view without merging.',
|
|
21174
|
+
kind: 'destructive',
|
|
21175
|
+
requiresConfirmation: true,
|
|
21176
|
+
},
|
|
21177
|
+
{
|
|
21178
|
+
id: 'triage-pr-approve',
|
|
21179
|
+
key: '',
|
|
21180
|
+
label: 'Approve pull request',
|
|
21181
|
+
description: 'Submit an approving review on the cursored pull request.',
|
|
21182
|
+
kind: 'normal',
|
|
21183
|
+
requiresConfirmation: true,
|
|
21184
|
+
},
|
|
21185
|
+
{
|
|
21186
|
+
id: 'triage-pr-request-changes',
|
|
21187
|
+
key: '',
|
|
21188
|
+
label: 'Request changes on pull request',
|
|
21189
|
+
description: 'Submit a change-request review on the cursored pull request (prompts for body, then confirms).',
|
|
21190
|
+
kind: 'normal',
|
|
21191
|
+
requiresConfirmation: true,
|
|
21192
|
+
},
|
|
20495
21193
|
{
|
|
20496
21194
|
// Per-view-only: scoped to the history view in inkInput so `R`
|
|
20497
21195
|
// doesn't fire elsewhere (it's also `R` for rename in branches
|
|
@@ -20887,6 +21585,20 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
20887
21585
|
description: 'Push the dedicated pull-request action panel for the current branch.',
|
|
20888
21586
|
contexts: ['normal'],
|
|
20889
21587
|
},
|
|
21588
|
+
{
|
|
21589
|
+
id: 'navigatePullRequestTriage',
|
|
21590
|
+
keys: ['gP'],
|
|
21591
|
+
label: 'PR triage',
|
|
21592
|
+
description: 'Push the multi-PR triage list view (#882). Capital P disambiguates from `gp` which targets the single-PR panel for the current branch.',
|
|
21593
|
+
contexts: ['normal'],
|
|
21594
|
+
},
|
|
21595
|
+
{
|
|
21596
|
+
id: 'navigateIssues',
|
|
21597
|
+
keys: ['gi'],
|
|
21598
|
+
label: 'issues',
|
|
21599
|
+
description: 'Push the issue triage list view (#882).',
|
|
21600
|
+
contexts: ['normal'],
|
|
21601
|
+
},
|
|
20890
21602
|
{
|
|
20891
21603
|
id: 'navigateConflicts',
|
|
20892
21604
|
keys: ['gx'],
|
|
@@ -21065,6 +21777,8 @@ const GLOBAL_BINDING_IDS = [
|
|
|
21065
21777
|
'navigateStash',
|
|
21066
21778
|
'navigateWorktrees',
|
|
21067
21779
|
'navigatePullRequest',
|
|
21780
|
+
'navigatePullRequestTriage',
|
|
21781
|
+
'navigateIssues',
|
|
21068
21782
|
'navigateConflicts',
|
|
21069
21783
|
'navigateReflog',
|
|
21070
21784
|
'navigateBisect',
|
|
@@ -21302,6 +22016,24 @@ function getLogInkFooterHints(options) {
|
|
|
21302
22016
|
global: NORMAL_GLOBAL_HINTS,
|
|
21303
22017
|
};
|
|
21304
22018
|
}
|
|
22019
|
+
if (options.activeView === 'issues') {
|
|
22020
|
+
return {
|
|
22021
|
+
// #882 phase 4-6 — read + additive mutations + destructive
|
|
22022
|
+
// (gated through y-confirm) + filter cycling. AI summarize
|
|
22023
|
+
// (`I`) deferred to a follow-up.
|
|
22024
|
+
contextual: ['↑/↓ issues', 'f filter', 'O open', 'y yank URL', 'c comment', 'L label', 'A assign', 'x close*', 'X reopen', 'esc back'],
|
|
22025
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
22026
|
+
};
|
|
22027
|
+
}
|
|
22028
|
+
if (options.activeView === 'pull-request-triage') {
|
|
22029
|
+
return {
|
|
22030
|
+
// #882 phase 4-6 — full PR action panel scoped to the triage
|
|
22031
|
+
// list + filter cycling. AI summarize (`I`) deferred to a
|
|
22032
|
+
// follow-up.
|
|
22033
|
+
contextual: ['↑/↓ PRs', 'f filter', 'O open', 'y yank URL', 'c comment', 'L label', 'A assign', 'm merge*', 'x close*', 'a approve', 'R changes*', 'esc back'],
|
|
22034
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
22035
|
+
};
|
|
22036
|
+
}
|
|
21305
22037
|
if (options.activeView === 'submodules') {
|
|
21306
22038
|
return {
|
|
21307
22039
|
contextual: ['↑/↓ entries', 'y yank path', 'Y yank sha', '/ filter', 'esc back'],
|
|
@@ -21482,6 +22214,109 @@ function filterLogInkPaletteCommands(commands, filter, recent) {
|
|
|
21482
22214
|
.map((entry) => entry.command);
|
|
21483
22215
|
}
|
|
21484
22216
|
|
|
22217
|
+
/**
|
|
22218
|
+
* Canned filter presets for the issue / PR triage TUI views
|
|
22219
|
+
* (#882 phase 6). Each preset compiles to the same shape the
|
|
22220
|
+
* underlying list fetchers (`getIssueList` / `getPullRequestList`)
|
|
22221
|
+
* already accept — there's no new `gh` surface area, just a
|
|
22222
|
+
* curated set of common triage angles surfaced as a single
|
|
22223
|
+
* keystroke (`f` cycles).
|
|
22224
|
+
*
|
|
22225
|
+
* The presets are deliberately *not* a 1:1 mirror across the two
|
|
22226
|
+
* surfaces:
|
|
22227
|
+
*
|
|
22228
|
+
* - Issues have no draft / mergeable concept, so `draft` /
|
|
22229
|
+
* `mergeable` are skipped on the issue list.
|
|
22230
|
+
* - PRs have a `merged` state distinct from `closed`; issues
|
|
22231
|
+
* don't.
|
|
22232
|
+
* - `mine` semantics differ subtly: for issues it tends to
|
|
22233
|
+
* mean "I'm the assignee" (issues are tasks people pick up);
|
|
22234
|
+
* for PRs it means "I'm the author" (PRs are work people
|
|
22235
|
+
* post). The presets bake those in so the user doesn't have
|
|
22236
|
+
* to think about it.
|
|
22237
|
+
*/
|
|
22238
|
+
/** Cycle order — must match the keystroke walk on `f`. */
|
|
22239
|
+
const ISSUE_FILTER_PRESETS = [
|
|
22240
|
+
'open',
|
|
22241
|
+
'closed',
|
|
22242
|
+
'mine',
|
|
22243
|
+
'assigned',
|
|
22244
|
+
];
|
|
22245
|
+
const PULL_REQUEST_FILTER_PRESETS = [
|
|
22246
|
+
'open',
|
|
22247
|
+
'draft',
|
|
22248
|
+
'mine',
|
|
22249
|
+
'assigned',
|
|
22250
|
+
'closed',
|
|
22251
|
+
'merged',
|
|
22252
|
+
];
|
|
22253
|
+
const ISSUE_FILTER_LABELS = {
|
|
22254
|
+
open: 'open',
|
|
22255
|
+
closed: 'closed',
|
|
22256
|
+
mine: 'mine (assigned)',
|
|
22257
|
+
assigned: 'assigned to me',
|
|
22258
|
+
};
|
|
22259
|
+
const PULL_REQUEST_FILTER_LABELS = {
|
|
22260
|
+
open: 'open',
|
|
22261
|
+
draft: 'draft',
|
|
22262
|
+
mine: 'mine (authored)',
|
|
22263
|
+
assigned: 'assigned to me',
|
|
22264
|
+
closed: 'closed',
|
|
22265
|
+
merged: 'merged',
|
|
22266
|
+
};
|
|
22267
|
+
/**
|
|
22268
|
+
* Resolve a preset to the filter object the data fetcher accepts.
|
|
22269
|
+
* Pure mapping — no `gh` calls. Kept separate from `getIssueList` /
|
|
22270
|
+
* `getPullRequestList` so unit tests can assert the mapping
|
|
22271
|
+
* independently from the fetch pipeline.
|
|
22272
|
+
*/
|
|
22273
|
+
function issueFilterForPreset(preset) {
|
|
22274
|
+
switch (preset) {
|
|
22275
|
+
case 'open':
|
|
22276
|
+
return { state: 'open' };
|
|
22277
|
+
case 'closed':
|
|
22278
|
+
return { state: 'closed' };
|
|
22279
|
+
case 'mine':
|
|
22280
|
+
// Issues are tasks — "mine" is what *I'm working on*, i.e.
|
|
22281
|
+
// assigned to me + still open. Same as `assigned` plus the
|
|
22282
|
+
// open-state filter for ergonomic single-keystroke focus on
|
|
22283
|
+
// the active backlog.
|
|
22284
|
+
return { state: 'open', assignee: '@me' };
|
|
22285
|
+
case 'assigned':
|
|
22286
|
+
return { assignee: '@me' };
|
|
22287
|
+
}
|
|
22288
|
+
}
|
|
22289
|
+
function pullRequestFilterForPreset(preset) {
|
|
22290
|
+
switch (preset) {
|
|
22291
|
+
case 'open':
|
|
22292
|
+
return { state: 'open' };
|
|
22293
|
+
case 'draft':
|
|
22294
|
+
// gh's `--draft` flag implies `--state open`; surface that
|
|
22295
|
+
// explicitly so the canonicalize step doesn't elide it.
|
|
22296
|
+
return { state: 'open', draft: true };
|
|
22297
|
+
case 'mine':
|
|
22298
|
+
// PRs are work — "mine" is what *I authored*. Most useful
|
|
22299
|
+
// when looking at one's own backlog of in-flight PRs.
|
|
22300
|
+
return { state: 'open', author: '@me' };
|
|
22301
|
+
case 'assigned':
|
|
22302
|
+
return { assignee: '@me' };
|
|
22303
|
+
case 'closed':
|
|
22304
|
+
return { state: 'closed' };
|
|
22305
|
+
case 'merged':
|
|
22306
|
+
return { state: 'merged' };
|
|
22307
|
+
}
|
|
22308
|
+
}
|
|
22309
|
+
function cycleIssueFilterPreset(current) {
|
|
22310
|
+
const index = ISSUE_FILTER_PRESETS.indexOf(current);
|
|
22311
|
+
const next = (index + 1) % ISSUE_FILTER_PRESETS.length;
|
|
22312
|
+
return ISSUE_FILTER_PRESETS[next];
|
|
22313
|
+
}
|
|
22314
|
+
function cyclePullRequestFilterPreset(current) {
|
|
22315
|
+
const index = PULL_REQUEST_FILTER_PRESETS.indexOf(current);
|
|
22316
|
+
const next = (index + 1) % PULL_REQUEST_FILTER_PRESETS.length;
|
|
22317
|
+
return PULL_REQUEST_FILTER_PRESETS[next];
|
|
22318
|
+
}
|
|
22319
|
+
|
|
21485
22320
|
/**
|
|
21486
22321
|
* Sort modes for the promoted views (P4.2).
|
|
21487
22322
|
*
|
|
@@ -21869,6 +22704,10 @@ function createLogInkState(rows, options = {}) {
|
|
|
21869
22704
|
selectedConflictFileIndex: 0,
|
|
21870
22705
|
selectedReflogIndex: 0,
|
|
21871
22706
|
selectedSubmoduleIndex: 0,
|
|
22707
|
+
selectedIssueIndex: 0,
|
|
22708
|
+
selectedPullRequestTriageIndex: 0,
|
|
22709
|
+
selectedIssueFilter: 'open',
|
|
22710
|
+
selectedPullRequestFilter: 'open',
|
|
21872
22711
|
repoStack: [{ label: options.repoLabel || 'root' }],
|
|
21873
22712
|
branchSort: DEFAULT_BRANCH_SORT_MODE,
|
|
21874
22713
|
tagSort: DEFAULT_TAG_SORT_MODE,
|
|
@@ -22131,6 +22970,36 @@ function applyLogInkAction(state, action) {
|
|
|
22131
22970
|
selectedSubmoduleIndex: clampIndex(state.selectedSubmoduleIndex + action.delta, action.count),
|
|
22132
22971
|
pendingKey: undefined,
|
|
22133
22972
|
};
|
|
22973
|
+
case 'moveIssue':
|
|
22974
|
+
return {
|
|
22975
|
+
...state,
|
|
22976
|
+
selectedIssueIndex: clampIndex(state.selectedIssueIndex + action.delta, action.count),
|
|
22977
|
+
pendingKey: undefined,
|
|
22978
|
+
};
|
|
22979
|
+
case 'movePullRequestTriage':
|
|
22980
|
+
return {
|
|
22981
|
+
...state,
|
|
22982
|
+
selectedPullRequestTriageIndex: clampIndex(state.selectedPullRequestTriageIndex + action.delta, action.count),
|
|
22983
|
+
pendingKey: undefined,
|
|
22984
|
+
};
|
|
22985
|
+
case 'cycleIssueFilter':
|
|
22986
|
+
// Advance the preset, snap the cursor to the top of the
|
|
22987
|
+
// (newly filtered) list — same UX rule as `cycleBranchSort`.
|
|
22988
|
+
// The list refetches on preset change via the effect in
|
|
22989
|
+
// app.ts, so the cursor at 0 lands on whatever was promoted.
|
|
22990
|
+
return {
|
|
22991
|
+
...state,
|
|
22992
|
+
selectedIssueFilter: cycleIssueFilterPreset(state.selectedIssueFilter),
|
|
22993
|
+
selectedIssueIndex: 0,
|
|
22994
|
+
pendingKey: undefined,
|
|
22995
|
+
};
|
|
22996
|
+
case 'cyclePullRequestTriageFilter':
|
|
22997
|
+
return {
|
|
22998
|
+
...state,
|
|
22999
|
+
selectedPullRequestFilter: cyclePullRequestFilterPreset(state.selectedPullRequestFilter),
|
|
23000
|
+
selectedPullRequestTriageIndex: 0,
|
|
23001
|
+
pendingKey: undefined,
|
|
23002
|
+
};
|
|
22134
23003
|
case 'moveWorktreeListEntry':
|
|
22135
23004
|
return {
|
|
22136
23005
|
...state,
|
|
@@ -22953,6 +23822,22 @@ function isReflogActionTarget(state) {
|
|
|
22953
23822
|
function isSubmodulesActionTarget(state) {
|
|
22954
23823
|
return state.activeView === 'submodules' && state.focus === 'commits';
|
|
22955
23824
|
}
|
|
23825
|
+
/**
|
|
23826
|
+
* Issue triage list (#882 phase 3). Same shape as the other promoted
|
|
23827
|
+
* read-only views — j/k move the cursor when the commits pane is
|
|
23828
|
+
* focused on the dedicated view.
|
|
23829
|
+
*/
|
|
23830
|
+
function isIssueActionTarget(state) {
|
|
23831
|
+
return state.activeView === 'issues' && state.focus === 'commits';
|
|
23832
|
+
}
|
|
23833
|
+
/**
|
|
23834
|
+
* Pull-request triage list (#882 phase 3). Distinct from the existing
|
|
23835
|
+
* `pull-request` single-PR action panel — this is the multi-PR list
|
|
23836
|
+
* surface and its cursor lives in `selectedPullRequestTriageIndex`.
|
|
23837
|
+
*/
|
|
23838
|
+
function isPullRequestTriageActionTarget(state) {
|
|
23839
|
+
return state.activeView === 'pull-request-triage' && state.focus === 'commits';
|
|
23840
|
+
}
|
|
22956
23841
|
function isWorktreeActionTarget(state) {
|
|
22957
23842
|
return (state.activeView === 'worktrees' && state.focus === 'commits') ||
|
|
22958
23843
|
(state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
|
|
@@ -23102,6 +23987,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
23102
23987
|
return [action({ type: 'pushView', value: 'worktrees' })];
|
|
23103
23988
|
case 'navigatePullRequest':
|
|
23104
23989
|
return [action({ type: 'pushView', value: 'pull-request' })];
|
|
23990
|
+
case 'navigatePullRequestTriage':
|
|
23991
|
+
return [action({ type: 'pushView', value: 'pull-request-triage' })];
|
|
23992
|
+
case 'navigateIssues':
|
|
23993
|
+
return [action({ type: 'pushView', value: 'issues' })];
|
|
23105
23994
|
case 'navigateConflicts':
|
|
23106
23995
|
return [action({ type: 'pushView', value: 'conflicts' })];
|
|
23107
23996
|
case 'navigateReflog':
|
|
@@ -23276,6 +24165,69 @@ function submitInputPrompt(state) {
|
|
|
23276
24165
|
action({ type: 'closeInputPrompt' }),
|
|
23277
24166
|
];
|
|
23278
24167
|
}
|
|
24168
|
+
// #882 phase 4 — triage-view mutation prompts. Each kind routes to
|
|
24169
|
+
// its by-number workflow id; the runner reads the cursored item
|
|
24170
|
+
// from state + filtered list and runs the matching `gh` action.
|
|
24171
|
+
if (state.inputPrompt.kind === 'triage-issue-comment') {
|
|
24172
|
+
return [
|
|
24173
|
+
{ type: 'runWorkflowAction', id: 'triage-issue-comment', payload: value },
|
|
24174
|
+
action({ type: 'closeInputPrompt' }),
|
|
24175
|
+
];
|
|
24176
|
+
}
|
|
24177
|
+
if (state.inputPrompt.kind === 'triage-issue-label') {
|
|
24178
|
+
return [
|
|
24179
|
+
{ type: 'runWorkflowAction', id: 'triage-issue-label', payload: value },
|
|
24180
|
+
action({ type: 'closeInputPrompt' }),
|
|
24181
|
+
];
|
|
24182
|
+
}
|
|
24183
|
+
if (state.inputPrompt.kind === 'triage-issue-assign') {
|
|
24184
|
+
return [
|
|
24185
|
+
{ type: 'runWorkflowAction', id: 'triage-issue-assign', payload: value },
|
|
24186
|
+
action({ type: 'closeInputPrompt' }),
|
|
24187
|
+
];
|
|
24188
|
+
}
|
|
24189
|
+
if (state.inputPrompt.kind === 'triage-pr-comment') {
|
|
24190
|
+
return [
|
|
24191
|
+
{ type: 'runWorkflowAction', id: 'triage-pr-comment', payload: value },
|
|
24192
|
+
action({ type: 'closeInputPrompt' }),
|
|
24193
|
+
];
|
|
24194
|
+
}
|
|
24195
|
+
if (state.inputPrompt.kind === 'triage-pr-label') {
|
|
24196
|
+
return [
|
|
24197
|
+
{ type: 'runWorkflowAction', id: 'triage-pr-label', payload: value },
|
|
24198
|
+
action({ type: 'closeInputPrompt' }),
|
|
24199
|
+
];
|
|
24200
|
+
}
|
|
24201
|
+
if (state.inputPrompt.kind === 'triage-pr-assign') {
|
|
24202
|
+
return [
|
|
24203
|
+
{ type: 'runWorkflowAction', id: 'triage-pr-assign', payload: value },
|
|
24204
|
+
action({ type: 'closeInputPrompt' }),
|
|
24205
|
+
];
|
|
24206
|
+
}
|
|
24207
|
+
// #882 phase 5 — destructive prompt submissions route through the
|
|
24208
|
+
// y-confirm path (not directly to runWorkflowAction) so the user
|
|
24209
|
+
// gets a final "are you sure?" before anything ships. The
|
|
24210
|
+
// collected value (strategy / body) rides along as the
|
|
24211
|
+
// confirmation payload.
|
|
24212
|
+
if (state.inputPrompt.kind === 'triage-pr-merge-strategy') {
|
|
24213
|
+
const strategy = value.toLowerCase();
|
|
24214
|
+
if (strategy !== 'merge' && strategy !== 'squash' && strategy !== 'rebase') {
|
|
24215
|
+
return [action({
|
|
24216
|
+
type: 'setStatus',
|
|
24217
|
+
value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
|
|
24218
|
+
})];
|
|
24219
|
+
}
|
|
24220
|
+
return [
|
|
24221
|
+
action({ type: 'setPendingConfirmation', value: 'triage-pr-merge', payload: strategy }),
|
|
24222
|
+
action({ type: 'closeInputPrompt' }),
|
|
24223
|
+
];
|
|
24224
|
+
}
|
|
24225
|
+
if (state.inputPrompt.kind === 'triage-pr-request-changes') {
|
|
24226
|
+
return [
|
|
24227
|
+
action({ type: 'setPendingConfirmation', value: 'triage-pr-request-changes', payload: value }),
|
|
24228
|
+
action({ type: 'closeInputPrompt' }),
|
|
24229
|
+
];
|
|
24230
|
+
}
|
|
23279
24231
|
if (state.inputPrompt.kind === 'pr-request-changes') {
|
|
23280
24232
|
return [
|
|
23281
24233
|
action({ type: 'setPendingConfirmation', value: 'request-changes-pr', payload: value }),
|
|
@@ -23698,6 +24650,23 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
23698
24650
|
action({ type: 'setStatus', value: 'jumped to pull request' }),
|
|
23699
24651
|
];
|
|
23700
24652
|
}
|
|
24653
|
+
// `gP` chord (#882 phase 3): jump to the multi-PR triage list.
|
|
24654
|
+
// Capital P disambiguates from `gp` (current-branch PR panel).
|
|
24655
|
+
// Pleasingly symmetric with `gi` for issues — both lead to the
|
|
24656
|
+
// read-only list views shipped in #882.
|
|
24657
|
+
if (state.pendingKey === 'g' && inputValue === 'P') {
|
|
24658
|
+
return [
|
|
24659
|
+
action({ type: 'pushView', value: 'pull-request-triage' }),
|
|
24660
|
+
action({ type: 'setStatus', value: 'jumped to PR triage' }),
|
|
24661
|
+
];
|
|
24662
|
+
}
|
|
24663
|
+
// `gi` chord (#882 phase 3): jump to the issue triage list.
|
|
24664
|
+
if (state.pendingKey === 'g' && inputValue === 'i') {
|
|
24665
|
+
return [
|
|
24666
|
+
action({ type: 'pushView', value: 'issues' }),
|
|
24667
|
+
action({ type: 'setStatus', value: 'jumped to issues' }),
|
|
24668
|
+
];
|
|
24669
|
+
}
|
|
23701
24670
|
if (state.pendingKey === 'g' && inputValue === 'x') {
|
|
23702
24671
|
return [
|
|
23703
24672
|
action({ type: 'pushView', value: 'conflicts' }),
|
|
@@ -24136,6 +25105,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
24136
25105
|
if (isSubmodulesActionTarget(state) && context.submoduleCount) {
|
|
24137
25106
|
return [action({ type: 'moveSubmodule', delta: -1, count: context.submoduleCount })];
|
|
24138
25107
|
}
|
|
25108
|
+
if (isIssueActionTarget(state) && context.issueCount) {
|
|
25109
|
+
return [action({ type: 'moveIssue', delta: -1, count: context.issueCount })];
|
|
25110
|
+
}
|
|
25111
|
+
if (isPullRequestTriageActionTarget(state) && context.pullRequestTriageCount) {
|
|
25112
|
+
return [action({
|
|
25113
|
+
type: 'movePullRequestTriage',
|
|
25114
|
+
delta: -1,
|
|
25115
|
+
count: context.pullRequestTriageCount,
|
|
25116
|
+
})];
|
|
25117
|
+
}
|
|
24139
25118
|
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
24140
25119
|
return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
|
|
24141
25120
|
}
|
|
@@ -24223,6 +25202,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
24223
25202
|
if (isSubmodulesActionTarget(state) && context.submoduleCount) {
|
|
24224
25203
|
return [action({ type: 'moveSubmodule', delta: 1, count: context.submoduleCount })];
|
|
24225
25204
|
}
|
|
25205
|
+
if (isIssueActionTarget(state) && context.issueCount) {
|
|
25206
|
+
return [action({ type: 'moveIssue', delta: 1, count: context.issueCount })];
|
|
25207
|
+
}
|
|
25208
|
+
if (isPullRequestTriageActionTarget(state) && context.pullRequestTriageCount) {
|
|
25209
|
+
return [action({
|
|
25210
|
+
type: 'movePullRequestTriage',
|
|
25211
|
+
delta: 1,
|
|
25212
|
+
count: context.pullRequestTriageCount,
|
|
25213
|
+
})];
|
|
25214
|
+
}
|
|
24226
25215
|
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
24227
25216
|
return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
|
|
24228
25217
|
}
|
|
@@ -24639,6 +25628,120 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
24639
25628
|
multiline: true,
|
|
24640
25629
|
})];
|
|
24641
25630
|
}
|
|
25631
|
+
// #882 phase 4 — issue triage per-row actions. Scoped to the
|
|
25632
|
+
// `'issues'` view + commits focus so the single-letter keys stay
|
|
25633
|
+
// free elsewhere. Each prompts; submit dispatches the matching
|
|
25634
|
+
// `triage-issue-*` workflow which routes through `gh issue` and
|
|
25635
|
+
// invalidates both the in-memory + disk caches on success.
|
|
25636
|
+
if (state.activeView === 'issues' && state.focus === 'commits') {
|
|
25637
|
+
if (inputValue === 'O' && context.issueSelectedUrl) {
|
|
25638
|
+
return [{ type: 'runWorkflowAction', id: 'triage-issue-open' }];
|
|
25639
|
+
}
|
|
25640
|
+
if (inputValue === 'c' && context.issueCount) {
|
|
25641
|
+
return [action({
|
|
25642
|
+
type: 'openInputPrompt',
|
|
25643
|
+
kind: 'triage-issue-comment',
|
|
25644
|
+
label: 'Comment body (Enter newline · Ctrl+D submit)',
|
|
25645
|
+
multiline: true,
|
|
25646
|
+
})];
|
|
25647
|
+
}
|
|
25648
|
+
if (inputValue === 'L' && context.issueCount) {
|
|
25649
|
+
return [action({
|
|
25650
|
+
type: 'openInputPrompt',
|
|
25651
|
+
kind: 'triage-issue-label',
|
|
25652
|
+
label: 'Label name to add',
|
|
25653
|
+
})];
|
|
25654
|
+
}
|
|
25655
|
+
if (inputValue === 'A' && context.issueCount) {
|
|
25656
|
+
return [action({
|
|
25657
|
+
type: 'openInputPrompt',
|
|
25658
|
+
kind: 'triage-issue-assign',
|
|
25659
|
+
label: 'Assignee login (or @me)',
|
|
25660
|
+
initial: '@me',
|
|
25661
|
+
})];
|
|
25662
|
+
}
|
|
25663
|
+
// #882 phase 5 — destructive issue mutations. Both gated through
|
|
25664
|
+
// the y-confirm path. `x` closes (matches `pull-request` view's
|
|
25665
|
+
// close binding); `X` reopens, useful to undo a stray close.
|
|
25666
|
+
if (inputValue === 'x' && context.issueCount) {
|
|
25667
|
+
return [action({ type: 'setPendingConfirmation', value: 'triage-issue-close' })];
|
|
25668
|
+
}
|
|
25669
|
+
if (inputValue === 'X' && context.issueCount) {
|
|
25670
|
+
return [action({ type: 'setPendingConfirmation', value: 'triage-issue-reopen' })];
|
|
25671
|
+
}
|
|
25672
|
+
// #882 phase 6 — cycle the canned filter preset (open → closed
|
|
25673
|
+
// → mine → assigned → open). The effect in app.ts watches
|
|
25674
|
+
// `state.selectedIssueFilter` and refetches with the matching
|
|
25675
|
+
// filter object, so the list updates without an explicit
|
|
25676
|
+
// refresh keystroke.
|
|
25677
|
+
if (inputValue === 'f') {
|
|
25678
|
+
return [action({ type: 'cycleIssueFilter' })];
|
|
25679
|
+
}
|
|
25680
|
+
}
|
|
25681
|
+
// #882 phase 4 — PR triage per-row actions. Same shape as the
|
|
25682
|
+
// issue handlers above; distinct view id so the keys don't
|
|
25683
|
+
// collide with the single-PR action panel (`pull-request`).
|
|
25684
|
+
if (state.activeView === 'pull-request-triage' && state.focus === 'commits') {
|
|
25685
|
+
if (inputValue === 'O' && context.pullRequestTriageSelectedUrl) {
|
|
25686
|
+
return [{ type: 'runWorkflowAction', id: 'triage-pr-open' }];
|
|
25687
|
+
}
|
|
25688
|
+
if (inputValue === 'c' && context.pullRequestTriageCount) {
|
|
25689
|
+
return [action({
|
|
25690
|
+
type: 'openInputPrompt',
|
|
25691
|
+
kind: 'triage-pr-comment',
|
|
25692
|
+
label: 'Comment body (Enter newline · Ctrl+D submit)',
|
|
25693
|
+
multiline: true,
|
|
25694
|
+
})];
|
|
25695
|
+
}
|
|
25696
|
+
if (inputValue === 'L' && context.pullRequestTriageCount) {
|
|
25697
|
+
return [action({
|
|
25698
|
+
type: 'openInputPrompt',
|
|
25699
|
+
kind: 'triage-pr-label',
|
|
25700
|
+
label: 'Label name to add',
|
|
25701
|
+
})];
|
|
25702
|
+
}
|
|
25703
|
+
if (inputValue === 'A' && context.pullRequestTriageCount) {
|
|
25704
|
+
return [action({
|
|
25705
|
+
type: 'openInputPrompt',
|
|
25706
|
+
kind: 'triage-pr-assign',
|
|
25707
|
+
label: 'Assignee login (or @me)',
|
|
25708
|
+
initial: '@me',
|
|
25709
|
+
})];
|
|
25710
|
+
}
|
|
25711
|
+
// #882 phase 5 — destructive PR mutations on the triage view.
|
|
25712
|
+
// Mirror the single-PR action panel's keys (m / x / a / R) but
|
|
25713
|
+
// route to the by-number workflows. `m` and `R` open input
|
|
25714
|
+
// prompts first; submit lands the strategy / body as the
|
|
25715
|
+
// confirmation payload, which the runner picks up after y.
|
|
25716
|
+
if (inputValue === 'm' && context.pullRequestTriageCount) {
|
|
25717
|
+
return [action({
|
|
25718
|
+
type: 'openInputPrompt',
|
|
25719
|
+
kind: 'triage-pr-merge-strategy',
|
|
25720
|
+
label: 'Merge strategy (merge / squash / rebase)',
|
|
25721
|
+
})];
|
|
25722
|
+
}
|
|
25723
|
+
if (inputValue === 'x' && context.pullRequestTriageCount) {
|
|
25724
|
+
return [action({ type: 'setPendingConfirmation', value: 'triage-pr-close' })];
|
|
25725
|
+
}
|
|
25726
|
+
if (inputValue === 'a' && context.pullRequestTriageCount) {
|
|
25727
|
+
return [action({ type: 'setPendingConfirmation', value: 'triage-pr-approve' })];
|
|
25728
|
+
}
|
|
25729
|
+
if (inputValue === 'R' && context.pullRequestTriageCount) {
|
|
25730
|
+
return [action({
|
|
25731
|
+
type: 'openInputPrompt',
|
|
25732
|
+
kind: 'triage-pr-request-changes',
|
|
25733
|
+
label: 'Request changes — review body (Enter newline · Ctrl+D submit)',
|
|
25734
|
+
multiline: true,
|
|
25735
|
+
})];
|
|
25736
|
+
}
|
|
25737
|
+
// #882 phase 6 — cycle the canned filter preset (open → draft
|
|
25738
|
+
// → mine → assigned → closed → merged → open). The effect in
|
|
25739
|
+
// app.ts watches `state.selectedPullRequestFilter` and refetches
|
|
25740
|
+
// with the matching filter object.
|
|
25741
|
+
if (inputValue === 'f') {
|
|
25742
|
+
return [action({ type: 'cyclePullRequestTriageFilter' })];
|
|
25743
|
+
}
|
|
25744
|
+
}
|
|
24642
25745
|
// Global stash hotkey: `S` opens a stash-message prompt and
|
|
24643
25746
|
// `createStash` runs once submitted. Available everywhere there's
|
|
24644
25747
|
// not a more modal handler in front of it.
|
|
@@ -24889,6 +25992,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
24889
25992
|
if (isSubmodulesActionTarget(state) && context.submoduleCount) {
|
|
24890
25993
|
return [{ type: 'yankFromActiveView', short }];
|
|
24891
25994
|
}
|
|
25995
|
+
// #882 phase 4 — triage views: y yanks the cursored issue / PR
|
|
25996
|
+
// URL so the user can paste it into a chat / PR description
|
|
25997
|
+
// without dropping back to the browser. Y is a no-op on these
|
|
25998
|
+
// views — there's no compact alternate identifier worth a
|
|
25999
|
+
// second key.
|
|
26000
|
+
if (isIssueActionTarget(state) && context.issueSelectedUrl) {
|
|
26001
|
+
return [{ type: 'yankFromActiveView' }];
|
|
26002
|
+
}
|
|
26003
|
+
if (isPullRequestTriageActionTarget(state) && context.pullRequestTriageSelectedUrl) {
|
|
26004
|
+
return [{ type: 'yankFromActiveView' }];
|
|
26005
|
+
}
|
|
24892
26006
|
}
|
|
24893
26007
|
// Enter on a stash row pushes the diff view scoped to that stash.
|
|
24894
26008
|
// The runtime loads `git stash show -p <ref>` once the view is
|
|
@@ -25398,9 +26512,56 @@ const LOG_INK_DEFAULT_ROWS = 40;
|
|
|
25398
26512
|
* gives both views their own air.
|
|
25399
26513
|
*/
|
|
25400
26514
|
const INSPECTOR_TABBED_BELOW_ROWS = 28;
|
|
26515
|
+
/**
|
|
26516
|
+
* Density-tier breakpoints in columns. Picked so the three legacy
|
|
26517
|
+
* panels (sidebar ~24-32 + inspector ~20-32 + main) still leave the
|
|
26518
|
+
* history panel with at least ~40 usable cells before we start
|
|
26519
|
+
* collapsing chrome:
|
|
26520
|
+
*
|
|
26521
|
+
* wide >= 160 — plenty of room; keep absolute dates
|
|
26522
|
+
* normal >= 120 — relative dates save 8-ish cells without hiding info
|
|
26523
|
+
* tight >= 100 — drop date entirely; subject + refs are the priority
|
|
26524
|
+
* rail < 100 — even with side panels collapsed the row is tight;
|
|
26525
|
+
* stack to two lines and rail the side panels at rest
|
|
26526
|
+
*/
|
|
26527
|
+
const LAYOUT_TIGHT_BELOW = 120;
|
|
26528
|
+
const LAYOUT_NORMAL_BELOW = 160;
|
|
26529
|
+
const LAYOUT_RAIL_BELOW = 100;
|
|
26530
|
+
/**
|
|
26531
|
+
* Fixed cell width for a railed side panel. Just wide enough for a
|
|
26532
|
+
* 1-cell icon + a 2-3 digit count after subtracting border (2) and
|
|
26533
|
+
* padding (2). Going narrower clips the count; going wider defeats
|
|
26534
|
+
* the purpose of railing in the first place.
|
|
26535
|
+
*/
|
|
26536
|
+
const LAYOUT_RAIL_PANEL_WIDTH = 8;
|
|
26537
|
+
const SIDEBAR_AT_REST_BY_TIER = {
|
|
26538
|
+
rail: { min: 22, max: 28, fraction: 0.24 }, // unused — rail collapses to LAYOUT_RAIL_PANEL_WIDTH
|
|
26539
|
+
tight: { min: 22, max: 28, fraction: 0.24 },
|
|
26540
|
+
normal: { min: 22, max: 30, fraction: 0.22 },
|
|
26541
|
+
wide: { min: 28, max: 48, fraction: 0.24 },
|
|
26542
|
+
};
|
|
26543
|
+
function calcSidebarAtRestWidth(columns, density) {
|
|
26544
|
+
const config = SIDEBAR_AT_REST_BY_TIER[density];
|
|
26545
|
+
return Math.max(config.min, Math.min(config.max, Math.floor(columns * config.fraction)));
|
|
26546
|
+
}
|
|
25401
26547
|
function getLogInkLayout(input) {
|
|
25402
26548
|
const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
|
|
25403
26549
|
const rows = input.rows || LOG_INK_DEFAULT_ROWS;
|
|
26550
|
+
const density = columns >= LAYOUT_NORMAL_BELOW
|
|
26551
|
+
? 'wide'
|
|
26552
|
+
: columns >= LAYOUT_TIGHT_BELOW
|
|
26553
|
+
? 'normal'
|
|
26554
|
+
: columns >= LAYOUT_RAIL_BELOW
|
|
26555
|
+
? 'tight'
|
|
26556
|
+
: 'rail';
|
|
26557
|
+
// Rail collapse: only happens at the narrowest tier, and only for
|
|
26558
|
+
// the panel that does NOT currently hold focus AND is not being
|
|
26559
|
+
// commandeered by the help overlay. Focus always wins — pressing
|
|
26560
|
+
// tab to the sidebar pops it back open even on an 80-cell terminal
|
|
26561
|
+
// so the user can actually use it. The help overlay also wins for
|
|
26562
|
+
// the inspector since that's where its descriptions render.
|
|
26563
|
+
const sidebarRailed = density === 'rail' && !input.sidebarFocused;
|
|
26564
|
+
const inspectorRailed = density === 'rail' && !input.inspectorFocused && !input.helpOverlayActive;
|
|
25404
26565
|
// Inspector width — at rest 20-32 cells (~22% of width), focused
|
|
25405
26566
|
// 36-60 cells (~40% of width). Narrow rest state keeps the commit
|
|
25406
26567
|
// graph dominant; focus expansion gives the inspector room for long
|
|
@@ -25411,17 +26572,30 @@ function getLogInkLayout(input) {
|
|
|
25411
26572
|
// hotkey descriptions render in full instead of truncating to
|
|
25412
26573
|
// "Move focus...". Capped at 100 cells so a wide terminal doesn't
|
|
25413
26574
|
// waste an absurd amount of horizontal space on the cheat sheet.
|
|
26575
|
+
//
|
|
26576
|
+
// Rail collapse wins over the at-rest range but loses to focus and
|
|
26577
|
+
// to the help overlay — both of those represent deliberate user
|
|
26578
|
+
// intent to read the panel.
|
|
25414
26579
|
const detailWidth = input.helpOverlayActive
|
|
25415
26580
|
? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
|
|
25416
26581
|
: input.inspectorFocused
|
|
25417
26582
|
? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
|
|
25418
|
-
:
|
|
25419
|
-
|
|
25420
|
-
|
|
25421
|
-
//
|
|
26583
|
+
: inspectorRailed
|
|
26584
|
+
? LAYOUT_RAIL_PANEL_WIDTH
|
|
26585
|
+
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
26586
|
+
// Sidebar at rest is tier-aware (see `SIDEBAR_AT_REST_BY_TIER`):
|
|
26587
|
+
// tight stays compact (22-28), normal shrinks slightly (22-30),
|
|
26588
|
+
// wide grows naturally (28-48) so the side panel doesn't get pinned
|
|
26589
|
+
// at an arbitrary cap on big terminals while the main panel hogs
|
|
26590
|
+
// 80% of the width. Focused: 32-50 cells (~36% of width),
|
|
26591
|
+
// regardless of tier — deliberate user intent to read the sidebar
|
|
26592
|
+
// deserves the extra width. Rail mode (narrow terminal, unfocused)
|
|
26593
|
+
// collapses to a fixed 8-cell strip with tab glyphs only.
|
|
25422
26594
|
const sidebarWidth = input.sidebarFocused
|
|
25423
26595
|
? Math.max(32, Math.min(50, Math.floor(columns * 0.36)))
|
|
25424
|
-
:
|
|
26596
|
+
: sidebarRailed
|
|
26597
|
+
? LAYOUT_RAIL_PANEL_WIDTH
|
|
26598
|
+
: calcSidebarAtRestWidth(columns, density);
|
|
25425
26599
|
return {
|
|
25426
26600
|
bodyRows: Math.max(8, rows - 5),
|
|
25427
26601
|
columns,
|
|
@@ -25431,6 +26605,10 @@ function getLogInkLayout(input) {
|
|
|
25431
26605
|
sidebarWidth,
|
|
25432
26606
|
tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
|
|
25433
26607
|
inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
|
|
26608
|
+
density,
|
|
26609
|
+
sidebarRailed,
|
|
26610
|
+
inspectorRailed,
|
|
26611
|
+
historyRowMode: density === 'rail' ? 'stacked' : 'single',
|
|
25434
26612
|
};
|
|
25435
26613
|
}
|
|
25436
26614
|
|
|
@@ -26140,6 +27318,382 @@ function stageConflictResolved(git, path) {
|
|
|
26140
27318
|
return runAction$1(() => git.raw(['add', '--', path]), `Staged ${path} (marked resolved)`);
|
|
26141
27319
|
}
|
|
26142
27320
|
|
|
27321
|
+
/**
|
|
27322
|
+
* Per-item issue detail fetcher (#882 inspector hydration). The list
|
|
27323
|
+
* payload from `gh issue list` deliberately omits bodies and
|
|
27324
|
+
* comments to keep the list fetch cheap; this module fills those in
|
|
27325
|
+
* on demand when the user rests the cursor on a specific issue.
|
|
27326
|
+
*
|
|
27327
|
+
* Called from the workstation runtime with a debounced timer so
|
|
27328
|
+
* rapid j/k navigation doesn't spam `gh`. Results land in a
|
|
27329
|
+
* `Map<number, IssueDetail>` cache on `LogInkContext` keyed by
|
|
27330
|
+
* issue number, so cursoring back to a previously-fetched item
|
|
27331
|
+
* shows instantly.
|
|
27332
|
+
*/
|
|
27333
|
+
/**
|
|
27334
|
+
* `gh issue view <#> --json` field list. Kept separate from the
|
|
27335
|
+
* list-view field list since the detail view only needs the
|
|
27336
|
+
* fields that the list payload doesn't already carry.
|
|
27337
|
+
*/
|
|
27338
|
+
const ISSUE_DETAIL_JSON_FIELDS = ['number', 'body', 'comments'].join(',');
|
|
27339
|
+
function parseIssueComments(value) {
|
|
27340
|
+
if (!Array.isArray(value))
|
|
27341
|
+
return [];
|
|
27342
|
+
return value.map((entry) => {
|
|
27343
|
+
const raw = entry;
|
|
27344
|
+
const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
|
|
27345
|
+
? String(raw.author.login)
|
|
27346
|
+
: undefined;
|
|
27347
|
+
return {
|
|
27348
|
+
author,
|
|
27349
|
+
body: typeof raw.body === 'string' ? raw.body : '',
|
|
27350
|
+
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
|
|
27351
|
+
};
|
|
27352
|
+
});
|
|
27353
|
+
}
|
|
27354
|
+
function parseIssueDetail(output) {
|
|
27355
|
+
const trimmed = output.trim();
|
|
27356
|
+
if (!trimmed)
|
|
27357
|
+
return undefined;
|
|
27358
|
+
const raw = JSON.parse(trimmed);
|
|
27359
|
+
if (typeof raw.number !== 'number')
|
|
27360
|
+
return undefined;
|
|
27361
|
+
return {
|
|
27362
|
+
number: raw.number,
|
|
27363
|
+
body: typeof raw.body === 'string' ? raw.body : '',
|
|
27364
|
+
comments: parseIssueComments(raw.comments),
|
|
27365
|
+
};
|
|
27366
|
+
}
|
|
27367
|
+
async function getIssueDetail(issueNumber, runner = defaultGhRunner) {
|
|
27368
|
+
try {
|
|
27369
|
+
const output = await runner([
|
|
27370
|
+
'issue',
|
|
27371
|
+
'view',
|
|
27372
|
+
String(issueNumber),
|
|
27373
|
+
'--json',
|
|
27374
|
+
ISSUE_DETAIL_JSON_FIELDS,
|
|
27375
|
+
]);
|
|
27376
|
+
const detail = parseIssueDetail(output);
|
|
27377
|
+
if (!detail) {
|
|
27378
|
+
return { ok: false, message: `Empty response from gh for issue #${issueNumber}` };
|
|
27379
|
+
}
|
|
27380
|
+
return { ok: true, detail };
|
|
27381
|
+
}
|
|
27382
|
+
catch (error) {
|
|
27383
|
+
return {
|
|
27384
|
+
ok: false,
|
|
27385
|
+
message: error instanceof Error ? error.message : String(error),
|
|
27386
|
+
};
|
|
27387
|
+
}
|
|
27388
|
+
}
|
|
27389
|
+
|
|
27390
|
+
/**
|
|
27391
|
+
* Low-risk issue mutations driven from the issue-triage TUI (#882
|
|
27392
|
+
* phase 4). Mirrors `pullRequestActions.ts`'s shape — each function
|
|
27393
|
+
* wraps a single `gh issue <verb>` invocation through the shared
|
|
27394
|
+
* runner indirection so tests can mock the shell-out cleanly.
|
|
27395
|
+
*
|
|
27396
|
+
* "Low risk" here means: reversible by re-invoking with the
|
|
27397
|
+
* opposite flag (`--add-label` ↔ `--remove-label`), or strictly
|
|
27398
|
+
* additive (comment). The destructive verbs (`close`, `reopen`,
|
|
27399
|
+
* `delete`) land in phase 5 alongside the PR-level destructive
|
|
27400
|
+
* mutations, all gated through the y-confirm path.
|
|
27401
|
+
*/
|
|
27402
|
+
async function runGhAction$1(runner, args, successMessage) {
|
|
27403
|
+
try {
|
|
27404
|
+
return successMessage(await runner(args));
|
|
27405
|
+
}
|
|
27406
|
+
catch (error) {
|
|
27407
|
+
return {
|
|
27408
|
+
ok: false,
|
|
27409
|
+
message: error.message,
|
|
27410
|
+
};
|
|
27411
|
+
}
|
|
27412
|
+
}
|
|
27413
|
+
function commentIssue(issueNumber, body, runner = defaultGhRunner) {
|
|
27414
|
+
if (!body.trim()) {
|
|
27415
|
+
return Promise.resolve({ ok: false, message: 'Comment body required' });
|
|
27416
|
+
}
|
|
27417
|
+
return runGhAction$1(runner, ['issue', 'comment', String(issueNumber), '--body', body], (output) => ({
|
|
27418
|
+
ok: true,
|
|
27419
|
+
message: output.trim() || `Commented on issue #${issueNumber}`,
|
|
27420
|
+
}));
|
|
27421
|
+
}
|
|
27422
|
+
function addIssueLabel(issueNumber, label, runner = defaultGhRunner) {
|
|
27423
|
+
if (!label.trim()) {
|
|
27424
|
+
return Promise.resolve({ ok: false, message: 'Label name required' });
|
|
27425
|
+
}
|
|
27426
|
+
return runGhAction$1(runner, ['issue', 'edit', String(issueNumber), '--add-label', label], () => ({
|
|
27427
|
+
ok: true,
|
|
27428
|
+
message: `Added label '${label}' to issue #${issueNumber}`,
|
|
27429
|
+
}));
|
|
27430
|
+
}
|
|
27431
|
+
function addIssueAssignee(issueNumber, assignee, runner = defaultGhRunner) {
|
|
27432
|
+
if (!assignee.trim()) {
|
|
27433
|
+
return Promise.resolve({ ok: false, message: 'Assignee login required' });
|
|
27434
|
+
}
|
|
27435
|
+
return runGhAction$1(runner, ['issue', 'edit', String(issueNumber), '--add-assignee', assignee], () => ({
|
|
27436
|
+
ok: true,
|
|
27437
|
+
message: `Assigned ${assignee} to issue #${issueNumber}`,
|
|
27438
|
+
}));
|
|
27439
|
+
}
|
|
27440
|
+
/**
|
|
27441
|
+
* Destructive issue verbs (#882 phase 5). Both routed through the
|
|
27442
|
+
* y-confirm path in the workstation; the action functions themselves
|
|
27443
|
+
* make no extra guarantee — every gh-side error surfaces via the
|
|
27444
|
+
* standard `runGhAction` error wrapper.
|
|
27445
|
+
*/
|
|
27446
|
+
function closeIssue(issueNumber, runner = defaultGhRunner) {
|
|
27447
|
+
return runGhAction$1(runner, ['issue', 'close', String(issueNumber)], (output) => ({
|
|
27448
|
+
ok: true,
|
|
27449
|
+
message: output.trim() || `Closed issue #${issueNumber}`,
|
|
27450
|
+
}));
|
|
27451
|
+
}
|
|
27452
|
+
function reopenIssue(issueNumber, runner = defaultGhRunner) {
|
|
27453
|
+
return runGhAction$1(runner, ['issue', 'reopen', String(issueNumber)], (output) => ({
|
|
27454
|
+
ok: true,
|
|
27455
|
+
message: output.trim() || `Reopened issue #${issueNumber}`,
|
|
27456
|
+
}));
|
|
27457
|
+
}
|
|
27458
|
+
|
|
27459
|
+
/**
|
|
27460
|
+
* Per-item PR detail fetcher (#882 inspector hydration). Mirrors
|
|
27461
|
+
* `issueDetailData.ts`'s shape — pulls body, comments, reviews,
|
|
27462
|
+
* and the status-check rollup on demand when the user rests the
|
|
27463
|
+
* cursor on a PR row.
|
|
27464
|
+
*
|
|
27465
|
+
* Distinct from the existing `pullRequestData.ts` which fetches
|
|
27466
|
+
* the CURRENT BRANCH's PR via `gh pr view` (no number arg). This
|
|
27467
|
+
* fetcher takes an explicit PR number so the triage view can
|
|
27468
|
+
* hydrate any cursored PR, not just the one matching the current
|
|
27469
|
+
* branch.
|
|
27470
|
+
*/
|
|
27471
|
+
/**
|
|
27472
|
+
* `gh pr view <#> --json` field list. Subset of what
|
|
27473
|
+
* `pullRequestData.ts`'s `PULL_REQUEST_VIEW_JSON_FIELDS` includes —
|
|
27474
|
+
* the triage list payload already carries the structural metadata
|
|
27475
|
+
* (state, isDraft, branches, labels, etc.), so the detail fetch
|
|
27476
|
+
* only needs the heavy/expensive fields that the list omits.
|
|
27477
|
+
*/
|
|
27478
|
+
const PULL_REQUEST_DETAIL_JSON_FIELDS = [
|
|
27479
|
+
'number',
|
|
27480
|
+
'body',
|
|
27481
|
+
'comments',
|
|
27482
|
+
'reviews',
|
|
27483
|
+
'statusCheckRollup',
|
|
27484
|
+
].join(',');
|
|
27485
|
+
function parseComments(value) {
|
|
27486
|
+
if (!Array.isArray(value))
|
|
27487
|
+
return [];
|
|
27488
|
+
return value.map((entry) => {
|
|
27489
|
+
const raw = entry;
|
|
27490
|
+
const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
|
|
27491
|
+
? String(raw.author.login)
|
|
27492
|
+
: undefined;
|
|
27493
|
+
return {
|
|
27494
|
+
author,
|
|
27495
|
+
body: typeof raw.body === 'string' ? raw.body : '',
|
|
27496
|
+
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
|
|
27497
|
+
};
|
|
27498
|
+
});
|
|
27499
|
+
}
|
|
27500
|
+
function parseReviews(value) {
|
|
27501
|
+
if (!Array.isArray(value))
|
|
27502
|
+
return [];
|
|
27503
|
+
return value
|
|
27504
|
+
.map((entry) => {
|
|
27505
|
+
const raw = entry;
|
|
27506
|
+
const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
|
|
27507
|
+
? String(raw.author.login)
|
|
27508
|
+
: undefined;
|
|
27509
|
+
return {
|
|
27510
|
+
author,
|
|
27511
|
+
state: typeof raw.state === 'string' ? raw.state : '',
|
|
27512
|
+
body: typeof raw.body === 'string' ? raw.body : '',
|
|
27513
|
+
submittedAt: typeof raw.submittedAt === 'string' ? raw.submittedAt : '',
|
|
27514
|
+
};
|
|
27515
|
+
})
|
|
27516
|
+
// gh occasionally returns review entries without an author when the
|
|
27517
|
+
// reviewer's account is deleted. Those are unactionable noise here;
|
|
27518
|
+
// strip them so the inspector doesn't render anonymous rows.
|
|
27519
|
+
.filter((review) => review.author || review.body);
|
|
27520
|
+
}
|
|
27521
|
+
function parseStatusCheckRollup(value) {
|
|
27522
|
+
if (!Array.isArray(value))
|
|
27523
|
+
return [];
|
|
27524
|
+
return value.map((entry) => {
|
|
27525
|
+
const raw = entry;
|
|
27526
|
+
return {
|
|
27527
|
+
name: String(raw.name || raw.context || 'check'),
|
|
27528
|
+
status: typeof raw.status === 'string' ? raw.status : undefined,
|
|
27529
|
+
conclusion: typeof raw.conclusion === 'string' ? raw.conclusion : undefined,
|
|
27530
|
+
};
|
|
27531
|
+
});
|
|
27532
|
+
}
|
|
27533
|
+
function parsePullRequestDetail(output) {
|
|
27534
|
+
const trimmed = output.trim();
|
|
27535
|
+
if (!trimmed)
|
|
27536
|
+
return undefined;
|
|
27537
|
+
const raw = JSON.parse(trimmed);
|
|
27538
|
+
if (typeof raw.number !== 'number')
|
|
27539
|
+
return undefined;
|
|
27540
|
+
return {
|
|
27541
|
+
number: raw.number,
|
|
27542
|
+
body: typeof raw.body === 'string' ? raw.body : '',
|
|
27543
|
+
comments: parseComments(raw.comments),
|
|
27544
|
+
reviews: parseReviews(raw.reviews),
|
|
27545
|
+
statusCheckRollup: parseStatusCheckRollup(raw.statusCheckRollup),
|
|
27546
|
+
};
|
|
27547
|
+
}
|
|
27548
|
+
async function getPullRequestDetail(pullRequestNumber, runner = defaultGhRunner) {
|
|
27549
|
+
try {
|
|
27550
|
+
const output = await runner([
|
|
27551
|
+
'pr',
|
|
27552
|
+
'view',
|
|
27553
|
+
String(pullRequestNumber),
|
|
27554
|
+
'--json',
|
|
27555
|
+
PULL_REQUEST_DETAIL_JSON_FIELDS,
|
|
27556
|
+
]);
|
|
27557
|
+
const detail = parsePullRequestDetail(output);
|
|
27558
|
+
if (!detail) {
|
|
27559
|
+
return {
|
|
27560
|
+
ok: false,
|
|
27561
|
+
message: `Empty response from gh for pull request #${pullRequestNumber}`,
|
|
27562
|
+
};
|
|
27563
|
+
}
|
|
27564
|
+
return { ok: true, detail };
|
|
27565
|
+
}
|
|
27566
|
+
catch (error) {
|
|
27567
|
+
return {
|
|
27568
|
+
ok: false,
|
|
27569
|
+
message: error instanceof Error ? error.message : String(error),
|
|
27570
|
+
};
|
|
27571
|
+
}
|
|
27572
|
+
}
|
|
27573
|
+
|
|
27574
|
+
/**
|
|
27575
|
+
* `gh pr list --json` field list. Trimmer than `pullRequestData.ts`'s
|
|
27576
|
+
* single-PR field set — the triage list view doesn't need bodies,
|
|
27577
|
+
* statusCheckRollup, or per-review breakdowns (those live in the
|
|
27578
|
+
* inspector that opens when the user picks a row).
|
|
27579
|
+
*/
|
|
27580
|
+
const PULL_REQUEST_LIST_JSON_FIELDS = [
|
|
27581
|
+
'number',
|
|
27582
|
+
'title',
|
|
27583
|
+
'url',
|
|
27584
|
+
'state',
|
|
27585
|
+
'isDraft',
|
|
27586
|
+
'headRefName',
|
|
27587
|
+
'baseRefName',
|
|
27588
|
+
'author',
|
|
27589
|
+
'assignees',
|
|
27590
|
+
'labels',
|
|
27591
|
+
'reviewDecision',
|
|
27592
|
+
'mergeable',
|
|
27593
|
+
'mergeStateStatus',
|
|
27594
|
+
'createdAt',
|
|
27595
|
+
'updatedAt',
|
|
27596
|
+
].join(',');
|
|
27597
|
+
function parsePullRequestListItems(output) {
|
|
27598
|
+
const trimmed = output.trim();
|
|
27599
|
+
if (!trimmed)
|
|
27600
|
+
return [];
|
|
27601
|
+
const raw = JSON.parse(trimmed);
|
|
27602
|
+
return raw.map((entry) => {
|
|
27603
|
+
const author = entry.author && typeof entry.author === 'object' && 'login' in entry.author
|
|
27604
|
+
? String(entry.author.login)
|
|
27605
|
+
: undefined;
|
|
27606
|
+
const assignees = Array.isArray(entry.assignees)
|
|
27607
|
+
? entry.assignees
|
|
27608
|
+
.map((a) => (a && 'login' in a ? String(a.login) : ''))
|
|
27609
|
+
.filter(Boolean)
|
|
27610
|
+
: undefined;
|
|
27611
|
+
const labels = Array.isArray(entry.labels)
|
|
27612
|
+
? entry.labels
|
|
27613
|
+
.map((l) => (l && 'name' in l ? String(l.name) : ''))
|
|
27614
|
+
.filter(Boolean)
|
|
27615
|
+
: undefined;
|
|
27616
|
+
return {
|
|
27617
|
+
number: entry.number,
|
|
27618
|
+
title: String(entry.title || ''),
|
|
27619
|
+
url: String(entry.url || ''),
|
|
27620
|
+
state: String(entry.state || ''),
|
|
27621
|
+
isDraft: Boolean(entry.isDraft),
|
|
27622
|
+
headRefName: String(entry.headRefName || ''),
|
|
27623
|
+
baseRefName: String(entry.baseRefName || ''),
|
|
27624
|
+
author,
|
|
27625
|
+
assignees,
|
|
27626
|
+
labels,
|
|
27627
|
+
reviewDecision: typeof entry.reviewDecision === 'string' ? entry.reviewDecision : undefined,
|
|
27628
|
+
mergeable: typeof entry.mergeable === 'string' ? entry.mergeable : undefined,
|
|
27629
|
+
mergeStateStatus: typeof entry.mergeStateStatus === 'string' ? entry.mergeStateStatus : undefined,
|
|
27630
|
+
createdAt: String(entry.createdAt || ''),
|
|
27631
|
+
updatedAt: String(entry.updatedAt || ''),
|
|
27632
|
+
};
|
|
27633
|
+
});
|
|
27634
|
+
}
|
|
27635
|
+
function buildGhArgs(filter) {
|
|
27636
|
+
const args = ['pr', 'list', '--json', PULL_REQUEST_LIST_JSON_FIELDS];
|
|
27637
|
+
if (filter.state)
|
|
27638
|
+
args.push('--state', filter.state);
|
|
27639
|
+
if (filter.assignee)
|
|
27640
|
+
args.push('--assignee', filter.assignee);
|
|
27641
|
+
if (filter.author)
|
|
27642
|
+
args.push('--author', filter.author);
|
|
27643
|
+
if (filter.label)
|
|
27644
|
+
args.push('--label', filter.label);
|
|
27645
|
+
if (filter.search)
|
|
27646
|
+
args.push('--search', filter.search);
|
|
27647
|
+
if (filter.draft)
|
|
27648
|
+
args.push('--draft');
|
|
27649
|
+
if (filter.base)
|
|
27650
|
+
args.push('--base', filter.base);
|
|
27651
|
+
if (filter.head)
|
|
27652
|
+
args.push('--head', filter.head);
|
|
27653
|
+
if (typeof filter.limit === 'number')
|
|
27654
|
+
args.push('--limit', String(filter.limit));
|
|
27655
|
+
return args;
|
|
27656
|
+
}
|
|
27657
|
+
async function getPullRequestList(git, filter = {}, runner = defaultGhRunner) {
|
|
27658
|
+
const repository = await getGitHubRepository(git);
|
|
27659
|
+
if (!repository) {
|
|
27660
|
+
return {
|
|
27661
|
+
available: false,
|
|
27662
|
+
authenticated: false,
|
|
27663
|
+
filter,
|
|
27664
|
+
message: 'No GitHub remote detected.',
|
|
27665
|
+
};
|
|
27666
|
+
}
|
|
27667
|
+
if (!(await isGhAuthenticated(runner))) {
|
|
27668
|
+
return {
|
|
27669
|
+
available: true,
|
|
27670
|
+
authenticated: false,
|
|
27671
|
+
repository,
|
|
27672
|
+
filter,
|
|
27673
|
+
message: 'GitHub CLI is missing or not authenticated.',
|
|
27674
|
+
};
|
|
27675
|
+
}
|
|
27676
|
+
try {
|
|
27677
|
+
const output = await runner(buildGhArgs(filter));
|
|
27678
|
+
return {
|
|
27679
|
+
available: true,
|
|
27680
|
+
authenticated: true,
|
|
27681
|
+
repository,
|
|
27682
|
+
filter,
|
|
27683
|
+
pullRequests: parsePullRequestListItems(output),
|
|
27684
|
+
};
|
|
27685
|
+
}
|
|
27686
|
+
catch (error) {
|
|
27687
|
+
return {
|
|
27688
|
+
available: true,
|
|
27689
|
+
authenticated: true,
|
|
27690
|
+
repository,
|
|
27691
|
+
filter,
|
|
27692
|
+
message: error instanceof Error ? error.message : 'Failed to fetch pull request list.',
|
|
27693
|
+
};
|
|
27694
|
+
}
|
|
27695
|
+
}
|
|
27696
|
+
|
|
26143
27697
|
function parseCreatedPullRequestUrl(output) {
|
|
26144
27698
|
return output
|
|
26145
27699
|
.split('\n')
|
|
@@ -26240,6 +27794,77 @@ function commentPullRequest(body, runner = defaultGhRunner) {
|
|
|
26240
27794
|
message: output.trim() || 'Comment added',
|
|
26241
27795
|
}));
|
|
26242
27796
|
}
|
|
27797
|
+
/**
|
|
27798
|
+
* Triage-view variants (#882 phase 4). Same shape as
|
|
27799
|
+
* `commentPullRequest` above but target a specific PR by number,
|
|
27800
|
+
* which is what the multi-PR triage list needs (the cursor isn't
|
|
27801
|
+
* necessarily on the current branch's PR). Kept as siblings rather
|
|
27802
|
+
* than overloads so the single-PR call sites stay untouched and
|
|
27803
|
+
* the API stays readable at the call site (no need to pass
|
|
27804
|
+
* `undefined` to skip the number arg).
|
|
27805
|
+
*/
|
|
27806
|
+
function commentPullRequestByNumber(pullRequestNumber, body, runner = defaultGhRunner) {
|
|
27807
|
+
if (!body.trim()) {
|
|
27808
|
+
return Promise.resolve({ ok: false, message: 'Comment body required' });
|
|
27809
|
+
}
|
|
27810
|
+
return runGhAction(runner, ['pr', 'comment', String(pullRequestNumber), '--body', body], (output) => ({
|
|
27811
|
+
ok: true,
|
|
27812
|
+
message: output.trim() || `Commented on pull request #${pullRequestNumber}`,
|
|
27813
|
+
}));
|
|
27814
|
+
}
|
|
27815
|
+
function addPullRequestLabel(pullRequestNumber, label, runner = defaultGhRunner) {
|
|
27816
|
+
if (!label.trim()) {
|
|
27817
|
+
return Promise.resolve({ ok: false, message: 'Label name required' });
|
|
27818
|
+
}
|
|
27819
|
+
return runGhAction(runner, ['pr', 'edit', String(pullRequestNumber), '--add-label', label], () => ({
|
|
27820
|
+
ok: true,
|
|
27821
|
+
message: `Added label '${label}' to pull request #${pullRequestNumber}`,
|
|
27822
|
+
}));
|
|
27823
|
+
}
|
|
27824
|
+
function addPullRequestAssignee(pullRequestNumber, assignee, runner = defaultGhRunner) {
|
|
27825
|
+
if (!assignee.trim()) {
|
|
27826
|
+
return Promise.resolve({ ok: false, message: 'Assignee login required' });
|
|
27827
|
+
}
|
|
27828
|
+
return runGhAction(runner, ['pr', 'edit', String(pullRequestNumber), '--add-assignee', assignee], () => ({
|
|
27829
|
+
ok: true,
|
|
27830
|
+
message: `Assigned ${assignee} to pull request #${pullRequestNumber}`,
|
|
27831
|
+
}));
|
|
27832
|
+
}
|
|
27833
|
+
/**
|
|
27834
|
+
* Destructive PR verbs targeting a specific number (#882 phase 5).
|
|
27835
|
+
* Siblings of the existing current-branch functions above; both
|
|
27836
|
+
* variants delegate to the same `runGhAction` wrapper so error
|
|
27837
|
+
* shaping stays uniform.
|
|
27838
|
+
*/
|
|
27839
|
+
function mergePullRequestByNumber(pullRequestNumber, strategy, runner = defaultGhRunner) {
|
|
27840
|
+
return runGhAction(runner, ['pr', 'merge', String(pullRequestNumber), `--${strategy}`], (output) => ({
|
|
27841
|
+
ok: true,
|
|
27842
|
+
message: output.trim() ||
|
|
27843
|
+
`Merged pull request #${pullRequestNumber} with ${strategy}`,
|
|
27844
|
+
}));
|
|
27845
|
+
}
|
|
27846
|
+
function closePullRequestByNumber(pullRequestNumber, runner = defaultGhRunner) {
|
|
27847
|
+
return runGhAction(runner, ['pr', 'close', String(pullRequestNumber)], (output) => ({
|
|
27848
|
+
ok: true,
|
|
27849
|
+
message: output.trim() || `Closed pull request #${pullRequestNumber}`,
|
|
27850
|
+
}));
|
|
27851
|
+
}
|
|
27852
|
+
function approvePullRequestByNumber(pullRequestNumber, runner = defaultGhRunner) {
|
|
27853
|
+
return runGhAction(runner, ['pr', 'review', String(pullRequestNumber), '--approve'], (output) => ({
|
|
27854
|
+
ok: true,
|
|
27855
|
+
message: output.trim() || `Approved pull request #${pullRequestNumber}`,
|
|
27856
|
+
}));
|
|
27857
|
+
}
|
|
27858
|
+
function requestChangesPullRequestByNumber(pullRequestNumber, body, runner = defaultGhRunner) {
|
|
27859
|
+
if (!body.trim()) {
|
|
27860
|
+
return Promise.resolve({ ok: false, message: 'Review body required for change-request' });
|
|
27861
|
+
}
|
|
27862
|
+
return runGhAction(runner, ['pr', 'review', String(pullRequestNumber), '--request-changes', '--body', body], (output) => ({
|
|
27863
|
+
ok: true,
|
|
27864
|
+
message: output.trim() ||
|
|
27865
|
+
`Requested changes on pull request #${pullRequestNumber}`,
|
|
27866
|
+
}));
|
|
27867
|
+
}
|
|
26243
27868
|
|
|
26244
27869
|
async function runAction(action, successMessage) {
|
|
26245
27870
|
try {
|
|
@@ -27549,10 +29174,70 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
27549
29174
|
return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
|
|
27550
29175
|
}, 'tab-worktrees', visibleListCount);
|
|
27551
29176
|
}
|
|
27552
|
-
|
|
29177
|
+
/**
|
|
29178
|
+
* Single-letter glyph for a sidebar tab in rail mode. Letters always
|
|
29179
|
+
* carry the meaning so this stays useful under ASCII; the rail is too
|
|
29180
|
+
* narrow to fit the full tab label. Pairs with `sidebarTabCount` for
|
|
29181
|
+
* the trailing count.
|
|
29182
|
+
*/
|
|
29183
|
+
function sidebarTabRailGlyph(tab) {
|
|
29184
|
+
switch (tab) {
|
|
29185
|
+
case 'status':
|
|
29186
|
+
return 'S';
|
|
29187
|
+
case 'branches':
|
|
29188
|
+
return 'B';
|
|
29189
|
+
case 'tags':
|
|
29190
|
+
return 'T';
|
|
29191
|
+
case 'stashes':
|
|
29192
|
+
return '$';
|
|
29193
|
+
case 'worktrees':
|
|
29194
|
+
return 'W';
|
|
29195
|
+
default:
|
|
29196
|
+
return '·';
|
|
29197
|
+
}
|
|
29198
|
+
}
|
|
29199
|
+
/**
|
|
29200
|
+
* Rail-mode sidebar — shown on terminals < 100 columns when the
|
|
29201
|
+
* sidebar does not hold focus. Five vertically stacked tab glyphs
|
|
29202
|
+
* with optional counts; the active tab is bracketed. Pressing Tab to
|
|
29203
|
+
* focus the sidebar pops it back to the full accordion (the layout
|
|
29204
|
+
* un-rails it on focus, this renderer is never called in that case).
|
|
29205
|
+
*/
|
|
29206
|
+
function renderSidebarRail(h, components, state, context, width, theme, focused, tabs) {
|
|
29207
|
+
const { Box, Text } = components;
|
|
29208
|
+
return h(Box, {
|
|
29209
|
+
borderColor: focusBorderColor(theme, focused),
|
|
29210
|
+
borderStyle: theme.borderStyle,
|
|
29211
|
+
flexDirection: 'column',
|
|
29212
|
+
width,
|
|
29213
|
+
paddingX: 1,
|
|
29214
|
+
}, h(Text, { bold: true, dimColor: !focused }, 'Repo'), h(Text, { dimColor: true }, '────'), ...tabs.map((tab) => {
|
|
29215
|
+
const isActive = tab === state.sidebarTab;
|
|
29216
|
+
const glyph = sidebarTabRailGlyph(tab);
|
|
29217
|
+
const count = sidebarTabCount(tab, context);
|
|
29218
|
+
// Count fits in 2 cells (rail content area is ~4 cells); 99+
|
|
29219
|
+
// collapses to `+` so we never overflow.
|
|
29220
|
+
const countText = count === undefined
|
|
29221
|
+
? ''
|
|
29222
|
+
: count > 99
|
|
29223
|
+
? '+'
|
|
29224
|
+
: String(count);
|
|
29225
|
+
const body = isActive ? `[${glyph}]` : ` ${glyph} `;
|
|
29226
|
+
const text = countText ? `${body}${countText}` : body;
|
|
29227
|
+
return h(Text, {
|
|
29228
|
+
key: `rail-${tab}`,
|
|
29229
|
+
bold: isActive,
|
|
29230
|
+
dimColor: !isActive,
|
|
29231
|
+
}, text);
|
|
29232
|
+
}));
|
|
29233
|
+
}
|
|
29234
|
+
function renderSidebar(h, components, state, context, contextStatus, width, bodyRows, theme, railed = false) {
|
|
27553
29235
|
const { Box, Text } = components;
|
|
27554
29236
|
const focused = state.focus === 'sidebar';
|
|
27555
29237
|
const tabs = getLogInkSidebarTabs();
|
|
29238
|
+
if (railed) {
|
|
29239
|
+
return renderSidebarRail(h, components, state, context, width, theme, focused, tabs);
|
|
29240
|
+
}
|
|
27556
29241
|
// Accordion layout — every tab's title is visible on its own line, but
|
|
27557
29242
|
// only the active tab expands its content underneath. Switching tabs
|
|
27558
29243
|
// (1-5 / [/]) collapses the previous and expands the next.
|
|
@@ -27839,6 +29524,34 @@ function formatLogInkSubmodulesEmpty({ filter }) {
|
|
|
27839
29524
|
}
|
|
27840
29525
|
return 'No submodules registered. Add one with `git submodule add <url> <path>` from the shell.';
|
|
27841
29526
|
}
|
|
29527
|
+
function formatLogInkIssuesEmpty({ filter }) {
|
|
29528
|
+
if (filter.trim()) {
|
|
29529
|
+
return `No issues match filter '${filter}'. Press ctrl+u to clear.`;
|
|
29530
|
+
}
|
|
29531
|
+
return 'No issues match the current GitHub filter (default: open issues).';
|
|
29532
|
+
}
|
|
29533
|
+
function formatLogInkPullRequestTriageEmpty({ filter, }) {
|
|
29534
|
+
if (filter.trim()) {
|
|
29535
|
+
return `No pull requests match filter '${filter}'. Press ctrl+u to clear.`;
|
|
29536
|
+
}
|
|
29537
|
+
return 'No pull requests match the current GitHub filter (default: open PRs).';
|
|
29538
|
+
}
|
|
29539
|
+
/**
|
|
29540
|
+
* Surface-level fallback when the GitHub CLI is missing or not
|
|
29541
|
+
* authenticated. The triage views (#882) all share this empty-state
|
|
29542
|
+
* copy — the underlying problem is the same regardless of which
|
|
29543
|
+
* surface the user is on, and the recovery is identical.
|
|
29544
|
+
*/
|
|
29545
|
+
function formatLogInkGitHubUnauthenticated({ resource, }) {
|
|
29546
|
+
return `${resource} require the GitHub CLI. Install \`gh\` and run \`gh auth login\` to enable triage.`;
|
|
29547
|
+
}
|
|
29548
|
+
/**
|
|
29549
|
+
* Surface-level fallback when the repo has no GitHub remote. Same
|
|
29550
|
+
* shared message across the triage surfaces.
|
|
29551
|
+
*/
|
|
29552
|
+
function formatLogInkGitHubNoRemote({ resource, }) {
|
|
29553
|
+
return `${resource} require a GitHub remote (origin or fallback). None detected for this repo.`;
|
|
29554
|
+
}
|
|
27842
29555
|
|
|
27843
29556
|
/**
|
|
27844
29557
|
* Branches surface — promoted view listing local branches with sort,
|
|
@@ -28729,6 +30442,179 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
28729
30442
|
: []));
|
|
28730
30443
|
}
|
|
28731
30444
|
|
|
30445
|
+
/**
|
|
30446
|
+
* Strip refs that are already represented by a branch tip chip so the
|
|
30447
|
+
* trailing `[ref] [ref]` list doesn't repeat what the chip is already
|
|
30448
|
+
* showing. The chip carries the primary branch name; the trailing
|
|
30449
|
+
* list keeps everything else — including remote-tracking variants
|
|
30450
|
+
* (`origin/X`) and `origin/HEAD` — because those convey "remote is
|
|
30451
|
+
* also at this commit" info the chip alone doesn't.
|
|
30452
|
+
*
|
|
30453
|
+
* Removes:
|
|
30454
|
+
* - exact match of the chipped name (`main`, `feat/foo`)
|
|
30455
|
+
* - `HEAD -> <name>` for the chipped name
|
|
30456
|
+
* - bare `HEAD` when the chip is the HEAD branch (only paranoia;
|
|
30457
|
+
* git typically emits `HEAD -> name` not both, but a detached
|
|
30458
|
+
* fixup commit may have produced both)
|
|
30459
|
+
*/
|
|
30460
|
+
function filterChippedRefs(refs, chip) {
|
|
30461
|
+
if (!chip)
|
|
30462
|
+
return refs;
|
|
30463
|
+
const headDecoration = `HEAD -> ${chip.name}`;
|
|
30464
|
+
return refs.filter((ref) => {
|
|
30465
|
+
if (ref === chip.name)
|
|
30466
|
+
return false;
|
|
30467
|
+
if (ref === headDecoration)
|
|
30468
|
+
return false;
|
|
30469
|
+
if (chip.isHead && ref === 'HEAD')
|
|
30470
|
+
return false;
|
|
30471
|
+
return true;
|
|
30472
|
+
});
|
|
30473
|
+
}
|
|
30474
|
+
function getBranchTipChip(refs) {
|
|
30475
|
+
for (const ref of refs) {
|
|
30476
|
+
if (ref.startsWith('HEAD -> ')) {
|
|
30477
|
+
const name = ref.slice('HEAD -> '.length).trim();
|
|
30478
|
+
if (name)
|
|
30479
|
+
return { name, isHead: true };
|
|
30480
|
+
}
|
|
30481
|
+
}
|
|
30482
|
+
for (const ref of refs) {
|
|
30483
|
+
if (ref === 'HEAD' ||
|
|
30484
|
+
ref.startsWith('HEAD -> ') ||
|
|
30485
|
+
ref.startsWith('tag: ') ||
|
|
30486
|
+
ref.includes('/')) {
|
|
30487
|
+
continue;
|
|
30488
|
+
}
|
|
30489
|
+
if (ref.trim())
|
|
30490
|
+
return { name: ref.trim(), isHead: false };
|
|
30491
|
+
}
|
|
30492
|
+
for (const ref of refs) {
|
|
30493
|
+
if (ref.startsWith('tag: ') || ref === 'HEAD' || ref.startsWith('HEAD -> ')) {
|
|
30494
|
+
continue;
|
|
30495
|
+
}
|
|
30496
|
+
if (ref.includes('/') && ref.trim()) {
|
|
30497
|
+
return { name: ref.trim(), isHead: false };
|
|
30498
|
+
}
|
|
30499
|
+
}
|
|
30500
|
+
return undefined;
|
|
30501
|
+
}
|
|
30502
|
+
|
|
30503
|
+
const PREFIX_PATTERN = /^([a-z]+)(\(([^)]+)\))?(!)?:\s+/;
|
|
30504
|
+
function parseConventionalCommitPrefix(message) {
|
|
30505
|
+
const match = PREFIX_PATTERN.exec(message);
|
|
30506
|
+
if (!match)
|
|
30507
|
+
return undefined;
|
|
30508
|
+
const [whole, type, , scope, breakingMarker] = match;
|
|
30509
|
+
return {
|
|
30510
|
+
prefix: whole,
|
|
30511
|
+
rest: message.slice(whole.length),
|
|
30512
|
+
type,
|
|
30513
|
+
scope: scope || undefined,
|
|
30514
|
+
breaking: Boolean(breakingMarker),
|
|
30515
|
+
};
|
|
30516
|
+
}
|
|
30517
|
+
/**
|
|
30518
|
+
* Pick the theme color used to paint a conventional-commit prefix.
|
|
30519
|
+
*
|
|
30520
|
+
* Rough mapping intent:
|
|
30521
|
+
* - feat → success (new capability, growth)
|
|
30522
|
+
* - fix → warning (was a problem; eye-catch)
|
|
30523
|
+
* - docs / refactor / perf → info / accent (intent-bearing change)
|
|
30524
|
+
* - test / style / build / ci → muted (mechanical / housekeeping)
|
|
30525
|
+
* - chore → muted
|
|
30526
|
+
* - revert → danger (signals "this undid something")
|
|
30527
|
+
*
|
|
30528
|
+
* Unknown types fall through to `accent` so a project-specific
|
|
30529
|
+
* convention (`wip:`, `release:`, etc.) still reads as the typed
|
|
30530
|
+
* prefix rather than blending into the subject. Returns `undefined`
|
|
30531
|
+
* under `theme.noColor` so the prefix stays plain — the textual
|
|
30532
|
+
* `feat:` carries the meaning by itself.
|
|
30533
|
+
*
|
|
30534
|
+
* Breaking changes (`!:`) override the type color with `danger` so
|
|
30535
|
+
* the row reads as "stop and look at this" regardless of which type
|
|
30536
|
+
* it is.
|
|
30537
|
+
*/
|
|
30538
|
+
function getConventionalCommitColor(parsed, theme) {
|
|
30539
|
+
if (theme.noColor)
|
|
30540
|
+
return undefined;
|
|
30541
|
+
if (parsed.breaking)
|
|
30542
|
+
return theme.colors.danger;
|
|
30543
|
+
switch (parsed.type) {
|
|
30544
|
+
case 'feat':
|
|
30545
|
+
return theme.colors.success;
|
|
30546
|
+
case 'fix':
|
|
30547
|
+
return theme.colors.warning;
|
|
30548
|
+
case 'docs':
|
|
30549
|
+
case 'refactor':
|
|
30550
|
+
case 'perf':
|
|
30551
|
+
return theme.colors.info;
|
|
30552
|
+
case 'test':
|
|
30553
|
+
case 'style':
|
|
30554
|
+
case 'build':
|
|
30555
|
+
case 'ci':
|
|
30556
|
+
case 'chore':
|
|
30557
|
+
return theme.colors.muted;
|
|
30558
|
+
case 'revert':
|
|
30559
|
+
return theme.colors.danger;
|
|
30560
|
+
default:
|
|
30561
|
+
return theme.colors.accent;
|
|
30562
|
+
}
|
|
30563
|
+
}
|
|
30564
|
+
|
|
30565
|
+
/**
|
|
30566
|
+
* Date formatting helpers for the Ink TUI surfaces.
|
|
30567
|
+
*
|
|
30568
|
+
* The branch list already ships its own "X ago" formatter
|
|
30569
|
+
* (`formatBranchLastTouched` in iconography.ts) sized for a sidebar
|
|
30570
|
+
* row with room to breathe. The history surface needs a tighter
|
|
30571
|
+
* variant: the date column is fixed-width and competes with the
|
|
30572
|
+
* commit message for cells, so a 2-3 character form is the budget.
|
|
30573
|
+
*
|
|
30574
|
+
* Inputs match what `git log --date=short` produces:
|
|
30575
|
+
* `YYYY-MM-DD`. Caller passes `now` so tests can pin the reference
|
|
30576
|
+
* instant.
|
|
30577
|
+
*
|
|
30578
|
+
* Outputs (rounded toward the nearest unit, no `ago` suffix):
|
|
30579
|
+
* - `today` for same UTC day
|
|
30580
|
+
* - `1d` … `13d` for 1-13 days
|
|
30581
|
+
* - `2w` … `8w` for 2-8 weeks
|
|
30582
|
+
* - `2mo` … `11mo` for 2-11 months
|
|
30583
|
+
* - `2y`+ for older
|
|
30584
|
+
* - `''` for malformed inputs (caller renders nothing)
|
|
30585
|
+
*
|
|
30586
|
+
* Day comparison is in UTC so a commit dated "yesterday" never reads
|
|
30587
|
+
* "today" depending on the operator's timezone.
|
|
30588
|
+
*/
|
|
30589
|
+
function formatCompactRelativeDate(iso, now) {
|
|
30590
|
+
if (!iso)
|
|
30591
|
+
return '';
|
|
30592
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
|
30593
|
+
if (!match)
|
|
30594
|
+
return '';
|
|
30595
|
+
const year = Number.parseInt(match[1], 10);
|
|
30596
|
+
const month = Number.parseInt(match[2], 10);
|
|
30597
|
+
const day = Number.parseInt(match[3], 10);
|
|
30598
|
+
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day))
|
|
30599
|
+
return '';
|
|
30600
|
+
const commitUtc = Date.UTC(year, month - 1, day);
|
|
30601
|
+
const nowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
30602
|
+
const oneDay = 24 * 60 * 60 * 1000;
|
|
30603
|
+
const days = Math.floor((nowUtc - commitUtc) / oneDay);
|
|
30604
|
+
if (days <= 0)
|
|
30605
|
+
return 'today';
|
|
30606
|
+
if (days < 14)
|
|
30607
|
+
return `${days}d`;
|
|
30608
|
+
const weeks = Math.floor(days / 7);
|
|
30609
|
+
if (weeks < 9)
|
|
30610
|
+
return `${weeks}w`;
|
|
30611
|
+
const months = Math.floor(days / 30);
|
|
30612
|
+
if (months < 12)
|
|
30613
|
+
return `${months}mo`;
|
|
30614
|
+
const years = Math.floor(days / 365);
|
|
30615
|
+
return `${years}y`;
|
|
30616
|
+
}
|
|
30617
|
+
|
|
28732
30618
|
/**
|
|
28733
30619
|
* The chars `git log --graph` emits for branch topology — `*`, `|`, `\`,
|
|
28734
30620
|
* `/`, `_`, ` `. ASCII-only output is bulletproof for legacy terminals
|
|
@@ -28978,6 +30864,54 @@ function getLaneColor(laneId, theme) {
|
|
|
28978
30864
|
return palette[laneId % palette.length];
|
|
28979
30865
|
}
|
|
28980
30866
|
|
|
30867
|
+
const MONTH_NAMES = [
|
|
30868
|
+
'January', 'February', 'March', 'April', 'May', 'June',
|
|
30869
|
+
'July', 'August', 'September', 'October', 'November', 'December',
|
|
30870
|
+
];
|
|
30871
|
+
function getDateBucket(iso, now) {
|
|
30872
|
+
if (!iso)
|
|
30873
|
+
return { key: 'unknown', label: 'Unknown date' };
|
|
30874
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
|
30875
|
+
if (!match)
|
|
30876
|
+
return { key: 'unknown', label: 'Unknown date' };
|
|
30877
|
+
const year = Number.parseInt(match[1], 10);
|
|
30878
|
+
const month = Number.parseInt(match[2], 10);
|
|
30879
|
+
const day = Number.parseInt(match[3], 10);
|
|
30880
|
+
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
|
|
30881
|
+
return { key: 'unknown', label: 'Unknown date' };
|
|
30882
|
+
}
|
|
30883
|
+
const commitUtc = Date.UTC(year, month - 1, day);
|
|
30884
|
+
const nowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
30885
|
+
const oneDay = 24 * 60 * 60 * 1000;
|
|
30886
|
+
const days = Math.floor((nowUtc - commitUtc) / oneDay);
|
|
30887
|
+
// Future-dated commits (clock skew, bad commit dates) collapse to
|
|
30888
|
+
// today rather than confusing the user with an "in the future"
|
|
30889
|
+
// bucket.
|
|
30890
|
+
if (days <= 0)
|
|
30891
|
+
return { key: 'today', label: 'Today' };
|
|
30892
|
+
if (days === 1)
|
|
30893
|
+
return { key: 'yesterday', label: 'Yesterday' };
|
|
30894
|
+
if (days < 7)
|
|
30895
|
+
return { key: 'this-week', label: 'This week' };
|
|
30896
|
+
if (days < 14)
|
|
30897
|
+
return { key: 'last-week', label: 'Last week' };
|
|
30898
|
+
// Inside the same calendar month → one "earlier this month" bucket
|
|
30899
|
+
// so the user sees a single section rather than per-day groupings
|
|
30900
|
+
// for a commit-heavy week.
|
|
30901
|
+
if (year === now.getUTCFullYear() && month - 1 === now.getUTCMonth()) {
|
|
30902
|
+
return { key: 'earlier-this-month', label: 'Earlier this month' };
|
|
30903
|
+
}
|
|
30904
|
+
// Older months use the calendar-month label so the bucket reads
|
|
30905
|
+
// naturally even years back (`April 2024`). The key embeds the
|
|
30906
|
+
// year+month so different months stay in distinct buckets without
|
|
30907
|
+
// colliding on month name alone.
|
|
30908
|
+
const monthLabel = MONTH_NAMES[month - 1] ?? `Month ${month}`;
|
|
30909
|
+
return {
|
|
30910
|
+
key: `month-${match[1]}-${match[2]}`,
|
|
30911
|
+
label: `${monthLabel} ${year}`,
|
|
30912
|
+
};
|
|
30913
|
+
}
|
|
30914
|
+
|
|
28981
30915
|
/**
|
|
28982
30916
|
* Pick the commit glyph based on parent count + HEAD-ness so the
|
|
28983
30917
|
* renderer can flag merges and the current head visually. HEAD wins
|
|
@@ -29006,9 +30940,8 @@ function commitKey(commit) {
|
|
|
29006
30940
|
function graphWidth(items) {
|
|
29007
30941
|
return Math.max(1, ...items.map((item) => item.graph.length));
|
|
29008
30942
|
}
|
|
29009
|
-
function
|
|
29010
|
-
|
|
29011
|
-
return state.filteredCommits.slice(start, start + visibleCount).map((commit, offset) => ({
|
|
30943
|
+
function makeCompactCommitItem(commit, selected) {
|
|
30944
|
+
return {
|
|
29012
30945
|
type: 'commit',
|
|
29013
30946
|
commit,
|
|
29014
30947
|
graph: '*',
|
|
@@ -29017,24 +30950,197 @@ function toCompactItems(state, visibleCount) {
|
|
|
29017
30950
|
// glance. Lane id stays undefined so the segment renders muted —
|
|
29018
30951
|
// matching the legacy compact appearance, just with a richer glyph.
|
|
29019
30952
|
laneSegments: [{ text: commitGlyphFor(commit), laneId: undefined }],
|
|
29020
|
-
selected
|
|
29021
|
-
}
|
|
30953
|
+
selected,
|
|
30954
|
+
};
|
|
30955
|
+
}
|
|
30956
|
+
function bucketHeaderItem(label) {
|
|
30957
|
+
return { type: 'bucket-header', graph: '', label };
|
|
30958
|
+
}
|
|
30959
|
+
function toCompactItems(state, visibleCount, bucketingNow) {
|
|
30960
|
+
const start = clampWindowStart(state.selectedIndex, state.filteredCommits.length, visibleCount);
|
|
30961
|
+
const slice = state.filteredCommits.slice(start, start + visibleCount);
|
|
30962
|
+
if (!bucketingNow) {
|
|
30963
|
+
return slice.map((commit, offset) => makeCompactCommitItem(commit, start + offset === state.selectedIndex));
|
|
30964
|
+
}
|
|
30965
|
+
// With bucketing on: emit a sticky header above the first visible
|
|
30966
|
+
// commit and an additional header each time the bucket changes. The
|
|
30967
|
+
// header occupies one row from the visibleCount budget every time
|
|
30968
|
+
// it fires, so the visible commit count drops slightly in exchange
|
|
30969
|
+
// for always-on temporal orientation.
|
|
30970
|
+
const items = [];
|
|
30971
|
+
let prevBucket = undefined;
|
|
30972
|
+
for (let offset = 0; offset < slice.length && items.length < visibleCount; offset += 1) {
|
|
30973
|
+
const commit = slice[offset];
|
|
30974
|
+
const bucket = getDateBucket(commit.date, bucketingNow);
|
|
30975
|
+
if (bucket.key !== prevBucket) {
|
|
30976
|
+
items.push(bucketHeaderItem(bucket.label));
|
|
30977
|
+
prevBucket = bucket.key;
|
|
30978
|
+
if (items.length >= visibleCount)
|
|
30979
|
+
break;
|
|
30980
|
+
}
|
|
30981
|
+
items.push(makeCompactCommitItem(commit, start + offset === state.selectedIndex));
|
|
30982
|
+
}
|
|
30983
|
+
return items;
|
|
29022
30984
|
}
|
|
29023
30985
|
function isSelectedCommit(row, selected) {
|
|
29024
30986
|
return row.type === 'commit' && selected ? commitKey(row) === commitKey(selected) : false;
|
|
29025
30987
|
}
|
|
29026
|
-
|
|
30988
|
+
/**
|
|
30989
|
+
* Build the vertical-only graph string that follows a commit row when
|
|
30990
|
+
* `withSpacers` is enabled. Every commit-cell glyph (`*`) is rewritten
|
|
30991
|
+
* to a lane bar (`|`) so the synthetic row continues every open lane
|
|
30992
|
+
* without re-rendering the commit dot. All other graph chars pass
|
|
30993
|
+
* through unchanged, so a commit graph like `* | |` becomes `| | |`.
|
|
30994
|
+
*/
|
|
30995
|
+
function buildSpacerGraph(commitGraph) {
|
|
30996
|
+
return commitGraph.replace(/\*/g, '|');
|
|
30997
|
+
}
|
|
30998
|
+
/**
|
|
30999
|
+
* Walk `state.rows` and inject a synthetic spacer entry after every
|
|
31000
|
+
* commit row when `withSpacers` is true. The spacer is a graph-only
|
|
31001
|
+
* row that renders as `|` per active lane so consecutive commits have
|
|
31002
|
+
* a clear vertical rhythm without losing topology continuity.
|
|
31003
|
+
*
|
|
31004
|
+
* The spacer is suppressed in two cases where it would create visible
|
|
31005
|
+
* "tearing" on the graph column:
|
|
31006
|
+
*
|
|
31007
|
+
* 1. The next row is git's own graph-only topology row (`|\` /
|
|
31008
|
+
* `|/` / `| |`). That row already provides vertical breathing
|
|
31009
|
+
* AND draws the lane transition; sandwiching our spacer between
|
|
31010
|
+
* the commit and the transition produces an extra all-pipes row
|
|
31011
|
+
* that reads as misalignment.
|
|
31012
|
+
*
|
|
31013
|
+
* 2. The current commit's graph contains a backslash or forward
|
|
31014
|
+
* slash (the compressed forms git uses for `*\` / `*` followed
|
|
31015
|
+
* by slash, when it draws the fork on the same row as the
|
|
31016
|
+
* commit). The spacer's commit-glyph → lane-bar rewrite would
|
|
31017
|
+
* leave the diagonal intact, rendering a second corner glyph
|
|
31018
|
+
* immediately below the merge — a duplicate that looks like a
|
|
31019
|
+
* glyph stutter.
|
|
31020
|
+
*
|
|
31021
|
+
* When `withSpacers` is false the list is identity-mapped from
|
|
31022
|
+
* source rows, preserving the legacy zero-padding behavior for any
|
|
31023
|
+
* caller that wants raw git topology (filters, tests, etc.).
|
|
31024
|
+
*/
|
|
31025
|
+
function commitGraphIsSimple(graph) {
|
|
31026
|
+
return !/[\\/]/.test(graph);
|
|
31027
|
+
}
|
|
31028
|
+
function expandRowsWithSpacers(rows, withSpacers) {
|
|
31029
|
+
const out = [];
|
|
31030
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
31031
|
+
const row = rows[i];
|
|
31032
|
+
out.push({ kind: 'source', row });
|
|
31033
|
+
if (!withSpacers || row.type !== 'commit')
|
|
31034
|
+
continue;
|
|
31035
|
+
if (!commitGraphIsSimple(row.graph || '*'))
|
|
31036
|
+
continue;
|
|
31037
|
+
const next = rows[i + 1];
|
|
31038
|
+
if (next && next.type === 'graph')
|
|
31039
|
+
continue;
|
|
31040
|
+
out.push({ kind: 'spacer', sourceCommit: row });
|
|
31041
|
+
}
|
|
31042
|
+
return out;
|
|
31043
|
+
}
|
|
31044
|
+
/**
|
|
31045
|
+
* Walk an already-expanded row list and inject `bucket-header`
|
|
31046
|
+
* entries immediately before each commit whose date bucket differs
|
|
31047
|
+
* from the previous commit's. The very first commit always gets a
|
|
31048
|
+
* header so the user lands inside a labeled section regardless of
|
|
31049
|
+
* where the scroll window starts. Non-commit entries (spacers, git
|
|
31050
|
+
* topology rows) pass through unchanged.
|
|
31051
|
+
*/
|
|
31052
|
+
function injectBucketHeaders(rows, now) {
|
|
31053
|
+
const out = [];
|
|
31054
|
+
let prevBucket = undefined;
|
|
31055
|
+
for (const entry of rows) {
|
|
31056
|
+
if (entry.kind === 'source' && entry.row.type === 'commit') {
|
|
31057
|
+
const bucket = getDateBucket(entry.row.date, now);
|
|
31058
|
+
if (bucket.key !== prevBucket) {
|
|
31059
|
+
out.push({ kind: 'bucket-header', label: bucket.label });
|
|
31060
|
+
prevBucket = bucket.key;
|
|
31061
|
+
}
|
|
31062
|
+
}
|
|
31063
|
+
out.push(entry);
|
|
31064
|
+
}
|
|
31065
|
+
return out;
|
|
31066
|
+
}
|
|
31067
|
+
/**
|
|
31068
|
+
* Find the most recent bucket header at or above `start` so a slice
|
|
31069
|
+
* that begins mid-bucket can still surface its section label. Used
|
|
31070
|
+
* for the "sticky header" behavior — when the window scrolls past
|
|
31071
|
+
* the natural header position, prepend the header to the slice so
|
|
31072
|
+
* the user always sees which bucket they're in. Returns the label
|
|
31073
|
+
* to prepend, or `undefined` when no prepend is needed (either
|
|
31074
|
+
* `expanded[start]` is already a header, or there is no earlier
|
|
31075
|
+
* header in the list).
|
|
31076
|
+
*/
|
|
31077
|
+
function findStickyBucketLabel(expanded, start) {
|
|
31078
|
+
if (start < expanded.length && expanded[start].kind === 'bucket-header')
|
|
31079
|
+
return undefined;
|
|
31080
|
+
for (let i = start - 1; i >= 0; i -= 1) {
|
|
31081
|
+
const entry = expanded[i];
|
|
31082
|
+
if (entry.kind === 'bucket-header')
|
|
31083
|
+
return entry.label;
|
|
31084
|
+
}
|
|
31085
|
+
return undefined;
|
|
31086
|
+
}
|
|
31087
|
+
function toFullGraphItems(state, visibleCount, options = {
|
|
31088
|
+
withSpacers: false,
|
|
31089
|
+
bucketingNow: undefined,
|
|
31090
|
+
}) {
|
|
29027
31091
|
const selected = state.filteredCommits[state.selectedIndex];
|
|
29028
|
-
const
|
|
29029
|
-
const
|
|
31092
|
+
const withSpacers = expandRowsWithSpacers(state.rows, options.withSpacers);
|
|
31093
|
+
const expanded = options.bucketingNow
|
|
31094
|
+
? injectBucketHeaders(withSpacers, options.bucketingNow)
|
|
31095
|
+
: withSpacers;
|
|
31096
|
+
const selectedExpandedIndex = expanded.findIndex((entry) => entry.kind === 'source' && isSelectedCommit(entry.row, selected));
|
|
31097
|
+
const start = clampWindowStart(selectedExpandedIndex >= 0 ? selectedExpandedIndex : 0, expanded.length, visibleCount);
|
|
29030
31098
|
// Lane tracking is order-dependent — fast-forward the tracker through
|
|
29031
31099
|
// every row above the visible window so lane ids stay stable as the
|
|
29032
31100
|
// user scrolls. Without this, scrolling would re-color lanes from a
|
|
29033
|
-
// fresh tracker each time.
|
|
31101
|
+
// fresh tracker each time. Spacers contribute their vertical-only
|
|
31102
|
+
// graph to the prefix so the tracker sees a no-op advance and lane
|
|
31103
|
+
// state stays consistent at the window boundary. Bucket headers
|
|
31104
|
+
// skip the tracker entirely since they have no graph string.
|
|
29034
31105
|
const tracker = createLaneTrackerState();
|
|
29035
|
-
const
|
|
29036
|
-
|
|
29037
|
-
|
|
31106
|
+
const prefixGraphs = [];
|
|
31107
|
+
for (let k = 0; k < start; k += 1) {
|
|
31108
|
+
const entry = expanded[k];
|
|
31109
|
+
if (entry.kind === 'bucket-header')
|
|
31110
|
+
continue;
|
|
31111
|
+
if (entry.kind === 'spacer') {
|
|
31112
|
+
prefixGraphs.push(buildSpacerGraph(entry.sourceCommit.graph || '*'));
|
|
31113
|
+
continue;
|
|
31114
|
+
}
|
|
31115
|
+
prefixGraphs.push(entry.row.type === 'commit' ? entry.row.graph || '*' : entry.row.graph);
|
|
31116
|
+
}
|
|
31117
|
+
advanceTrackerThrough(prefixGraphs, tracker, prefixGraphs.length);
|
|
31118
|
+
// Sticky header — if the slice would start partway into a bucket
|
|
31119
|
+
// (most commonly when scrolling), prepend the bucket label so the
|
|
31120
|
+
// user keeps temporal context. The prepend costs one row from the
|
|
31121
|
+
// visible budget, so the slice itself shrinks by 1.
|
|
31122
|
+
const stickyLabel = options.bucketingNow
|
|
31123
|
+
? findStickyBucketLabel(expanded, start)
|
|
31124
|
+
: undefined;
|
|
31125
|
+
const sliceCount = stickyLabel ? visibleCount - 1 : visibleCount;
|
|
31126
|
+
const sliced = expanded.slice(start, start + sliceCount);
|
|
31127
|
+
const finalEntries = stickyLabel
|
|
31128
|
+
? [{ kind: 'bucket-header', label: stickyLabel }, ...sliced]
|
|
31129
|
+
: sliced;
|
|
31130
|
+
return finalEntries.map((entry) => {
|
|
31131
|
+
if (entry.kind === 'bucket-header') {
|
|
31132
|
+
return bucketHeaderItem(entry.label);
|
|
31133
|
+
}
|
|
31134
|
+
if (entry.kind === 'spacer') {
|
|
31135
|
+
const graph = buildSpacerGraph(entry.sourceCommit.graph || '*');
|
|
31136
|
+
return {
|
|
31137
|
+
type: 'graph',
|
|
31138
|
+
graph,
|
|
31139
|
+
laneSegments: renderGraphRowSegments(graph, tracker, { ascii: false }),
|
|
31140
|
+
spacer: true,
|
|
31141
|
+
};
|
|
31142
|
+
}
|
|
31143
|
+
const { row } = entry;
|
|
29038
31144
|
if (row.type === 'graph') {
|
|
29039
31145
|
return {
|
|
29040
31146
|
type: 'graph',
|
|
@@ -29053,10 +31159,18 @@ function toFullGraphItems(state, visibleCount) {
|
|
|
29053
31159
|
};
|
|
29054
31160
|
});
|
|
29055
31161
|
}
|
|
29056
|
-
function getVisibleLogInkHistory(state, visibleCount) {
|
|
31162
|
+
function getVisibleLogInkHistory(state, visibleCount, options = {}) {
|
|
31163
|
+
// Bucketing only makes sense for chronologically ordered output —
|
|
31164
|
+
// an active search filter shuffles commits by relevance, so the
|
|
31165
|
+
// adjacent-bucket invariant breaks down and the divider would
|
|
31166
|
+
// read as noise.
|
|
31167
|
+
const bucketingNow = state.filter ? undefined : options.dateBucketingNow;
|
|
29057
31168
|
const items = state.fullGraph && !state.filter
|
|
29058
|
-
? toFullGraphItems(state, visibleCount
|
|
29059
|
-
|
|
31169
|
+
? toFullGraphItems(state, visibleCount, {
|
|
31170
|
+
withSpacers: Boolean(options.fullGraphSpacing),
|
|
31171
|
+
bucketingNow,
|
|
31172
|
+
})
|
|
31173
|
+
: toCompactItems(state, visibleCount, bucketingNow);
|
|
29060
31174
|
return {
|
|
29061
31175
|
graphWidth: graphWidth(items),
|
|
29062
31176
|
items,
|
|
@@ -29082,6 +31196,96 @@ function formatInkRefLabels(refs) {
|
|
|
29082
31196
|
* `formatHistoryFetchArgs`) lived in inkRuntime.ts only to support
|
|
29083
31197
|
* this surface; they migrate together.
|
|
29084
31198
|
*/
|
|
31199
|
+
/**
|
|
31200
|
+
* How the date column should render for a given density tier:
|
|
31201
|
+
* - wide → absolute `YYYY-MM-DD`
|
|
31202
|
+
* - normal → compact relative form (`2d`, `3w`, `2mo`)
|
|
31203
|
+
* - tight → hidden entirely (column dropped)
|
|
31204
|
+
* - rail → caller picks `rowMode='stacked'`; this fn isn't consulted
|
|
31205
|
+
*
|
|
31206
|
+
* Compact mode (the user toggling away from the full graph) forces
|
|
31207
|
+
* the tight behavior regardless of density. Compact is the "scan
|
|
31208
|
+
* mode" — the date is the first thing the user is willing to drop in
|
|
31209
|
+
* exchange for more visible commits per screen.
|
|
31210
|
+
*
|
|
31211
|
+
* When `bucketed` is true the surface is rendering section dividers
|
|
31212
|
+
* (`── Today ──`) above commits, so the per-row date column would be
|
|
31213
|
+
* redundant. We drop it entirely and let the message column expand.
|
|
31214
|
+
*/
|
|
31215
|
+
function pickDateText(commit, density, fullGraph, bucketed, now) {
|
|
31216
|
+
if (bucketed)
|
|
31217
|
+
return '';
|
|
31218
|
+
if (!fullGraph)
|
|
31219
|
+
return '';
|
|
31220
|
+
if (density === 'wide')
|
|
31221
|
+
return commit.date;
|
|
31222
|
+
if (density === 'normal')
|
|
31223
|
+
return formatCompactRelativeDate(commit.date, now);
|
|
31224
|
+
return '';
|
|
31225
|
+
}
|
|
31226
|
+
/**
|
|
31227
|
+
* Maximum cells the chip body (between the brackets) is allowed to
|
|
31228
|
+
* occupy. Anything longer is truncated with an ellipsis so a
|
|
31229
|
+
* `[origin/claude/issues-prs-cache]` chip — 32 cells of chrome —
|
|
31230
|
+
* doesn't eat the whole subject column on a narrow terminal. Picked
|
|
31231
|
+
* empirically: 20 cells fits common branch shapes (`feat/foo`,
|
|
31232
|
+
* `claude/graph-fidelity`, `main`) without truncation.
|
|
31233
|
+
*/
|
|
31234
|
+
const BRANCH_CHIP_MAX_NAME_WIDTH = 20;
|
|
31235
|
+
/**
|
|
31236
|
+
* Render a pill-style chip for a branch tip — colored background
|
|
31237
|
+
* with the branch name reverse-printed inside, so the chip reads as
|
|
31238
|
+
* a distinct visual category (block) rather than colored text (which
|
|
31239
|
+
* collides with `docs:`/`refactor:`/`perf:` conventional-commit
|
|
31240
|
+
* prefixes that also use `info`). Current branch (HEAD -> X) uses
|
|
31241
|
+
* success-green; other branch tips use info-blue.
|
|
31242
|
+
*
|
|
31243
|
+
* Implementation: `inverse: true` + `color: <accent>` is the
|
|
31244
|
+
* portable way to render "colored background with terminal-default
|
|
31245
|
+
* foreground" — it adapts to dark vs light terminals without
|
|
31246
|
+
* hardcoding a black/white fg. Tags are never chipped; they stay in
|
|
31247
|
+
* the trailing ref list. The chip emits its own trailing space so
|
|
31248
|
+
* callers concatenate it directly into the row without a separator.
|
|
31249
|
+
*
|
|
31250
|
+
* Selection styling is opt-out: when the row is selected, the outer
|
|
31251
|
+
* `inverse: true` + selection background already covers everything,
|
|
31252
|
+
* and a second `inverse` on the chip would flip it back to plain. We
|
|
31253
|
+
* drop the pill styling for selected rows and let the row's own
|
|
31254
|
+
* inverse highlight carry through cleanly.
|
|
31255
|
+
*
|
|
31256
|
+
* Returns the rendered node alongside its cell width AND the chip
|
|
31257
|
+
* descriptor so the caller can pass it to `filterChippedRefs` and
|
|
31258
|
+
* avoid emitting the same branch a second time in the trailing list.
|
|
31259
|
+
*/
|
|
31260
|
+
function renderBranchTipChip(h, Text, commit, theme, key, selected) {
|
|
31261
|
+
const chip = getBranchTipChip(commit.refs);
|
|
31262
|
+
if (!chip)
|
|
31263
|
+
return { node: null, width: 0, chip };
|
|
31264
|
+
const truncated = truncateCells(chip.name, BRANCH_CHIP_MAX_NAME_WIDTH);
|
|
31265
|
+
// Inner pill body is `name`; the trailing space sits OUTSIDE the
|
|
31266
|
+
// colored block so the bg doesn't bleed into the message column.
|
|
31267
|
+
// The brackets are gone — the colored block is its own visual
|
|
31268
|
+
// affordance and the brackets would add 2 cells of chrome that
|
|
31269
|
+
// duplicate the affordance.
|
|
31270
|
+
const body = ` ${truncated} `;
|
|
31271
|
+
// Selected row OR noColor mode → drop pill styling. Selected rows
|
|
31272
|
+
// get the row-level inverse highlight; noColor terminals fall
|
|
31273
|
+
// back to bracketed text so the chip still parses visually.
|
|
31274
|
+
if (selected || theme.noColor) {
|
|
31275
|
+
const fallbackLabel = `[${truncated}] `;
|
|
31276
|
+
return {
|
|
31277
|
+
node: h(Text, { key, bold: chip.isHead }, fallbackLabel),
|
|
31278
|
+
width: cellWidth(fallbackLabel),
|
|
31279
|
+
chip,
|
|
31280
|
+
};
|
|
31281
|
+
}
|
|
31282
|
+
const accent = chip.isHead ? theme.colors.success : theme.colors.info;
|
|
31283
|
+
return {
|
|
31284
|
+
node: h(Text, {}, h(Text, { key, inverse: true, color: accent, bold: chip.isHead }, body), h(Text, { key: `${key}-pad` }, ' ')),
|
|
31285
|
+
width: cellWidth(body) + 1,
|
|
31286
|
+
chip,
|
|
31287
|
+
};
|
|
31288
|
+
}
|
|
29085
31289
|
function formatHistoryFetchArgs(args) {
|
|
29086
31290
|
const parts = [];
|
|
29087
31291
|
if (args.author)
|
|
@@ -29090,6 +31294,36 @@ function formatHistoryFetchArgs(args) {
|
|
|
29090
31294
|
parts.push(`-- ${args.path}`);
|
|
29091
31295
|
return parts.join(' ') || 'none';
|
|
29092
31296
|
}
|
|
31297
|
+
/**
|
|
31298
|
+
* Render a commit subject with the conventional-commit prefix
|
|
31299
|
+
* (`feat:`, `fix(scope)!:`, …) painted in a type-specific color so
|
|
31300
|
+
* the eye can bucket commits by type while scanning.
|
|
31301
|
+
*
|
|
31302
|
+
* Truncation lives at the message level above this helper — the
|
|
31303
|
+
* caller has already shortened `text` to the available room. We just
|
|
31304
|
+
* split on the parsed prefix length and emit two spans. If the
|
|
31305
|
+
* shortened text is too narrow to include the full prefix (e.g. a
|
|
31306
|
+
* tight panel that cut into `feat`), we fall back to a single plain
|
|
31307
|
+
* span so the partial prefix doesn't read as a malformed colored
|
|
31308
|
+
* fragment.
|
|
31309
|
+
*
|
|
31310
|
+
* Returns the spans flat so the caller can splat them into the row's
|
|
31311
|
+
* outer Text alongside other segments without an extra wrapper.
|
|
31312
|
+
*/
|
|
31313
|
+
function renderTypedSubject(h, Text, text, theme, key) {
|
|
31314
|
+
const parsed = parseConventionalCommitPrefix(text);
|
|
31315
|
+
if (!parsed) {
|
|
31316
|
+
return [h(Text, { key: `${key}-msg` }, text)];
|
|
31317
|
+
}
|
|
31318
|
+
if (text.length < parsed.prefix.length) {
|
|
31319
|
+
return [h(Text, { key: `${key}-msg` }, text)];
|
|
31320
|
+
}
|
|
31321
|
+
const color = getConventionalCommitColor(parsed, theme);
|
|
31322
|
+
return [
|
|
31323
|
+
h(Text, { key: `${key}-type`, color, bold: parsed.breaking }, parsed.prefix),
|
|
31324
|
+
h(Text, { key: `${key}-rest` }, text.slice(parsed.prefix.length)),
|
|
31325
|
+
];
|
|
31326
|
+
}
|
|
29093
31327
|
/**
|
|
29094
31328
|
* Render `LaneSegment[]` as a flat list of Text spans, one per lane
|
|
29095
31329
|
* (#791 stage 2). Each segment paints in its lane's palette color so
|
|
@@ -29135,8 +31369,7 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, opti
|
|
|
29135
31369
|
* Truncation is per-segment so the variable-length message field gets
|
|
29136
31370
|
* the leftover budget after fixed segments are accounted for.
|
|
29137
31371
|
*/
|
|
29138
|
-
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, laneSegments, isRecent = false) {
|
|
29139
|
-
const refs = formatInkRefLabels(commit.refs);
|
|
31372
|
+
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, density, fullGraph, bucketed, now, laneSegments, isRecent = false) {
|
|
29140
31373
|
// Total cells available to the row content. Earlier revisions used a
|
|
29141
31374
|
// hardcoded 140 here, which let row content overflow whenever the
|
|
29142
31375
|
// panel was narrower than that — Ink would wrap onto a second visual
|
|
@@ -29144,7 +31377,19 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
|
|
|
29144
31377
|
// continuation rather than its own commit (#830). Subtracting 4
|
|
29145
31378
|
// accounts for the panel's left + right border + 1-cell padding.
|
|
29146
31379
|
const totalWidth = Math.max(20, panelWidth - 4);
|
|
29147
|
-
const
|
|
31380
|
+
const dateText = pickDateText(commit, density, fullGraph, bucketed, now);
|
|
31381
|
+
const dateSegmentWidth = dateText ? dateText.length + 1 : 0;
|
|
31382
|
+
// Branch chip prefix — only renders in full-graph mode so compact
|
|
31383
|
+
// (scan) mode stays minimal. Chip occupies cells immediately after
|
|
31384
|
+
// the shortHash and before the message; truncation math reserves
|
|
31385
|
+
// its width before sizing the message column. Trailing refs filter
|
|
31386
|
+
// out whatever the chip already shows so the row doesn't print
|
|
31387
|
+
// `[main] feat: x [HEAD -> main]` with the same info on both ends.
|
|
31388
|
+
const chip = fullGraph
|
|
31389
|
+
? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-chip`, selected)
|
|
31390
|
+
: { node: null, width: 0, chip: undefined };
|
|
31391
|
+
const refs = formatInkRefLabels(filterChippedRefs(commit.refs, chip.chip));
|
|
31392
|
+
const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + dateSegmentWidth + chip.width;
|
|
29148
31393
|
// Refs trail the message and shrink first when the row is narrow:
|
|
29149
31394
|
// the user can always see the full ref list in the inspector, so
|
|
29150
31395
|
// the headline subject keeps priority over decoration.
|
|
@@ -29182,7 +31427,81 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
|
|
|
29182
31427
|
// the primary cue but boldness makes the row read as "this is
|
|
29183
31428
|
// worth looking at" even without color.
|
|
29184
31429
|
bold: selected || isRecent,
|
|
29185
|
-
}, commit.shortHash), ' ',
|
|
31430
|
+
}, commit.shortHash), ' ',
|
|
31431
|
+
// Date column drops out entirely at `tight` density — no spacer
|
|
31432
|
+
// either, so the message column slides left into the freed cells.
|
|
31433
|
+
dateText
|
|
31434
|
+
? h(Text, { key: `${commit.hash}-${index}-date`, dimColor: true }, dateText, ' ')
|
|
31435
|
+
: null,
|
|
31436
|
+
// Branch chip prefix (full-graph mode only) lands right before the
|
|
31437
|
+
// message so the eye reads "branch · subject" as a unit.
|
|
31438
|
+
chip.node, ...renderTypedSubject(h, Text, message, theme, `${commit.hash}-${index}-subj`), refsTrunc ? h(Text, { color: accent }, refsTrunc) : null);
|
|
31439
|
+
}
|
|
31440
|
+
/**
|
|
31441
|
+
* Stacked variant used at `rowMode='stacked'` (rail tier). Each
|
|
31442
|
+
* commit takes two lines so the message never has to share its row
|
|
31443
|
+
* with the date / refs / hash on a sub-90-cell terminal:
|
|
31444
|
+
* line 1: graph · shortHash · subject
|
|
31445
|
+
* line 2: dim padding · date · refs
|
|
31446
|
+
*
|
|
31447
|
+
* Selection styling lives on the line-1 outer span; the secondary
|
|
31448
|
+
* line stays dim regardless of selection so it doesn't pull the eye
|
|
31449
|
+
* away from the subject.
|
|
31450
|
+
*/
|
|
31451
|
+
function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth, selected, theme, index, panelWidth, fullGraph, now, laneSegments, isRecent = false) {
|
|
31452
|
+
const totalWidth = Math.max(20, panelWidth - 4);
|
|
31453
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
31454
|
+
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
31455
|
+
const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
|
|
31456
|
+
// Line 1 — subject row. Mostly mirrors the single-line layout but
|
|
31457
|
+
// skips the date and refs so the message has the whole tail to
|
|
31458
|
+
// itself. Branch chip rides between the hash and the subject the
|
|
31459
|
+
// same way as the single-line variant, but only in full-graph mode.
|
|
31460
|
+
const recentMarkerWidth = isRecent ? 2 : 0;
|
|
31461
|
+
const chip = fullGraph
|
|
31462
|
+
? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-stk-chip`, selected)
|
|
31463
|
+
: { node: null, width: 0, chip: undefined };
|
|
31464
|
+
const lineOneFixed = graphWidth + 1 + commit.shortHash.length + 1 + recentMarkerWidth + chip.width;
|
|
31465
|
+
const subject = truncateCells(commit.message, Math.max(8, totalWidth - lineOneFixed));
|
|
31466
|
+
const graphChildren = laneSegments && !theme.ascii
|
|
31467
|
+
? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `cs${index}`)
|
|
31468
|
+
: [h(Text, { color: muted, dimColor: theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
|
|
31469
|
+
const lineOne = h(Text, {
|
|
31470
|
+
key: `${commit.hash}-${index}-l1`,
|
|
31471
|
+
backgroundColor: selectedBg,
|
|
31472
|
+
inverse: selected,
|
|
31473
|
+
}, ...graphChildren, ' ', isRecent
|
|
31474
|
+
? h(Text, { color: accent, bold: true }, theme.ascii ? '* ' : '▎ ')
|
|
31475
|
+
: null, h(Text, { color: accent, bold: selected || isRecent }, commit.shortHash), ' ', chip.node, ...renderTypedSubject(h, Text, subject, theme, `${commit.hash}-${index}-stk-subj`));
|
|
31476
|
+
// Line 2 — metadata row, padded to align with the start of the
|
|
31477
|
+
// shortHash on line 1 so the eye still groups them as one commit.
|
|
31478
|
+
// Selection background does not extend here so we don't get a thick
|
|
31479
|
+
// double-row highlight on a tight terminal. Trailing refs are
|
|
31480
|
+
// filtered against the chip so we don't repeat the branch tip both
|
|
31481
|
+
// as a leading chip and a trailing label.
|
|
31482
|
+
const indent = ' '.repeat(graphWidth + 1);
|
|
31483
|
+
const dateText = formatCompactRelativeDate(commit.date, now);
|
|
31484
|
+
const refs = formatInkRefLabels(filterChippedRefs(commit.refs, chip.chip));
|
|
31485
|
+
const metaRoom = Math.max(8, totalWidth - indent.length - (dateText ? dateText.length + 1 : 0));
|
|
31486
|
+
const refsTrunc = refs ? truncateCells(refs, metaRoom) : '';
|
|
31487
|
+
// If both pieces are empty (date unparseable + no refs), show a
|
|
31488
|
+
// bullet so the row's structure still reads as two-line and the
|
|
31489
|
+
// user doesn't think they hit a render bug.
|
|
31490
|
+
const metaContent = dateText || refsTrunc
|
|
31491
|
+
? [
|
|
31492
|
+
dateText ? h(Text, { key: `${commit.hash}-${index}-l2-date` }, dateText) : null,
|
|
31493
|
+
dateText && refsTrunc ? h(Text, { key: `${commit.hash}-${index}-l2-sep` }, ' ') : null,
|
|
31494
|
+
refsTrunc ? h(Text, { key: `${commit.hash}-${index}-l2-refs` }, refsTrunc) : null,
|
|
31495
|
+
].filter(Boolean)
|
|
31496
|
+
: [h(Text, { key: `${commit.hash}-${index}-l2-empty` }, '·')];
|
|
31497
|
+
const lineTwo = h(Text, {
|
|
31498
|
+
key: `${commit.hash}-${index}-l2`,
|
|
31499
|
+
dimColor: true,
|
|
31500
|
+
}, indent, ...metaContent);
|
|
31501
|
+
return h(Box, {
|
|
31502
|
+
key: `${commit.hash}-${index}-stack`,
|
|
31503
|
+
flexDirection: 'column',
|
|
31504
|
+
}, lineOne, lineTwo);
|
|
29186
31505
|
}
|
|
29187
31506
|
/**
|
|
29188
31507
|
* Render the synthetic "(+) new commit" affordance shown above the real
|
|
@@ -29211,7 +31530,7 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
|
|
|
29211
31530
|
backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
|
|
29212
31531
|
}, truncateCells(label, 140));
|
|
29213
31532
|
}
|
|
29214
|
-
function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
|
|
31533
|
+
function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = new Date()) {
|
|
29215
31534
|
const { Box, Text } = components;
|
|
29216
31535
|
const focused = state.focus === 'commits';
|
|
29217
31536
|
const worktree = context.worktree;
|
|
@@ -29227,8 +31546,26 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
29227
31546
|
const showPendingRow = worktreeDirty &&
|
|
29228
31547
|
!state.filter &&
|
|
29229
31548
|
state.selectedIndex === 0;
|
|
29230
|
-
|
|
29231
|
-
|
|
31549
|
+
// Stacked rows take two terminal lines each, so the visible item
|
|
31550
|
+
// budget is halved before the pending-row / chrome subtraction.
|
|
31551
|
+
// Full-graph mode injects a spacer row after every commit for
|
|
31552
|
+
// comfortable rhythm — the data-layer items still count 1 per row,
|
|
31553
|
+
// so the listRows budget passes straight through; the spacer rows
|
|
31554
|
+
// just consume some of that budget instead of additional commits.
|
|
31555
|
+
// Date bucketing is the new way the surface communicates "when" —
|
|
31556
|
+
// headers replace the per-row date column whenever the result set
|
|
31557
|
+
// is chronological (no active filter) AND the user has bucketing
|
|
31558
|
+
// enabled in `logTui.dateBucketing`. The filter check is the second
|
|
31559
|
+
// guardrail: even with bucketing enabled, an active search filter
|
|
31560
|
+
// shuffles commits by relevance so the adjacent-bucket invariant
|
|
31561
|
+
// breaks down and the dividers would read as noise.
|
|
31562
|
+
const fullGraphSpacing = state.fullGraph && !state.filter;
|
|
31563
|
+
const dateBucketingNow = !dateBucketingEnabled || state.filter ? undefined : now;
|
|
31564
|
+
const chromeRows = showPendingRow ? 5 : 4;
|
|
31565
|
+
const listRows = rowMode === 'stacked'
|
|
31566
|
+
? Math.max(2, Math.floor((bodyRows - chromeRows) / 2))
|
|
31567
|
+
: Math.max(3, bodyRows - chromeRows);
|
|
31568
|
+
const visible = getVisibleLogInkHistory(state, listRows, { fullGraphSpacing, dateBucketingNow });
|
|
29232
31569
|
const loadState = loadingMoreCommits
|
|
29233
31570
|
? 'loading older commits'
|
|
29234
31571
|
: hasMoreCommits
|
|
@@ -29263,23 +31600,51 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
29263
31600
|
totalCommits: state.commits.length,
|
|
29264
31601
|
}))
|
|
29265
31602
|
: visible.items.map((item, index) => {
|
|
31603
|
+
if (item.type === 'bucket-header') {
|
|
31604
|
+
// Section divider — `── Today ────────────`. The label is
|
|
31605
|
+
// bold to anchor the eye, the surrounding rule is dim so
|
|
31606
|
+
// the divider reads as chrome rather than competing with
|
|
31607
|
+
// commit content. Rule fills the panel's interior width
|
|
31608
|
+
// (minus border + padding); the label rides inside it.
|
|
31609
|
+
const contentWidth = Math.max(10, width - 4);
|
|
31610
|
+
const labelCells = cellWidth(item.label) + 2; // pad the label with surrounding spaces
|
|
31611
|
+
const ruleAfter = Math.max(0, contentWidth - 3 - labelCells);
|
|
31612
|
+
return h(Text, {
|
|
31613
|
+
key: `bucket-${index}-${item.label}`,
|
|
31614
|
+
dimColor: true,
|
|
31615
|
+
}, h(Text, undefined, '── '), h(Text, { bold: true }, item.label), h(Text, undefined, ' '), h(Text, undefined, '─'.repeat(ruleAfter)));
|
|
31616
|
+
}
|
|
29266
31617
|
if (item.type === 'graph') {
|
|
29267
|
-
// Graph-only rows
|
|
29268
|
-
//
|
|
29269
|
-
//
|
|
29270
|
-
//
|
|
29271
|
-
//
|
|
29272
|
-
//
|
|
31618
|
+
// Graph-only rows split into two visual categories:
|
|
31619
|
+
//
|
|
31620
|
+
// - git's own lane-closure scaffolding (`|/`, `|\`, etc.)
|
|
31621
|
+
// stays dim-on-dim so it reads as connector chrome that
|
|
31622
|
+
// recedes behind the commits it joins (#831). The eye
|
|
31623
|
+
// should never confuse a fork/close row for a commit
|
|
31624
|
+
// somebody accidentally skipped.
|
|
31625
|
+
//
|
|
31626
|
+
// - synthetic spacers we inject between linear commits
|
|
31627
|
+
// (`spacer: true`) render at FULL lane brightness so the
|
|
31628
|
+
// trunk lane bar visibly connects consecutive commits.
|
|
31629
|
+
// They are explicitly NOT scaffolding — they exist to
|
|
31630
|
+
// make linear-history rhythm read as one continuous lane.
|
|
31631
|
+
const isSpacer = item.spacer === true;
|
|
29273
31632
|
if (item.laneSegments && !theme.ascii) {
|
|
29274
|
-
return h(Text, {
|
|
31633
|
+
return h(Text, {
|
|
31634
|
+
key: `graph-${index}-${item.graph}`,
|
|
31635
|
+
dimColor: !isSpacer,
|
|
31636
|
+
}, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`, { forceDim: !isSpacer }));
|
|
29275
31637
|
}
|
|
29276
31638
|
return h(Text, {
|
|
29277
31639
|
key: `graph-${index}-${item.graph}`,
|
|
29278
31640
|
color: theme.noColor ? undefined : theme.colors.muted,
|
|
29279
|
-
dimColor:
|
|
31641
|
+
dimColor: !isSpacer,
|
|
29280
31642
|
}, truncateCells(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), Math.max(8, width - 4)));
|
|
29281
31643
|
}
|
|
29282
|
-
|
|
31644
|
+
if (rowMode === 'stacked') {
|
|
31645
|
+
return renderStackedCommitHistoryRow(h, Text, Box, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, state.fullGraph, now, item.laneSegments, recentCommitsSet.has(item.commit.hash));
|
|
31646
|
+
}
|
|
31647
|
+
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, density, state.fullGraph, Boolean(dateBucketingNow), now, item.laneSegments, recentCommitsSet.has(item.commit.hash));
|
|
29283
31648
|
}));
|
|
29284
31649
|
}
|
|
29285
31650
|
|
|
@@ -29654,6 +32019,138 @@ function wrapErrorMessage(message, maxWidth) {
|
|
|
29654
32019
|
return lines;
|
|
29655
32020
|
}
|
|
29656
32021
|
|
|
32022
|
+
/**
|
|
32023
|
+
* Issues triage surface (#882 phase 3). Read-only list view rendered
|
|
32024
|
+
* in the main panel when `state.activeView === 'issues'`. Mirrors the
|
|
32025
|
+
* branches / tags surface pattern: pure renderer, no hooks, no async.
|
|
32026
|
+
* Data flows in via `context.issueList`; the cursor position lives at
|
|
32027
|
+
* `state.selectedIssueIndex`.
|
|
32028
|
+
*
|
|
32029
|
+
* Per-row actions (assign, label, comment, close) and AI summarize
|
|
32030
|
+
* land in phase 4-6. This phase ships navigation only.
|
|
32031
|
+
*/
|
|
32032
|
+
function stateColor$1(theme, state) {
|
|
32033
|
+
if (theme.noColor)
|
|
32034
|
+
return undefined;
|
|
32035
|
+
switch (state.toUpperCase()) {
|
|
32036
|
+
case 'OPEN':
|
|
32037
|
+
return theme.colors.success;
|
|
32038
|
+
case 'CLOSED':
|
|
32039
|
+
return theme.colors.danger;
|
|
32040
|
+
default:
|
|
32041
|
+
return theme.colors.muted;
|
|
32042
|
+
}
|
|
32043
|
+
}
|
|
32044
|
+
function matchesIssueFilter(issue, filter) {
|
|
32045
|
+
if (!filter)
|
|
32046
|
+
return true;
|
|
32047
|
+
return matchesPromotedFilter([
|
|
32048
|
+
`#${issue.number}`,
|
|
32049
|
+
issue.title,
|
|
32050
|
+
issue.author || '',
|
|
32051
|
+
...(issue.labels || []),
|
|
32052
|
+
...(issue.assignees || []),
|
|
32053
|
+
], filter);
|
|
32054
|
+
}
|
|
32055
|
+
function renderIssuesTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
32056
|
+
const { Box, Text } = components;
|
|
32057
|
+
const focused = state.focus === 'commits';
|
|
32058
|
+
const overview = context.issueList;
|
|
32059
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'issueList');
|
|
32060
|
+
// Resolve the "what should the panel say" headline first, then fan
|
|
32061
|
+
// out to the row body. The chrome (border + title + headerRight) is
|
|
32062
|
+
// identical across loading / unavailable / unauthenticated / empty
|
|
32063
|
+
// / populated states; only the body changes.
|
|
32064
|
+
let headerRight = '';
|
|
32065
|
+
let bodyLines = [];
|
|
32066
|
+
if (loading || !overview) {
|
|
32067
|
+
headerRight = 'loading issues';
|
|
32068
|
+
bodyLines = [
|
|
32069
|
+
h(Text, { key: 'issues-loading', dimColor: true }, formatLogInkLoading({ resource: 'issues' })),
|
|
32070
|
+
];
|
|
32071
|
+
}
|
|
32072
|
+
else if (!overview.available) {
|
|
32073
|
+
headerRight = 'unavailable';
|
|
32074
|
+
bodyLines = [
|
|
32075
|
+
h(Text, { key: 'issues-no-remote', dimColor: true }, formatLogInkGitHubNoRemote({ resource: 'Issues' })),
|
|
32076
|
+
];
|
|
32077
|
+
}
|
|
32078
|
+
else if (!overview.authenticated) {
|
|
32079
|
+
headerRight = 'gh not authenticated';
|
|
32080
|
+
bodyLines = [
|
|
32081
|
+
h(Text, { key: 'issues-unauth', dimColor: true }, formatLogInkGitHubUnauthenticated({ resource: 'Issues' })),
|
|
32082
|
+
];
|
|
32083
|
+
}
|
|
32084
|
+
else if (overview.message && !overview.issues) {
|
|
32085
|
+
headerRight = 'error';
|
|
32086
|
+
bodyLines = [
|
|
32087
|
+
h(Text, { key: 'issues-error', dimColor: true, color: theme.noColor ? undefined : theme.colors.danger }, overview.message),
|
|
32088
|
+
];
|
|
32089
|
+
}
|
|
32090
|
+
else {
|
|
32091
|
+
const all = overview.issues || [];
|
|
32092
|
+
const visible = state.filter
|
|
32093
|
+
? all.filter((issue) => matchesIssueFilter(issue, state.filter))
|
|
32094
|
+
: all;
|
|
32095
|
+
const selected = Math.max(0, Math.min(state.selectedIssueIndex, Math.max(0, visible.length - 1)));
|
|
32096
|
+
const listRows = Math.max(4, bodyRows - 4);
|
|
32097
|
+
const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
|
|
32098
|
+
const windowed = visible.slice(startIndex, startIndex + listRows);
|
|
32099
|
+
const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
|
|
32100
|
+
const presetLabel = `▼ ${ISSUE_FILTER_LABELS[state.selectedIssueFilter]}`;
|
|
32101
|
+
const repoLabel = overview.repository
|
|
32102
|
+
? `${overview.repository.owner}/${overview.repository.name}`
|
|
32103
|
+
: '';
|
|
32104
|
+
headerRight = `${repoLabel ? `${repoLabel} · ` : ''}${visible.length}/${all.length} | ${presetLabel}${filterLabel}`;
|
|
32105
|
+
if (visible.length === 0) {
|
|
32106
|
+
bodyLines = [
|
|
32107
|
+
h(Text, { key: 'issues-empty', dimColor: true }, formatLogInkIssuesEmpty({ filter: state.filter })),
|
|
32108
|
+
];
|
|
32109
|
+
}
|
|
32110
|
+
else {
|
|
32111
|
+
// Column widths derived from the visible window so columns stay
|
|
32112
|
+
// aligned without one outlier title pushing the rest sideways.
|
|
32113
|
+
// Capped to keep the title column from being squeezed out on
|
|
32114
|
+
// narrow terminals.
|
|
32115
|
+
const numberColWidth = Math.min(6, Math.max(...windowed.map((i) => `#${i.number}`.length), 3));
|
|
32116
|
+
const authorColWidth = Math.min(16, Math.max(...windowed.map((i) => (i.author || '').length), 4));
|
|
32117
|
+
bodyLines = windowed.map((issue, offset) => {
|
|
32118
|
+
const index = startIndex + offset;
|
|
32119
|
+
const isSelected = index === selected;
|
|
32120
|
+
const cursor = isSelected ? '>' : ' ';
|
|
32121
|
+
const numStr = `#${issue.number}`.padEnd(numberColWidth);
|
|
32122
|
+
const stateStr = issue.state.toLowerCase().padEnd(6);
|
|
32123
|
+
const authorStr = (issue.author || '').padEnd(authorColWidth);
|
|
32124
|
+
const commentsStr = typeof issue.comments === 'number' && issue.comments > 0
|
|
32125
|
+
? ` ${issue.comments}c`
|
|
32126
|
+
: '';
|
|
32127
|
+
// The title column gets whatever is left after the prefix
|
|
32128
|
+
// columns. Truncate so the row stays a single visual line.
|
|
32129
|
+
const head = `${cursor} `;
|
|
32130
|
+
const prefix = `${numStr} ${stateStr} ${authorStr} `;
|
|
32131
|
+
const titleBudget = Math.max(8, width - 4 - head.length - prefix.length - commentsStr.length);
|
|
32132
|
+
const titleStr = truncateCells(issue.title, titleBudget);
|
|
32133
|
+
const labelStr = (issue.labels || []).length
|
|
32134
|
+
? ` [${(issue.labels || []).join(' ')}]`
|
|
32135
|
+
: '';
|
|
32136
|
+
return h(Text, {
|
|
32137
|
+
key: `issue-${index}`,
|
|
32138
|
+
bold: isSelected,
|
|
32139
|
+
dimColor: !isSelected,
|
|
32140
|
+
}, head, h(Text, { dimColor: true }, numStr + ' '), h(Text, { color: stateColor$1(theme, issue.state), dimColor: !isSelected }, stateStr + ' '), h(Text, { color: theme.noColor ? undefined : theme.colors.accent, dimColor: !isSelected }, authorStr + ' '), titleStr, h(Text, { dimColor: true }, labelStr), h(Text, { dimColor: true }, commentsStr));
|
|
32141
|
+
});
|
|
32142
|
+
}
|
|
32143
|
+
}
|
|
32144
|
+
return h(Box, {
|
|
32145
|
+
borderColor: focusBorderColor(theme, focused),
|
|
32146
|
+
borderStyle: theme.borderStyle,
|
|
32147
|
+
flexDirection: 'column',
|
|
32148
|
+
flexShrink: 0,
|
|
32149
|
+
paddingX: 1,
|
|
32150
|
+
width,
|
|
32151
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Issues', focused)), h(Text, { dimColor: true }, headerRight)), ...bodyLines);
|
|
32152
|
+
}
|
|
32153
|
+
|
|
29657
32154
|
/**
|
|
29658
32155
|
* Normalize gh's two parallel signals (`status` for in-flight check
|
|
29659
32156
|
* runs, `conclusion` for completed runs and status contexts) into a
|
|
@@ -29918,6 +32415,205 @@ function renderPullRequestSurface(h, components, state, context, contextStatus,
|
|
|
29918
32415
|
: []));
|
|
29919
32416
|
}
|
|
29920
32417
|
|
|
32418
|
+
/**
|
|
32419
|
+
* Pull-request triage surface (#882 phase 3). Read-only list view
|
|
32420
|
+
* rendered in the main panel when `state.activeView ===
|
|
32421
|
+
* 'pull-request-triage'`. Distinct from the existing single-PR action
|
|
32422
|
+
* panel (`'pull-request'`, chord `gp`) — this is the multi-PR list
|
|
32423
|
+
* surface (chord `gP`).
|
|
32424
|
+
*
|
|
32425
|
+
* Pure renderer; data flows in via `context.pullRequestList`. Per-row
|
|
32426
|
+
* actions (merge, approve, request-changes, close, comment) and AI
|
|
32427
|
+
* summarize land in phase 4-6.
|
|
32428
|
+
*/
|
|
32429
|
+
function stateColor(theme, state, isDraft) {
|
|
32430
|
+
if (theme.noColor)
|
|
32431
|
+
return undefined;
|
|
32432
|
+
if (isDraft)
|
|
32433
|
+
return theme.colors.muted;
|
|
32434
|
+
switch (state.toUpperCase()) {
|
|
32435
|
+
case 'OPEN':
|
|
32436
|
+
return theme.colors.success;
|
|
32437
|
+
case 'CLOSED':
|
|
32438
|
+
return theme.colors.danger;
|
|
32439
|
+
case 'MERGED':
|
|
32440
|
+
return theme.colors.accent;
|
|
32441
|
+
default:
|
|
32442
|
+
return theme.colors.muted;
|
|
32443
|
+
}
|
|
32444
|
+
}
|
|
32445
|
+
function reviewGlyph(decision) {
|
|
32446
|
+
switch (decision) {
|
|
32447
|
+
case 'APPROVED':
|
|
32448
|
+
return '✓';
|
|
32449
|
+
case 'CHANGES_REQUESTED':
|
|
32450
|
+
return '✗';
|
|
32451
|
+
case 'REVIEW_REQUIRED':
|
|
32452
|
+
return '?';
|
|
32453
|
+
default:
|
|
32454
|
+
return ' ';
|
|
32455
|
+
}
|
|
32456
|
+
}
|
|
32457
|
+
function mergeableGlyph(mergeStateStatus, mergeable) {
|
|
32458
|
+
if (mergeStateStatus === 'CLEAN')
|
|
32459
|
+
return '●';
|
|
32460
|
+
if (mergeStateStatus === 'BLOCKED')
|
|
32461
|
+
return '●';
|
|
32462
|
+
if (mergeStateStatus === 'DIRTY' || mergeable === 'CONFLICTING')
|
|
32463
|
+
return '●';
|
|
32464
|
+
if (mergeStateStatus === 'BEHIND')
|
|
32465
|
+
return '●';
|
|
32466
|
+
if (mergeStateStatus === 'UNSTABLE')
|
|
32467
|
+
return '●';
|
|
32468
|
+
return '·';
|
|
32469
|
+
}
|
|
32470
|
+
function mergeableColor(theme, mergeStateStatus, mergeable) {
|
|
32471
|
+
if (theme.noColor)
|
|
32472
|
+
return undefined;
|
|
32473
|
+
if (mergeStateStatus === 'CLEAN')
|
|
32474
|
+
return theme.colors.success;
|
|
32475
|
+
if (mergeStateStatus === 'BLOCKED' || mergeStateStatus === 'UNSTABLE')
|
|
32476
|
+
return theme.colors.warning;
|
|
32477
|
+
if (mergeStateStatus === 'DIRTY' || mergeable === 'CONFLICTING')
|
|
32478
|
+
return theme.colors.danger;
|
|
32479
|
+
if (mergeStateStatus === 'BEHIND')
|
|
32480
|
+
return theme.colors.accent;
|
|
32481
|
+
return theme.colors.muted;
|
|
32482
|
+
}
|
|
32483
|
+
function reviewColor(theme, decision) {
|
|
32484
|
+
if (theme.noColor)
|
|
32485
|
+
return undefined;
|
|
32486
|
+
switch (decision) {
|
|
32487
|
+
case 'APPROVED':
|
|
32488
|
+
return theme.colors.success;
|
|
32489
|
+
case 'CHANGES_REQUESTED':
|
|
32490
|
+
return theme.colors.danger;
|
|
32491
|
+
case 'REVIEW_REQUIRED':
|
|
32492
|
+
return theme.colors.warning;
|
|
32493
|
+
default:
|
|
32494
|
+
return theme.colors.muted;
|
|
32495
|
+
}
|
|
32496
|
+
}
|
|
32497
|
+
function matchesPullRequestFilter(pr, filter) {
|
|
32498
|
+
if (!filter)
|
|
32499
|
+
return true;
|
|
32500
|
+
return matchesPromotedFilter([
|
|
32501
|
+
`#${pr.number}`,
|
|
32502
|
+
pr.title,
|
|
32503
|
+
pr.author || '',
|
|
32504
|
+
pr.headRefName,
|
|
32505
|
+
pr.baseRefName,
|
|
32506
|
+
...(pr.labels || []),
|
|
32507
|
+
...(pr.assignees || []),
|
|
32508
|
+
], filter);
|
|
32509
|
+
}
|
|
32510
|
+
function renderPullRequestTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
32511
|
+
const { Box, Text } = components;
|
|
32512
|
+
const focused = state.focus === 'commits';
|
|
32513
|
+
const overview = context.pullRequestList;
|
|
32514
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequestList');
|
|
32515
|
+
let headerRight = '';
|
|
32516
|
+
let bodyLines = [];
|
|
32517
|
+
if (loading || !overview) {
|
|
32518
|
+
headerRight = 'loading pull requests';
|
|
32519
|
+
bodyLines = [
|
|
32520
|
+
h(Text, { key: 'pr-triage-loading', dimColor: true }, formatLogInkLoading({ resource: 'pull requests' })),
|
|
32521
|
+
];
|
|
32522
|
+
}
|
|
32523
|
+
else if (!overview.available) {
|
|
32524
|
+
headerRight = 'unavailable';
|
|
32525
|
+
bodyLines = [
|
|
32526
|
+
h(Text, { key: 'pr-triage-no-remote', dimColor: true }, formatLogInkGitHubNoRemote({ resource: 'Pull requests' })),
|
|
32527
|
+
];
|
|
32528
|
+
}
|
|
32529
|
+
else if (!overview.authenticated) {
|
|
32530
|
+
headerRight = 'gh not authenticated';
|
|
32531
|
+
bodyLines = [
|
|
32532
|
+
h(Text, { key: 'pr-triage-unauth', dimColor: true }, formatLogInkGitHubUnauthenticated({ resource: 'Pull requests' })),
|
|
32533
|
+
];
|
|
32534
|
+
}
|
|
32535
|
+
else if (overview.message && !overview.pullRequests) {
|
|
32536
|
+
headerRight = 'error';
|
|
32537
|
+
bodyLines = [
|
|
32538
|
+
h(Text, {
|
|
32539
|
+
key: 'pr-triage-error',
|
|
32540
|
+
dimColor: true,
|
|
32541
|
+
color: theme.noColor ? undefined : theme.colors.danger,
|
|
32542
|
+
}, overview.message),
|
|
32543
|
+
];
|
|
32544
|
+
}
|
|
32545
|
+
else {
|
|
32546
|
+
const all = overview.pullRequests || [];
|
|
32547
|
+
const visible = state.filter
|
|
32548
|
+
? all.filter((pr) => matchesPullRequestFilter(pr, state.filter))
|
|
32549
|
+
: all;
|
|
32550
|
+
const selected = Math.max(0, Math.min(state.selectedPullRequestTriageIndex, Math.max(0, visible.length - 1)));
|
|
32551
|
+
const listRows = Math.max(4, bodyRows - 4);
|
|
32552
|
+
const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
|
|
32553
|
+
const windowed = visible.slice(startIndex, startIndex + listRows);
|
|
32554
|
+
const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
|
|
32555
|
+
const presetLabel = `▼ ${PULL_REQUEST_FILTER_LABELS[state.selectedPullRequestFilter]}`;
|
|
32556
|
+
const repoLabel = overview.repository
|
|
32557
|
+
? `${overview.repository.owner}/${overview.repository.name}`
|
|
32558
|
+
: '';
|
|
32559
|
+
headerRight = `${repoLabel ? `${repoLabel} · ` : ''}${visible.length}/${all.length} | ${presetLabel}${filterLabel}`;
|
|
32560
|
+
if (visible.length === 0) {
|
|
32561
|
+
bodyLines = [
|
|
32562
|
+
h(Text, { key: 'pr-triage-empty', dimColor: true }, formatLogInkPullRequestTriageEmpty({ filter: state.filter })),
|
|
32563
|
+
];
|
|
32564
|
+
}
|
|
32565
|
+
else {
|
|
32566
|
+
const numberColWidth = Math.min(6, Math.max(...windowed.map((p) => `#${p.number}`.length), 3));
|
|
32567
|
+
const authorColWidth = Math.min(16, Math.max(...windowed.map((p) => (p.author || '').length), 4));
|
|
32568
|
+
const branchColWidth = Math.min(24, Math.max(...windowed.map((p) => p.headRefName.length), 6));
|
|
32569
|
+
bodyLines = windowed.map((pr, offset) => {
|
|
32570
|
+
const index = startIndex + offset;
|
|
32571
|
+
const isSelected = index === selected;
|
|
32572
|
+
const cursor = isSelected ? '>' : ' ';
|
|
32573
|
+
const numStr = `#${pr.number}`.padEnd(numberColWidth);
|
|
32574
|
+
const stateLabel = pr.isDraft ? 'draft' : pr.state.toLowerCase();
|
|
32575
|
+
const stateStr = stateLabel.padEnd(6);
|
|
32576
|
+
const mergeStr = mergeableGlyph(pr.mergeStateStatus, pr.mergeable);
|
|
32577
|
+
const reviewStr = reviewGlyph(pr.reviewDecision);
|
|
32578
|
+
const authorStr = (pr.author || '').padEnd(authorColWidth);
|
|
32579
|
+
const branchStr = truncateCells(pr.headRefName, branchColWidth).padEnd(branchColWidth);
|
|
32580
|
+
const labelStr = (pr.labels || []).length
|
|
32581
|
+
? ` [${(pr.labels || []).join(' ')}]`
|
|
32582
|
+
: '';
|
|
32583
|
+
const head = `${cursor} `;
|
|
32584
|
+
const prefix = `${numStr} ${stateStr} ${mergeStr} ${reviewStr} ${authorStr} ${branchStr} `;
|
|
32585
|
+
const titleBudget = Math.max(8, width - 4 - head.length - prefix.length);
|
|
32586
|
+
const titleStr = truncateCells(pr.title, titleBudget);
|
|
32587
|
+
return h(Text, {
|
|
32588
|
+
key: `pr-triage-${index}`,
|
|
32589
|
+
bold: isSelected,
|
|
32590
|
+
dimColor: !isSelected,
|
|
32591
|
+
}, head, h(Text, { dimColor: true }, numStr + ' '), h(Text, {
|
|
32592
|
+
color: stateColor(theme, pr.state, pr.isDraft),
|
|
32593
|
+
dimColor: !isSelected,
|
|
32594
|
+
}, stateStr + ' '), h(Text, {
|
|
32595
|
+
color: mergeableColor(theme, pr.mergeStateStatus, pr.mergeable),
|
|
32596
|
+
dimColor: !isSelected,
|
|
32597
|
+
}, mergeStr + ' '), h(Text, {
|
|
32598
|
+
color: reviewColor(theme, pr.reviewDecision),
|
|
32599
|
+
dimColor: !isSelected,
|
|
32600
|
+
}, reviewStr + ' '), h(Text, {
|
|
32601
|
+
color: theme.noColor ? undefined : theme.colors.accent,
|
|
32602
|
+
dimColor: !isSelected,
|
|
32603
|
+
}, authorStr + ' '), h(Text, { dimColor: true }, branchStr + ' '), titleStr, h(Text, { dimColor: true }, labelStr));
|
|
32604
|
+
});
|
|
32605
|
+
}
|
|
32606
|
+
}
|
|
32607
|
+
return h(Box, {
|
|
32608
|
+
borderColor: focusBorderColor(theme, focused),
|
|
32609
|
+
borderStyle: theme.borderStyle,
|
|
32610
|
+
flexDirection: 'column',
|
|
32611
|
+
flexShrink: 0,
|
|
32612
|
+
paddingX: 1,
|
|
32613
|
+
width,
|
|
32614
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Pull requests', focused)), h(Text, { dimColor: true }, headerRight)), ...bodyLines);
|
|
32615
|
+
}
|
|
32616
|
+
|
|
29921
32617
|
/**
|
|
29922
32618
|
* Reflog surface (#781). Renders `git reflog` chronologically — every
|
|
29923
32619
|
* HEAD movement (commit, checkout, merge, reset, …) with relative time,
|
|
@@ -30497,7 +33193,7 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
30497
33193
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
|
|
30498
33194
|
* of #890. No behavior change.
|
|
30499
33195
|
*/
|
|
30500
|
-
function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame) {
|
|
33196
|
+
function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled) {
|
|
30501
33197
|
// Split-plan overlay (#907 polish): renders in the MAIN panel (not
|
|
30502
33198
|
// detail) when active, because the content — multiple commit groups
|
|
30503
33199
|
// with file lists, rationale, hunks — needs the full center width
|
|
@@ -30541,13 +33237,19 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
30541
33237
|
if (state.activeView === 'pull-request') {
|
|
30542
33238
|
return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
30543
33239
|
}
|
|
33240
|
+
if (state.activeView === 'pull-request-triage') {
|
|
33241
|
+
return renderPullRequestTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
33242
|
+
}
|
|
33243
|
+
if (state.activeView === 'issues') {
|
|
33244
|
+
return renderIssuesTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
33245
|
+
}
|
|
30544
33246
|
if (state.activeView === 'conflicts') {
|
|
30545
33247
|
return renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
30546
33248
|
}
|
|
30547
33249
|
if (state.activeView === 'changelog') {
|
|
30548
33250
|
return renderChangelogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
30549
33251
|
}
|
|
30550
|
-
return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
|
|
33252
|
+
return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled);
|
|
30551
33253
|
}
|
|
30552
33254
|
|
|
30553
33255
|
/**
|
|
@@ -30645,6 +33347,230 @@ function formatStashPreview(stash, options = {}) {
|
|
|
30645
33347
|
}
|
|
30646
33348
|
return out;
|
|
30647
33349
|
}
|
|
33350
|
+
/* ------------------------- detail-section helpers ----------------------- */
|
|
33351
|
+
/**
|
|
33352
|
+
* Render the first `maxLines` non-empty lines of an issue / PR body
|
|
33353
|
+
* as preview lines. Returns an empty array when the body itself is
|
|
33354
|
+
* empty (or whitespace only) so callers can `out.push(...body(...))`
|
|
33355
|
+
* without an extra guard. Trailer appears only when content was
|
|
33356
|
+
* actually truncated.
|
|
33357
|
+
*/
|
|
33358
|
+
function bodyExcerptLines(body, maxLines) {
|
|
33359
|
+
if (!body.trim())
|
|
33360
|
+
return [];
|
|
33361
|
+
const lines = body.replace(/\r\n/g, '\n').split('\n');
|
|
33362
|
+
// Drop leading blanks so the excerpt opens on the first real line
|
|
33363
|
+
// rather than rendering an awkward "blank line, then body".
|
|
33364
|
+
while (lines.length > 0 && !lines[0].trim())
|
|
33365
|
+
lines.shift();
|
|
33366
|
+
const shown = lines.slice(0, maxLines);
|
|
33367
|
+
const truncated = lines.length > maxLines;
|
|
33368
|
+
const out = [
|
|
33369
|
+
heading('Body'),
|
|
33370
|
+
...shown.map((l) => line(l)),
|
|
33371
|
+
];
|
|
33372
|
+
if (truncated) {
|
|
33373
|
+
out.push(dim(`… ${lines.length - maxLines} more line(s)`));
|
|
33374
|
+
}
|
|
33375
|
+
return out;
|
|
33376
|
+
}
|
|
33377
|
+
function shortenLine(value, maxLength) {
|
|
33378
|
+
const flattened = value.replace(/\s+/g, ' ').trim();
|
|
33379
|
+
if (flattened.length <= maxLength)
|
|
33380
|
+
return flattened;
|
|
33381
|
+
return `${flattened.slice(0, Math.max(0, maxLength - 1))}…`;
|
|
33382
|
+
}
|
|
33383
|
+
function commentsSection(comments, maxShown) {
|
|
33384
|
+
if (comments.length === 0)
|
|
33385
|
+
return [];
|
|
33386
|
+
const recent = comments.slice(-3);
|
|
33387
|
+
const out = [heading(`Comments (${comments.length})`)];
|
|
33388
|
+
for (const comment of recent) {
|
|
33389
|
+
const who = comment.author || 'anonymous';
|
|
33390
|
+
out.push(line(`@${who}: ${shortenLine(comment.body, 80)}`));
|
|
33391
|
+
}
|
|
33392
|
+
if (comments.length > recent.length) {
|
|
33393
|
+
out.push(dim(`… ${comments.length - recent.length} earlier comment(s)`));
|
|
33394
|
+
}
|
|
33395
|
+
return out;
|
|
33396
|
+
}
|
|
33397
|
+
function reviewsSection(reviews) {
|
|
33398
|
+
if (reviews.length === 0)
|
|
33399
|
+
return [];
|
|
33400
|
+
const out = [heading(`Reviews (${reviews.length})`)];
|
|
33401
|
+
for (const review of reviews) {
|
|
33402
|
+
const who = review.author || 'anonymous';
|
|
33403
|
+
const stateLabel = (review.state || 'commented').toLowerCase().replace(/_/g, ' ');
|
|
33404
|
+
const inlineBody = review.body ? ` — ${shortenLine(review.body, 60)}` : '';
|
|
33405
|
+
out.push(line(`@${who} (${stateLabel})${inlineBody}`));
|
|
33406
|
+
}
|
|
33407
|
+
return out;
|
|
33408
|
+
}
|
|
33409
|
+
function statusChecksSection(checks) {
|
|
33410
|
+
if (checks.length === 0)
|
|
33411
|
+
return [];
|
|
33412
|
+
const grouped = {
|
|
33413
|
+
success: 0,
|
|
33414
|
+
failure: 0,
|
|
33415
|
+
pending: 0,
|
|
33416
|
+
other: 0,
|
|
33417
|
+
};
|
|
33418
|
+
for (const check of checks) {
|
|
33419
|
+
const result = check.conclusion?.toLowerCase() ?? check.status?.toLowerCase() ?? '';
|
|
33420
|
+
if (result === 'success')
|
|
33421
|
+
grouped.success++;
|
|
33422
|
+
else if (result === 'failure' || result === 'cancelled' || result === 'timed_out')
|
|
33423
|
+
grouped.failure++;
|
|
33424
|
+
else if (result === 'pending' || result === 'queued' || result === 'in_progress')
|
|
33425
|
+
grouped.pending++;
|
|
33426
|
+
else
|
|
33427
|
+
grouped.other++;
|
|
33428
|
+
}
|
|
33429
|
+
const parts = [];
|
|
33430
|
+
if (grouped.success)
|
|
33431
|
+
parts.push(`${grouped.success} pass`);
|
|
33432
|
+
if (grouped.failure)
|
|
33433
|
+
parts.push(`${grouped.failure} fail`);
|
|
33434
|
+
if (grouped.pending)
|
|
33435
|
+
parts.push(`${grouped.pending} pending`);
|
|
33436
|
+
if (grouped.other)
|
|
33437
|
+
parts.push(`${grouped.other} other`);
|
|
33438
|
+
return [
|
|
33439
|
+
heading(`Checks (${checks.length})`),
|
|
33440
|
+
line(parts.join(' · ')),
|
|
33441
|
+
];
|
|
33442
|
+
}
|
|
33443
|
+
/* -------------------------------- issue -------------------------------- */
|
|
33444
|
+
/**
|
|
33445
|
+
* Format an issue triage entry into preview lines (#882 phase 3,
|
|
33446
|
+
* body + comments added in the inspector-hydration follow-up).
|
|
33447
|
+
* The list payload from `gh issue list --json` carries metadata
|
|
33448
|
+
* only; the optional `detail` argument is filled by the runtime's
|
|
33449
|
+
* debounced hydration effect when the cursor rests on a row, and
|
|
33450
|
+
* unlocks the body / comments sections.
|
|
33451
|
+
*/
|
|
33452
|
+
function formatIssueTriagePreview(issue, detail) {
|
|
33453
|
+
if (!issue) {
|
|
33454
|
+
return [dim('Select an issue to preview.')];
|
|
33455
|
+
}
|
|
33456
|
+
const out = [
|
|
33457
|
+
heading(`#${issue.number} · ${issue.title}`),
|
|
33458
|
+
blank(),
|
|
33459
|
+
line(`State: ${issue.state.toLowerCase()}`),
|
|
33460
|
+
];
|
|
33461
|
+
if (issue.author)
|
|
33462
|
+
out.push(line(`Author: ${issue.author}`));
|
|
33463
|
+
if (issue.assignees && issue.assignees.length > 0) {
|
|
33464
|
+
out.push(line(`Assigned: ${issue.assignees.join(', ')}`));
|
|
33465
|
+
}
|
|
33466
|
+
if (issue.labels && issue.labels.length > 0) {
|
|
33467
|
+
out.push(line(`Labels: ${issue.labels.join(', ')}`));
|
|
33468
|
+
}
|
|
33469
|
+
if (typeof issue.comments === 'number') {
|
|
33470
|
+
out.push(line(`Comments: ${issue.comments}`));
|
|
33471
|
+
}
|
|
33472
|
+
out.push(blank());
|
|
33473
|
+
if (issue.createdAt)
|
|
33474
|
+
out.push(line(`Created: ${issue.createdAt}`));
|
|
33475
|
+
if (issue.updatedAt)
|
|
33476
|
+
out.push(line(`Updated: ${issue.updatedAt}`));
|
|
33477
|
+
out.push(blank());
|
|
33478
|
+
out.push(dim(issue.url));
|
|
33479
|
+
// Hydrated sections (body + recent comments). Inserted only when
|
|
33480
|
+
// the runtime has finished the per-cursor-rest detail fetch and
|
|
33481
|
+
// populated the cache.
|
|
33482
|
+
if (detail) {
|
|
33483
|
+
const body = bodyExcerptLines(detail.body, 6);
|
|
33484
|
+
if (body.length > 0) {
|
|
33485
|
+
out.push(blank());
|
|
33486
|
+
out.push(...body);
|
|
33487
|
+
}
|
|
33488
|
+
const comments = commentsSection(detail.comments);
|
|
33489
|
+
if (comments.length > 0) {
|
|
33490
|
+
out.push(blank());
|
|
33491
|
+
out.push(...comments);
|
|
33492
|
+
}
|
|
33493
|
+
}
|
|
33494
|
+
else if (typeof issue.comments === 'number' && issue.comments > 0) {
|
|
33495
|
+
// Pre-hydration affordance — tell the user the body / comments
|
|
33496
|
+
// section is coming, so a 250ms wait doesn't look like a bug.
|
|
33497
|
+
out.push(blank());
|
|
33498
|
+
out.push(dim('Loading body + comments…'));
|
|
33499
|
+
}
|
|
33500
|
+
return out;
|
|
33501
|
+
}
|
|
33502
|
+
/* ----------------------------- pull request ---------------------------- */
|
|
33503
|
+
/**
|
|
33504
|
+
* Format a pull-request triage entry into preview lines (#882 phase 3,
|
|
33505
|
+
* body / comments / reviews / checks added in the inspector-hydration
|
|
33506
|
+
* follow-up). Optional `detail` argument is filled by the runtime's
|
|
33507
|
+
* debounced hydration effect when the cursor rests on a row.
|
|
33508
|
+
*/
|
|
33509
|
+
function formatPullRequestTriagePreview(pr, detail) {
|
|
33510
|
+
if (!pr) {
|
|
33511
|
+
return [dim('Select a pull request to preview.')];
|
|
33512
|
+
}
|
|
33513
|
+
const out = [
|
|
33514
|
+
heading(`#${pr.number} · ${pr.title}`),
|
|
33515
|
+
blank(),
|
|
33516
|
+
line(`State: ${pr.isDraft ? 'draft' : pr.state.toLowerCase()}`),
|
|
33517
|
+
];
|
|
33518
|
+
if (pr.author)
|
|
33519
|
+
out.push(line(`Author: ${pr.author}`));
|
|
33520
|
+
out.push(line(`Branches: ${pr.headRefName} → ${pr.baseRefName}`));
|
|
33521
|
+
if (pr.mergeable || pr.mergeStateStatus) {
|
|
33522
|
+
const merge = [pr.mergeable, pr.mergeStateStatus].filter(Boolean).join(' / ');
|
|
33523
|
+
out.push(line(`Mergeable: ${merge.toLowerCase()}`));
|
|
33524
|
+
}
|
|
33525
|
+
if (pr.reviewDecision) {
|
|
33526
|
+
out.push(line(`Review: ${pr.reviewDecision.toLowerCase().replace(/_/g, ' ')}`));
|
|
33527
|
+
}
|
|
33528
|
+
if (pr.assignees && pr.assignees.length > 0) {
|
|
33529
|
+
out.push(line(`Assigned: ${pr.assignees.join(', ')}`));
|
|
33530
|
+
}
|
|
33531
|
+
if (pr.labels && pr.labels.length > 0) {
|
|
33532
|
+
out.push(line(`Labels: ${pr.labels.join(', ')}`));
|
|
33533
|
+
}
|
|
33534
|
+
out.push(blank());
|
|
33535
|
+
if (pr.createdAt)
|
|
33536
|
+
out.push(line(`Created: ${pr.createdAt}`));
|
|
33537
|
+
if (pr.updatedAt)
|
|
33538
|
+
out.push(line(`Updated: ${pr.updatedAt}`));
|
|
33539
|
+
out.push(blank());
|
|
33540
|
+
out.push(dim(pr.url));
|
|
33541
|
+
// Hydrated sections — body, status checks, reviews, comments.
|
|
33542
|
+
// Status checks come BEFORE reviews because failing CI is usually
|
|
33543
|
+
// what a triager wants to see first; reviews come second because
|
|
33544
|
+
// they're the human-judgment layer on top.
|
|
33545
|
+
if (detail) {
|
|
33546
|
+
const body = bodyExcerptLines(detail.body, 6);
|
|
33547
|
+
if (body.length > 0) {
|
|
33548
|
+
out.push(blank());
|
|
33549
|
+
out.push(...body);
|
|
33550
|
+
}
|
|
33551
|
+
const checks = statusChecksSection(detail.statusCheckRollup);
|
|
33552
|
+
if (checks.length > 0) {
|
|
33553
|
+
out.push(blank());
|
|
33554
|
+
out.push(...checks);
|
|
33555
|
+
}
|
|
33556
|
+
const reviews = reviewsSection(detail.reviews);
|
|
33557
|
+
if (reviews.length > 0) {
|
|
33558
|
+
out.push(blank());
|
|
33559
|
+
out.push(...reviews);
|
|
33560
|
+
}
|
|
33561
|
+
const comments = commentsSection(detail.comments);
|
|
33562
|
+
if (comments.length > 0) {
|
|
33563
|
+
out.push(blank());
|
|
33564
|
+
out.push(...comments);
|
|
33565
|
+
}
|
|
33566
|
+
}
|
|
33567
|
+
else {
|
|
33568
|
+
// Pre-hydration affordance — same as the issue preview.
|
|
33569
|
+
out.push(blank());
|
|
33570
|
+
out.push(dim('Loading body + reviews + comments…'));
|
|
33571
|
+
}
|
|
33572
|
+
return out;
|
|
33573
|
+
}
|
|
30648
33574
|
|
|
30649
33575
|
/**
|
|
30650
33576
|
* Detail / inspector / preview surface family.
|
|
@@ -30683,6 +33609,15 @@ function formatStashPreview(stash, options = {}) {
|
|
|
30683
33609
|
* minimum from `getLogInkLayout`) so an overflowing label never wraps and
|
|
30684
33610
|
* collides with the next row.
|
|
30685
33611
|
*/
|
|
33612
|
+
/**
|
|
33613
|
+
* Format the file-count portion of the inspector stats line. Pluralize
|
|
33614
|
+
* "files" only when the count is not 1 so `1 file +12/-22` reads
|
|
33615
|
+
* naturally instead of `1 files`.
|
|
33616
|
+
*/
|
|
33617
|
+
function formatCommitStatLine(stats) {
|
|
33618
|
+
const label = stats.filesChanged === 1 ? 'file' : 'files';
|
|
33619
|
+
return `${stats.filesChanged} ${label} +${stats.insertions}/-${stats.deletions}`;
|
|
33620
|
+
}
|
|
30686
33621
|
function renderInspectorActionsSection(h, Text, context, width, theme, options = {}) {
|
|
30687
33622
|
const actions = getInspectorActions(context);
|
|
30688
33623
|
if (!actions.length)
|
|
@@ -30857,7 +33792,7 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
30857
33792
|
cursorActive: focused && state.inspectorTab === 'actions',
|
|
30858
33793
|
}));
|
|
30859
33794
|
}
|
|
30860
|
-
const statLine =
|
|
33795
|
+
const statLine = formatCommitStatLine(detail.stats);
|
|
30861
33796
|
// P5.1 — link the commit hash and each ref out to GitHub when we know
|
|
30862
33797
|
// the remote. OSC 8 escapes embed inline; supportsHyperlinks() decides
|
|
30863
33798
|
// whether to wrap or fall through to plain text.
|
|
@@ -30977,7 +33912,7 @@ function renderCommitDiffDetail(h, components, state, detail, loading, width, th
|
|
|
30977
33912
|
dimColor: index > 1,
|
|
30978
33913
|
}, truncateCells(line, width - 4))));
|
|
30979
33914
|
}
|
|
30980
|
-
const statLine =
|
|
33915
|
+
const statLine = formatCommitStatLine(detail.stats);
|
|
30981
33916
|
const headerLines = [
|
|
30982
33917
|
detail.message,
|
|
30983
33918
|
'',
|
|
@@ -31206,6 +34141,64 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
|
|
|
31206
34141
|
? null
|
|
31207
34142
|
: h(Text, { key: 'commit-state', dimColor: true }, truncateCells(stateLine, width - 4)));
|
|
31208
34143
|
}
|
|
34144
|
+
/**
|
|
34145
|
+
* Issue triage preview pane (#882 phase 3). Mirrors the branch / tag /
|
|
34146
|
+
* stash preview pattern — `renderPreviewPanel` chrome, formatter pulls
|
|
34147
|
+
* the cursored item via `state.selectedIssueIndex`. Shown when
|
|
34148
|
+
* `state.activeView === 'issues'`.
|
|
34149
|
+
*/
|
|
34150
|
+
function renderIssueTriagePreviewPanel(h, components, state, context, contextStatus, width, theme, focused) {
|
|
34151
|
+
const { Box, Text } = components;
|
|
34152
|
+
if (isLogInkContextKeyLoading(contextStatus, 'issueList')) {
|
|
34153
|
+
return renderPreviewPanel(h, { Box, Text }, 'Issue preview', [{ text: formatLogInkLoading({ resource: 'issues' }), emphasis: 'dim' }], width, theme, focused);
|
|
34154
|
+
}
|
|
34155
|
+
const all = context.issueList?.issues || [];
|
|
34156
|
+
const visible = state.filter
|
|
34157
|
+
? all.filter((issue) => matchesPromotedFilter([
|
|
34158
|
+
`#${issue.number}`,
|
|
34159
|
+
issue.title,
|
|
34160
|
+
issue.author || '',
|
|
34161
|
+
...(issue.labels || []),
|
|
34162
|
+
...(issue.assignees || []),
|
|
34163
|
+
], state.filter))
|
|
34164
|
+
: all;
|
|
34165
|
+
const index = Math.max(0, Math.min(state.selectedIssueIndex, Math.max(0, visible.length - 1)));
|
|
34166
|
+
const issue = visible[index];
|
|
34167
|
+
const detail = issue
|
|
34168
|
+
? context.issueDetailByNumber?.get(issue.number)
|
|
34169
|
+
: undefined;
|
|
34170
|
+
return renderPreviewPanel(h, { Box, Text }, 'Issue preview', formatIssueTriagePreview(issue, detail), width, theme, focused);
|
|
34171
|
+
}
|
|
34172
|
+
/**
|
|
34173
|
+
* Pull-request triage preview pane (#882 phase 3). Shown when
|
|
34174
|
+
* `state.activeView === 'pull-request-triage'`. Distinct from the
|
|
34175
|
+
* single-PR action panel's right pane (which renders the full
|
|
34176
|
+
* inspector with status checks, reviews, and action keys).
|
|
34177
|
+
*/
|
|
34178
|
+
function renderPullRequestTriagePreviewPanel(h, components, state, context, contextStatus, width, theme, focused) {
|
|
34179
|
+
const { Box, Text } = components;
|
|
34180
|
+
if (isLogInkContextKeyLoading(contextStatus, 'pullRequestList')) {
|
|
34181
|
+
return renderPreviewPanel(h, { Box, Text }, 'Pull request preview', [{ text: formatLogInkLoading({ resource: 'pull requests' }), emphasis: 'dim' }], width, theme, focused);
|
|
34182
|
+
}
|
|
34183
|
+
const all = context.pullRequestList?.pullRequests || [];
|
|
34184
|
+
const visible = state.filter
|
|
34185
|
+
? all.filter((pr) => matchesPromotedFilter([
|
|
34186
|
+
`#${pr.number}`,
|
|
34187
|
+
pr.title,
|
|
34188
|
+
pr.author || '',
|
|
34189
|
+
pr.headRefName,
|
|
34190
|
+
pr.baseRefName,
|
|
34191
|
+
...(pr.labels || []),
|
|
34192
|
+
...(pr.assignees || []),
|
|
34193
|
+
], state.filter))
|
|
34194
|
+
: all;
|
|
34195
|
+
const index = Math.max(0, Math.min(state.selectedPullRequestTriageIndex, Math.max(0, visible.length - 1)));
|
|
34196
|
+
const pr = visible[index];
|
|
34197
|
+
const detail = pr
|
|
34198
|
+
? context.pullRequestDetailByNumber?.get(pr.number)
|
|
34199
|
+
: undefined;
|
|
34200
|
+
return renderPreviewPanel(h, { Box, Text }, 'Pull request preview', formatPullRequestTriagePreview(pr, detail), width, theme, focused);
|
|
34201
|
+
}
|
|
31209
34202
|
|
|
31210
34203
|
/**
|
|
31211
34204
|
* Detail-panel dispatcher. Routes to the right detail / overlay
|
|
@@ -31221,8 +34214,41 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
|
|
|
31221
34214
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
|
|
31222
34215
|
* of #890. No behavior change.
|
|
31223
34216
|
*/
|
|
31224
|
-
|
|
34217
|
+
/**
|
|
34218
|
+
* Rail-mode inspector — shown on terminals < 100 columns when the
|
|
34219
|
+
* detail panel does not hold focus. The full inspector (commit body,
|
|
34220
|
+
* file list, actions) does not survive truncation to ~4 content cells
|
|
34221
|
+
* so we collapse to a stack with the panel label and the selected
|
|
34222
|
+
* commit's shortHash. Focus pops the panel back to its expanded
|
|
34223
|
+
* widths via the layout, so this renderer is only reached at rest.
|
|
34224
|
+
*
|
|
34225
|
+
* Help / overlay states are still handled by their own renderers
|
|
34226
|
+
* above; this short-circuit only kicks in for the regular "view the
|
|
34227
|
+
* commit" cases.
|
|
34228
|
+
*/
|
|
34229
|
+
function renderInspectorRail(h, components, state, detail, width, theme, focused) {
|
|
34230
|
+
const { Box, Text } = components;
|
|
34231
|
+
// Prefer the loaded detail's hash (canonical) but fall back to the
|
|
34232
|
+
// selected list row's shortHash so the rail isn't blank on the
|
|
34233
|
+
// first render before getCommitDetail resolves.
|
|
34234
|
+
const selectedRow = getSelectedInkCommit(state);
|
|
34235
|
+
const hashText = detail?.hash.slice(0, 4)
|
|
34236
|
+
?? selectedRow?.shortHash.slice(0, 4)
|
|
34237
|
+
?? '····';
|
|
34238
|
+
return h(Box, {
|
|
34239
|
+
borderColor: focusBorderColor(theme, focused),
|
|
34240
|
+
borderStyle: theme.borderStyle,
|
|
34241
|
+
flexDirection: 'column',
|
|
34242
|
+
width,
|
|
34243
|
+
paddingX: 1,
|
|
34244
|
+
}, h(Text, { bold: true, dimColor: !focused }, panelTitle('Insp', focused)), h(Text, { dimColor: true }, '────'), h(Text, { color: theme.noColor ? undefined : theme.colors.accent }, hashText));
|
|
34245
|
+
}
|
|
34246
|
+
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, railed = false) {
|
|
31225
34247
|
const focused = state.focus === 'detail';
|
|
34248
|
+
// Overlays (help / palette / input / confirmation / chord) take
|
|
34249
|
+
// precedence over rail because they always claim the panel's width
|
|
34250
|
+
// via the help-overlay layout branch — and railing those would
|
|
34251
|
+
// defeat their whole purpose (the user is reading them).
|
|
31226
34252
|
if (state.showHelp) {
|
|
31227
34253
|
return renderHelpPanel(h, components, state, width, theme, focused);
|
|
31228
34254
|
}
|
|
@@ -31248,6 +34274,15 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
31248
34274
|
if (state.pendingKey && !state.splitPlan) {
|
|
31249
34275
|
return renderChordOverlay(h, components, state, width, theme, focused);
|
|
31250
34276
|
}
|
|
34277
|
+
// Rail mode applies only after every overlay above has had its say
|
|
34278
|
+
// — those would all be unreadable at 4 cells of content. The layout
|
|
34279
|
+
// also clears `railed` whenever the inspector takes focus, so we
|
|
34280
|
+
// can safely short-circuit the per-view dispatch here without
|
|
34281
|
+
// worrying about hiding the panel from a user who's actively
|
|
34282
|
+
// reading it.
|
|
34283
|
+
if (railed) {
|
|
34284
|
+
return renderInspectorRail(h, components, state, detail, width, theme, focused);
|
|
34285
|
+
}
|
|
31251
34286
|
// The synthetic "(+) new commit" row routes the inspector through the
|
|
31252
34287
|
// worktree summary so the user sees what's staged / unstaged at a glance
|
|
31253
34288
|
// — same surface as the compose view's right panel.
|
|
@@ -31288,6 +34323,12 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
31288
34323
|
if (state.activeView === 'submodules') {
|
|
31289
34324
|
return renderSubmodulePreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
|
|
31290
34325
|
}
|
|
34326
|
+
if (state.activeView === 'issues') {
|
|
34327
|
+
return renderIssueTriagePreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
|
|
34328
|
+
}
|
|
34329
|
+
if (state.activeView === 'pull-request-triage') {
|
|
34330
|
+
return renderPullRequestTriagePreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
|
|
34331
|
+
}
|
|
31291
34332
|
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
|
|
31292
34333
|
}
|
|
31293
34334
|
|
|
@@ -31508,7 +34549,7 @@ function enrichFilterActionWithRectification(action, state, context) {
|
|
|
31508
34549
|
}
|
|
31509
34550
|
}
|
|
31510
34551
|
function LogInkApp(deps) {
|
|
31511
|
-
const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
|
|
34552
|
+
const { appLabel, clipboardRunner, dateBucketingEnabled, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
|
|
31512
34553
|
const { Box, Text, useApp, useInput, useWindowSize } = ink;
|
|
31513
34554
|
const h = React.createElement;
|
|
31514
34555
|
const { exit } = useApp();
|
|
@@ -31702,6 +34743,35 @@ function LogInkApp(deps) {
|
|
|
31702
34743
|
return all;
|
|
31703
34744
|
return all.filter((entry) => matchesPromotedFilter([entry.name, entry.path, entry.trackingBranch || '', entry.url || ''], state.filter));
|
|
31704
34745
|
}, [context.submodules?.entries, state.filter]);
|
|
34746
|
+
// Issues + PR triage filtered lists (#882 phase 3). Same memo
|
|
34747
|
+
// pattern as the other promoted views — collapses per-keystroke
|
|
34748
|
+
// filter work to one pass per (data, filter) change.
|
|
34749
|
+
const filteredIssueList = React.useMemo(() => {
|
|
34750
|
+
const all = context.issueList?.issues || [];
|
|
34751
|
+
if (!state.filter)
|
|
34752
|
+
return all;
|
|
34753
|
+
return all.filter((issue) => matchesPromotedFilter([
|
|
34754
|
+
`#${issue.number}`,
|
|
34755
|
+
issue.title,
|
|
34756
|
+
issue.author || '',
|
|
34757
|
+
...(issue.labels || []),
|
|
34758
|
+
...(issue.assignees || []),
|
|
34759
|
+
], state.filter));
|
|
34760
|
+
}, [context.issueList?.issues, state.filter]);
|
|
34761
|
+
const filteredPullRequestTriageList = React.useMemo(() => {
|
|
34762
|
+
const all = context.pullRequestList?.pullRequests || [];
|
|
34763
|
+
if (!state.filter)
|
|
34764
|
+
return all;
|
|
34765
|
+
return all.filter((pr) => matchesPromotedFilter([
|
|
34766
|
+
`#${pr.number}`,
|
|
34767
|
+
pr.title,
|
|
34768
|
+
pr.author || '',
|
|
34769
|
+
pr.headRefName,
|
|
34770
|
+
pr.baseRefName,
|
|
34771
|
+
...(pr.labels || []),
|
|
34772
|
+
...(pr.assignees || []),
|
|
34773
|
+
], state.filter));
|
|
34774
|
+
}, [context.pullRequestList?.pullRequests, state.filter]);
|
|
31705
34775
|
const dispatch = React.useCallback((action) => {
|
|
31706
34776
|
setState((current) => applyLogInkAction(current, action));
|
|
31707
34777
|
}, []);
|
|
@@ -32085,6 +35155,149 @@ function LogInkApp(deps) {
|
|
|
32085
35155
|
active = false;
|
|
32086
35156
|
};
|
|
32087
35157
|
}, [git, state.activeView, context.pullRequest]);
|
|
35158
|
+
// Lazy-load the issue triage list (#882 phase 3, filter-aware
|
|
35159
|
+
// since phase 6). Fires on entry to the view AND on filter
|
|
35160
|
+
// preset changes (`f` cycles the preset; the dep on
|
|
35161
|
+
// `state.selectedIssueFilter` triggers the refetch). The
|
|
35162
|
+
// existing `context.issueList` guard collapses to a no-op when
|
|
35163
|
+
// the preset hasn't changed and data is already loaded.
|
|
35164
|
+
React.useEffect(() => {
|
|
35165
|
+
if (state.activeView !== 'issues')
|
|
35166
|
+
return;
|
|
35167
|
+
if (context.issueList)
|
|
35168
|
+
return;
|
|
35169
|
+
let active = true;
|
|
35170
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'loading'));
|
|
35171
|
+
const filter = issueFilterForPreset(state.selectedIssueFilter);
|
|
35172
|
+
void safe(getIssueList(git, filter)).then((value) => {
|
|
35173
|
+
if (!active)
|
|
35174
|
+
return;
|
|
35175
|
+
setContext((current) => ({
|
|
35176
|
+
...current,
|
|
35177
|
+
issueList: value,
|
|
35178
|
+
}));
|
|
35179
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'ready'));
|
|
35180
|
+
});
|
|
35181
|
+
return () => {
|
|
35182
|
+
active = false;
|
|
35183
|
+
};
|
|
35184
|
+
}, [git, state.activeView, context.issueList, state.selectedIssueFilter]);
|
|
35185
|
+
// Filter cycling: when the preset changes, drop the cached list
|
|
35186
|
+
// so the effect above re-fires with the new filter. Done as a
|
|
35187
|
+
// separate effect (rather than folded into the cycle reducer)
|
|
35188
|
+
// because the reducer is pure — fs / network side-effects live
|
|
35189
|
+
// in `useEffect`.
|
|
35190
|
+
React.useEffect(() => {
|
|
35191
|
+
if (state.activeView !== 'issues')
|
|
35192
|
+
return;
|
|
35193
|
+
setContext((current) => (current.issueList ? { ...current, issueList: undefined } : current));
|
|
35194
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'idle'));
|
|
35195
|
+
// We deliberately depend ONLY on the preset — not on
|
|
35196
|
+
// activeView — so re-entering the view doesn't re-fire and
|
|
35197
|
+
// discard the just-loaded data. The activeView guard above
|
|
35198
|
+
// keeps us from clearing data while the user is on a
|
|
35199
|
+
// different surface.
|
|
35200
|
+
}, [state.selectedIssueFilter]);
|
|
35201
|
+
// Lazy-load the PR triage list (#882 phase 3, filter-aware
|
|
35202
|
+
// since phase 6). Same pattern as the issue effect above.
|
|
35203
|
+
React.useEffect(() => {
|
|
35204
|
+
if (state.activeView !== 'pull-request-triage')
|
|
35205
|
+
return;
|
|
35206
|
+
if (context.pullRequestList)
|
|
35207
|
+
return;
|
|
35208
|
+
let active = true;
|
|
35209
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'loading'));
|
|
35210
|
+
const filter = pullRequestFilterForPreset(state.selectedPullRequestFilter);
|
|
35211
|
+
void safe(getPullRequestList(git, filter)).then((value) => {
|
|
35212
|
+
if (!active)
|
|
35213
|
+
return;
|
|
35214
|
+
setContext((current) => ({
|
|
35215
|
+
...current,
|
|
35216
|
+
pullRequestList: value,
|
|
35217
|
+
}));
|
|
35218
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'ready'));
|
|
35219
|
+
});
|
|
35220
|
+
return () => {
|
|
35221
|
+
active = false;
|
|
35222
|
+
};
|
|
35223
|
+
}, [git, state.activeView, context.pullRequestList, state.selectedPullRequestFilter]);
|
|
35224
|
+
React.useEffect(() => {
|
|
35225
|
+
if (state.activeView !== 'pull-request-triage')
|
|
35226
|
+
return;
|
|
35227
|
+
setContext((current) => current.pullRequestList ? { ...current, pullRequestList: undefined } : current);
|
|
35228
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'idle'));
|
|
35229
|
+
}, [state.selectedPullRequestFilter]);
|
|
35230
|
+
// Per-item inspector hydration (#882 follow-up to phase 6). When
|
|
35231
|
+
// the user rests the cursor on an issue / PR row for ~250ms, fetch
|
|
35232
|
+
// the body + comments (+ reviews + status checks for PRs) and
|
|
35233
|
+
// cache the result keyed by number. Cursoring back to a previously-
|
|
35234
|
+
// fetched item shows the cached entry instantly; rapid j/k
|
|
35235
|
+
// navigation never fires a `gh` call because the debounce timer
|
|
35236
|
+
// resets on every cursor move.
|
|
35237
|
+
//
|
|
35238
|
+
// The cache lives on `context.{issueDetailByNumber,
|
|
35239
|
+
// pullRequestDetailByNumber}` so it survives the per-keystroke
|
|
35240
|
+
// re-renders. It's intentionally Maps — `new Map(prev).set(k, v)`
|
|
35241
|
+
// keeps the immutable update story simple, and entries persist
|
|
35242
|
+
// until either the list is invalidated (post-mutation) or the
|
|
35243
|
+
// process exits.
|
|
35244
|
+
const DETAIL_HYDRATION_DELAY_MS = 250;
|
|
35245
|
+
React.useEffect(() => {
|
|
35246
|
+
if (state.activeView !== 'issues')
|
|
35247
|
+
return;
|
|
35248
|
+
const cursored = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
|
|
35249
|
+
if (!cursored)
|
|
35250
|
+
return;
|
|
35251
|
+
if (context.issueDetailByNumber?.has(cursored.number))
|
|
35252
|
+
return;
|
|
35253
|
+
let active = true;
|
|
35254
|
+
const timer = setTimeout(async () => {
|
|
35255
|
+
const result = await getIssueDetail(cursored.number);
|
|
35256
|
+
if (!active || !result.ok)
|
|
35257
|
+
return;
|
|
35258
|
+
setContext((current) => ({
|
|
35259
|
+
...current,
|
|
35260
|
+
issueDetailByNumber: new Map(current.issueDetailByNumber || []).set(result.detail.number, result.detail),
|
|
35261
|
+
}));
|
|
35262
|
+
}, DETAIL_HYDRATION_DELAY_MS);
|
|
35263
|
+
return () => {
|
|
35264
|
+
active = false;
|
|
35265
|
+
clearTimeout(timer);
|
|
35266
|
+
};
|
|
35267
|
+
}, [
|
|
35268
|
+
state.activeView,
|
|
35269
|
+
state.selectedIssueIndex,
|
|
35270
|
+
filteredIssueList,
|
|
35271
|
+
context.issueDetailByNumber,
|
|
35272
|
+
]);
|
|
35273
|
+
React.useEffect(() => {
|
|
35274
|
+
if (state.activeView !== 'pull-request-triage')
|
|
35275
|
+
return;
|
|
35276
|
+
const cursored = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
35277
|
+
if (!cursored)
|
|
35278
|
+
return;
|
|
35279
|
+
if (context.pullRequestDetailByNumber?.has(cursored.number))
|
|
35280
|
+
return;
|
|
35281
|
+
let active = true;
|
|
35282
|
+
const timer = setTimeout(async () => {
|
|
35283
|
+
const result = await getPullRequestDetail(cursored.number);
|
|
35284
|
+
if (!active || !result.ok)
|
|
35285
|
+
return;
|
|
35286
|
+
setContext((current) => ({
|
|
35287
|
+
...current,
|
|
35288
|
+
pullRequestDetailByNumber: new Map(current.pullRequestDetailByNumber || []).set(result.detail.number, result.detail),
|
|
35289
|
+
}));
|
|
35290
|
+
}, DETAIL_HYDRATION_DELAY_MS);
|
|
35291
|
+
return () => {
|
|
35292
|
+
active = false;
|
|
35293
|
+
clearTimeout(timer);
|
|
35294
|
+
};
|
|
35295
|
+
}, [
|
|
35296
|
+
state.activeView,
|
|
35297
|
+
state.selectedPullRequestTriageIndex,
|
|
35298
|
+
filteredPullRequestTriageList,
|
|
35299
|
+
context.pullRequestDetailByNumber,
|
|
35300
|
+
]);
|
|
32088
35301
|
React.useEffect(() => {
|
|
32089
35302
|
let active = true;
|
|
32090
35303
|
async function loadDetail() {
|
|
@@ -33031,6 +36244,56 @@ function LogInkApp(deps) {
|
|
|
33031
36244
|
const effectiveTarget = expectedTarget || target;
|
|
33032
36245
|
return applyHunkPatch(git, patchText, { target: effectiveTarget });
|
|
33033
36246
|
};
|
|
36247
|
+
// #882 phase 4 — post-mutation cache invalidation for the
|
|
36248
|
+
// issue / PR triage views. Each helper does two things:
|
|
36249
|
+
// 1. Clears the in-memory `context.issueList` /
|
|
36250
|
+
// `context.pullRequestList` entry so the view's `useEffect`
|
|
36251
|
+
// retriggers on the next render and the user sees their
|
|
36252
|
+
// change reflected immediately.
|
|
36253
|
+
// 2. Wipes the disk cache so a follow-up `coco issues` /
|
|
36254
|
+
// `coco prs` CLI call doesn't serve stale data from the
|
|
36255
|
+
// 5-minute TTL window. Sledgehammer rather than scalpel —
|
|
36256
|
+
// clearing per (repo, filter) tuple would require more
|
|
36257
|
+
// bookkeeping than the cache is worth.
|
|
36258
|
+
const invalidateIssueListCaches = (issueNumber) => {
|
|
36259
|
+
setContext((current) => {
|
|
36260
|
+
const next = { ...current, issueList: undefined };
|
|
36261
|
+
// Drop only the mutated issue's detail entry so other
|
|
36262
|
+
// hydrated entries survive — they're still accurate. When
|
|
36263
|
+
// no number is given (rare), wipe the whole detail map.
|
|
36264
|
+
if (current.issueDetailByNumber) {
|
|
36265
|
+
if (typeof issueNumber === 'number') {
|
|
36266
|
+
const trimmed = new Map(current.issueDetailByNumber);
|
|
36267
|
+
trimmed.delete(issueNumber);
|
|
36268
|
+
next.issueDetailByNumber = trimmed;
|
|
36269
|
+
}
|
|
36270
|
+
else {
|
|
36271
|
+
next.issueDetailByNumber = undefined;
|
|
36272
|
+
}
|
|
36273
|
+
}
|
|
36274
|
+
return next;
|
|
36275
|
+
});
|
|
36276
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'idle'));
|
|
36277
|
+
clearGitHubListCache();
|
|
36278
|
+
};
|
|
36279
|
+
const invalidatePullRequestListCaches = (pullRequestNumber) => {
|
|
36280
|
+
setContext((current) => {
|
|
36281
|
+
const next = { ...current, pullRequestList: undefined };
|
|
36282
|
+
if (current.pullRequestDetailByNumber) {
|
|
36283
|
+
if (typeof pullRequestNumber === 'number') {
|
|
36284
|
+
const trimmed = new Map(current.pullRequestDetailByNumber);
|
|
36285
|
+
trimmed.delete(pullRequestNumber);
|
|
36286
|
+
next.pullRequestDetailByNumber = trimmed;
|
|
36287
|
+
}
|
|
36288
|
+
else {
|
|
36289
|
+
next.pullRequestDetailByNumber = undefined;
|
|
36290
|
+
}
|
|
36291
|
+
}
|
|
36292
|
+
return next;
|
|
36293
|
+
});
|
|
36294
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'idle'));
|
|
36295
|
+
clearGitHubListCache();
|
|
36296
|
+
};
|
|
33034
36297
|
const handlers = {
|
|
33035
36298
|
'create-branch': async () => {
|
|
33036
36299
|
const name = payload?.trim();
|
|
@@ -33548,6 +36811,181 @@ function LogInkApp(deps) {
|
|
|
33548
36811
|
return { ok: false, message: 'Comment body required' };
|
|
33549
36812
|
return commentPullRequest(body);
|
|
33550
36813
|
},
|
|
36814
|
+
// #882 phase 4 — triage-view low-risk mutations. Each picks
|
|
36815
|
+
// the cursored item from the *filtered* list (matching what
|
|
36816
|
+
// the user sees on screen), runs the corresponding `gh` action,
|
|
36817
|
+
// and on success clears both the in-memory context entry and
|
|
36818
|
+
// the disk cache so the next view entry refetches. Comment
|
|
36819
|
+
// is additive; label / assign are toggleable via re-invocation
|
|
36820
|
+
// with --remove-* (deferred to phase 5 as part of the y-confirm
|
|
36821
|
+
// suite). Open / yank don't mutate so they skip the
|
|
36822
|
+
// invalidation step entirely.
|
|
36823
|
+
'triage-issue-open': async () => {
|
|
36824
|
+
const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
|
|
36825
|
+
if (!issue)
|
|
36826
|
+
return { ok: false, message: 'No issue under cursor' };
|
|
36827
|
+
try {
|
|
36828
|
+
await defaultOpenUrlRunner(issue.url);
|
|
36829
|
+
return { ok: true, message: `Opened ${issue.url}` };
|
|
36830
|
+
}
|
|
36831
|
+
catch (error) {
|
|
36832
|
+
return { ok: false, message: error.message };
|
|
36833
|
+
}
|
|
36834
|
+
},
|
|
36835
|
+
'triage-issue-comment': async () => {
|
|
36836
|
+
const body = payload?.trim();
|
|
36837
|
+
if (!body)
|
|
36838
|
+
return { ok: false, message: 'Comment body required' };
|
|
36839
|
+
const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
|
|
36840
|
+
if (!issue)
|
|
36841
|
+
return { ok: false, message: 'No issue under cursor' };
|
|
36842
|
+
const result = await commentIssue(issue.number, body);
|
|
36843
|
+
if (result.ok)
|
|
36844
|
+
invalidateIssueListCaches(issue.number);
|
|
36845
|
+
return result;
|
|
36846
|
+
},
|
|
36847
|
+
'triage-issue-label': async () => {
|
|
36848
|
+
const label = payload?.trim();
|
|
36849
|
+
if (!label)
|
|
36850
|
+
return { ok: false, message: 'Label name required' };
|
|
36851
|
+
const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
|
|
36852
|
+
if (!issue)
|
|
36853
|
+
return { ok: false, message: 'No issue under cursor' };
|
|
36854
|
+
const result = await addIssueLabel(issue.number, label);
|
|
36855
|
+
if (result.ok)
|
|
36856
|
+
invalidateIssueListCaches(issue.number);
|
|
36857
|
+
return result;
|
|
36858
|
+
},
|
|
36859
|
+
'triage-issue-assign': async () => {
|
|
36860
|
+
const assignee = payload?.trim();
|
|
36861
|
+
if (!assignee)
|
|
36862
|
+
return { ok: false, message: 'Assignee login required' };
|
|
36863
|
+
const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
|
|
36864
|
+
if (!issue)
|
|
36865
|
+
return { ok: false, message: 'No issue under cursor' };
|
|
36866
|
+
const result = await addIssueAssignee(issue.number, assignee);
|
|
36867
|
+
if (result.ok)
|
|
36868
|
+
invalidateIssueListCaches(issue.number);
|
|
36869
|
+
return result;
|
|
36870
|
+
},
|
|
36871
|
+
'triage-pr-open': async () => {
|
|
36872
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
36873
|
+
if (!pr)
|
|
36874
|
+
return { ok: false, message: 'No pull request under cursor' };
|
|
36875
|
+
try {
|
|
36876
|
+
await defaultOpenUrlRunner(pr.url);
|
|
36877
|
+
return { ok: true, message: `Opened ${pr.url}` };
|
|
36878
|
+
}
|
|
36879
|
+
catch (error) {
|
|
36880
|
+
return { ok: false, message: error.message };
|
|
36881
|
+
}
|
|
36882
|
+
},
|
|
36883
|
+
'triage-pr-comment': async () => {
|
|
36884
|
+
const body = payload?.trim();
|
|
36885
|
+
if (!body)
|
|
36886
|
+
return { ok: false, message: 'Comment body required' };
|
|
36887
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
36888
|
+
if (!pr)
|
|
36889
|
+
return { ok: false, message: 'No pull request under cursor' };
|
|
36890
|
+
const result = await commentPullRequestByNumber(pr.number, body);
|
|
36891
|
+
if (result.ok)
|
|
36892
|
+
invalidatePullRequestListCaches(pr.number);
|
|
36893
|
+
return result;
|
|
36894
|
+
},
|
|
36895
|
+
'triage-pr-label': async () => {
|
|
36896
|
+
const label = payload?.trim();
|
|
36897
|
+
if (!label)
|
|
36898
|
+
return { ok: false, message: 'Label name required' };
|
|
36899
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
36900
|
+
if (!pr)
|
|
36901
|
+
return { ok: false, message: 'No pull request under cursor' };
|
|
36902
|
+
const result = await addPullRequestLabel(pr.number, label);
|
|
36903
|
+
if (result.ok)
|
|
36904
|
+
invalidatePullRequestListCaches(pr.number);
|
|
36905
|
+
return result;
|
|
36906
|
+
},
|
|
36907
|
+
'triage-pr-assign': async () => {
|
|
36908
|
+
const assignee = payload?.trim();
|
|
36909
|
+
if (!assignee)
|
|
36910
|
+
return { ok: false, message: 'Assignee login required' };
|
|
36911
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
36912
|
+
if (!pr)
|
|
36913
|
+
return { ok: false, message: 'No pull request under cursor' };
|
|
36914
|
+
const result = await addPullRequestAssignee(pr.number, assignee);
|
|
36915
|
+
if (result.ok)
|
|
36916
|
+
invalidatePullRequestListCaches(pr.number);
|
|
36917
|
+
return result;
|
|
36918
|
+
},
|
|
36919
|
+
// #882 phase 5 — destructive triage mutations. Each is gated
|
|
36920
|
+
// through the y-confirm path so the user sees a prompt before
|
|
36921
|
+
// anything ships. The runner reads the cursored item from the
|
|
36922
|
+
// filtered list at confirm-time; the cursor can't move while
|
|
36923
|
+
// the confirmation overlay is up so there's no stale-target
|
|
36924
|
+
// window. Cache invalidation matches the phase-4 pattern.
|
|
36925
|
+
'triage-issue-close': async () => {
|
|
36926
|
+
const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
|
|
36927
|
+
if (!issue)
|
|
36928
|
+
return { ok: false, message: 'No issue under cursor' };
|
|
36929
|
+
const result = await closeIssue(issue.number);
|
|
36930
|
+
if (result.ok)
|
|
36931
|
+
invalidateIssueListCaches(issue.number);
|
|
36932
|
+
return result;
|
|
36933
|
+
},
|
|
36934
|
+
'triage-issue-reopen': async () => {
|
|
36935
|
+
const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
|
|
36936
|
+
if (!issue)
|
|
36937
|
+
return { ok: false, message: 'No issue under cursor' };
|
|
36938
|
+
const result = await reopenIssue(issue.number);
|
|
36939
|
+
if (result.ok)
|
|
36940
|
+
invalidateIssueListCaches(issue.number);
|
|
36941
|
+
return result;
|
|
36942
|
+
},
|
|
36943
|
+
'triage-pr-merge': async () => {
|
|
36944
|
+
const strategy = payload?.trim();
|
|
36945
|
+
if (!strategy || !isPullRequestMergeStrategy(strategy)) {
|
|
36946
|
+
return {
|
|
36947
|
+
ok: false,
|
|
36948
|
+
message: `Unknown merge strategy: ${strategy}. Use merge, squash, or rebase.`,
|
|
36949
|
+
};
|
|
36950
|
+
}
|
|
36951
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
36952
|
+
if (!pr)
|
|
36953
|
+
return { ok: false, message: 'No pull request under cursor' };
|
|
36954
|
+
const result = await mergePullRequestByNumber(pr.number, strategy);
|
|
36955
|
+
if (result.ok)
|
|
36956
|
+
invalidatePullRequestListCaches(pr.number);
|
|
36957
|
+
return result;
|
|
36958
|
+
},
|
|
36959
|
+
'triage-pr-close': async () => {
|
|
36960
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
36961
|
+
if (!pr)
|
|
36962
|
+
return { ok: false, message: 'No pull request under cursor' };
|
|
36963
|
+
const result = await closePullRequestByNumber(pr.number);
|
|
36964
|
+
if (result.ok)
|
|
36965
|
+
invalidatePullRequestListCaches(pr.number);
|
|
36966
|
+
return result;
|
|
36967
|
+
},
|
|
36968
|
+
'triage-pr-approve': async () => {
|
|
36969
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
36970
|
+
if (!pr)
|
|
36971
|
+
return { ok: false, message: 'No pull request under cursor' };
|
|
36972
|
+
const result = await approvePullRequestByNumber(pr.number);
|
|
36973
|
+
if (result.ok)
|
|
36974
|
+
invalidatePullRequestListCaches(pr.number);
|
|
36975
|
+
return result;
|
|
36976
|
+
},
|
|
36977
|
+
'triage-pr-request-changes': async () => {
|
|
36978
|
+
const body = payload?.trim();
|
|
36979
|
+
if (!body)
|
|
36980
|
+
return { ok: false, message: 'Review body required for change-request' };
|
|
36981
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
36982
|
+
if (!pr)
|
|
36983
|
+
return { ok: false, message: 'No pull request under cursor' };
|
|
36984
|
+
const result = await requestChangesPullRequestByNumber(pr.number, body);
|
|
36985
|
+
if (result.ok)
|
|
36986
|
+
invalidatePullRequestListCaches(pr.number);
|
|
36987
|
+
return result;
|
|
36988
|
+
},
|
|
33551
36989
|
// Status surface group-level batch ops (#791 follow-up). The
|
|
33552
36990
|
// input handler dispatches these when the user presses Enter on a
|
|
33553
36991
|
// group header. We re-derive the file list from the live
|
|
@@ -33703,6 +37141,29 @@ function LogInkApp(deps) {
|
|
|
33703
37141
|
}
|
|
33704
37142
|
}
|
|
33705
37143
|
}
|
|
37144
|
+
else if (view === 'issues') {
|
|
37145
|
+
// #882 phase 4 — y yanks the cursored issue's URL so the user
|
|
37146
|
+
// can paste it into Slack / a PR description / etc. without
|
|
37147
|
+
// dropping back to the browser. Short form (`Y`) is a no-op
|
|
37148
|
+
// here — there's no compact identifier worth a second key.
|
|
37149
|
+
const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
|
|
37150
|
+
if (issue) {
|
|
37151
|
+
value = issue.url;
|
|
37152
|
+
label = `issue #${issue.number} URL`;
|
|
37153
|
+
}
|
|
37154
|
+
}
|
|
37155
|
+
else if (view === 'pull-request-triage') {
|
|
37156
|
+
// #882 phase 4 — same URL-yank pattern for the multi-PR list.
|
|
37157
|
+
// Distinct from `pull-request` (single, current-branch); that
|
|
37158
|
+
// view falls through to the generic "Nothing to yank" path
|
|
37159
|
+
// below since the action panel already exposes O for browser
|
|
37160
|
+
// open.
|
|
37161
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
37162
|
+
if (pr) {
|
|
37163
|
+
value = pr.url;
|
|
37164
|
+
label = `pull request #${pr.number} URL`;
|
|
37165
|
+
}
|
|
37166
|
+
}
|
|
33706
37167
|
else if (view === 'bisect') {
|
|
33707
37168
|
// #879 item 3 — yank the first-bad commit sha from the
|
|
33708
37169
|
// completion panel. The headline answer is what the user
|
|
@@ -33773,6 +37234,8 @@ function LogInkApp(deps) {
|
|
|
33773
37234
|
context.submodules,
|
|
33774
37235
|
context.tags,
|
|
33775
37236
|
dispatch,
|
|
37237
|
+
filteredIssueList,
|
|
37238
|
+
filteredPullRequestTriageList,
|
|
33776
37239
|
selected,
|
|
33777
37240
|
selectedDetailFile,
|
|
33778
37241
|
stashDiffLines,
|
|
@@ -33785,6 +37248,8 @@ function LogInkApp(deps) {
|
|
|
33785
37248
|
state.filteredCommits,
|
|
33786
37249
|
state.selectedBranchIndex,
|
|
33787
37250
|
state.selectedIndex,
|
|
37251
|
+
state.selectedIssueIndex,
|
|
37252
|
+
state.selectedPullRequestTriageIndex,
|
|
33788
37253
|
state.selectedStashIndex,
|
|
33789
37254
|
state.selectedSubmoduleIndex,
|
|
33790
37255
|
state.selectedTagIndex,
|
|
@@ -34005,6 +37470,10 @@ function LogInkApp(deps) {
|
|
|
34005
37470
|
const reflogSelectedHash = filteredReflogList[Math.min(state.selectedReflogIndex, Math.max(0, filteredReflogList.length - 1))]?.hash;
|
|
34006
37471
|
const submoduleVisibleCount = filteredSubmoduleList.length;
|
|
34007
37472
|
filteredSubmoduleList[Math.min(state.selectedSubmoduleIndex, Math.max(0, filteredSubmoduleList.length - 1))]?.path;
|
|
37473
|
+
const issueVisibleCount = filteredIssueList.length;
|
|
37474
|
+
const issueSelectedUrl = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))]?.url;
|
|
37475
|
+
const pullRequestTriageVisibleCount = filteredPullRequestTriageList.length;
|
|
37476
|
+
const pullRequestTriageSelectedUrl = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))]?.url;
|
|
34008
37477
|
const worktreeVisibleCount = filteredWorktreeList.length;
|
|
34009
37478
|
// When the diff view is showing a stash patch, swap the previewLineCount
|
|
34010
37479
|
// to the stash diff length so the existing pageDetailPreview path
|
|
@@ -34036,6 +37505,10 @@ function LogInkApp(deps) {
|
|
|
34036
37505
|
reflogCount: reflogVisibleCount,
|
|
34037
37506
|
reflogSelectedHash,
|
|
34038
37507
|
submoduleCount: submoduleVisibleCount,
|
|
37508
|
+
issueCount: issueVisibleCount,
|
|
37509
|
+
issueSelectedUrl,
|
|
37510
|
+
pullRequestTriageCount: pullRequestTriageVisibleCount,
|
|
37511
|
+
pullRequestTriageSelectedUrl,
|
|
34039
37512
|
stashSelectedRef,
|
|
34040
37513
|
stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
|
|
34041
37514
|
stashDiffSelectedPath,
|
|
@@ -34203,7 +37676,7 @@ function LogInkApp(deps) {
|
|
|
34203
37676
|
if (showOnboarding) {
|
|
34204
37677
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
34205
37678
|
}
|
|
34206
|
-
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, bisectCandidateDetail, bisectCandidateLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme)), renderFooter(h, { Box, Text }, state, context, theme, idleTip, spinnerFrame));
|
|
37679
|
+
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, layout.sidebarRailed), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, layout.density, layout.historyRowMode, Boolean(dateBucketingEnabled)), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.inspectorRailed)), renderFooter(h, { Box, Text }, state, context, theme, idleTip, spinnerFrame));
|
|
34207
37680
|
}
|
|
34208
37681
|
|
|
34209
37682
|
/**
|
|
@@ -34512,6 +37985,9 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
|
34512
37985
|
appLabel: options.appLabel || 'coco log',
|
|
34513
37986
|
git,
|
|
34514
37987
|
idleTipsEnabled: Boolean(options.idleTips),
|
|
37988
|
+
// Resolve undefined → true so the default flips on automatically.
|
|
37989
|
+
// An explicit `false` from config opts out.
|
|
37990
|
+
dateBucketingEnabled: options.dateBucketing !== false,
|
|
34515
37991
|
ink,
|
|
34516
37992
|
initialView: options.initialView || 'history',
|
|
34517
37993
|
logArgv: options.logArgv,
|
|
@@ -34676,6 +38152,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
|
34676
38152
|
await startInkInteractiveLog(git, initialRows, {}, {
|
|
34677
38153
|
appLabel: 'coco',
|
|
34678
38154
|
idleTips: config.logTui?.idleTips,
|
|
38155
|
+
dateBucketing: config.logTui?.dateBucketing,
|
|
34679
38156
|
initialView: 'history',
|
|
34680
38157
|
loadRows,
|
|
34681
38158
|
logArgv,
|
|
@@ -34697,13 +38174,14 @@ async function startCocoUi(argv) {
|
|
|
34697
38174
|
await startInkInteractiveLog(git, cachedRows || [], {}, {
|
|
34698
38175
|
appLabel: 'coco',
|
|
34699
38176
|
idleTips: config.logTui?.idleTips,
|
|
38177
|
+
dateBucketing: config.logTui?.dateBucketing,
|
|
34700
38178
|
initialView: argv.view || 'history',
|
|
34701
38179
|
loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
|
|
34702
38180
|
logArgv,
|
|
34703
38181
|
theme: createUiTheme(config, argv),
|
|
34704
38182
|
});
|
|
34705
38183
|
}
|
|
34706
|
-
const handler$
|
|
38184
|
+
const handler$4 = async (argv) => {
|
|
34707
38185
|
await startCocoUi(argv);
|
|
34708
38186
|
};
|
|
34709
38187
|
|
|
@@ -34796,7 +38274,7 @@ function formatCommitDetail(detail, format) {
|
|
|
34796
38274
|
].join('\n');
|
|
34797
38275
|
}
|
|
34798
38276
|
|
|
34799
|
-
const handler$
|
|
38277
|
+
const handler$3 = async (argv) => {
|
|
34800
38278
|
// `--repo <dir>` (alias `--cwd`) — apply the global flag via the
|
|
34801
38279
|
// shared helper. After this returns, `process.cwd()` and the git
|
|
34802
38280
|
// instance are both bound to the targeted repo.
|
|
@@ -34831,11 +38309,169 @@ const handler$2 = async (argv) => {
|
|
|
34831
38309
|
};
|
|
34832
38310
|
|
|
34833
38311
|
var log = {
|
|
34834
|
-
command: command$
|
|
38312
|
+
command: command$4,
|
|
34835
38313
|
desc: 'Explore commit history with a branch graph, filters, and commit details.',
|
|
38314
|
+
builder: builder$4,
|
|
38315
|
+
handler: commandExecutor(handler$3),
|
|
38316
|
+
options: options$4,
|
|
38317
|
+
};
|
|
38318
|
+
|
|
38319
|
+
const command$3 = 'prs';
|
|
38320
|
+
const options$3 = {
|
|
38321
|
+
state: {
|
|
38322
|
+
type: 'string',
|
|
38323
|
+
choices: ['open', 'closed', 'merged', 'all'],
|
|
38324
|
+
description: 'Filter by PR state.',
|
|
38325
|
+
default: 'open',
|
|
38326
|
+
},
|
|
38327
|
+
assignee: {
|
|
38328
|
+
type: 'string',
|
|
38329
|
+
description: 'Filter by assignee GitHub login (or `@me`).',
|
|
38330
|
+
},
|
|
38331
|
+
author: {
|
|
38332
|
+
type: 'string',
|
|
38333
|
+
description: 'Filter by author GitHub login.',
|
|
38334
|
+
},
|
|
38335
|
+
label: {
|
|
38336
|
+
type: 'string',
|
|
38337
|
+
description: 'Filter by label name (comma-separated for AND).',
|
|
38338
|
+
},
|
|
38339
|
+
search: {
|
|
38340
|
+
type: 'string',
|
|
38341
|
+
description: 'Free-form GitHub PR search query.',
|
|
38342
|
+
},
|
|
38343
|
+
base: {
|
|
38344
|
+
type: 'string',
|
|
38345
|
+
description: 'Filter to PRs targeting a specific base branch.',
|
|
38346
|
+
},
|
|
38347
|
+
head: {
|
|
38348
|
+
type: 'string',
|
|
38349
|
+
description: 'Filter to PRs originating from a specific head branch.',
|
|
38350
|
+
},
|
|
38351
|
+
draft: {
|
|
38352
|
+
type: 'boolean',
|
|
38353
|
+
description: 'Limit to draft PRs only.',
|
|
38354
|
+
default: false,
|
|
38355
|
+
},
|
|
38356
|
+
mine: {
|
|
38357
|
+
type: 'boolean',
|
|
38358
|
+
description: 'Shorthand for `--assignee @me`.',
|
|
38359
|
+
default: false,
|
|
38360
|
+
},
|
|
38361
|
+
limit: {
|
|
38362
|
+
type: 'number',
|
|
38363
|
+
description: 'Maximum rows to fetch. Defaults to `gh`\'s own default.',
|
|
38364
|
+
},
|
|
38365
|
+
json: {
|
|
38366
|
+
type: 'boolean',
|
|
38367
|
+
description: 'Print machine-readable JSON instead of a formatted table.',
|
|
38368
|
+
default: false,
|
|
38369
|
+
},
|
|
38370
|
+
refresh: {
|
|
38371
|
+
type: 'boolean',
|
|
38372
|
+
description: 'Force fresh `gh` call (writes through to cache).',
|
|
38373
|
+
default: false,
|
|
38374
|
+
},
|
|
38375
|
+
'no-cache': {
|
|
38376
|
+
type: 'boolean',
|
|
38377
|
+
description: 'Skip the disk cache entirely (no read, no write).',
|
|
38378
|
+
default: false,
|
|
38379
|
+
},
|
|
38380
|
+
};
|
|
38381
|
+
const builder$3 = (yargs) => {
|
|
38382
|
+
return yargs.options(options$3).usage(getCommandUsageHeader(command$3));
|
|
38383
|
+
};
|
|
38384
|
+
|
|
38385
|
+
const handler$2 = async (argv, logger) => {
|
|
38386
|
+
const git = applyRepoFlag(argv);
|
|
38387
|
+
const repoPath = process.cwd();
|
|
38388
|
+
const filter = {
|
|
38389
|
+
state: argv.state,
|
|
38390
|
+
assignee: argv.mine ? '@me' : argv.assignee,
|
|
38391
|
+
author: argv.author,
|
|
38392
|
+
label: argv.label,
|
|
38393
|
+
search: argv.search,
|
|
38394
|
+
base: argv.base,
|
|
38395
|
+
head: argv.head,
|
|
38396
|
+
draft: argv.draft,
|
|
38397
|
+
limit: argv.limit,
|
|
38398
|
+
};
|
|
38399
|
+
const cacheEnabled = !argv.noCache;
|
|
38400
|
+
let prs;
|
|
38401
|
+
let fromCache = false;
|
|
38402
|
+
let cacheAgeMs;
|
|
38403
|
+
const repository = await getGitHubRepository(git);
|
|
38404
|
+
if (cacheEnabled && !argv.refresh) {
|
|
38405
|
+
const cached = readCachedList('prs', repoPath, filter);
|
|
38406
|
+
if (cached?.fresh) {
|
|
38407
|
+
prs = cached.payload.items;
|
|
38408
|
+
fromCache = true;
|
|
38409
|
+
cacheAgeMs = cached.ageMs;
|
|
38410
|
+
}
|
|
38411
|
+
}
|
|
38412
|
+
if (!prs) {
|
|
38413
|
+
const overview = await getPullRequestList(git, filter);
|
|
38414
|
+
if (!overview.available) {
|
|
38415
|
+
logger.log(chalk.red(overview.message || 'No GitHub remote detected.'));
|
|
38416
|
+
commandExit(1);
|
|
38417
|
+
return;
|
|
38418
|
+
}
|
|
38419
|
+
if (!overview.authenticated) {
|
|
38420
|
+
logger.log(chalk.yellow(overview.message || 'GitHub CLI is missing or not authenticated.'));
|
|
38421
|
+
logger.log(chalk.dim('Install `gh` and run `gh auth login` to enable PR triage.'));
|
|
38422
|
+
commandExit(1);
|
|
38423
|
+
return;
|
|
38424
|
+
}
|
|
38425
|
+
if (overview.message) {
|
|
38426
|
+
logger.log(chalk.red(overview.message));
|
|
38427
|
+
commandExit(1);
|
|
38428
|
+
return;
|
|
38429
|
+
}
|
|
38430
|
+
prs = overview.pullRequests || [];
|
|
38431
|
+
if (cacheEnabled) {
|
|
38432
|
+
writeCachedList(repoPath, filter, { kind: 'prs', items: prs });
|
|
38433
|
+
}
|
|
38434
|
+
}
|
|
38435
|
+
if (argv.json) {
|
|
38436
|
+
logger.log(JSON.stringify(prs, null, 2));
|
|
38437
|
+
return;
|
|
38438
|
+
}
|
|
38439
|
+
if (repository) {
|
|
38440
|
+
const filterParts = [];
|
|
38441
|
+
if (filter.state && filter.state !== 'open')
|
|
38442
|
+
filterParts.push(`state=${filter.state}`);
|
|
38443
|
+
if (filter.assignee)
|
|
38444
|
+
filterParts.push(`assignee=${filter.assignee}`);
|
|
38445
|
+
if (filter.author)
|
|
38446
|
+
filterParts.push(`author=${filter.author}`);
|
|
38447
|
+
if (filter.label)
|
|
38448
|
+
filterParts.push(`label=${filter.label}`);
|
|
38449
|
+
if (filter.search)
|
|
38450
|
+
filterParts.push(`search=${JSON.stringify(filter.search)}`);
|
|
38451
|
+
if (filter.base)
|
|
38452
|
+
filterParts.push(`base=${filter.base}`);
|
|
38453
|
+
if (filter.head)
|
|
38454
|
+
filterParts.push(`head=${filter.head}`);
|
|
38455
|
+
if (filter.draft)
|
|
38456
|
+
filterParts.push('draft');
|
|
38457
|
+
const suffix = filterParts.length ? chalk.dim(` (${filterParts.join(', ')})`) : '';
|
|
38458
|
+
const cacheTag = fromCache && typeof cacheAgeMs === 'number'
|
|
38459
|
+
? chalk.dim(` · cached ${Math.round(cacheAgeMs / 1000)}s ago`)
|
|
38460
|
+
: '';
|
|
38461
|
+
logger.log(chalk.bold(`${repository.owner}/${repository.name}`) +
|
|
38462
|
+
chalk.dim(` · ${prs.length} pull request${prs.length === 1 ? '' : 's'}`) +
|
|
38463
|
+
suffix +
|
|
38464
|
+
cacheTag);
|
|
38465
|
+
logger.log('');
|
|
38466
|
+
}
|
|
38467
|
+
logger.log(formatPullRequestList(prs));
|
|
38468
|
+
};
|
|
38469
|
+
|
|
38470
|
+
var prs = {
|
|
38471
|
+
command: command$3,
|
|
38472
|
+
desc: 'List GitHub pull requests for the current repository (read-only triage)',
|
|
34836
38473
|
builder: builder$3,
|
|
34837
38474
|
handler: commandExecutor(handler$2),
|
|
34838
|
-
options: options$3,
|
|
34839
38475
|
};
|
|
34840
38476
|
|
|
34841
38477
|
const RecapLlmResponseSchema = objectType({
|
|
@@ -35737,7 +39373,7 @@ var ui = {
|
|
|
35737
39373
|
command,
|
|
35738
39374
|
desc: 'Open the Coco Git workstation TUI.',
|
|
35739
39375
|
builder,
|
|
35740
|
-
handler: commandExecutor(handler$
|
|
39376
|
+
handler: commandExecutor(handler$4),
|
|
35741
39377
|
options,
|
|
35742
39378
|
};
|
|
35743
39379
|
|
|
@@ -35768,6 +39404,8 @@ y.command(doctor.command, doctor.desc, doctor.builder, doctor.handler);
|
|
|
35768
39404
|
y.command(log.command, log.desc, log.builder, log.handler);
|
|
35769
39405
|
y.command(ui.command, ui.desc, ui.builder, ui.handler);
|
|
35770
39406
|
y.command(cache.command, cache.desc, cache.builder, cache.handler);
|
|
39407
|
+
y.command(issues.command, issues.desc, issues.builder, issues.handler);
|
|
39408
|
+
y.command(prs.command, prs.desc, prs.builder, prs.handler);
|
|
35771
39409
|
async function main() {
|
|
35772
39410
|
await runPrefetchFromEnv();
|
|
35773
39411
|
y.help().parse(process.argv.slice(2));
|
|
@@ -36231,7 +39869,9 @@ exports.changelog = changelog;
|
|
|
36231
39869
|
exports.commit = commit;
|
|
36232
39870
|
exports.doctor = doctor;
|
|
36233
39871
|
exports.init = init;
|
|
39872
|
+
exports.issues = issues;
|
|
36234
39873
|
exports.log = log;
|
|
39874
|
+
exports.prs = prs;
|
|
36235
39875
|
exports.recap = recap;
|
|
36236
39876
|
exports.types = types;
|
|
36237
39877
|
exports.ui = ui;
|