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.esm.mjs
CHANGED
|
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
|
|
|
61
61
|
/**
|
|
62
62
|
* Current build version from package.json
|
|
63
63
|
*/
|
|
64
|
-
const BUILD_VERSION = "0.
|
|
64
|
+
const BUILD_VERSION = "0.51.0";
|
|
65
65
|
|
|
66
66
|
const isInteractive = (config) => {
|
|
67
67
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -128,11 +128,11 @@ function removeUndefined(obj) {
|
|
|
128
128
|
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
const dynamicImport$
|
|
131
|
+
const dynamicImport$1 = new Function('specifier', 'return import(specifier)');
|
|
132
132
|
let promptsPromise;
|
|
133
133
|
function loadInquirerPrompts() {
|
|
134
134
|
if (!promptsPromise) {
|
|
135
|
-
promptsPromise = dynamicImport$
|
|
135
|
+
promptsPromise = dynamicImport$1('@inquirer/prompts');
|
|
136
136
|
}
|
|
137
137
|
return promptsPromise;
|
|
138
138
|
}
|
|
@@ -1123,6 +1123,11 @@ const schema$1 = {
|
|
|
1123
1123
|
"idleTips": {
|
|
1124
1124
|
"type": "boolean",
|
|
1125
1125
|
"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."
|
|
1126
|
+
},
|
|
1127
|
+
"dateBucketing": {
|
|
1128
|
+
"type": "boolean",
|
|
1129
|
+
"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.",
|
|
1130
|
+
"default": true
|
|
1126
1131
|
}
|
|
1127
1132
|
},
|
|
1128
1133
|
"additionalProperties": false,
|
|
@@ -1579,6 +1584,9 @@ const schema$1 = {
|
|
|
1579
1584
|
"commit": {
|
|
1580
1585
|
"$ref": "#/definitions/LLMModel"
|
|
1581
1586
|
},
|
|
1587
|
+
"commitSplit": {
|
|
1588
|
+
"$ref": "#/definitions/LLMModel"
|
|
1589
|
+
},
|
|
1582
1590
|
"changelog": {
|
|
1583
1591
|
"$ref": "#/definitions/LLMModel"
|
|
1584
1592
|
},
|
|
@@ -2488,9 +2496,10 @@ const CACHE_SUBCOMMANDS = [
|
|
|
2488
2496
|
'parsers',
|
|
2489
2497
|
'prefetch',
|
|
2490
2498
|
'clear-parsers',
|
|
2499
|
+
'clear-github',
|
|
2491
2500
|
];
|
|
2492
|
-
const command$
|
|
2493
|
-
const builder$
|
|
2501
|
+
const command$a = 'cache <subcommand> [languages..]';
|
|
2502
|
+
const builder$a = (yargs) => {
|
|
2494
2503
|
return yargs
|
|
2495
2504
|
.positional('subcommand', {
|
|
2496
2505
|
describe: 'Cache action to run',
|
|
@@ -2502,7 +2511,7 @@ const builder$8 = (yargs) => {
|
|
|
2502
2511
|
type: 'string',
|
|
2503
2512
|
array: true,
|
|
2504
2513
|
})
|
|
2505
|
-
.usage(getCommandUsageHeader(command$
|
|
2514
|
+
.usage(getCommandUsageHeader(command$a));
|
|
2506
2515
|
};
|
|
2507
2516
|
|
|
2508
2517
|
/**
|
|
@@ -2952,15 +2961,15 @@ async function runPrefetchFromEnv(options) {
|
|
|
2952
2961
|
* cache file under ~500 KB on a typical repo (each entry is a
|
|
2953
2962
|
* sha256 hash + 200-500-byte summary).
|
|
2954
2963
|
*/
|
|
2955
|
-
const CACHE_SCHEMA_VERSION$
|
|
2956
|
-
const CACHE_DIR_NAME$
|
|
2964
|
+
const CACHE_SCHEMA_VERSION$2 = 1;
|
|
2965
|
+
const CACHE_DIR_NAME$2 = 'diff-summaries';
|
|
2957
2966
|
const CACHE_ENTRY_HARD_CAP = 500;
|
|
2958
|
-
function resolveCacheDir$
|
|
2967
|
+
function resolveCacheDir$5() {
|
|
2959
2968
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
2960
2969
|
if (xdg && xdg.trim().length > 0) {
|
|
2961
|
-
return path$1.join(xdg, 'coco', CACHE_DIR_NAME$
|
|
2970
|
+
return path$1.join(xdg, 'coco', CACHE_DIR_NAME$2);
|
|
2962
2971
|
}
|
|
2963
|
-
return path$1.join(os$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$
|
|
2972
|
+
return path$1.join(os$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$2);
|
|
2964
2973
|
}
|
|
2965
2974
|
function repoKey$3(repoPath) {
|
|
2966
2975
|
// sha256 here is a non-security cache-key derivation — deterministic
|
|
@@ -2970,7 +2979,7 @@ function repoKey$3(repoPath) {
|
|
|
2970
2979
|
return crypto.createHash('sha256').update(repoPath).digest('hex').slice(0, 16);
|
|
2971
2980
|
}
|
|
2972
2981
|
function getDiffSummaryCachePath(repoPath) {
|
|
2973
|
-
return path$1.join(resolveCacheDir$
|
|
2982
|
+
return path$1.join(resolveCacheDir$5(), `summaries.${repoKey$3(repoPath)}.json`);
|
|
2974
2983
|
}
|
|
2975
2984
|
/**
|
|
2976
2985
|
* Build the cache key for a (diff, model, prompt) tuple. sha256
|
|
@@ -2987,7 +2996,7 @@ function readEnvelope(filePath) {
|
|
|
2987
2996
|
try {
|
|
2988
2997
|
const raw = fs$1.readFileSync(filePath, 'utf8');
|
|
2989
2998
|
const parsed = JSON.parse(raw);
|
|
2990
|
-
if (parsed.version !== CACHE_SCHEMA_VERSION$
|
|
2999
|
+
if (parsed.version !== CACHE_SCHEMA_VERSION$2)
|
|
2991
3000
|
return undefined;
|
|
2992
3001
|
if (!parsed.entries || typeof parsed.entries !== 'object')
|
|
2993
3002
|
return undefined;
|
|
@@ -3009,7 +3018,7 @@ function readDiffSummary(repoPath, key) {
|
|
|
3009
3018
|
function writeDiffSummary(repoPath, key, entry) {
|
|
3010
3019
|
const filePath = getDiffSummaryCachePath(repoPath);
|
|
3011
3020
|
const existing = readEnvelope(filePath) || {
|
|
3012
|
-
version: CACHE_SCHEMA_VERSION$
|
|
3021
|
+
version: CACHE_SCHEMA_VERSION$2,
|
|
3013
3022
|
savedAt: new Date().toISOString(),
|
|
3014
3023
|
entries: {},
|
|
3015
3024
|
};
|
|
@@ -3078,6 +3087,132 @@ function clearDiffSummaryCache(repoPath) {
|
|
|
3078
3087
|
}
|
|
3079
3088
|
}
|
|
3080
3089
|
|
|
3090
|
+
/**
|
|
3091
|
+
* Disk-backed cache for `coco issues` / `coco prs` list fetches
|
|
3092
|
+
* (#882 phase 2). Triage is bursty — a user runs `coco issues` a
|
|
3093
|
+
* dozen times in a few minutes, then doesn't touch it for hours —
|
|
3094
|
+
* so a short TTL (default 5 minutes) buys a lot of latency back
|
|
3095
|
+
* without serving stale data outside that window.
|
|
3096
|
+
*
|
|
3097
|
+
* Best-effort, same as `overviewCache.ts`: read failures fall back
|
|
3098
|
+
* to "no cache" (the fetcher does a fresh `gh` call), write failures
|
|
3099
|
+
* are swallowed silently (next call just re-fetches). The cache is
|
|
3100
|
+
* never load-bearing — `gh` is always the source of truth.
|
|
3101
|
+
*
|
|
3102
|
+
* Keying: `{kind}.{repoHash}.{filterHash}.json` where:
|
|
3103
|
+
* - `kind` is `'issues'` or `'prs'` so the two surfaces don't
|
|
3104
|
+
* collide.
|
|
3105
|
+
* - `repoHash` is a stable short hash of the absolute repo path
|
|
3106
|
+
* (same scheme as `overviewCache.ts`).
|
|
3107
|
+
* - `filterHash` is a stable short hash of the canonicalized
|
|
3108
|
+
* filter object so different `--state` / `--assignee` / `--label`
|
|
3109
|
+
* combinations cache independently.
|
|
3110
|
+
*
|
|
3111
|
+
* No PII in filenames; no auth context is hashed; no
|
|
3112
|
+
* collision-resistance against an adversary is required.
|
|
3113
|
+
*/
|
|
3114
|
+
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
3115
|
+
const CACHE_SCHEMA_VERSION$1 = 1;
|
|
3116
|
+
const CACHE_DIR_NAME$1 = 'github';
|
|
3117
|
+
function resolveCacheDir$4() {
|
|
3118
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
3119
|
+
if (xdg && xdg.trim().length > 0) {
|
|
3120
|
+
return path$1.join(xdg, 'coco', CACHE_DIR_NAME$1);
|
|
3121
|
+
}
|
|
3122
|
+
return path$1.join(os$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$1);
|
|
3123
|
+
}
|
|
3124
|
+
function shortHash$1(input) {
|
|
3125
|
+
// sha1 here is a non-security cache-key derivation — we just need a
|
|
3126
|
+
// deterministic short identifier so two repos / filters at different
|
|
3127
|
+
// values never collide in the cache directory. No PII or auth
|
|
3128
|
+
// context is hashed and no collision-resistance against an adversary
|
|
3129
|
+
// is required.
|
|
3130
|
+
// DevSkim: ignore DS126858
|
|
3131
|
+
return crypto.createHash('sha1').update(input).digest('hex').slice(0, 16);
|
|
3132
|
+
}
|
|
3133
|
+
/**
|
|
3134
|
+
* Canonicalize the filter object into a stable string before hashing.
|
|
3135
|
+
* Sorts keys + drops undefined entries so equivalent filters
|
|
3136
|
+
* (`{state: 'open'}` and `{state: 'open', limit: undefined}`) hash to
|
|
3137
|
+
* the same key and share cached data.
|
|
3138
|
+
*/
|
|
3139
|
+
function canonicalizeFilter(filter) {
|
|
3140
|
+
const entries = Object.entries(filter)
|
|
3141
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
|
3142
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
3143
|
+
return JSON.stringify(entries);
|
|
3144
|
+
}
|
|
3145
|
+
function getCachePath(kind, repoPath, filter) {
|
|
3146
|
+
const repoHash = shortHash$1(repoPath);
|
|
3147
|
+
const filterHash = shortHash$1(canonicalizeFilter(filter));
|
|
3148
|
+
return path$1.join(resolveCacheDir$4(), `${kind}.${repoHash}.${filterHash}.json`);
|
|
3149
|
+
}
|
|
3150
|
+
function readCachedList(kind, repoPath, filter, ttlMs = DEFAULT_CACHE_TTL_MS) {
|
|
3151
|
+
try {
|
|
3152
|
+
const raw = fs$1.readFileSync(getCachePath(kind, repoPath, filter), 'utf8');
|
|
3153
|
+
const parsed = JSON.parse(raw);
|
|
3154
|
+
if (parsed.version !== CACHE_SCHEMA_VERSION$1)
|
|
3155
|
+
return undefined;
|
|
3156
|
+
if (!parsed.payload || parsed.payload.kind !== kind)
|
|
3157
|
+
return undefined;
|
|
3158
|
+
if (!Array.isArray(parsed.payload.items))
|
|
3159
|
+
return undefined;
|
|
3160
|
+
const savedAt = new Date(parsed.savedAt);
|
|
3161
|
+
if (Number.isNaN(savedAt.getTime()))
|
|
3162
|
+
return undefined;
|
|
3163
|
+
const ageMs = Date.now() - savedAt.getTime();
|
|
3164
|
+
return {
|
|
3165
|
+
payload: parsed.payload,
|
|
3166
|
+
savedAt,
|
|
3167
|
+
ageMs,
|
|
3168
|
+
fresh: ageMs < ttlMs,
|
|
3169
|
+
};
|
|
3170
|
+
}
|
|
3171
|
+
catch {
|
|
3172
|
+
return undefined;
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
function writeCachedList(repoPath, filter, payload) {
|
|
3176
|
+
const file = getCachePath(payload.kind, repoPath, filter);
|
|
3177
|
+
const envelope = {
|
|
3178
|
+
version: CACHE_SCHEMA_VERSION$1,
|
|
3179
|
+
savedAt: new Date().toISOString(),
|
|
3180
|
+
payload,
|
|
3181
|
+
};
|
|
3182
|
+
try {
|
|
3183
|
+
fs$1.mkdirSync(path$1.dirname(file), { recursive: true });
|
|
3184
|
+
fs$1.writeFileSync(file, JSON.stringify(envelope));
|
|
3185
|
+
}
|
|
3186
|
+
catch {
|
|
3187
|
+
// Best-effort persistence; swallow.
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
/**
|
|
3191
|
+
* Drop every cached file under the github cache directory. Used by
|
|
3192
|
+
* `--no-cache` / explicit purge commands. Best-effort: ENOENT on a
|
|
3193
|
+
* never-populated cache directory is treated as success.
|
|
3194
|
+
*/
|
|
3195
|
+
function clearGitHubListCache() {
|
|
3196
|
+
const dir = resolveCacheDir$4();
|
|
3197
|
+
let removed = 0;
|
|
3198
|
+
try {
|
|
3199
|
+
const entries = fs$1.readdirSync(dir);
|
|
3200
|
+
for (const entry of entries) {
|
|
3201
|
+
try {
|
|
3202
|
+
fs$1.unlinkSync(path$1.join(dir, entry));
|
|
3203
|
+
removed++;
|
|
3204
|
+
}
|
|
3205
|
+
catch {
|
|
3206
|
+
// Skip individual file failures; keep counting the rest.
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
catch {
|
|
3211
|
+
// Directory missing → nothing to clear, treat as success.
|
|
3212
|
+
}
|
|
3213
|
+
return { removed };
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3081
3216
|
/**
|
|
3082
3217
|
* Retrieves a SimpleGit instance for a repository.
|
|
3083
3218
|
*
|
|
@@ -3250,7 +3385,7 @@ async function promptLanguageSelection(logger) {
|
|
|
3250
3385
|
});
|
|
3251
3386
|
return picked;
|
|
3252
3387
|
}
|
|
3253
|
-
const handler$
|
|
3388
|
+
const handler$a = async (argv, logger) => {
|
|
3254
3389
|
const subcommand = argv.subcommand;
|
|
3255
3390
|
const positionalLanguages = (argv.languages || [])
|
|
3256
3391
|
.map((s) => s.trim())
|
|
@@ -3327,6 +3462,16 @@ const handler$8 = async (argv, logger) => {
|
|
|
3327
3462
|
}
|
|
3328
3463
|
return;
|
|
3329
3464
|
}
|
|
3465
|
+
if (subcommand === 'clear-github') {
|
|
3466
|
+
const result = clearGitHubListCache();
|
|
3467
|
+
if (result.removed === 0) {
|
|
3468
|
+
logger.log(chalk.dim('No GitHub triage cache to clear.'));
|
|
3469
|
+
return;
|
|
3470
|
+
}
|
|
3471
|
+
logger.log(chalk.green(`✓ cleared ${result.removed} cached GitHub triage list${result.removed === 1 ? '' : 's'}`));
|
|
3472
|
+
logger.log(chalk.dim('Cleared from ~/.cache/coco/github/'));
|
|
3473
|
+
return;
|
|
3474
|
+
}
|
|
3330
3475
|
if (subcommand === 'clear-parsers') {
|
|
3331
3476
|
const languages = listManifestLanguages();
|
|
3332
3477
|
let cleared = 0;
|
|
@@ -3345,15 +3490,15 @@ const handler$8 = async (argv, logger) => {
|
|
|
3345
3490
|
return;
|
|
3346
3491
|
}
|
|
3347
3492
|
logger.log(chalk.red(`Unknown cache subcommand: ${subcommand}`));
|
|
3348
|
-
logger.log(chalk.dim('Use one of: clear, info, parsers, prefetch, clear-parsers'));
|
|
3493
|
+
logger.log(chalk.dim('Use one of: clear, info, parsers, prefetch, clear-parsers, clear-github'));
|
|
3349
3494
|
process.exitCode = 1;
|
|
3350
3495
|
};
|
|
3351
3496
|
|
|
3352
3497
|
var cache = {
|
|
3353
|
-
command: command$
|
|
3498
|
+
command: command$a,
|
|
3354
3499
|
desc: 'Manage the diff-summary cache (clear, info)',
|
|
3355
|
-
builder: builder$
|
|
3356
|
-
handler: commandExecutor(handler$
|
|
3500
|
+
builder: builder$a,
|
|
3501
|
+
handler: commandExecutor(handler$a),
|
|
3357
3502
|
};
|
|
3358
3503
|
|
|
3359
3504
|
var util;
|
|
@@ -7142,11 +7287,11 @@ const ChangelogResponseSchema = objectType({
|
|
|
7142
7287
|
title: stringType(),
|
|
7143
7288
|
content: stringType(),
|
|
7144
7289
|
});
|
|
7145
|
-
const command$
|
|
7290
|
+
const command$9 = 'changelog';
|
|
7146
7291
|
/**
|
|
7147
7292
|
* Command line options via yargs
|
|
7148
7293
|
*/
|
|
7149
|
-
const options$
|
|
7294
|
+
const options$9 = {
|
|
7150
7295
|
range: {
|
|
7151
7296
|
type: 'string',
|
|
7152
7297
|
alias: 'r',
|
|
@@ -7193,8 +7338,8 @@ const options$7 = {
|
|
|
7193
7338
|
description: 'Toggle interactive mode',
|
|
7194
7339
|
},
|
|
7195
7340
|
};
|
|
7196
|
-
const builder$
|
|
7197
|
-
return yargs.options(options$
|
|
7341
|
+
const builder$9 = (yargs) => {
|
|
7342
|
+
return yargs.options(options$9).usage(getCommandUsageHeader(command$9));
|
|
7198
7343
|
};
|
|
7199
7344
|
|
|
7200
7345
|
/**
|
|
@@ -7398,6 +7543,12 @@ const OPENAI_DYNAMIC_DEFAULTS = {
|
|
|
7398
7543
|
cost: {
|
|
7399
7544
|
summarize: 'gpt-4.1-nano',
|
|
7400
7545
|
commit: 'gpt-4.1-mini',
|
|
7546
|
+
// `commitSplit` floors at mini even in cost mode. The split
|
|
7547
|
+
// planner emits structured JSON with strict cross-group
|
|
7548
|
+
// constraints (files appear exactly once, hunks fully cover or
|
|
7549
|
+
// not at all). Nano-class models fail those constraints often
|
|
7550
|
+
// enough that the cost win is eaten by the 3-retry budget.
|
|
7551
|
+
commitSplit: 'gpt-4.1-mini',
|
|
7401
7552
|
changelog: 'gpt-4.1-mini',
|
|
7402
7553
|
review: 'gpt-4.1-mini',
|
|
7403
7554
|
recap: 'gpt-4.1-nano',
|
|
@@ -7407,6 +7558,7 @@ const OPENAI_DYNAMIC_DEFAULTS = {
|
|
|
7407
7558
|
balanced: {
|
|
7408
7559
|
summarize: 'gpt-4.1-mini',
|
|
7409
7560
|
commit: 'gpt-4.1-mini',
|
|
7561
|
+
commitSplit: 'gpt-4.1',
|
|
7410
7562
|
changelog: 'gpt-4.1',
|
|
7411
7563
|
review: 'gpt-4.1',
|
|
7412
7564
|
recap: 'gpt-4.1-mini',
|
|
@@ -7416,6 +7568,7 @@ const OPENAI_DYNAMIC_DEFAULTS = {
|
|
|
7416
7568
|
quality: {
|
|
7417
7569
|
summarize: 'gpt-4.1-mini',
|
|
7418
7570
|
commit: 'gpt-4.1',
|
|
7571
|
+
commitSplit: 'gpt-4.1',
|
|
7419
7572
|
changelog: 'gpt-4.1',
|
|
7420
7573
|
review: 'gpt-4.1',
|
|
7421
7574
|
recap: 'gpt-4.1',
|
|
@@ -7427,6 +7580,8 @@ const ANTHROPIC_DYNAMIC_DEFAULTS = {
|
|
|
7427
7580
|
cost: {
|
|
7428
7581
|
summarize: 'claude-3-5-haiku-latest',
|
|
7429
7582
|
commit: 'claude-3-5-haiku-latest',
|
|
7583
|
+
// Floor at sonnet — see note on OpenAI commitSplit above.
|
|
7584
|
+
commitSplit: 'claude-3-5-sonnet-latest',
|
|
7430
7585
|
changelog: 'claude-3-5-sonnet-latest',
|
|
7431
7586
|
review: 'claude-3-5-sonnet-latest',
|
|
7432
7587
|
recap: 'claude-3-5-haiku-latest',
|
|
@@ -7436,6 +7591,7 @@ const ANTHROPIC_DYNAMIC_DEFAULTS = {
|
|
|
7436
7591
|
balanced: {
|
|
7437
7592
|
summarize: 'claude-3-5-haiku-latest',
|
|
7438
7593
|
commit: 'claude-3-5-sonnet-latest',
|
|
7594
|
+
commitSplit: 'claude-3-7-sonnet-latest',
|
|
7439
7595
|
changelog: 'claude-3-5-sonnet-latest',
|
|
7440
7596
|
review: 'claude-3-7-sonnet-latest',
|
|
7441
7597
|
recap: 'claude-3-5-sonnet-latest',
|
|
@@ -7445,6 +7601,7 @@ const ANTHROPIC_DYNAMIC_DEFAULTS = {
|
|
|
7445
7601
|
quality: {
|
|
7446
7602
|
summarize: 'claude-3-5-sonnet-latest',
|
|
7447
7603
|
commit: 'claude-3-7-sonnet-latest',
|
|
7604
|
+
commitSplit: 'claude-sonnet-4-0',
|
|
7448
7605
|
changelog: 'claude-3-7-sonnet-latest',
|
|
7449
7606
|
review: 'claude-sonnet-4-0',
|
|
7450
7607
|
recap: 'claude-3-7-sonnet-latest',
|
|
@@ -7456,6 +7613,8 @@ const OLLAMA_DYNAMIC_DEFAULTS = {
|
|
|
7456
7613
|
cost: {
|
|
7457
7614
|
summarize: 'llama3.2:3b',
|
|
7458
7615
|
commit: 'llama3.1:8b',
|
|
7616
|
+
// Floor at the coder-tuned 14b — see note on OpenAI commitSplit above.
|
|
7617
|
+
commitSplit: 'qwen2.5-coder:14b',
|
|
7459
7618
|
changelog: 'llama3.1:8b',
|
|
7460
7619
|
review: 'qwen2.5-coder:7b',
|
|
7461
7620
|
recap: 'llama3.2:3b',
|
|
@@ -7465,6 +7624,7 @@ const OLLAMA_DYNAMIC_DEFAULTS = {
|
|
|
7465
7624
|
balanced: {
|
|
7466
7625
|
summarize: 'llama3.1:8b',
|
|
7467
7626
|
commit: 'qwen2.5-coder:14b',
|
|
7627
|
+
commitSplit: 'qwen2.5-coder:32b',
|
|
7468
7628
|
changelog: 'qwen2.5-coder:14b',
|
|
7469
7629
|
review: 'qwen2.5-coder:32b',
|
|
7470
7630
|
recap: 'llama3.1:8b',
|
|
@@ -7474,6 +7634,7 @@ const OLLAMA_DYNAMIC_DEFAULTS = {
|
|
|
7474
7634
|
quality: {
|
|
7475
7635
|
summarize: 'qwen2.5-coder:14b',
|
|
7476
7636
|
commit: 'qwen2.5-coder:32b',
|
|
7637
|
+
commitSplit: 'qwen2.5-coder:32b',
|
|
7477
7638
|
changelog: 'qwen2.5-coder:32b',
|
|
7478
7639
|
review: 'qwen2.5-coder:32b',
|
|
7479
7640
|
recap: 'qwen2.5-coder:14b',
|
|
@@ -7489,6 +7650,7 @@ const DYNAMIC_DEFAULTS = {
|
|
|
7489
7650
|
const DYNAMIC_MODEL_TASKS = [
|
|
7490
7651
|
'summarize',
|
|
7491
7652
|
'commit',
|
|
7653
|
+
'commitSplit',
|
|
7492
7654
|
'changelog',
|
|
7493
7655
|
'review',
|
|
7494
7656
|
'recap',
|
|
@@ -9202,7 +9364,9 @@ function summarizeRustStructuralDiff(fileDiff) {
|
|
|
9202
9364
|
* on every diff — the regex fallback is the correct steady state
|
|
9203
9365
|
* in that case.
|
|
9204
9366
|
*/
|
|
9205
|
-
|
|
9367
|
+
async function loadTreeSitterModule() {
|
|
9368
|
+
return import('web-tree-sitter');
|
|
9369
|
+
}
|
|
9206
9370
|
/**
|
|
9207
9371
|
* Locate the bundled .wasm files. Tries the dist layout first (the
|
|
9208
9372
|
* common case for installed packages), then falls back to the
|
|
@@ -9283,7 +9447,7 @@ async function ensureRuntime() {
|
|
|
9283
9447
|
return undefined;
|
|
9284
9448
|
let mod;
|
|
9285
9449
|
try {
|
|
9286
|
-
mod = await
|
|
9450
|
+
mod = await loadTreeSitterModule();
|
|
9287
9451
|
}
|
|
9288
9452
|
catch {
|
|
9289
9453
|
return undefined;
|
|
@@ -14034,7 +14198,7 @@ async function processInWaves(items, processor, maxConcurrent = 6) {
|
|
|
14034
14198
|
}
|
|
14035
14199
|
return results;
|
|
14036
14200
|
}
|
|
14037
|
-
const handler$
|
|
14201
|
+
const handler$9 = async (argv, logger) => {
|
|
14038
14202
|
const git = applyRepoFlag(argv);
|
|
14039
14203
|
const config = loadConfig(argv);
|
|
14040
14204
|
const key = getApiKeyForModel(config);
|
|
@@ -14259,11 +14423,11 @@ const handler$7 = async (argv, logger) => {
|
|
|
14259
14423
|
};
|
|
14260
14424
|
|
|
14261
14425
|
var changelog = {
|
|
14262
|
-
command: command$
|
|
14426
|
+
command: command$9,
|
|
14263
14427
|
desc: 'Generate a changelog from current or target branch, provided commit range, or since the last tag.',
|
|
14264
|
-
builder: builder$
|
|
14265
|
-
handler: commandExecutor(handler$
|
|
14266
|
-
options: options$
|
|
14428
|
+
builder: builder$9,
|
|
14429
|
+
handler: commandExecutor(handler$9),
|
|
14430
|
+
options: options$9,
|
|
14267
14431
|
};
|
|
14268
14432
|
|
|
14269
14433
|
const conventionalTypeRegex = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:/;
|
|
@@ -14280,11 +14444,11 @@ const ConventionalCommitMessageResponseSchema = objectType({
|
|
|
14280
14444
|
body: stringType().describe("Body of the commit message")
|
|
14281
14445
|
// .max(280, "Body must be 280 characters or less"),
|
|
14282
14446
|
}).describe("Object with Conventional Commit message 'title' and 'body' adhering to Conventional Commits specification");
|
|
14283
|
-
const command$
|
|
14447
|
+
const command$8 = 'commit';
|
|
14284
14448
|
/**
|
|
14285
14449
|
* Command line options via yargs
|
|
14286
14450
|
*/
|
|
14287
|
-
const options$
|
|
14451
|
+
const options$8 = {
|
|
14288
14452
|
i: {
|
|
14289
14453
|
alias: 'interactive',
|
|
14290
14454
|
description: 'Toggle interactive mode',
|
|
@@ -14356,8 +14520,8 @@ const options$6 = {
|
|
|
14356
14520
|
default: false,
|
|
14357
14521
|
},
|
|
14358
14522
|
};
|
|
14359
|
-
const builder$
|
|
14360
|
-
return yargs.options(options$
|
|
14523
|
+
const builder$8 = (yargs) => {
|
|
14524
|
+
return yargs.options(options$8).usage(getCommandUsageHeader(command$8));
|
|
14361
14525
|
};
|
|
14362
14526
|
|
|
14363
14527
|
/**
|
|
@@ -14971,6 +15135,73 @@ function formatPlanValidationIssuesError(issues) {
|
|
|
14971
15135
|
.filter(Boolean)
|
|
14972
15136
|
.join('; ');
|
|
14973
15137
|
}
|
|
15138
|
+
/**
|
|
15139
|
+
* Salvage a plan that lists the same file in `files[]` of more than
|
|
15140
|
+
* one group. Weaker models (e.g. `gpt-4.1-nano`) hit this often when
|
|
15141
|
+
* the staged set has many files — they re-assert files across groups
|
|
15142
|
+
* even though the prompt forbids it.
|
|
15143
|
+
*
|
|
15144
|
+
* Recovery: walk groups in plan order, keep the FIRST occurrence of
|
|
15145
|
+
* each file path, drop subsequent occurrences. Plan-order is used
|
|
15146
|
+
* because the LLM tends to put the most thematically-correct
|
|
15147
|
+
* assignment in the first group it considered the file for; later
|
|
15148
|
+
* appearances are usually accidental re-emissions.
|
|
15149
|
+
*
|
|
15150
|
+
* If dropping a duplicate leaves a group with empty `files[]` AND
|
|
15151
|
+
* empty `hunks[]`, `dropEmptyGroups` (run last) filters it out so
|
|
15152
|
+
* the apply path never sees a group with nothing to commit.
|
|
15153
|
+
*
|
|
15154
|
+
* Returns a NEW plan object — original is not mutated.
|
|
15155
|
+
*/
|
|
15156
|
+
function rescueDuplicateFiles(plan) {
|
|
15157
|
+
const seen = new Set();
|
|
15158
|
+
let mutated = false;
|
|
15159
|
+
const rescuedGroups = plan.groups.map((group) => {
|
|
15160
|
+
const keptFiles = [];
|
|
15161
|
+
for (const file of group.files || []) {
|
|
15162
|
+
if (seen.has(file)) {
|
|
15163
|
+
mutated = true;
|
|
15164
|
+
continue;
|
|
15165
|
+
}
|
|
15166
|
+
seen.add(file);
|
|
15167
|
+
keptFiles.push(file);
|
|
15168
|
+
}
|
|
15169
|
+
return { ...group, files: keptFiles };
|
|
15170
|
+
});
|
|
15171
|
+
if (!mutated)
|
|
15172
|
+
return plan;
|
|
15173
|
+
return { ...plan, groups: rescuedGroups };
|
|
15174
|
+
}
|
|
15175
|
+
/**
|
|
15176
|
+
* Salvage a plan that lists the same hunk ID in `hunks[]` of more
|
|
15177
|
+
* than one group. Same failure mode as duplicate files but for the
|
|
15178
|
+
* hunk-level assignments.
|
|
15179
|
+
*
|
|
15180
|
+
* Recovery: keep the FIRST occurrence of each hunk ID across groups
|
|
15181
|
+
* (plan order), drop subsequent ones. `dropEmptyGroups` handles any
|
|
15182
|
+
* group left fully empty.
|
|
15183
|
+
*
|
|
15184
|
+
* Returns a NEW plan object — original is not mutated.
|
|
15185
|
+
*/
|
|
15186
|
+
function rescueDuplicateHunks(plan) {
|
|
15187
|
+
const seen = new Set();
|
|
15188
|
+
let mutated = false;
|
|
15189
|
+
const rescuedGroups = plan.groups.map((group) => {
|
|
15190
|
+
const keptHunks = [];
|
|
15191
|
+
for (const hunkId of group.hunks || []) {
|
|
15192
|
+
if (seen.has(hunkId)) {
|
|
15193
|
+
mutated = true;
|
|
15194
|
+
continue;
|
|
15195
|
+
}
|
|
15196
|
+
seen.add(hunkId);
|
|
15197
|
+
keptHunks.push(hunkId);
|
|
15198
|
+
}
|
|
15199
|
+
return { ...group, hunks: keptHunks };
|
|
15200
|
+
});
|
|
15201
|
+
if (!mutated)
|
|
15202
|
+
return plan;
|
|
15203
|
+
return { ...plan, groups: rescuedGroups };
|
|
15204
|
+
}
|
|
14974
15205
|
/**
|
|
14975
15206
|
* Salvage a plan that references hunk IDs not in the inventory by
|
|
14976
15207
|
* promoting those hunks to file-level assignments. The LLM commonly
|
|
@@ -15234,31 +15465,39 @@ async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged
|
|
|
15234
15465
|
});
|
|
15235
15466
|
// Rescue passes. Run in order — order matters:
|
|
15236
15467
|
//
|
|
15237
|
-
// 1.
|
|
15468
|
+
// 1. rescueDuplicateFiles / rescueDuplicateHunks: weak models
|
|
15469
|
+
// (e.g. gpt-4.1-nano) repeatedly re-assert the same file or
|
|
15470
|
+
// hunk across multiple groups. Keep the first occurrence,
|
|
15471
|
+
// drop the rest. Run FIRST so downstream rescues see a
|
|
15472
|
+
// deduplicated plan and don't re-process redundant entries.
|
|
15473
|
+
//
|
|
15474
|
+
// 2. rescuePhantomHunks (#918): LLM commonly emits "file::hunk-1"
|
|
15238
15475
|
// against an empty inventory (all staged files are new).
|
|
15239
15476
|
// Promote those to file-level assignments.
|
|
15240
15477
|
//
|
|
15241
|
-
//
|
|
15478
|
+
// 3. rescueMixedFiles (#919): LLM commonly puts a file in
|
|
15242
15479
|
// `files[]` of group A AND uses its hunks in `hunks[]` of
|
|
15243
15480
|
// group B. Drop the hunks (the file-level claim is more
|
|
15244
15481
|
// specific). Must run AFTER phantom-hunk rescue because the
|
|
15245
15482
|
// rescue itself can create mixed-files situations.
|
|
15246
15483
|
//
|
|
15247
|
-
//
|
|
15484
|
+
// 4. rescueMissingFiles (#921): LLM occasionally forgets a
|
|
15248
15485
|
// staged file across every group. Append a synthetic "misc"
|
|
15249
15486
|
// group so the plan covers every staged file.
|
|
15250
15487
|
//
|
|
15251
|
-
//
|
|
15252
|
-
// empty files[] AND empty hunks[] when
|
|
15253
|
-
//
|
|
15254
|
-
//
|
|
15255
|
-
//
|
|
15256
|
-
//
|
|
15257
|
-
//
|
|
15488
|
+
// 5. dropEmptyGroups: earlier rescues can leave a group with
|
|
15489
|
+
// empty files[] AND empty hunks[] when their only contents
|
|
15490
|
+
// got dropped. Apply-time, an empty group means `git commit`
|
|
15491
|
+
// with nothing staged, which throws and aborts mid-loop
|
|
15492
|
+
// after the up-front `git reset` has already wiped the
|
|
15493
|
+
// index. Filter the empty groups out LAST so the apply path
|
|
15494
|
+
// can't hit them.
|
|
15258
15495
|
//
|
|
15259
15496
|
// All rescues are no-ops when there's nothing to rescue, so
|
|
15260
15497
|
// running them unconditionally costs nothing on healthy plans.
|
|
15261
|
-
const
|
|
15498
|
+
const dedupedFiles = rescueDuplicateFiles(rawPlan);
|
|
15499
|
+
const dedupedHunks = rescueDuplicateHunks(dedupedFiles);
|
|
15500
|
+
const phantomRescued = rescuePhantomHunks(dedupedHunks, staged, hunkInventory);
|
|
15262
15501
|
const mixedRescued = rescueMixedFiles(phantomRescued, hunkInventory);
|
|
15263
15502
|
const missingRescued = rescueMissingFiles(mixedRescued, staged, hunkInventory);
|
|
15264
15503
|
const plan = dropEmptyGroups(missingRescued);
|
|
@@ -15607,7 +15846,7 @@ async function applyCommitSplitPlan({ plan, changes, hunkInventory, git, logger,
|
|
|
15607
15846
|
* and apply would risk drift (small LLM nondeterminism, staged-state
|
|
15608
15847
|
* changes the user didn't intend, etc.).
|
|
15609
15848
|
*/
|
|
15610
|
-
async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, llm, }) {
|
|
15849
|
+
async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, llm, planLlm, planService, }) {
|
|
15611
15850
|
const changes = await getChanges({
|
|
15612
15851
|
git,
|
|
15613
15852
|
options: {
|
|
@@ -15680,8 +15919,10 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
|
|
|
15680
15919
|
// the conventional-commits ruleset (or generic style).
|
|
15681
15920
|
}
|
|
15682
15921
|
}
|
|
15922
|
+
const resolvedPlanLlm = planLlm ?? llm;
|
|
15923
|
+
const resolvedPlanModel = planService?.model ?? config.service.model;
|
|
15683
15924
|
const { plan } = await generateValidatedCommitSplitPlan({
|
|
15684
|
-
llm,
|
|
15925
|
+
llm: resolvedPlanLlm,
|
|
15685
15926
|
prompt: COMMIT_SPLIT_PROMPT,
|
|
15686
15927
|
variables: {
|
|
15687
15928
|
file_inventory: fileInventory,
|
|
@@ -15699,15 +15940,24 @@ async function prepareCommitSplitPlan({ argv, config, git, logger, tokenizer, ll
|
|
|
15699
15940
|
metadata: {
|
|
15700
15941
|
command: 'commit',
|
|
15701
15942
|
provider: config.service.provider,
|
|
15702
|
-
model: String(
|
|
15943
|
+
model: String(resolvedPlanModel),
|
|
15703
15944
|
conventional: useConventional,
|
|
15704
15945
|
},
|
|
15705
15946
|
maxAttempts: DEFAULT_MAX_PLAN_ATTEMPTS,
|
|
15706
15947
|
});
|
|
15707
15948
|
return { plan, context: { changes, hunkInventory } };
|
|
15708
15949
|
}
|
|
15709
|
-
async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, }) {
|
|
15710
|
-
const result = await prepareCommitSplitPlan({
|
|
15950
|
+
async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, planLlm, planService, }) {
|
|
15951
|
+
const result = await prepareCommitSplitPlan({
|
|
15952
|
+
argv,
|
|
15953
|
+
config,
|
|
15954
|
+
git,
|
|
15955
|
+
logger,
|
|
15956
|
+
tokenizer,
|
|
15957
|
+
llm,
|
|
15958
|
+
planLlm,
|
|
15959
|
+
planService,
|
|
15960
|
+
});
|
|
15711
15961
|
if ('empty' in result) {
|
|
15712
15962
|
return 'No staged changes found.';
|
|
15713
15963
|
}
|
|
@@ -15726,13 +15976,14 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, })
|
|
|
15726
15976
|
return formatCommitSplitPlan(plan);
|
|
15727
15977
|
}
|
|
15728
15978
|
|
|
15729
|
-
const handler$
|
|
15979
|
+
const handler$8 = async (argv, logger) => {
|
|
15730
15980
|
const git = applyRepoFlag(argv);
|
|
15731
15981
|
const config = loadConfig(argv);
|
|
15732
15982
|
const key = getApiKeyForModel(config);
|
|
15733
15983
|
const { provider } = getModelAndProviderFromConfig(config);
|
|
15734
15984
|
const commitService = resolveDynamicService(config, 'commit');
|
|
15735
15985
|
const summaryService = resolveDynamicService(config, 'summarize');
|
|
15986
|
+
const splitService = resolveDynamicService(config, 'commitSplit');
|
|
15736
15987
|
const model = commitService.model;
|
|
15737
15988
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
15738
15989
|
logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
|
|
@@ -15741,6 +15992,12 @@ const handler$6 = async (argv, logger) => {
|
|
|
15741
15992
|
const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
|
|
15742
15993
|
const llm = getLlm(provider, model, { ...config, service: commitService });
|
|
15743
15994
|
const summaryLlm = getLlm(provider, summaryService.model, { ...config, service: summaryService });
|
|
15995
|
+
// The split planner uses a dedicated LLM because its output schema
|
|
15996
|
+
// is far stricter than the regular commit-message path (every staged
|
|
15997
|
+
// file claimed exactly once, no cross-group duplication, hunk-vs-
|
|
15998
|
+
// file mode exclusivity). Weak models fail those constraints often
|
|
15999
|
+
// enough that the `cost` preference floors `commitSplit` at mini.
|
|
16000
|
+
const splitLlm = getLlm(provider, splitService.model, { ...config, service: splitService });
|
|
15744
16001
|
const INTERACTIVE = argv.interactive || isInteractive(config);
|
|
15745
16002
|
if (INTERACTIVE) {
|
|
15746
16003
|
if (!config.hideCocoBanner) {
|
|
@@ -15759,6 +16016,7 @@ const handler$6 = async (argv, logger) => {
|
|
|
15759
16016
|
color: 'green',
|
|
15760
16017
|
});
|
|
15761
16018
|
if (isCommitSplitCommand(argv)) {
|
|
16019
|
+
logger.verbose(`→ split planner: ${provider} (${splitService.model})`, { color: 'green' });
|
|
15762
16020
|
const splitResult = await handleCommitSplit({
|
|
15763
16021
|
argv,
|
|
15764
16022
|
config,
|
|
@@ -15766,6 +16024,8 @@ const handler$6 = async (argv, logger) => {
|
|
|
15766
16024
|
logger,
|
|
15767
16025
|
tokenizer,
|
|
15768
16026
|
llm,
|
|
16027
|
+
planLlm: splitLlm,
|
|
16028
|
+
planService: splitService,
|
|
15769
16029
|
});
|
|
15770
16030
|
const splitMode = INTERACTIVE ? 'interactive' : (config.mode || 'stdout');
|
|
15771
16031
|
await handleResult({
|
|
@@ -16191,23 +16451,23 @@ IMPORTANT RULES:
|
|
|
16191
16451
|
};
|
|
16192
16452
|
|
|
16193
16453
|
var commit = {
|
|
16194
|
-
command: command$
|
|
16454
|
+
command: command$8,
|
|
16195
16455
|
desc: 'Summarize the staged changes in a commit message.',
|
|
16196
|
-
builder: builder$
|
|
16197
|
-
handler: commandExecutor(handler$
|
|
16198
|
-
options: options$
|
|
16456
|
+
builder: builder$8,
|
|
16457
|
+
handler: commandExecutor(handler$8),
|
|
16458
|
+
options: options$8,
|
|
16199
16459
|
};
|
|
16200
16460
|
|
|
16201
|
-
const command$
|
|
16202
|
-
const options$
|
|
16461
|
+
const command$7 = 'doctor';
|
|
16462
|
+
const options$7 = {
|
|
16203
16463
|
fix: {
|
|
16204
16464
|
description: 'Attempt to auto-fix detected issues and write the updated config',
|
|
16205
16465
|
type: 'boolean',
|
|
16206
16466
|
default: false,
|
|
16207
16467
|
},
|
|
16208
16468
|
};
|
|
16209
|
-
const builder$
|
|
16210
|
-
return yargs.options(options$
|
|
16469
|
+
const builder$7 = (yargs) => {
|
|
16470
|
+
return yargs.options(options$7).usage(getCommandUsageHeader(command$7));
|
|
16211
16471
|
};
|
|
16212
16472
|
|
|
16213
16473
|
/**
|
|
@@ -16486,7 +16746,7 @@ function formatSourceInfo(sources) {
|
|
|
16486
16746
|
}
|
|
16487
16747
|
return lines;
|
|
16488
16748
|
}
|
|
16489
|
-
const handler$
|
|
16749
|
+
const handler$7 = async (argv, logger) => {
|
|
16490
16750
|
// Honor the global --repo flag so `coco doctor --repo <X>`
|
|
16491
16751
|
// inspects X's config sources, not the launcher's cwd. The chdir
|
|
16492
16752
|
// has to happen before loadConfig so `findUp` walks the targeted
|
|
@@ -16593,17 +16853,17 @@ const handler$5 = async (argv, logger) => {
|
|
|
16593
16853
|
};
|
|
16594
16854
|
|
|
16595
16855
|
var doctor = {
|
|
16596
|
-
command: command$
|
|
16856
|
+
command: command$7,
|
|
16597
16857
|
desc: 'Check your coco configuration for common issues and suggest fixes',
|
|
16598
|
-
builder: builder$
|
|
16599
|
-
handler: commandExecutor(handler$
|
|
16858
|
+
builder: builder$7,
|
|
16859
|
+
handler: commandExecutor(handler$7),
|
|
16600
16860
|
};
|
|
16601
16861
|
|
|
16602
|
-
const command$
|
|
16862
|
+
const command$6 = 'init';
|
|
16603
16863
|
/**
|
|
16604
16864
|
* Command line options via yargs
|
|
16605
16865
|
*/
|
|
16606
|
-
const options$
|
|
16866
|
+
const options$6 = {
|
|
16607
16867
|
scope: {
|
|
16608
16868
|
type: 'string',
|
|
16609
16869
|
description: 'configure coco for the current user or project?',
|
|
@@ -16615,8 +16875,8 @@ const options$4 = {
|
|
|
16615
16875
|
default: false,
|
|
16616
16876
|
},
|
|
16617
16877
|
};
|
|
16618
|
-
const builder$
|
|
16619
|
-
return yargs.options(options$
|
|
16878
|
+
const builder$6 = (yargs) => {
|
|
16879
|
+
return yargs.options(options$6).usage(getCommandUsageHeader(command$6));
|
|
16620
16880
|
};
|
|
16621
16881
|
|
|
16622
16882
|
/**
|
|
@@ -16971,7 +17231,7 @@ const questions = {
|
|
|
16971
17231
|
}),
|
|
16972
17232
|
};
|
|
16973
17233
|
|
|
16974
|
-
const handler$
|
|
17234
|
+
const handler$6 = async (argv, logger) => {
|
|
16975
17235
|
// Honor the global --repo flag so `coco init --repo <X> --scope project`
|
|
16976
17236
|
// writes the project config to X, not the launcher's cwd. The
|
|
16977
17237
|
// chdir has to happen before getProjectConfigFilePath resolves
|
|
@@ -17155,15 +17415,417 @@ async function installCommitlintPackages(scope, logger) {
|
|
|
17155
17415
|
}
|
|
17156
17416
|
|
|
17157
17417
|
var init = {
|
|
17158
|
-
command: command$
|
|
17418
|
+
command: command$6,
|
|
17159
17419
|
desc: 'install & configure coco globally or for the current project',
|
|
17160
|
-
builder: builder$
|
|
17161
|
-
handler: commandExecutor(handler$
|
|
17162
|
-
options: options$
|
|
17420
|
+
builder: builder$6,
|
|
17421
|
+
handler: commandExecutor(handler$6),
|
|
17422
|
+
options: options$6,
|
|
17163
17423
|
};
|
|
17164
17424
|
|
|
17165
|
-
const command$
|
|
17166
|
-
const options$
|
|
17425
|
+
const command$5 = 'issues';
|
|
17426
|
+
const options$5 = {
|
|
17427
|
+
state: {
|
|
17428
|
+
type: 'string',
|
|
17429
|
+
choices: ['open', 'closed', 'all'],
|
|
17430
|
+
description: 'Filter by issue state.',
|
|
17431
|
+
default: 'open',
|
|
17432
|
+
},
|
|
17433
|
+
assignee: {
|
|
17434
|
+
type: 'string',
|
|
17435
|
+
description: 'Filter by assignee GitHub login (or `@me`).',
|
|
17436
|
+
},
|
|
17437
|
+
author: {
|
|
17438
|
+
type: 'string',
|
|
17439
|
+
description: 'Filter by author GitHub login.',
|
|
17440
|
+
},
|
|
17441
|
+
label: {
|
|
17442
|
+
type: 'string',
|
|
17443
|
+
description: 'Filter by label name (comma-separated for AND).',
|
|
17444
|
+
},
|
|
17445
|
+
search: {
|
|
17446
|
+
type: 'string',
|
|
17447
|
+
description: 'Free-form GitHub issue search query.',
|
|
17448
|
+
},
|
|
17449
|
+
mine: {
|
|
17450
|
+
type: 'boolean',
|
|
17451
|
+
description: 'Shorthand for `--assignee @me`.',
|
|
17452
|
+
default: false,
|
|
17453
|
+
},
|
|
17454
|
+
limit: {
|
|
17455
|
+
type: 'number',
|
|
17456
|
+
description: 'Maximum rows to fetch. Defaults to `gh`\'s own default.',
|
|
17457
|
+
},
|
|
17458
|
+
json: {
|
|
17459
|
+
type: 'boolean',
|
|
17460
|
+
description: 'Print machine-readable JSON instead of a formatted table.',
|
|
17461
|
+
default: false,
|
|
17462
|
+
},
|
|
17463
|
+
refresh: {
|
|
17464
|
+
type: 'boolean',
|
|
17465
|
+
description: 'Force fresh `gh` call (writes through to cache).',
|
|
17466
|
+
default: false,
|
|
17467
|
+
},
|
|
17468
|
+
'no-cache': {
|
|
17469
|
+
type: 'boolean',
|
|
17470
|
+
description: 'Skip the disk cache entirely (no read, no write).',
|
|
17471
|
+
default: false,
|
|
17472
|
+
},
|
|
17473
|
+
};
|
|
17474
|
+
const builder$5 = (yargs) => {
|
|
17475
|
+
return yargs.options(options$5).usage(getCommandUsageHeader(command$5));
|
|
17476
|
+
};
|
|
17477
|
+
|
|
17478
|
+
const execFileAsync = promisify(execFile);
|
|
17479
|
+
function parseGitHubRemoteUrl$1(url) {
|
|
17480
|
+
const trimmed = url.trim().replace(/\.git$/, '');
|
|
17481
|
+
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/(.+)$/);
|
|
17482
|
+
const httpsMatch = trimmed.match(/^https:\/\/github\.com\/([^/]+)\/(.+)$/);
|
|
17483
|
+
const match = sshMatch || httpsMatch;
|
|
17484
|
+
if (!match) {
|
|
17485
|
+
return undefined;
|
|
17486
|
+
}
|
|
17487
|
+
return {
|
|
17488
|
+
owner: match[1],
|
|
17489
|
+
name: match[2],
|
|
17490
|
+
};
|
|
17491
|
+
}
|
|
17492
|
+
async function defaultGhRunner(args) {
|
|
17493
|
+
const result = await execFileAsync('gh', args);
|
|
17494
|
+
return result.stdout;
|
|
17495
|
+
}
|
|
17496
|
+
async function getGitHubRepository(git) {
|
|
17497
|
+
const remotes = await git.getRemotes(true);
|
|
17498
|
+
const remote = remotes.find((entry) => entry.name === 'origin') || remotes[0];
|
|
17499
|
+
const url = remote?.refs.push || remote?.refs.fetch;
|
|
17500
|
+
return url ? parseGitHubRemoteUrl$1(url) : undefined;
|
|
17501
|
+
}
|
|
17502
|
+
/**
|
|
17503
|
+
* Probe `gh auth status` and return whether the GitHub CLI is
|
|
17504
|
+
* installed AND authenticated. Used by every data fetcher to short-
|
|
17505
|
+
* circuit before issuing real API calls — keeps the failure-mode
|
|
17506
|
+
* messaging consistent ("CLI missing or not authenticated") instead
|
|
17507
|
+
* of leaking through as a generic spawn error.
|
|
17508
|
+
*/
|
|
17509
|
+
async function isGhAuthenticated(runner) {
|
|
17510
|
+
try {
|
|
17511
|
+
await runner(['auth', 'status', '--hostname', 'github.com']);
|
|
17512
|
+
return true;
|
|
17513
|
+
}
|
|
17514
|
+
catch {
|
|
17515
|
+
return false;
|
|
17516
|
+
}
|
|
17517
|
+
}
|
|
17518
|
+
|
|
17519
|
+
/**
|
|
17520
|
+
* Pad a (possibly already-colored) string to `width` visible columns.
|
|
17521
|
+
* We can't naively `String#padEnd` after coloring because ANSI escape
|
|
17522
|
+
* codes inflate `.length` past the visible width. The pattern here is
|
|
17523
|
+
* "measure the plain string, color last" — every formatter below
|
|
17524
|
+
* computes column widths from raw values and passes the visible
|
|
17525
|
+
* length explicitly so `padToVisible` only needs to add spaces.
|
|
17526
|
+
*/
|
|
17527
|
+
function padToVisible(colored, visibleLength, width) {
|
|
17528
|
+
if (visibleLength >= width)
|
|
17529
|
+
return colored;
|
|
17530
|
+
return colored + ' '.repeat(width - visibleLength);
|
|
17531
|
+
}
|
|
17532
|
+
const STATE_COLORS = {
|
|
17533
|
+
OPEN: chalk.green,
|
|
17534
|
+
CLOSED: chalk.red,
|
|
17535
|
+
MERGED: chalk.magenta,
|
|
17536
|
+
};
|
|
17537
|
+
function colorState(state) {
|
|
17538
|
+
const fn = STATE_COLORS[state.toUpperCase()] || chalk.dim;
|
|
17539
|
+
return fn(state.toLowerCase());
|
|
17540
|
+
}
|
|
17541
|
+
function formatLabels(labels) {
|
|
17542
|
+
if (!labels || labels.length === 0)
|
|
17543
|
+
return '';
|
|
17544
|
+
return chalk.dim(labels.map((l) => `[${l}]`).join(' '));
|
|
17545
|
+
}
|
|
17546
|
+
function formatReviewDecision(decision) {
|
|
17547
|
+
if (!decision)
|
|
17548
|
+
return ' ';
|
|
17549
|
+
switch (decision) {
|
|
17550
|
+
case 'APPROVED':
|
|
17551
|
+
return chalk.green('✓');
|
|
17552
|
+
case 'CHANGES_REQUESTED':
|
|
17553
|
+
return chalk.red('✗');
|
|
17554
|
+
case 'REVIEW_REQUIRED':
|
|
17555
|
+
return chalk.yellow('?');
|
|
17556
|
+
default:
|
|
17557
|
+
return chalk.dim(decision.slice(0, 1));
|
|
17558
|
+
}
|
|
17559
|
+
}
|
|
17560
|
+
function formatMergeable(mergeable, mergeStateStatus) {
|
|
17561
|
+
if (mergeStateStatus === 'CLEAN')
|
|
17562
|
+
return chalk.green('●');
|
|
17563
|
+
if (mergeStateStatus === 'BLOCKED')
|
|
17564
|
+
return chalk.yellow('●');
|
|
17565
|
+
if (mergeStateStatus === 'DIRTY' || mergeable === 'CONFLICTING')
|
|
17566
|
+
return chalk.red('●');
|
|
17567
|
+
if (mergeStateStatus === 'BEHIND')
|
|
17568
|
+
return chalk.cyan('●');
|
|
17569
|
+
if (mergeStateStatus === 'UNSTABLE')
|
|
17570
|
+
return chalk.yellow('●');
|
|
17571
|
+
return chalk.dim('●');
|
|
17572
|
+
}
|
|
17573
|
+
function formatIssueList(items) {
|
|
17574
|
+
if (items.length === 0) {
|
|
17575
|
+
return chalk.dim('No issues match the current filter.');
|
|
17576
|
+
}
|
|
17577
|
+
const numberWidth = Math.max(...items.map((i) => `#${i.number}`.length));
|
|
17578
|
+
const authorWidth = Math.max(...items.map((i) => (i.author ? i.author.length : 0)), 1);
|
|
17579
|
+
const stateWidth = 6;
|
|
17580
|
+
return items
|
|
17581
|
+
.map((issue) => {
|
|
17582
|
+
const numRaw = `#${issue.number}`;
|
|
17583
|
+
const num = padToVisible(chalk.dim(numRaw), numRaw.length, numberWidth);
|
|
17584
|
+
const stateRaw = issue.state.toLowerCase();
|
|
17585
|
+
const state = padToVisible(colorState(issue.state), stateRaw.length, stateWidth);
|
|
17586
|
+
const authorRaw = issue.author || '';
|
|
17587
|
+
const author = padToVisible(chalk.cyan(authorRaw), authorRaw.length, authorWidth);
|
|
17588
|
+
const comments = typeof issue.comments === 'number' && issue.comments > 0
|
|
17589
|
+
? chalk.dim(` ${issue.comments}c`)
|
|
17590
|
+
: '';
|
|
17591
|
+
const labels = formatLabels(issue.labels);
|
|
17592
|
+
const parts = [num, state, author, issue.title];
|
|
17593
|
+
if (labels)
|
|
17594
|
+
parts.push(labels);
|
|
17595
|
+
return parts.join(' ') + comments;
|
|
17596
|
+
})
|
|
17597
|
+
.join('\n');
|
|
17598
|
+
}
|
|
17599
|
+
function formatPullRequestList(items) {
|
|
17600
|
+
if (items.length === 0) {
|
|
17601
|
+
return chalk.dim('No pull requests match the current filter.');
|
|
17602
|
+
}
|
|
17603
|
+
const numberWidth = Math.max(...items.map((i) => `#${i.number}`.length));
|
|
17604
|
+
const authorWidth = Math.max(...items.map((i) => (i.author ? i.author.length : 0)), 1);
|
|
17605
|
+
const headWidth = Math.min(Math.max(...items.map((i) => i.headRefName.length), 1), 28);
|
|
17606
|
+
const stateWidth = 6;
|
|
17607
|
+
return items
|
|
17608
|
+
.map((pr) => {
|
|
17609
|
+
const numRaw = `#${pr.number}`;
|
|
17610
|
+
const num = padToVisible(chalk.dim(numRaw), numRaw.length, numberWidth);
|
|
17611
|
+
const stateRaw = pr.isDraft ? 'draft' : pr.state.toLowerCase();
|
|
17612
|
+
const stateColored = pr.isDraft ? chalk.dim('draft') : colorState(pr.state);
|
|
17613
|
+
const state = padToVisible(stateColored, stateRaw.length, stateWidth);
|
|
17614
|
+
const mergeable = formatMergeable(pr.mergeable, pr.mergeStateStatus);
|
|
17615
|
+
const review = formatReviewDecision(pr.reviewDecision);
|
|
17616
|
+
const authorRaw = pr.author || '';
|
|
17617
|
+
const author = padToVisible(chalk.cyan(authorRaw), authorRaw.length, authorWidth);
|
|
17618
|
+
const branchTruncated = pr.headRefName.length > headWidth
|
|
17619
|
+
? pr.headRefName.slice(0, headWidth - 1) + '…'
|
|
17620
|
+
: pr.headRefName;
|
|
17621
|
+
const branch = padToVisible(chalk.dim(branchTruncated), branchTruncated.length, headWidth);
|
|
17622
|
+
const labels = formatLabels(pr.labels);
|
|
17623
|
+
const parts = [num, state, mergeable, review, author, branch, pr.title];
|
|
17624
|
+
if (labels)
|
|
17625
|
+
parts.push(labels);
|
|
17626
|
+
return parts.join(' ');
|
|
17627
|
+
})
|
|
17628
|
+
.join('\n');
|
|
17629
|
+
}
|
|
17630
|
+
|
|
17631
|
+
/**
|
|
17632
|
+
* `gh issue list --json` field list. Centralized so any future
|
|
17633
|
+
* re-fetch (refresh, cache invalidation) requests the same shape and
|
|
17634
|
+
* the parser can rely on every field being present (even if optional).
|
|
17635
|
+
*/
|
|
17636
|
+
const ISSUE_LIST_JSON_FIELDS = [
|
|
17637
|
+
'number',
|
|
17638
|
+
'title',
|
|
17639
|
+
'url',
|
|
17640
|
+
'state',
|
|
17641
|
+
'author',
|
|
17642
|
+
'assignees',
|
|
17643
|
+
'labels',
|
|
17644
|
+
'comments',
|
|
17645
|
+
'createdAt',
|
|
17646
|
+
'updatedAt',
|
|
17647
|
+
].join(',');
|
|
17648
|
+
function parseIssueListItems(output) {
|
|
17649
|
+
const trimmed = output.trim();
|
|
17650
|
+
if (!trimmed)
|
|
17651
|
+
return [];
|
|
17652
|
+
const raw = JSON.parse(trimmed);
|
|
17653
|
+
return raw.map((entry) => {
|
|
17654
|
+
const author = entry.author && typeof entry.author === 'object' && 'login' in entry.author
|
|
17655
|
+
? String(entry.author.login)
|
|
17656
|
+
: undefined;
|
|
17657
|
+
const assignees = Array.isArray(entry.assignees)
|
|
17658
|
+
? entry.assignees
|
|
17659
|
+
.map((a) => (a && 'login' in a ? String(a.login) : ''))
|
|
17660
|
+
.filter(Boolean)
|
|
17661
|
+
: undefined;
|
|
17662
|
+
const labels = Array.isArray(entry.labels)
|
|
17663
|
+
? entry.labels
|
|
17664
|
+
.map((l) => (l && 'name' in l ? String(l.name) : ''))
|
|
17665
|
+
.filter(Boolean)
|
|
17666
|
+
: undefined;
|
|
17667
|
+
return {
|
|
17668
|
+
number: entry.number,
|
|
17669
|
+
title: String(entry.title || ''),
|
|
17670
|
+
url: String(entry.url || ''),
|
|
17671
|
+
state: String(entry.state || ''),
|
|
17672
|
+
author,
|
|
17673
|
+
assignees,
|
|
17674
|
+
labels,
|
|
17675
|
+
comments: typeof entry.comments === 'number' ? entry.comments : undefined,
|
|
17676
|
+
createdAt: String(entry.createdAt || ''),
|
|
17677
|
+
updatedAt: String(entry.updatedAt || ''),
|
|
17678
|
+
};
|
|
17679
|
+
});
|
|
17680
|
+
}
|
|
17681
|
+
function buildGhArgs$1(filter) {
|
|
17682
|
+
const args = ['issue', 'list', '--json', ISSUE_LIST_JSON_FIELDS];
|
|
17683
|
+
if (filter.state)
|
|
17684
|
+
args.push('--state', filter.state);
|
|
17685
|
+
if (filter.assignee)
|
|
17686
|
+
args.push('--assignee', filter.assignee);
|
|
17687
|
+
if (filter.author)
|
|
17688
|
+
args.push('--author', filter.author);
|
|
17689
|
+
if (filter.label)
|
|
17690
|
+
args.push('--label', filter.label);
|
|
17691
|
+
if (filter.search)
|
|
17692
|
+
args.push('--search', filter.search);
|
|
17693
|
+
if (typeof filter.limit === 'number')
|
|
17694
|
+
args.push('--limit', String(filter.limit));
|
|
17695
|
+
return args;
|
|
17696
|
+
}
|
|
17697
|
+
async function getIssueList(git, filter = {}, runner = defaultGhRunner) {
|
|
17698
|
+
const repository = await getGitHubRepository(git);
|
|
17699
|
+
if (!repository) {
|
|
17700
|
+
return {
|
|
17701
|
+
available: false,
|
|
17702
|
+
authenticated: false,
|
|
17703
|
+
filter,
|
|
17704
|
+
message: 'No GitHub remote detected.',
|
|
17705
|
+
};
|
|
17706
|
+
}
|
|
17707
|
+
if (!(await isGhAuthenticated(runner))) {
|
|
17708
|
+
return {
|
|
17709
|
+
available: true,
|
|
17710
|
+
authenticated: false,
|
|
17711
|
+
repository,
|
|
17712
|
+
filter,
|
|
17713
|
+
message: 'GitHub CLI is missing or not authenticated.',
|
|
17714
|
+
};
|
|
17715
|
+
}
|
|
17716
|
+
try {
|
|
17717
|
+
const output = await runner(buildGhArgs$1(filter));
|
|
17718
|
+
return {
|
|
17719
|
+
available: true,
|
|
17720
|
+
authenticated: true,
|
|
17721
|
+
repository,
|
|
17722
|
+
filter,
|
|
17723
|
+
issues: parseIssueListItems(output),
|
|
17724
|
+
};
|
|
17725
|
+
}
|
|
17726
|
+
catch (error) {
|
|
17727
|
+
return {
|
|
17728
|
+
available: true,
|
|
17729
|
+
authenticated: true,
|
|
17730
|
+
repository,
|
|
17731
|
+
filter,
|
|
17732
|
+
message: error instanceof Error ? error.message : 'Failed to fetch issue list.',
|
|
17733
|
+
};
|
|
17734
|
+
}
|
|
17735
|
+
}
|
|
17736
|
+
|
|
17737
|
+
const handler$5 = async (argv, logger) => {
|
|
17738
|
+
const git = applyRepoFlag(argv);
|
|
17739
|
+
// `applyRepoFlag` chdir'd to the repo path (or kept process.cwd
|
|
17740
|
+
// when --repo was omitted), so the cache key derives from a stable
|
|
17741
|
+
// absolute path either way.
|
|
17742
|
+
const repoPath = process.cwd();
|
|
17743
|
+
const filter = {
|
|
17744
|
+
state: argv.state,
|
|
17745
|
+
assignee: argv.mine ? '@me' : argv.assignee,
|
|
17746
|
+
author: argv.author,
|
|
17747
|
+
label: argv.label,
|
|
17748
|
+
search: argv.search,
|
|
17749
|
+
limit: argv.limit,
|
|
17750
|
+
};
|
|
17751
|
+
const cacheEnabled = !argv.noCache;
|
|
17752
|
+
let issues;
|
|
17753
|
+
let fromCache = false;
|
|
17754
|
+
let cacheAgeMs;
|
|
17755
|
+
// Repository metadata is needed for the header in both code paths
|
|
17756
|
+
// (cache hit and fresh fetch). The cache hit path skips
|
|
17757
|
+
// `getIssueList` entirely, so probe it directly here. Cheap — no
|
|
17758
|
+
// network, just a single `git remote` parse.
|
|
17759
|
+
const repository = await getGitHubRepository(git);
|
|
17760
|
+
if (cacheEnabled && !argv.refresh) {
|
|
17761
|
+
const cached = readCachedList('issues', repoPath, filter);
|
|
17762
|
+
if (cached?.fresh) {
|
|
17763
|
+
issues = cached.payload.items;
|
|
17764
|
+
fromCache = true;
|
|
17765
|
+
cacheAgeMs = cached.ageMs;
|
|
17766
|
+
}
|
|
17767
|
+
}
|
|
17768
|
+
if (!issues) {
|
|
17769
|
+
const overview = await getIssueList(git, filter);
|
|
17770
|
+
if (!overview.available) {
|
|
17771
|
+
logger.log(chalk.red(overview.message || 'No GitHub remote detected.'));
|
|
17772
|
+
commandExit(1);
|
|
17773
|
+
return;
|
|
17774
|
+
}
|
|
17775
|
+
if (!overview.authenticated) {
|
|
17776
|
+
logger.log(chalk.yellow(overview.message || 'GitHub CLI is missing or not authenticated.'));
|
|
17777
|
+
logger.log(chalk.dim('Install `gh` and run `gh auth login` to enable issue triage.'));
|
|
17778
|
+
commandExit(1);
|
|
17779
|
+
return;
|
|
17780
|
+
}
|
|
17781
|
+
if (overview.message) {
|
|
17782
|
+
logger.log(chalk.red(overview.message));
|
|
17783
|
+
commandExit(1);
|
|
17784
|
+
return;
|
|
17785
|
+
}
|
|
17786
|
+
issues = overview.issues || [];
|
|
17787
|
+
if (cacheEnabled) {
|
|
17788
|
+
writeCachedList(repoPath, filter, { kind: 'issues', items: issues });
|
|
17789
|
+
}
|
|
17790
|
+
}
|
|
17791
|
+
if (argv.json) {
|
|
17792
|
+
logger.log(JSON.stringify(issues, null, 2));
|
|
17793
|
+
return;
|
|
17794
|
+
}
|
|
17795
|
+
if (repository) {
|
|
17796
|
+
const filterParts = [];
|
|
17797
|
+
if (filter.state && filter.state !== 'open')
|
|
17798
|
+
filterParts.push(`state=${filter.state}`);
|
|
17799
|
+
if (filter.assignee)
|
|
17800
|
+
filterParts.push(`assignee=${filter.assignee}`);
|
|
17801
|
+
if (filter.author)
|
|
17802
|
+
filterParts.push(`author=${filter.author}`);
|
|
17803
|
+
if (filter.label)
|
|
17804
|
+
filterParts.push(`label=${filter.label}`);
|
|
17805
|
+
if (filter.search)
|
|
17806
|
+
filterParts.push(`search=${JSON.stringify(filter.search)}`);
|
|
17807
|
+
const suffix = filterParts.length ? chalk.dim(` (${filterParts.join(', ')})`) : '';
|
|
17808
|
+
const cacheTag = fromCache && typeof cacheAgeMs === 'number'
|
|
17809
|
+
? chalk.dim(` · cached ${Math.round(cacheAgeMs / 1000)}s ago`)
|
|
17810
|
+
: '';
|
|
17811
|
+
logger.log(chalk.bold(`${repository.owner}/${repository.name}`) +
|
|
17812
|
+
chalk.dim(` · ${issues.length} issue${issues.length === 1 ? '' : 's'}`) +
|
|
17813
|
+
suffix +
|
|
17814
|
+
cacheTag);
|
|
17815
|
+
logger.log('');
|
|
17816
|
+
}
|
|
17817
|
+
logger.log(formatIssueList(issues));
|
|
17818
|
+
};
|
|
17819
|
+
|
|
17820
|
+
var issues = {
|
|
17821
|
+
command: command$5,
|
|
17822
|
+
desc: 'List GitHub issues for the current repository (read-only triage)',
|
|
17823
|
+
builder: builder$5,
|
|
17824
|
+
handler: commandExecutor(handler$5),
|
|
17825
|
+
};
|
|
17826
|
+
|
|
17827
|
+
const command$4 = 'log';
|
|
17828
|
+
const options$4 = {
|
|
17167
17829
|
i: {
|
|
17168
17830
|
description: 'Open the interactive terminal log UI',
|
|
17169
17831
|
type: 'boolean',
|
|
@@ -17226,8 +17888,8 @@ const options$3 = {
|
|
|
17226
17888
|
default: 'compact',
|
|
17227
17889
|
},
|
|
17228
17890
|
};
|
|
17229
|
-
const builder$
|
|
17230
|
-
return yargs.options(options$
|
|
17891
|
+
const builder$4 = (yargs) => {
|
|
17892
|
+
return yargs.options(options$4).usage(getCommandUsageHeader(command$4));
|
|
17231
17893
|
};
|
|
17232
17894
|
|
|
17233
17895
|
/**
|
|
@@ -17801,30 +18463,6 @@ async function getBranchOverview(git) {
|
|
|
17801
18463
|
};
|
|
17802
18464
|
}
|
|
17803
18465
|
|
|
17804
|
-
const execFileAsync = promisify(execFile);
|
|
17805
|
-
function parseGitHubRemoteUrl$1(url) {
|
|
17806
|
-
const trimmed = url.trim().replace(/\.git$/, '');
|
|
17807
|
-
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/(.+)$/);
|
|
17808
|
-
const httpsMatch = trimmed.match(/^https:\/\/github\.com\/([^/]+)\/(.+)$/);
|
|
17809
|
-
const match = sshMatch || httpsMatch;
|
|
17810
|
-
if (!match) {
|
|
17811
|
-
return undefined;
|
|
17812
|
-
}
|
|
17813
|
-
return {
|
|
17814
|
-
owner: match[1],
|
|
17815
|
-
name: match[2],
|
|
17816
|
-
};
|
|
17817
|
-
}
|
|
17818
|
-
async function defaultGhRunner(args) {
|
|
17819
|
-
const result = await execFileAsync('gh', args);
|
|
17820
|
-
return result.stdout;
|
|
17821
|
-
}
|
|
17822
|
-
async function getGitHubRepository(git) {
|
|
17823
|
-
const remotes = await git.getRemotes(true);
|
|
17824
|
-
const remote = remotes.find((entry) => entry.name === 'origin') || remotes[0];
|
|
17825
|
-
const url = remote?.refs.push || remote?.refs.fetch;
|
|
17826
|
-
return url ? parseGitHubRemoteUrl$1(url) : undefined;
|
|
17827
|
-
}
|
|
17828
18466
|
function parsePullRequestInfo(output) {
|
|
17829
18467
|
const trimmed = output.trim();
|
|
17830
18468
|
if (!trimmed) {
|
|
@@ -17903,10 +18541,7 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
|
|
|
17903
18541
|
message: 'No GitHub remote detected.',
|
|
17904
18542
|
};
|
|
17905
18543
|
}
|
|
17906
|
-
|
|
17907
|
-
await runner(['auth', 'status', '--hostname', 'github.com']);
|
|
17908
|
-
}
|
|
17909
|
-
catch {
|
|
18544
|
+
if (!(await isGhAuthenticated(runner))) {
|
|
17910
18545
|
return {
|
|
17911
18546
|
available: true,
|
|
17912
18547
|
authenticated: false,
|
|
@@ -19783,6 +20418,7 @@ async function runCommitSplitPlanWorkflow(input = {}) {
|
|
|
19783
20418
|
const key = getApiKeyForModel(config);
|
|
19784
20419
|
const { provider } = getModelAndProviderFromConfig(config);
|
|
19785
20420
|
const commitService = resolveDynamicService(config, 'commit');
|
|
20421
|
+
const splitService = resolveDynamicService(config, 'commitSplit');
|
|
19786
20422
|
const model = commitService.model;
|
|
19787
20423
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
19788
20424
|
return {
|
|
@@ -19793,6 +20429,10 @@ async function runCommitSplitPlanWorkflow(input = {}) {
|
|
|
19793
20429
|
try {
|
|
19794
20430
|
const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
|
|
19795
20431
|
const llm = getLlm(provider, model, { ...config, service: commitService });
|
|
20432
|
+
const planLlm = getLlm(provider, splitService.model, {
|
|
20433
|
+
...config,
|
|
20434
|
+
service: splitService,
|
|
20435
|
+
});
|
|
19796
20436
|
const result = await prepareCommitSplitPlan({
|
|
19797
20437
|
argv,
|
|
19798
20438
|
config,
|
|
@@ -19800,6 +20440,8 @@ async function runCommitSplitPlanWorkflow(input = {}) {
|
|
|
19800
20440
|
logger,
|
|
19801
20441
|
tokenizer,
|
|
19802
20442
|
llm,
|
|
20443
|
+
planLlm,
|
|
20444
|
+
planService: splitService,
|
|
19803
20445
|
});
|
|
19804
20446
|
if ('empty' in result) {
|
|
19805
20447
|
return {
|
|
@@ -19941,7 +20583,7 @@ async function runPullRequestBodyWorkflow(input = {}) {
|
|
|
19941
20583
|
const argv = createChangelogArgv({ branch: baseBranch });
|
|
19942
20584
|
let raw = '';
|
|
19943
20585
|
try {
|
|
19944
|
-
raw = await captureStdout(() => handler$
|
|
20586
|
+
raw = await captureStdout(() => handler$9(argv, new Logger({
|
|
19945
20587
|
verbose: true,
|
|
19946
20588
|
silent: false,
|
|
19947
20589
|
})));
|
|
@@ -20004,7 +20646,7 @@ async function runChangelogTextWorkflow(input = {}) {
|
|
|
20004
20646
|
const argv = createChangelogArgv(input);
|
|
20005
20647
|
let raw = '';
|
|
20006
20648
|
try {
|
|
20007
|
-
raw = await captureStdout(() => handler$
|
|
20649
|
+
raw = await captureStdout(() => handler$9(argv, new Logger({
|
|
20008
20650
|
verbose: true,
|
|
20009
20651
|
silent: false,
|
|
20010
20652
|
})));
|
|
@@ -20026,10 +20668,12 @@ async function runChangelogTextWorkflow(input = {}) {
|
|
|
20026
20668
|
const LOG_INK_CONTEXT_KEYS = [
|
|
20027
20669
|
'bisect',
|
|
20028
20670
|
'branches',
|
|
20671
|
+
'issueList',
|
|
20029
20672
|
'lfs',
|
|
20030
20673
|
'operation',
|
|
20031
20674
|
'provider',
|
|
20032
20675
|
'pullRequest',
|
|
20676
|
+
'pullRequestList',
|
|
20033
20677
|
'reflog',
|
|
20034
20678
|
'stashes',
|
|
20035
20679
|
'submodules',
|
|
@@ -20475,6 +21119,60 @@ function getLogInkWorkflowActions() {
|
|
|
20475
21119
|
kind: 'normal',
|
|
20476
21120
|
requiresConfirmation: false,
|
|
20477
21121
|
},
|
|
21122
|
+
// #882 phase 5 — triage-view destructive verbs. Each routed
|
|
21123
|
+
// through the y-confirm path so single-keystroke `x` / `a` /
|
|
21124
|
+
// `R` / `m` never silently rewrites publicly-visible state.
|
|
21125
|
+
// The runner reads the cursored item from the filtered list
|
|
21126
|
+
// at confirm-time — the cursor can't move while the
|
|
21127
|
+
// confirmation overlay is up, so no stale-target risk.
|
|
21128
|
+
{
|
|
21129
|
+
id: 'triage-issue-close',
|
|
21130
|
+
key: '',
|
|
21131
|
+
label: 'Close issue',
|
|
21132
|
+
description: 'Close the cursored issue on the triage list view.',
|
|
21133
|
+
kind: 'destructive',
|
|
21134
|
+
requiresConfirmation: true,
|
|
21135
|
+
},
|
|
21136
|
+
{
|
|
21137
|
+
id: 'triage-issue-reopen',
|
|
21138
|
+
key: '',
|
|
21139
|
+
label: 'Reopen issue',
|
|
21140
|
+
description: 'Reopen the cursored issue on the triage list view.',
|
|
21141
|
+
kind: 'normal',
|
|
21142
|
+
requiresConfirmation: true,
|
|
21143
|
+
},
|
|
21144
|
+
{
|
|
21145
|
+
id: 'triage-pr-merge',
|
|
21146
|
+
key: '',
|
|
21147
|
+
label: 'Merge pull request',
|
|
21148
|
+
description: 'Merge the cursored pull request on the triage list view (prompts for merge / squash / rebase, then confirms).',
|
|
21149
|
+
kind: 'destructive',
|
|
21150
|
+
requiresConfirmation: true,
|
|
21151
|
+
},
|
|
21152
|
+
{
|
|
21153
|
+
id: 'triage-pr-close',
|
|
21154
|
+
key: '',
|
|
21155
|
+
label: 'Close pull request',
|
|
21156
|
+
description: 'Close the cursored pull request on the triage list view without merging.',
|
|
21157
|
+
kind: 'destructive',
|
|
21158
|
+
requiresConfirmation: true,
|
|
21159
|
+
},
|
|
21160
|
+
{
|
|
21161
|
+
id: 'triage-pr-approve',
|
|
21162
|
+
key: '',
|
|
21163
|
+
label: 'Approve pull request',
|
|
21164
|
+
description: 'Submit an approving review on the cursored pull request.',
|
|
21165
|
+
kind: 'normal',
|
|
21166
|
+
requiresConfirmation: true,
|
|
21167
|
+
},
|
|
21168
|
+
{
|
|
21169
|
+
id: 'triage-pr-request-changes',
|
|
21170
|
+
key: '',
|
|
21171
|
+
label: 'Request changes on pull request',
|
|
21172
|
+
description: 'Submit a change-request review on the cursored pull request (prompts for body, then confirms).',
|
|
21173
|
+
kind: 'normal',
|
|
21174
|
+
requiresConfirmation: true,
|
|
21175
|
+
},
|
|
20478
21176
|
{
|
|
20479
21177
|
// Per-view-only: scoped to the history view in inkInput so `R`
|
|
20480
21178
|
// doesn't fire elsewhere (it's also `R` for rename in branches
|
|
@@ -20870,6 +21568,20 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
20870
21568
|
description: 'Push the dedicated pull-request action panel for the current branch.',
|
|
20871
21569
|
contexts: ['normal'],
|
|
20872
21570
|
},
|
|
21571
|
+
{
|
|
21572
|
+
id: 'navigatePullRequestTriage',
|
|
21573
|
+
keys: ['gP'],
|
|
21574
|
+
label: 'PR triage',
|
|
21575
|
+
description: 'Push the multi-PR triage list view (#882). Capital P disambiguates from `gp` which targets the single-PR panel for the current branch.',
|
|
21576
|
+
contexts: ['normal'],
|
|
21577
|
+
},
|
|
21578
|
+
{
|
|
21579
|
+
id: 'navigateIssues',
|
|
21580
|
+
keys: ['gi'],
|
|
21581
|
+
label: 'issues',
|
|
21582
|
+
description: 'Push the issue triage list view (#882).',
|
|
21583
|
+
contexts: ['normal'],
|
|
21584
|
+
},
|
|
20873
21585
|
{
|
|
20874
21586
|
id: 'navigateConflicts',
|
|
20875
21587
|
keys: ['gx'],
|
|
@@ -21048,6 +21760,8 @@ const GLOBAL_BINDING_IDS = [
|
|
|
21048
21760
|
'navigateStash',
|
|
21049
21761
|
'navigateWorktrees',
|
|
21050
21762
|
'navigatePullRequest',
|
|
21763
|
+
'navigatePullRequestTriage',
|
|
21764
|
+
'navigateIssues',
|
|
21051
21765
|
'navigateConflicts',
|
|
21052
21766
|
'navigateReflog',
|
|
21053
21767
|
'navigateBisect',
|
|
@@ -21285,6 +21999,24 @@ function getLogInkFooterHints(options) {
|
|
|
21285
21999
|
global: NORMAL_GLOBAL_HINTS,
|
|
21286
22000
|
};
|
|
21287
22001
|
}
|
|
22002
|
+
if (options.activeView === 'issues') {
|
|
22003
|
+
return {
|
|
22004
|
+
// #882 phase 4-6 — read + additive mutations + destructive
|
|
22005
|
+
// (gated through y-confirm) + filter cycling. AI summarize
|
|
22006
|
+
// (`I`) deferred to a follow-up.
|
|
22007
|
+
contextual: ['↑/↓ issues', 'f filter', 'O open', 'y yank URL', 'c comment', 'L label', 'A assign', 'x close*', 'X reopen', 'esc back'],
|
|
22008
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
22009
|
+
};
|
|
22010
|
+
}
|
|
22011
|
+
if (options.activeView === 'pull-request-triage') {
|
|
22012
|
+
return {
|
|
22013
|
+
// #882 phase 4-6 — full PR action panel scoped to the triage
|
|
22014
|
+
// list + filter cycling. AI summarize (`I`) deferred to a
|
|
22015
|
+
// follow-up.
|
|
22016
|
+
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'],
|
|
22017
|
+
global: NORMAL_GLOBAL_HINTS,
|
|
22018
|
+
};
|
|
22019
|
+
}
|
|
21288
22020
|
if (options.activeView === 'submodules') {
|
|
21289
22021
|
return {
|
|
21290
22022
|
contextual: ['↑/↓ entries', 'y yank path', 'Y yank sha', '/ filter', 'esc back'],
|
|
@@ -21465,6 +22197,109 @@ function filterLogInkPaletteCommands(commands, filter, recent) {
|
|
|
21465
22197
|
.map((entry) => entry.command);
|
|
21466
22198
|
}
|
|
21467
22199
|
|
|
22200
|
+
/**
|
|
22201
|
+
* Canned filter presets for the issue / PR triage TUI views
|
|
22202
|
+
* (#882 phase 6). Each preset compiles to the same shape the
|
|
22203
|
+
* underlying list fetchers (`getIssueList` / `getPullRequestList`)
|
|
22204
|
+
* already accept — there's no new `gh` surface area, just a
|
|
22205
|
+
* curated set of common triage angles surfaced as a single
|
|
22206
|
+
* keystroke (`f` cycles).
|
|
22207
|
+
*
|
|
22208
|
+
* The presets are deliberately *not* a 1:1 mirror across the two
|
|
22209
|
+
* surfaces:
|
|
22210
|
+
*
|
|
22211
|
+
* - Issues have no draft / mergeable concept, so `draft` /
|
|
22212
|
+
* `mergeable` are skipped on the issue list.
|
|
22213
|
+
* - PRs have a `merged` state distinct from `closed`; issues
|
|
22214
|
+
* don't.
|
|
22215
|
+
* - `mine` semantics differ subtly: for issues it tends to
|
|
22216
|
+
* mean "I'm the assignee" (issues are tasks people pick up);
|
|
22217
|
+
* for PRs it means "I'm the author" (PRs are work people
|
|
22218
|
+
* post). The presets bake those in so the user doesn't have
|
|
22219
|
+
* to think about it.
|
|
22220
|
+
*/
|
|
22221
|
+
/** Cycle order — must match the keystroke walk on `f`. */
|
|
22222
|
+
const ISSUE_FILTER_PRESETS = [
|
|
22223
|
+
'open',
|
|
22224
|
+
'closed',
|
|
22225
|
+
'mine',
|
|
22226
|
+
'assigned',
|
|
22227
|
+
];
|
|
22228
|
+
const PULL_REQUEST_FILTER_PRESETS = [
|
|
22229
|
+
'open',
|
|
22230
|
+
'draft',
|
|
22231
|
+
'mine',
|
|
22232
|
+
'assigned',
|
|
22233
|
+
'closed',
|
|
22234
|
+
'merged',
|
|
22235
|
+
];
|
|
22236
|
+
const ISSUE_FILTER_LABELS = {
|
|
22237
|
+
open: 'open',
|
|
22238
|
+
closed: 'closed',
|
|
22239
|
+
mine: 'mine (assigned)',
|
|
22240
|
+
assigned: 'assigned to me',
|
|
22241
|
+
};
|
|
22242
|
+
const PULL_REQUEST_FILTER_LABELS = {
|
|
22243
|
+
open: 'open',
|
|
22244
|
+
draft: 'draft',
|
|
22245
|
+
mine: 'mine (authored)',
|
|
22246
|
+
assigned: 'assigned to me',
|
|
22247
|
+
closed: 'closed',
|
|
22248
|
+
merged: 'merged',
|
|
22249
|
+
};
|
|
22250
|
+
/**
|
|
22251
|
+
* Resolve a preset to the filter object the data fetcher accepts.
|
|
22252
|
+
* Pure mapping — no `gh` calls. Kept separate from `getIssueList` /
|
|
22253
|
+
* `getPullRequestList` so unit tests can assert the mapping
|
|
22254
|
+
* independently from the fetch pipeline.
|
|
22255
|
+
*/
|
|
22256
|
+
function issueFilterForPreset(preset) {
|
|
22257
|
+
switch (preset) {
|
|
22258
|
+
case 'open':
|
|
22259
|
+
return { state: 'open' };
|
|
22260
|
+
case 'closed':
|
|
22261
|
+
return { state: 'closed' };
|
|
22262
|
+
case 'mine':
|
|
22263
|
+
// Issues are tasks — "mine" is what *I'm working on*, i.e.
|
|
22264
|
+
// assigned to me + still open. Same as `assigned` plus the
|
|
22265
|
+
// open-state filter for ergonomic single-keystroke focus on
|
|
22266
|
+
// the active backlog.
|
|
22267
|
+
return { state: 'open', assignee: '@me' };
|
|
22268
|
+
case 'assigned':
|
|
22269
|
+
return { assignee: '@me' };
|
|
22270
|
+
}
|
|
22271
|
+
}
|
|
22272
|
+
function pullRequestFilterForPreset(preset) {
|
|
22273
|
+
switch (preset) {
|
|
22274
|
+
case 'open':
|
|
22275
|
+
return { state: 'open' };
|
|
22276
|
+
case 'draft':
|
|
22277
|
+
// gh's `--draft` flag implies `--state open`; surface that
|
|
22278
|
+
// explicitly so the canonicalize step doesn't elide it.
|
|
22279
|
+
return { state: 'open', draft: true };
|
|
22280
|
+
case 'mine':
|
|
22281
|
+
// PRs are work — "mine" is what *I authored*. Most useful
|
|
22282
|
+
// when looking at one's own backlog of in-flight PRs.
|
|
22283
|
+
return { state: 'open', author: '@me' };
|
|
22284
|
+
case 'assigned':
|
|
22285
|
+
return { assignee: '@me' };
|
|
22286
|
+
case 'closed':
|
|
22287
|
+
return { state: 'closed' };
|
|
22288
|
+
case 'merged':
|
|
22289
|
+
return { state: 'merged' };
|
|
22290
|
+
}
|
|
22291
|
+
}
|
|
22292
|
+
function cycleIssueFilterPreset(current) {
|
|
22293
|
+
const index = ISSUE_FILTER_PRESETS.indexOf(current);
|
|
22294
|
+
const next = (index + 1) % ISSUE_FILTER_PRESETS.length;
|
|
22295
|
+
return ISSUE_FILTER_PRESETS[next];
|
|
22296
|
+
}
|
|
22297
|
+
function cyclePullRequestFilterPreset(current) {
|
|
22298
|
+
const index = PULL_REQUEST_FILTER_PRESETS.indexOf(current);
|
|
22299
|
+
const next = (index + 1) % PULL_REQUEST_FILTER_PRESETS.length;
|
|
22300
|
+
return PULL_REQUEST_FILTER_PRESETS[next];
|
|
22301
|
+
}
|
|
22302
|
+
|
|
21468
22303
|
/**
|
|
21469
22304
|
* Sort modes for the promoted views (P4.2).
|
|
21470
22305
|
*
|
|
@@ -21852,6 +22687,10 @@ function createLogInkState(rows, options = {}) {
|
|
|
21852
22687
|
selectedConflictFileIndex: 0,
|
|
21853
22688
|
selectedReflogIndex: 0,
|
|
21854
22689
|
selectedSubmoduleIndex: 0,
|
|
22690
|
+
selectedIssueIndex: 0,
|
|
22691
|
+
selectedPullRequestTriageIndex: 0,
|
|
22692
|
+
selectedIssueFilter: 'open',
|
|
22693
|
+
selectedPullRequestFilter: 'open',
|
|
21855
22694
|
repoStack: [{ label: options.repoLabel || 'root' }],
|
|
21856
22695
|
branchSort: DEFAULT_BRANCH_SORT_MODE,
|
|
21857
22696
|
tagSort: DEFAULT_TAG_SORT_MODE,
|
|
@@ -22114,6 +22953,36 @@ function applyLogInkAction(state, action) {
|
|
|
22114
22953
|
selectedSubmoduleIndex: clampIndex(state.selectedSubmoduleIndex + action.delta, action.count),
|
|
22115
22954
|
pendingKey: undefined,
|
|
22116
22955
|
};
|
|
22956
|
+
case 'moveIssue':
|
|
22957
|
+
return {
|
|
22958
|
+
...state,
|
|
22959
|
+
selectedIssueIndex: clampIndex(state.selectedIssueIndex + action.delta, action.count),
|
|
22960
|
+
pendingKey: undefined,
|
|
22961
|
+
};
|
|
22962
|
+
case 'movePullRequestTriage':
|
|
22963
|
+
return {
|
|
22964
|
+
...state,
|
|
22965
|
+
selectedPullRequestTriageIndex: clampIndex(state.selectedPullRequestTriageIndex + action.delta, action.count),
|
|
22966
|
+
pendingKey: undefined,
|
|
22967
|
+
};
|
|
22968
|
+
case 'cycleIssueFilter':
|
|
22969
|
+
// Advance the preset, snap the cursor to the top of the
|
|
22970
|
+
// (newly filtered) list — same UX rule as `cycleBranchSort`.
|
|
22971
|
+
// The list refetches on preset change via the effect in
|
|
22972
|
+
// app.ts, so the cursor at 0 lands on whatever was promoted.
|
|
22973
|
+
return {
|
|
22974
|
+
...state,
|
|
22975
|
+
selectedIssueFilter: cycleIssueFilterPreset(state.selectedIssueFilter),
|
|
22976
|
+
selectedIssueIndex: 0,
|
|
22977
|
+
pendingKey: undefined,
|
|
22978
|
+
};
|
|
22979
|
+
case 'cyclePullRequestTriageFilter':
|
|
22980
|
+
return {
|
|
22981
|
+
...state,
|
|
22982
|
+
selectedPullRequestFilter: cyclePullRequestFilterPreset(state.selectedPullRequestFilter),
|
|
22983
|
+
selectedPullRequestTriageIndex: 0,
|
|
22984
|
+
pendingKey: undefined,
|
|
22985
|
+
};
|
|
22117
22986
|
case 'moveWorktreeListEntry':
|
|
22118
22987
|
return {
|
|
22119
22988
|
...state,
|
|
@@ -22936,6 +23805,22 @@ function isReflogActionTarget(state) {
|
|
|
22936
23805
|
function isSubmodulesActionTarget(state) {
|
|
22937
23806
|
return state.activeView === 'submodules' && state.focus === 'commits';
|
|
22938
23807
|
}
|
|
23808
|
+
/**
|
|
23809
|
+
* Issue triage list (#882 phase 3). Same shape as the other promoted
|
|
23810
|
+
* read-only views — j/k move the cursor when the commits pane is
|
|
23811
|
+
* focused on the dedicated view.
|
|
23812
|
+
*/
|
|
23813
|
+
function isIssueActionTarget(state) {
|
|
23814
|
+
return state.activeView === 'issues' && state.focus === 'commits';
|
|
23815
|
+
}
|
|
23816
|
+
/**
|
|
23817
|
+
* Pull-request triage list (#882 phase 3). Distinct from the existing
|
|
23818
|
+
* `pull-request` single-PR action panel — this is the multi-PR list
|
|
23819
|
+
* surface and its cursor lives in `selectedPullRequestTriageIndex`.
|
|
23820
|
+
*/
|
|
23821
|
+
function isPullRequestTriageActionTarget(state) {
|
|
23822
|
+
return state.activeView === 'pull-request-triage' && state.focus === 'commits';
|
|
23823
|
+
}
|
|
22939
23824
|
function isWorktreeActionTarget(state) {
|
|
22940
23825
|
return (state.activeView === 'worktrees' && state.focus === 'commits') ||
|
|
22941
23826
|
(state.focus === 'sidebar' && state.sidebarTab === 'worktrees');
|
|
@@ -23085,6 +23970,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
23085
23970
|
return [action({ type: 'pushView', value: 'worktrees' })];
|
|
23086
23971
|
case 'navigatePullRequest':
|
|
23087
23972
|
return [action({ type: 'pushView', value: 'pull-request' })];
|
|
23973
|
+
case 'navigatePullRequestTriage':
|
|
23974
|
+
return [action({ type: 'pushView', value: 'pull-request-triage' })];
|
|
23975
|
+
case 'navigateIssues':
|
|
23976
|
+
return [action({ type: 'pushView', value: 'issues' })];
|
|
23088
23977
|
case 'navigateConflicts':
|
|
23089
23978
|
return [action({ type: 'pushView', value: 'conflicts' })];
|
|
23090
23979
|
case 'navigateReflog':
|
|
@@ -23259,6 +24148,69 @@ function submitInputPrompt(state) {
|
|
|
23259
24148
|
action({ type: 'closeInputPrompt' }),
|
|
23260
24149
|
];
|
|
23261
24150
|
}
|
|
24151
|
+
// #882 phase 4 — triage-view mutation prompts. Each kind routes to
|
|
24152
|
+
// its by-number workflow id; the runner reads the cursored item
|
|
24153
|
+
// from state + filtered list and runs the matching `gh` action.
|
|
24154
|
+
if (state.inputPrompt.kind === 'triage-issue-comment') {
|
|
24155
|
+
return [
|
|
24156
|
+
{ type: 'runWorkflowAction', id: 'triage-issue-comment', payload: value },
|
|
24157
|
+
action({ type: 'closeInputPrompt' }),
|
|
24158
|
+
];
|
|
24159
|
+
}
|
|
24160
|
+
if (state.inputPrompt.kind === 'triage-issue-label') {
|
|
24161
|
+
return [
|
|
24162
|
+
{ type: 'runWorkflowAction', id: 'triage-issue-label', payload: value },
|
|
24163
|
+
action({ type: 'closeInputPrompt' }),
|
|
24164
|
+
];
|
|
24165
|
+
}
|
|
24166
|
+
if (state.inputPrompt.kind === 'triage-issue-assign') {
|
|
24167
|
+
return [
|
|
24168
|
+
{ type: 'runWorkflowAction', id: 'triage-issue-assign', payload: value },
|
|
24169
|
+
action({ type: 'closeInputPrompt' }),
|
|
24170
|
+
];
|
|
24171
|
+
}
|
|
24172
|
+
if (state.inputPrompt.kind === 'triage-pr-comment') {
|
|
24173
|
+
return [
|
|
24174
|
+
{ type: 'runWorkflowAction', id: 'triage-pr-comment', payload: value },
|
|
24175
|
+
action({ type: 'closeInputPrompt' }),
|
|
24176
|
+
];
|
|
24177
|
+
}
|
|
24178
|
+
if (state.inputPrompt.kind === 'triage-pr-label') {
|
|
24179
|
+
return [
|
|
24180
|
+
{ type: 'runWorkflowAction', id: 'triage-pr-label', payload: value },
|
|
24181
|
+
action({ type: 'closeInputPrompt' }),
|
|
24182
|
+
];
|
|
24183
|
+
}
|
|
24184
|
+
if (state.inputPrompt.kind === 'triage-pr-assign') {
|
|
24185
|
+
return [
|
|
24186
|
+
{ type: 'runWorkflowAction', id: 'triage-pr-assign', payload: value },
|
|
24187
|
+
action({ type: 'closeInputPrompt' }),
|
|
24188
|
+
];
|
|
24189
|
+
}
|
|
24190
|
+
// #882 phase 5 — destructive prompt submissions route through the
|
|
24191
|
+
// y-confirm path (not directly to runWorkflowAction) so the user
|
|
24192
|
+
// gets a final "are you sure?" before anything ships. The
|
|
24193
|
+
// collected value (strategy / body) rides along as the
|
|
24194
|
+
// confirmation payload.
|
|
24195
|
+
if (state.inputPrompt.kind === 'triage-pr-merge-strategy') {
|
|
24196
|
+
const strategy = value.toLowerCase();
|
|
24197
|
+
if (strategy !== 'merge' && strategy !== 'squash' && strategy !== 'rebase') {
|
|
24198
|
+
return [action({
|
|
24199
|
+
type: 'setStatus',
|
|
24200
|
+
value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
|
|
24201
|
+
})];
|
|
24202
|
+
}
|
|
24203
|
+
return [
|
|
24204
|
+
action({ type: 'setPendingConfirmation', value: 'triage-pr-merge', payload: strategy }),
|
|
24205
|
+
action({ type: 'closeInputPrompt' }),
|
|
24206
|
+
];
|
|
24207
|
+
}
|
|
24208
|
+
if (state.inputPrompt.kind === 'triage-pr-request-changes') {
|
|
24209
|
+
return [
|
|
24210
|
+
action({ type: 'setPendingConfirmation', value: 'triage-pr-request-changes', payload: value }),
|
|
24211
|
+
action({ type: 'closeInputPrompt' }),
|
|
24212
|
+
];
|
|
24213
|
+
}
|
|
23262
24214
|
if (state.inputPrompt.kind === 'pr-request-changes') {
|
|
23263
24215
|
return [
|
|
23264
24216
|
action({ type: 'setPendingConfirmation', value: 'request-changes-pr', payload: value }),
|
|
@@ -23681,6 +24633,23 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
23681
24633
|
action({ type: 'setStatus', value: 'jumped to pull request' }),
|
|
23682
24634
|
];
|
|
23683
24635
|
}
|
|
24636
|
+
// `gP` chord (#882 phase 3): jump to the multi-PR triage list.
|
|
24637
|
+
// Capital P disambiguates from `gp` (current-branch PR panel).
|
|
24638
|
+
// Pleasingly symmetric with `gi` for issues — both lead to the
|
|
24639
|
+
// read-only list views shipped in #882.
|
|
24640
|
+
if (state.pendingKey === 'g' && inputValue === 'P') {
|
|
24641
|
+
return [
|
|
24642
|
+
action({ type: 'pushView', value: 'pull-request-triage' }),
|
|
24643
|
+
action({ type: 'setStatus', value: 'jumped to PR triage' }),
|
|
24644
|
+
];
|
|
24645
|
+
}
|
|
24646
|
+
// `gi` chord (#882 phase 3): jump to the issue triage list.
|
|
24647
|
+
if (state.pendingKey === 'g' && inputValue === 'i') {
|
|
24648
|
+
return [
|
|
24649
|
+
action({ type: 'pushView', value: 'issues' }),
|
|
24650
|
+
action({ type: 'setStatus', value: 'jumped to issues' }),
|
|
24651
|
+
];
|
|
24652
|
+
}
|
|
23684
24653
|
if (state.pendingKey === 'g' && inputValue === 'x') {
|
|
23685
24654
|
return [
|
|
23686
24655
|
action({ type: 'pushView', value: 'conflicts' }),
|
|
@@ -24119,6 +25088,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
24119
25088
|
if (isSubmodulesActionTarget(state) && context.submoduleCount) {
|
|
24120
25089
|
return [action({ type: 'moveSubmodule', delta: -1, count: context.submoduleCount })];
|
|
24121
25090
|
}
|
|
25091
|
+
if (isIssueActionTarget(state) && context.issueCount) {
|
|
25092
|
+
return [action({ type: 'moveIssue', delta: -1, count: context.issueCount })];
|
|
25093
|
+
}
|
|
25094
|
+
if (isPullRequestTriageActionTarget(state) && context.pullRequestTriageCount) {
|
|
25095
|
+
return [action({
|
|
25096
|
+
type: 'movePullRequestTriage',
|
|
25097
|
+
delta: -1,
|
|
25098
|
+
count: context.pullRequestTriageCount,
|
|
25099
|
+
})];
|
|
25100
|
+
}
|
|
24122
25101
|
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
24123
25102
|
return [action({ type: 'moveWorktreeListEntry', delta: -1, count: context.worktreeListCount })];
|
|
24124
25103
|
}
|
|
@@ -24206,6 +25185,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
24206
25185
|
if (isSubmodulesActionTarget(state) && context.submoduleCount) {
|
|
24207
25186
|
return [action({ type: 'moveSubmodule', delta: 1, count: context.submoduleCount })];
|
|
24208
25187
|
}
|
|
25188
|
+
if (isIssueActionTarget(state) && context.issueCount) {
|
|
25189
|
+
return [action({ type: 'moveIssue', delta: 1, count: context.issueCount })];
|
|
25190
|
+
}
|
|
25191
|
+
if (isPullRequestTriageActionTarget(state) && context.pullRequestTriageCount) {
|
|
25192
|
+
return [action({
|
|
25193
|
+
type: 'movePullRequestTriage',
|
|
25194
|
+
delta: 1,
|
|
25195
|
+
count: context.pullRequestTriageCount,
|
|
25196
|
+
})];
|
|
25197
|
+
}
|
|
24209
25198
|
if (isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
24210
25199
|
return [action({ type: 'moveWorktreeListEntry', delta: 1, count: context.worktreeListCount })];
|
|
24211
25200
|
}
|
|
@@ -24622,6 +25611,120 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
24622
25611
|
multiline: true,
|
|
24623
25612
|
})];
|
|
24624
25613
|
}
|
|
25614
|
+
// #882 phase 4 — issue triage per-row actions. Scoped to the
|
|
25615
|
+
// `'issues'` view + commits focus so the single-letter keys stay
|
|
25616
|
+
// free elsewhere. Each prompts; submit dispatches the matching
|
|
25617
|
+
// `triage-issue-*` workflow which routes through `gh issue` and
|
|
25618
|
+
// invalidates both the in-memory + disk caches on success.
|
|
25619
|
+
if (state.activeView === 'issues' && state.focus === 'commits') {
|
|
25620
|
+
if (inputValue === 'O' && context.issueSelectedUrl) {
|
|
25621
|
+
return [{ type: 'runWorkflowAction', id: 'triage-issue-open' }];
|
|
25622
|
+
}
|
|
25623
|
+
if (inputValue === 'c' && context.issueCount) {
|
|
25624
|
+
return [action({
|
|
25625
|
+
type: 'openInputPrompt',
|
|
25626
|
+
kind: 'triage-issue-comment',
|
|
25627
|
+
label: 'Comment body (Enter newline · Ctrl+D submit)',
|
|
25628
|
+
multiline: true,
|
|
25629
|
+
})];
|
|
25630
|
+
}
|
|
25631
|
+
if (inputValue === 'L' && context.issueCount) {
|
|
25632
|
+
return [action({
|
|
25633
|
+
type: 'openInputPrompt',
|
|
25634
|
+
kind: 'triage-issue-label',
|
|
25635
|
+
label: 'Label name to add',
|
|
25636
|
+
})];
|
|
25637
|
+
}
|
|
25638
|
+
if (inputValue === 'A' && context.issueCount) {
|
|
25639
|
+
return [action({
|
|
25640
|
+
type: 'openInputPrompt',
|
|
25641
|
+
kind: 'triage-issue-assign',
|
|
25642
|
+
label: 'Assignee login (or @me)',
|
|
25643
|
+
initial: '@me',
|
|
25644
|
+
})];
|
|
25645
|
+
}
|
|
25646
|
+
// #882 phase 5 — destructive issue mutations. Both gated through
|
|
25647
|
+
// the y-confirm path. `x` closes (matches `pull-request` view's
|
|
25648
|
+
// close binding); `X` reopens, useful to undo a stray close.
|
|
25649
|
+
if (inputValue === 'x' && context.issueCount) {
|
|
25650
|
+
return [action({ type: 'setPendingConfirmation', value: 'triage-issue-close' })];
|
|
25651
|
+
}
|
|
25652
|
+
if (inputValue === 'X' && context.issueCount) {
|
|
25653
|
+
return [action({ type: 'setPendingConfirmation', value: 'triage-issue-reopen' })];
|
|
25654
|
+
}
|
|
25655
|
+
// #882 phase 6 — cycle the canned filter preset (open → closed
|
|
25656
|
+
// → mine → assigned → open). The effect in app.ts watches
|
|
25657
|
+
// `state.selectedIssueFilter` and refetches with the matching
|
|
25658
|
+
// filter object, so the list updates without an explicit
|
|
25659
|
+
// refresh keystroke.
|
|
25660
|
+
if (inputValue === 'f') {
|
|
25661
|
+
return [action({ type: 'cycleIssueFilter' })];
|
|
25662
|
+
}
|
|
25663
|
+
}
|
|
25664
|
+
// #882 phase 4 — PR triage per-row actions. Same shape as the
|
|
25665
|
+
// issue handlers above; distinct view id so the keys don't
|
|
25666
|
+
// collide with the single-PR action panel (`pull-request`).
|
|
25667
|
+
if (state.activeView === 'pull-request-triage' && state.focus === 'commits') {
|
|
25668
|
+
if (inputValue === 'O' && context.pullRequestTriageSelectedUrl) {
|
|
25669
|
+
return [{ type: 'runWorkflowAction', id: 'triage-pr-open' }];
|
|
25670
|
+
}
|
|
25671
|
+
if (inputValue === 'c' && context.pullRequestTriageCount) {
|
|
25672
|
+
return [action({
|
|
25673
|
+
type: 'openInputPrompt',
|
|
25674
|
+
kind: 'triage-pr-comment',
|
|
25675
|
+
label: 'Comment body (Enter newline · Ctrl+D submit)',
|
|
25676
|
+
multiline: true,
|
|
25677
|
+
})];
|
|
25678
|
+
}
|
|
25679
|
+
if (inputValue === 'L' && context.pullRequestTriageCount) {
|
|
25680
|
+
return [action({
|
|
25681
|
+
type: 'openInputPrompt',
|
|
25682
|
+
kind: 'triage-pr-label',
|
|
25683
|
+
label: 'Label name to add',
|
|
25684
|
+
})];
|
|
25685
|
+
}
|
|
25686
|
+
if (inputValue === 'A' && context.pullRequestTriageCount) {
|
|
25687
|
+
return [action({
|
|
25688
|
+
type: 'openInputPrompt',
|
|
25689
|
+
kind: 'triage-pr-assign',
|
|
25690
|
+
label: 'Assignee login (or @me)',
|
|
25691
|
+
initial: '@me',
|
|
25692
|
+
})];
|
|
25693
|
+
}
|
|
25694
|
+
// #882 phase 5 — destructive PR mutations on the triage view.
|
|
25695
|
+
// Mirror the single-PR action panel's keys (m / x / a / R) but
|
|
25696
|
+
// route to the by-number workflows. `m` and `R` open input
|
|
25697
|
+
// prompts first; submit lands the strategy / body as the
|
|
25698
|
+
// confirmation payload, which the runner picks up after y.
|
|
25699
|
+
if (inputValue === 'm' && context.pullRequestTriageCount) {
|
|
25700
|
+
return [action({
|
|
25701
|
+
type: 'openInputPrompt',
|
|
25702
|
+
kind: 'triage-pr-merge-strategy',
|
|
25703
|
+
label: 'Merge strategy (merge / squash / rebase)',
|
|
25704
|
+
})];
|
|
25705
|
+
}
|
|
25706
|
+
if (inputValue === 'x' && context.pullRequestTriageCount) {
|
|
25707
|
+
return [action({ type: 'setPendingConfirmation', value: 'triage-pr-close' })];
|
|
25708
|
+
}
|
|
25709
|
+
if (inputValue === 'a' && context.pullRequestTriageCount) {
|
|
25710
|
+
return [action({ type: 'setPendingConfirmation', value: 'triage-pr-approve' })];
|
|
25711
|
+
}
|
|
25712
|
+
if (inputValue === 'R' && context.pullRequestTriageCount) {
|
|
25713
|
+
return [action({
|
|
25714
|
+
type: 'openInputPrompt',
|
|
25715
|
+
kind: 'triage-pr-request-changes',
|
|
25716
|
+
label: 'Request changes — review body (Enter newline · Ctrl+D submit)',
|
|
25717
|
+
multiline: true,
|
|
25718
|
+
})];
|
|
25719
|
+
}
|
|
25720
|
+
// #882 phase 6 — cycle the canned filter preset (open → draft
|
|
25721
|
+
// → mine → assigned → closed → merged → open). The effect in
|
|
25722
|
+
// app.ts watches `state.selectedPullRequestFilter` and refetches
|
|
25723
|
+
// with the matching filter object.
|
|
25724
|
+
if (inputValue === 'f') {
|
|
25725
|
+
return [action({ type: 'cyclePullRequestTriageFilter' })];
|
|
25726
|
+
}
|
|
25727
|
+
}
|
|
24625
25728
|
// Global stash hotkey: `S` opens a stash-message prompt and
|
|
24626
25729
|
// `createStash` runs once submitted. Available everywhere there's
|
|
24627
25730
|
// not a more modal handler in front of it.
|
|
@@ -24872,6 +25975,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
24872
25975
|
if (isSubmodulesActionTarget(state) && context.submoduleCount) {
|
|
24873
25976
|
return [{ type: 'yankFromActiveView', short }];
|
|
24874
25977
|
}
|
|
25978
|
+
// #882 phase 4 — triage views: y yanks the cursored issue / PR
|
|
25979
|
+
// URL so the user can paste it into a chat / PR description
|
|
25980
|
+
// without dropping back to the browser. Y is a no-op on these
|
|
25981
|
+
// views — there's no compact alternate identifier worth a
|
|
25982
|
+
// second key.
|
|
25983
|
+
if (isIssueActionTarget(state) && context.issueSelectedUrl) {
|
|
25984
|
+
return [{ type: 'yankFromActiveView' }];
|
|
25985
|
+
}
|
|
25986
|
+
if (isPullRequestTriageActionTarget(state) && context.pullRequestTriageSelectedUrl) {
|
|
25987
|
+
return [{ type: 'yankFromActiveView' }];
|
|
25988
|
+
}
|
|
24875
25989
|
}
|
|
24876
25990
|
// Enter on a stash row pushes the diff view scoped to that stash.
|
|
24877
25991
|
// The runtime loads `git stash show -p <ref>` once the view is
|
|
@@ -25381,9 +26495,56 @@ const LOG_INK_DEFAULT_ROWS = 40;
|
|
|
25381
26495
|
* gives both views their own air.
|
|
25382
26496
|
*/
|
|
25383
26497
|
const INSPECTOR_TABBED_BELOW_ROWS = 28;
|
|
26498
|
+
/**
|
|
26499
|
+
* Density-tier breakpoints in columns. Picked so the three legacy
|
|
26500
|
+
* panels (sidebar ~24-32 + inspector ~20-32 + main) still leave the
|
|
26501
|
+
* history panel with at least ~40 usable cells before we start
|
|
26502
|
+
* collapsing chrome:
|
|
26503
|
+
*
|
|
26504
|
+
* wide >= 160 — plenty of room; keep absolute dates
|
|
26505
|
+
* normal >= 120 — relative dates save 8-ish cells without hiding info
|
|
26506
|
+
* tight >= 100 — drop date entirely; subject + refs are the priority
|
|
26507
|
+
* rail < 100 — even with side panels collapsed the row is tight;
|
|
26508
|
+
* stack to two lines and rail the side panels at rest
|
|
26509
|
+
*/
|
|
26510
|
+
const LAYOUT_TIGHT_BELOW = 120;
|
|
26511
|
+
const LAYOUT_NORMAL_BELOW = 160;
|
|
26512
|
+
const LAYOUT_RAIL_BELOW = 100;
|
|
26513
|
+
/**
|
|
26514
|
+
* Fixed cell width for a railed side panel. Just wide enough for a
|
|
26515
|
+
* 1-cell icon + a 2-3 digit count after subtracting border (2) and
|
|
26516
|
+
* padding (2). Going narrower clips the count; going wider defeats
|
|
26517
|
+
* the purpose of railing in the first place.
|
|
26518
|
+
*/
|
|
26519
|
+
const LAYOUT_RAIL_PANEL_WIDTH = 8;
|
|
26520
|
+
const SIDEBAR_AT_REST_BY_TIER = {
|
|
26521
|
+
rail: { min: 22, max: 28, fraction: 0.24 }, // unused — rail collapses to LAYOUT_RAIL_PANEL_WIDTH
|
|
26522
|
+
tight: { min: 22, max: 28, fraction: 0.24 },
|
|
26523
|
+
normal: { min: 22, max: 30, fraction: 0.22 },
|
|
26524
|
+
wide: { min: 28, max: 48, fraction: 0.24 },
|
|
26525
|
+
};
|
|
26526
|
+
function calcSidebarAtRestWidth(columns, density) {
|
|
26527
|
+
const config = SIDEBAR_AT_REST_BY_TIER[density];
|
|
26528
|
+
return Math.max(config.min, Math.min(config.max, Math.floor(columns * config.fraction)));
|
|
26529
|
+
}
|
|
25384
26530
|
function getLogInkLayout(input) {
|
|
25385
26531
|
const columns = input.columns || LOG_INK_DEFAULT_COLUMNS;
|
|
25386
26532
|
const rows = input.rows || LOG_INK_DEFAULT_ROWS;
|
|
26533
|
+
const density = columns >= LAYOUT_NORMAL_BELOW
|
|
26534
|
+
? 'wide'
|
|
26535
|
+
: columns >= LAYOUT_TIGHT_BELOW
|
|
26536
|
+
? 'normal'
|
|
26537
|
+
: columns >= LAYOUT_RAIL_BELOW
|
|
26538
|
+
? 'tight'
|
|
26539
|
+
: 'rail';
|
|
26540
|
+
// Rail collapse: only happens at the narrowest tier, and only for
|
|
26541
|
+
// the panel that does NOT currently hold focus AND is not being
|
|
26542
|
+
// commandeered by the help overlay. Focus always wins — pressing
|
|
26543
|
+
// tab to the sidebar pops it back open even on an 80-cell terminal
|
|
26544
|
+
// so the user can actually use it. The help overlay also wins for
|
|
26545
|
+
// the inspector since that's where its descriptions render.
|
|
26546
|
+
const sidebarRailed = density === 'rail' && !input.sidebarFocused;
|
|
26547
|
+
const inspectorRailed = density === 'rail' && !input.inspectorFocused && !input.helpOverlayActive;
|
|
25387
26548
|
// Inspector width — at rest 20-32 cells (~22% of width), focused
|
|
25388
26549
|
// 36-60 cells (~40% of width). Narrow rest state keeps the commit
|
|
25389
26550
|
// graph dominant; focus expansion gives the inspector room for long
|
|
@@ -25394,17 +26555,30 @@ function getLogInkLayout(input) {
|
|
|
25394
26555
|
// hotkey descriptions render in full instead of truncating to
|
|
25395
26556
|
// "Move focus...". Capped at 100 cells so a wide terminal doesn't
|
|
25396
26557
|
// waste an absurd amount of horizontal space on the cheat sheet.
|
|
26558
|
+
//
|
|
26559
|
+
// Rail collapse wins over the at-rest range but loses to focus and
|
|
26560
|
+
// to the help overlay — both of those represent deliberate user
|
|
26561
|
+
// intent to read the panel.
|
|
25397
26562
|
const detailWidth = input.helpOverlayActive
|
|
25398
26563
|
? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
|
|
25399
26564
|
: input.inspectorFocused
|
|
25400
26565
|
? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
|
|
25401
|
-
:
|
|
25402
|
-
|
|
25403
|
-
|
|
25404
|
-
//
|
|
26566
|
+
: inspectorRailed
|
|
26567
|
+
? LAYOUT_RAIL_PANEL_WIDTH
|
|
26568
|
+
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
26569
|
+
// Sidebar at rest is tier-aware (see `SIDEBAR_AT_REST_BY_TIER`):
|
|
26570
|
+
// tight stays compact (22-28), normal shrinks slightly (22-30),
|
|
26571
|
+
// wide grows naturally (28-48) so the side panel doesn't get pinned
|
|
26572
|
+
// at an arbitrary cap on big terminals while the main panel hogs
|
|
26573
|
+
// 80% of the width. Focused: 32-50 cells (~36% of width),
|
|
26574
|
+
// regardless of tier — deliberate user intent to read the sidebar
|
|
26575
|
+
// deserves the extra width. Rail mode (narrow terminal, unfocused)
|
|
26576
|
+
// collapses to a fixed 8-cell strip with tab glyphs only.
|
|
25405
26577
|
const sidebarWidth = input.sidebarFocused
|
|
25406
26578
|
? Math.max(32, Math.min(50, Math.floor(columns * 0.36)))
|
|
25407
|
-
:
|
|
26579
|
+
: sidebarRailed
|
|
26580
|
+
? LAYOUT_RAIL_PANEL_WIDTH
|
|
26581
|
+
: calcSidebarAtRestWidth(columns, density);
|
|
25408
26582
|
return {
|
|
25409
26583
|
bodyRows: Math.max(8, rows - 5),
|
|
25410
26584
|
columns,
|
|
@@ -25414,6 +26588,10 @@ function getLogInkLayout(input) {
|
|
|
25414
26588
|
sidebarWidth,
|
|
25415
26589
|
tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
|
|
25416
26590
|
inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
|
|
26591
|
+
density,
|
|
26592
|
+
sidebarRailed,
|
|
26593
|
+
inspectorRailed,
|
|
26594
|
+
historyRowMode: density === 'rail' ? 'stacked' : 'single',
|
|
25417
26595
|
};
|
|
25418
26596
|
}
|
|
25419
26597
|
|
|
@@ -26123,6 +27301,382 @@ function stageConflictResolved(git, path) {
|
|
|
26123
27301
|
return runAction$1(() => git.raw(['add', '--', path]), `Staged ${path} (marked resolved)`);
|
|
26124
27302
|
}
|
|
26125
27303
|
|
|
27304
|
+
/**
|
|
27305
|
+
* Per-item issue detail fetcher (#882 inspector hydration). The list
|
|
27306
|
+
* payload from `gh issue list` deliberately omits bodies and
|
|
27307
|
+
* comments to keep the list fetch cheap; this module fills those in
|
|
27308
|
+
* on demand when the user rests the cursor on a specific issue.
|
|
27309
|
+
*
|
|
27310
|
+
* Called from the workstation runtime with a debounced timer so
|
|
27311
|
+
* rapid j/k navigation doesn't spam `gh`. Results land in a
|
|
27312
|
+
* `Map<number, IssueDetail>` cache on `LogInkContext` keyed by
|
|
27313
|
+
* issue number, so cursoring back to a previously-fetched item
|
|
27314
|
+
* shows instantly.
|
|
27315
|
+
*/
|
|
27316
|
+
/**
|
|
27317
|
+
* `gh issue view <#> --json` field list. Kept separate from the
|
|
27318
|
+
* list-view field list since the detail view only needs the
|
|
27319
|
+
* fields that the list payload doesn't already carry.
|
|
27320
|
+
*/
|
|
27321
|
+
const ISSUE_DETAIL_JSON_FIELDS = ['number', 'body', 'comments'].join(',');
|
|
27322
|
+
function parseIssueComments(value) {
|
|
27323
|
+
if (!Array.isArray(value))
|
|
27324
|
+
return [];
|
|
27325
|
+
return value.map((entry) => {
|
|
27326
|
+
const raw = entry;
|
|
27327
|
+
const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
|
|
27328
|
+
? String(raw.author.login)
|
|
27329
|
+
: undefined;
|
|
27330
|
+
return {
|
|
27331
|
+
author,
|
|
27332
|
+
body: typeof raw.body === 'string' ? raw.body : '',
|
|
27333
|
+
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
|
|
27334
|
+
};
|
|
27335
|
+
});
|
|
27336
|
+
}
|
|
27337
|
+
function parseIssueDetail(output) {
|
|
27338
|
+
const trimmed = output.trim();
|
|
27339
|
+
if (!trimmed)
|
|
27340
|
+
return undefined;
|
|
27341
|
+
const raw = JSON.parse(trimmed);
|
|
27342
|
+
if (typeof raw.number !== 'number')
|
|
27343
|
+
return undefined;
|
|
27344
|
+
return {
|
|
27345
|
+
number: raw.number,
|
|
27346
|
+
body: typeof raw.body === 'string' ? raw.body : '',
|
|
27347
|
+
comments: parseIssueComments(raw.comments),
|
|
27348
|
+
};
|
|
27349
|
+
}
|
|
27350
|
+
async function getIssueDetail(issueNumber, runner = defaultGhRunner) {
|
|
27351
|
+
try {
|
|
27352
|
+
const output = await runner([
|
|
27353
|
+
'issue',
|
|
27354
|
+
'view',
|
|
27355
|
+
String(issueNumber),
|
|
27356
|
+
'--json',
|
|
27357
|
+
ISSUE_DETAIL_JSON_FIELDS,
|
|
27358
|
+
]);
|
|
27359
|
+
const detail = parseIssueDetail(output);
|
|
27360
|
+
if (!detail) {
|
|
27361
|
+
return { ok: false, message: `Empty response from gh for issue #${issueNumber}` };
|
|
27362
|
+
}
|
|
27363
|
+
return { ok: true, detail };
|
|
27364
|
+
}
|
|
27365
|
+
catch (error) {
|
|
27366
|
+
return {
|
|
27367
|
+
ok: false,
|
|
27368
|
+
message: error instanceof Error ? error.message : String(error),
|
|
27369
|
+
};
|
|
27370
|
+
}
|
|
27371
|
+
}
|
|
27372
|
+
|
|
27373
|
+
/**
|
|
27374
|
+
* Low-risk issue mutations driven from the issue-triage TUI (#882
|
|
27375
|
+
* phase 4). Mirrors `pullRequestActions.ts`'s shape — each function
|
|
27376
|
+
* wraps a single `gh issue <verb>` invocation through the shared
|
|
27377
|
+
* runner indirection so tests can mock the shell-out cleanly.
|
|
27378
|
+
*
|
|
27379
|
+
* "Low risk" here means: reversible by re-invoking with the
|
|
27380
|
+
* opposite flag (`--add-label` ↔ `--remove-label`), or strictly
|
|
27381
|
+
* additive (comment). The destructive verbs (`close`, `reopen`,
|
|
27382
|
+
* `delete`) land in phase 5 alongside the PR-level destructive
|
|
27383
|
+
* mutations, all gated through the y-confirm path.
|
|
27384
|
+
*/
|
|
27385
|
+
async function runGhAction$1(runner, args, successMessage) {
|
|
27386
|
+
try {
|
|
27387
|
+
return successMessage(await runner(args));
|
|
27388
|
+
}
|
|
27389
|
+
catch (error) {
|
|
27390
|
+
return {
|
|
27391
|
+
ok: false,
|
|
27392
|
+
message: error.message,
|
|
27393
|
+
};
|
|
27394
|
+
}
|
|
27395
|
+
}
|
|
27396
|
+
function commentIssue(issueNumber, body, runner = defaultGhRunner) {
|
|
27397
|
+
if (!body.trim()) {
|
|
27398
|
+
return Promise.resolve({ ok: false, message: 'Comment body required' });
|
|
27399
|
+
}
|
|
27400
|
+
return runGhAction$1(runner, ['issue', 'comment', String(issueNumber), '--body', body], (output) => ({
|
|
27401
|
+
ok: true,
|
|
27402
|
+
message: output.trim() || `Commented on issue #${issueNumber}`,
|
|
27403
|
+
}));
|
|
27404
|
+
}
|
|
27405
|
+
function addIssueLabel(issueNumber, label, runner = defaultGhRunner) {
|
|
27406
|
+
if (!label.trim()) {
|
|
27407
|
+
return Promise.resolve({ ok: false, message: 'Label name required' });
|
|
27408
|
+
}
|
|
27409
|
+
return runGhAction$1(runner, ['issue', 'edit', String(issueNumber), '--add-label', label], () => ({
|
|
27410
|
+
ok: true,
|
|
27411
|
+
message: `Added label '${label}' to issue #${issueNumber}`,
|
|
27412
|
+
}));
|
|
27413
|
+
}
|
|
27414
|
+
function addIssueAssignee(issueNumber, assignee, runner = defaultGhRunner) {
|
|
27415
|
+
if (!assignee.trim()) {
|
|
27416
|
+
return Promise.resolve({ ok: false, message: 'Assignee login required' });
|
|
27417
|
+
}
|
|
27418
|
+
return runGhAction$1(runner, ['issue', 'edit', String(issueNumber), '--add-assignee', assignee], () => ({
|
|
27419
|
+
ok: true,
|
|
27420
|
+
message: `Assigned ${assignee} to issue #${issueNumber}`,
|
|
27421
|
+
}));
|
|
27422
|
+
}
|
|
27423
|
+
/**
|
|
27424
|
+
* Destructive issue verbs (#882 phase 5). Both routed through the
|
|
27425
|
+
* y-confirm path in the workstation; the action functions themselves
|
|
27426
|
+
* make no extra guarantee — every gh-side error surfaces via the
|
|
27427
|
+
* standard `runGhAction` error wrapper.
|
|
27428
|
+
*/
|
|
27429
|
+
function closeIssue(issueNumber, runner = defaultGhRunner) {
|
|
27430
|
+
return runGhAction$1(runner, ['issue', 'close', String(issueNumber)], (output) => ({
|
|
27431
|
+
ok: true,
|
|
27432
|
+
message: output.trim() || `Closed issue #${issueNumber}`,
|
|
27433
|
+
}));
|
|
27434
|
+
}
|
|
27435
|
+
function reopenIssue(issueNumber, runner = defaultGhRunner) {
|
|
27436
|
+
return runGhAction$1(runner, ['issue', 'reopen', String(issueNumber)], (output) => ({
|
|
27437
|
+
ok: true,
|
|
27438
|
+
message: output.trim() || `Reopened issue #${issueNumber}`,
|
|
27439
|
+
}));
|
|
27440
|
+
}
|
|
27441
|
+
|
|
27442
|
+
/**
|
|
27443
|
+
* Per-item PR detail fetcher (#882 inspector hydration). Mirrors
|
|
27444
|
+
* `issueDetailData.ts`'s shape — pulls body, comments, reviews,
|
|
27445
|
+
* and the status-check rollup on demand when the user rests the
|
|
27446
|
+
* cursor on a PR row.
|
|
27447
|
+
*
|
|
27448
|
+
* Distinct from the existing `pullRequestData.ts` which fetches
|
|
27449
|
+
* the CURRENT BRANCH's PR via `gh pr view` (no number arg). This
|
|
27450
|
+
* fetcher takes an explicit PR number so the triage view can
|
|
27451
|
+
* hydrate any cursored PR, not just the one matching the current
|
|
27452
|
+
* branch.
|
|
27453
|
+
*/
|
|
27454
|
+
/**
|
|
27455
|
+
* `gh pr view <#> --json` field list. Subset of what
|
|
27456
|
+
* `pullRequestData.ts`'s `PULL_REQUEST_VIEW_JSON_FIELDS` includes —
|
|
27457
|
+
* the triage list payload already carries the structural metadata
|
|
27458
|
+
* (state, isDraft, branches, labels, etc.), so the detail fetch
|
|
27459
|
+
* only needs the heavy/expensive fields that the list omits.
|
|
27460
|
+
*/
|
|
27461
|
+
const PULL_REQUEST_DETAIL_JSON_FIELDS = [
|
|
27462
|
+
'number',
|
|
27463
|
+
'body',
|
|
27464
|
+
'comments',
|
|
27465
|
+
'reviews',
|
|
27466
|
+
'statusCheckRollup',
|
|
27467
|
+
].join(',');
|
|
27468
|
+
function parseComments(value) {
|
|
27469
|
+
if (!Array.isArray(value))
|
|
27470
|
+
return [];
|
|
27471
|
+
return value.map((entry) => {
|
|
27472
|
+
const raw = entry;
|
|
27473
|
+
const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
|
|
27474
|
+
? String(raw.author.login)
|
|
27475
|
+
: undefined;
|
|
27476
|
+
return {
|
|
27477
|
+
author,
|
|
27478
|
+
body: typeof raw.body === 'string' ? raw.body : '',
|
|
27479
|
+
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
|
|
27480
|
+
};
|
|
27481
|
+
});
|
|
27482
|
+
}
|
|
27483
|
+
function parseReviews(value) {
|
|
27484
|
+
if (!Array.isArray(value))
|
|
27485
|
+
return [];
|
|
27486
|
+
return value
|
|
27487
|
+
.map((entry) => {
|
|
27488
|
+
const raw = entry;
|
|
27489
|
+
const author = raw.author && typeof raw.author === 'object' && 'login' in raw.author
|
|
27490
|
+
? String(raw.author.login)
|
|
27491
|
+
: undefined;
|
|
27492
|
+
return {
|
|
27493
|
+
author,
|
|
27494
|
+
state: typeof raw.state === 'string' ? raw.state : '',
|
|
27495
|
+
body: typeof raw.body === 'string' ? raw.body : '',
|
|
27496
|
+
submittedAt: typeof raw.submittedAt === 'string' ? raw.submittedAt : '',
|
|
27497
|
+
};
|
|
27498
|
+
})
|
|
27499
|
+
// gh occasionally returns review entries without an author when the
|
|
27500
|
+
// reviewer's account is deleted. Those are unactionable noise here;
|
|
27501
|
+
// strip them so the inspector doesn't render anonymous rows.
|
|
27502
|
+
.filter((review) => review.author || review.body);
|
|
27503
|
+
}
|
|
27504
|
+
function parseStatusCheckRollup(value) {
|
|
27505
|
+
if (!Array.isArray(value))
|
|
27506
|
+
return [];
|
|
27507
|
+
return value.map((entry) => {
|
|
27508
|
+
const raw = entry;
|
|
27509
|
+
return {
|
|
27510
|
+
name: String(raw.name || raw.context || 'check'),
|
|
27511
|
+
status: typeof raw.status === 'string' ? raw.status : undefined,
|
|
27512
|
+
conclusion: typeof raw.conclusion === 'string' ? raw.conclusion : undefined,
|
|
27513
|
+
};
|
|
27514
|
+
});
|
|
27515
|
+
}
|
|
27516
|
+
function parsePullRequestDetail(output) {
|
|
27517
|
+
const trimmed = output.trim();
|
|
27518
|
+
if (!trimmed)
|
|
27519
|
+
return undefined;
|
|
27520
|
+
const raw = JSON.parse(trimmed);
|
|
27521
|
+
if (typeof raw.number !== 'number')
|
|
27522
|
+
return undefined;
|
|
27523
|
+
return {
|
|
27524
|
+
number: raw.number,
|
|
27525
|
+
body: typeof raw.body === 'string' ? raw.body : '',
|
|
27526
|
+
comments: parseComments(raw.comments),
|
|
27527
|
+
reviews: parseReviews(raw.reviews),
|
|
27528
|
+
statusCheckRollup: parseStatusCheckRollup(raw.statusCheckRollup),
|
|
27529
|
+
};
|
|
27530
|
+
}
|
|
27531
|
+
async function getPullRequestDetail(pullRequestNumber, runner = defaultGhRunner) {
|
|
27532
|
+
try {
|
|
27533
|
+
const output = await runner([
|
|
27534
|
+
'pr',
|
|
27535
|
+
'view',
|
|
27536
|
+
String(pullRequestNumber),
|
|
27537
|
+
'--json',
|
|
27538
|
+
PULL_REQUEST_DETAIL_JSON_FIELDS,
|
|
27539
|
+
]);
|
|
27540
|
+
const detail = parsePullRequestDetail(output);
|
|
27541
|
+
if (!detail) {
|
|
27542
|
+
return {
|
|
27543
|
+
ok: false,
|
|
27544
|
+
message: `Empty response from gh for pull request #${pullRequestNumber}`,
|
|
27545
|
+
};
|
|
27546
|
+
}
|
|
27547
|
+
return { ok: true, detail };
|
|
27548
|
+
}
|
|
27549
|
+
catch (error) {
|
|
27550
|
+
return {
|
|
27551
|
+
ok: false,
|
|
27552
|
+
message: error instanceof Error ? error.message : String(error),
|
|
27553
|
+
};
|
|
27554
|
+
}
|
|
27555
|
+
}
|
|
27556
|
+
|
|
27557
|
+
/**
|
|
27558
|
+
* `gh pr list --json` field list. Trimmer than `pullRequestData.ts`'s
|
|
27559
|
+
* single-PR field set — the triage list view doesn't need bodies,
|
|
27560
|
+
* statusCheckRollup, or per-review breakdowns (those live in the
|
|
27561
|
+
* inspector that opens when the user picks a row).
|
|
27562
|
+
*/
|
|
27563
|
+
const PULL_REQUEST_LIST_JSON_FIELDS = [
|
|
27564
|
+
'number',
|
|
27565
|
+
'title',
|
|
27566
|
+
'url',
|
|
27567
|
+
'state',
|
|
27568
|
+
'isDraft',
|
|
27569
|
+
'headRefName',
|
|
27570
|
+
'baseRefName',
|
|
27571
|
+
'author',
|
|
27572
|
+
'assignees',
|
|
27573
|
+
'labels',
|
|
27574
|
+
'reviewDecision',
|
|
27575
|
+
'mergeable',
|
|
27576
|
+
'mergeStateStatus',
|
|
27577
|
+
'createdAt',
|
|
27578
|
+
'updatedAt',
|
|
27579
|
+
].join(',');
|
|
27580
|
+
function parsePullRequestListItems(output) {
|
|
27581
|
+
const trimmed = output.trim();
|
|
27582
|
+
if (!trimmed)
|
|
27583
|
+
return [];
|
|
27584
|
+
const raw = JSON.parse(trimmed);
|
|
27585
|
+
return raw.map((entry) => {
|
|
27586
|
+
const author = entry.author && typeof entry.author === 'object' && 'login' in entry.author
|
|
27587
|
+
? String(entry.author.login)
|
|
27588
|
+
: undefined;
|
|
27589
|
+
const assignees = Array.isArray(entry.assignees)
|
|
27590
|
+
? entry.assignees
|
|
27591
|
+
.map((a) => (a && 'login' in a ? String(a.login) : ''))
|
|
27592
|
+
.filter(Boolean)
|
|
27593
|
+
: undefined;
|
|
27594
|
+
const labels = Array.isArray(entry.labels)
|
|
27595
|
+
? entry.labels
|
|
27596
|
+
.map((l) => (l && 'name' in l ? String(l.name) : ''))
|
|
27597
|
+
.filter(Boolean)
|
|
27598
|
+
: undefined;
|
|
27599
|
+
return {
|
|
27600
|
+
number: entry.number,
|
|
27601
|
+
title: String(entry.title || ''),
|
|
27602
|
+
url: String(entry.url || ''),
|
|
27603
|
+
state: String(entry.state || ''),
|
|
27604
|
+
isDraft: Boolean(entry.isDraft),
|
|
27605
|
+
headRefName: String(entry.headRefName || ''),
|
|
27606
|
+
baseRefName: String(entry.baseRefName || ''),
|
|
27607
|
+
author,
|
|
27608
|
+
assignees,
|
|
27609
|
+
labels,
|
|
27610
|
+
reviewDecision: typeof entry.reviewDecision === 'string' ? entry.reviewDecision : undefined,
|
|
27611
|
+
mergeable: typeof entry.mergeable === 'string' ? entry.mergeable : undefined,
|
|
27612
|
+
mergeStateStatus: typeof entry.mergeStateStatus === 'string' ? entry.mergeStateStatus : undefined,
|
|
27613
|
+
createdAt: String(entry.createdAt || ''),
|
|
27614
|
+
updatedAt: String(entry.updatedAt || ''),
|
|
27615
|
+
};
|
|
27616
|
+
});
|
|
27617
|
+
}
|
|
27618
|
+
function buildGhArgs(filter) {
|
|
27619
|
+
const args = ['pr', 'list', '--json', PULL_REQUEST_LIST_JSON_FIELDS];
|
|
27620
|
+
if (filter.state)
|
|
27621
|
+
args.push('--state', filter.state);
|
|
27622
|
+
if (filter.assignee)
|
|
27623
|
+
args.push('--assignee', filter.assignee);
|
|
27624
|
+
if (filter.author)
|
|
27625
|
+
args.push('--author', filter.author);
|
|
27626
|
+
if (filter.label)
|
|
27627
|
+
args.push('--label', filter.label);
|
|
27628
|
+
if (filter.search)
|
|
27629
|
+
args.push('--search', filter.search);
|
|
27630
|
+
if (filter.draft)
|
|
27631
|
+
args.push('--draft');
|
|
27632
|
+
if (filter.base)
|
|
27633
|
+
args.push('--base', filter.base);
|
|
27634
|
+
if (filter.head)
|
|
27635
|
+
args.push('--head', filter.head);
|
|
27636
|
+
if (typeof filter.limit === 'number')
|
|
27637
|
+
args.push('--limit', String(filter.limit));
|
|
27638
|
+
return args;
|
|
27639
|
+
}
|
|
27640
|
+
async function getPullRequestList(git, filter = {}, runner = defaultGhRunner) {
|
|
27641
|
+
const repository = await getGitHubRepository(git);
|
|
27642
|
+
if (!repository) {
|
|
27643
|
+
return {
|
|
27644
|
+
available: false,
|
|
27645
|
+
authenticated: false,
|
|
27646
|
+
filter,
|
|
27647
|
+
message: 'No GitHub remote detected.',
|
|
27648
|
+
};
|
|
27649
|
+
}
|
|
27650
|
+
if (!(await isGhAuthenticated(runner))) {
|
|
27651
|
+
return {
|
|
27652
|
+
available: true,
|
|
27653
|
+
authenticated: false,
|
|
27654
|
+
repository,
|
|
27655
|
+
filter,
|
|
27656
|
+
message: 'GitHub CLI is missing or not authenticated.',
|
|
27657
|
+
};
|
|
27658
|
+
}
|
|
27659
|
+
try {
|
|
27660
|
+
const output = await runner(buildGhArgs(filter));
|
|
27661
|
+
return {
|
|
27662
|
+
available: true,
|
|
27663
|
+
authenticated: true,
|
|
27664
|
+
repository,
|
|
27665
|
+
filter,
|
|
27666
|
+
pullRequests: parsePullRequestListItems(output),
|
|
27667
|
+
};
|
|
27668
|
+
}
|
|
27669
|
+
catch (error) {
|
|
27670
|
+
return {
|
|
27671
|
+
available: true,
|
|
27672
|
+
authenticated: true,
|
|
27673
|
+
repository,
|
|
27674
|
+
filter,
|
|
27675
|
+
message: error instanceof Error ? error.message : 'Failed to fetch pull request list.',
|
|
27676
|
+
};
|
|
27677
|
+
}
|
|
27678
|
+
}
|
|
27679
|
+
|
|
26126
27680
|
function parseCreatedPullRequestUrl(output) {
|
|
26127
27681
|
return output
|
|
26128
27682
|
.split('\n')
|
|
@@ -26223,6 +27777,77 @@ function commentPullRequest(body, runner = defaultGhRunner) {
|
|
|
26223
27777
|
message: output.trim() || 'Comment added',
|
|
26224
27778
|
}));
|
|
26225
27779
|
}
|
|
27780
|
+
/**
|
|
27781
|
+
* Triage-view variants (#882 phase 4). Same shape as
|
|
27782
|
+
* `commentPullRequest` above but target a specific PR by number,
|
|
27783
|
+
* which is what the multi-PR triage list needs (the cursor isn't
|
|
27784
|
+
* necessarily on the current branch's PR). Kept as siblings rather
|
|
27785
|
+
* than overloads so the single-PR call sites stay untouched and
|
|
27786
|
+
* the API stays readable at the call site (no need to pass
|
|
27787
|
+
* `undefined` to skip the number arg).
|
|
27788
|
+
*/
|
|
27789
|
+
function commentPullRequestByNumber(pullRequestNumber, body, runner = defaultGhRunner) {
|
|
27790
|
+
if (!body.trim()) {
|
|
27791
|
+
return Promise.resolve({ ok: false, message: 'Comment body required' });
|
|
27792
|
+
}
|
|
27793
|
+
return runGhAction(runner, ['pr', 'comment', String(pullRequestNumber), '--body', body], (output) => ({
|
|
27794
|
+
ok: true,
|
|
27795
|
+
message: output.trim() || `Commented on pull request #${pullRequestNumber}`,
|
|
27796
|
+
}));
|
|
27797
|
+
}
|
|
27798
|
+
function addPullRequestLabel(pullRequestNumber, label, runner = defaultGhRunner) {
|
|
27799
|
+
if (!label.trim()) {
|
|
27800
|
+
return Promise.resolve({ ok: false, message: 'Label name required' });
|
|
27801
|
+
}
|
|
27802
|
+
return runGhAction(runner, ['pr', 'edit', String(pullRequestNumber), '--add-label', label], () => ({
|
|
27803
|
+
ok: true,
|
|
27804
|
+
message: `Added label '${label}' to pull request #${pullRequestNumber}`,
|
|
27805
|
+
}));
|
|
27806
|
+
}
|
|
27807
|
+
function addPullRequestAssignee(pullRequestNumber, assignee, runner = defaultGhRunner) {
|
|
27808
|
+
if (!assignee.trim()) {
|
|
27809
|
+
return Promise.resolve({ ok: false, message: 'Assignee login required' });
|
|
27810
|
+
}
|
|
27811
|
+
return runGhAction(runner, ['pr', 'edit', String(pullRequestNumber), '--add-assignee', assignee], () => ({
|
|
27812
|
+
ok: true,
|
|
27813
|
+
message: `Assigned ${assignee} to pull request #${pullRequestNumber}`,
|
|
27814
|
+
}));
|
|
27815
|
+
}
|
|
27816
|
+
/**
|
|
27817
|
+
* Destructive PR verbs targeting a specific number (#882 phase 5).
|
|
27818
|
+
* Siblings of the existing current-branch functions above; both
|
|
27819
|
+
* variants delegate to the same `runGhAction` wrapper so error
|
|
27820
|
+
* shaping stays uniform.
|
|
27821
|
+
*/
|
|
27822
|
+
function mergePullRequestByNumber(pullRequestNumber, strategy, runner = defaultGhRunner) {
|
|
27823
|
+
return runGhAction(runner, ['pr', 'merge', String(pullRequestNumber), `--${strategy}`], (output) => ({
|
|
27824
|
+
ok: true,
|
|
27825
|
+
message: output.trim() ||
|
|
27826
|
+
`Merged pull request #${pullRequestNumber} with ${strategy}`,
|
|
27827
|
+
}));
|
|
27828
|
+
}
|
|
27829
|
+
function closePullRequestByNumber(pullRequestNumber, runner = defaultGhRunner) {
|
|
27830
|
+
return runGhAction(runner, ['pr', 'close', String(pullRequestNumber)], (output) => ({
|
|
27831
|
+
ok: true,
|
|
27832
|
+
message: output.trim() || `Closed pull request #${pullRequestNumber}`,
|
|
27833
|
+
}));
|
|
27834
|
+
}
|
|
27835
|
+
function approvePullRequestByNumber(pullRequestNumber, runner = defaultGhRunner) {
|
|
27836
|
+
return runGhAction(runner, ['pr', 'review', String(pullRequestNumber), '--approve'], (output) => ({
|
|
27837
|
+
ok: true,
|
|
27838
|
+
message: output.trim() || `Approved pull request #${pullRequestNumber}`,
|
|
27839
|
+
}));
|
|
27840
|
+
}
|
|
27841
|
+
function requestChangesPullRequestByNumber(pullRequestNumber, body, runner = defaultGhRunner) {
|
|
27842
|
+
if (!body.trim()) {
|
|
27843
|
+
return Promise.resolve({ ok: false, message: 'Review body required for change-request' });
|
|
27844
|
+
}
|
|
27845
|
+
return runGhAction(runner, ['pr', 'review', String(pullRequestNumber), '--request-changes', '--body', body], (output) => ({
|
|
27846
|
+
ok: true,
|
|
27847
|
+
message: output.trim() ||
|
|
27848
|
+
`Requested changes on pull request #${pullRequestNumber}`,
|
|
27849
|
+
}));
|
|
27850
|
+
}
|
|
26226
27851
|
|
|
26227
27852
|
async function runAction(action, successMessage) {
|
|
26228
27853
|
try {
|
|
@@ -27532,10 +29157,70 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
27532
29157
|
return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
|
|
27533
29158
|
}, 'tab-worktrees', visibleListCount);
|
|
27534
29159
|
}
|
|
27535
|
-
|
|
29160
|
+
/**
|
|
29161
|
+
* Single-letter glyph for a sidebar tab in rail mode. Letters always
|
|
29162
|
+
* carry the meaning so this stays useful under ASCII; the rail is too
|
|
29163
|
+
* narrow to fit the full tab label. Pairs with `sidebarTabCount` for
|
|
29164
|
+
* the trailing count.
|
|
29165
|
+
*/
|
|
29166
|
+
function sidebarTabRailGlyph(tab) {
|
|
29167
|
+
switch (tab) {
|
|
29168
|
+
case 'status':
|
|
29169
|
+
return 'S';
|
|
29170
|
+
case 'branches':
|
|
29171
|
+
return 'B';
|
|
29172
|
+
case 'tags':
|
|
29173
|
+
return 'T';
|
|
29174
|
+
case 'stashes':
|
|
29175
|
+
return '$';
|
|
29176
|
+
case 'worktrees':
|
|
29177
|
+
return 'W';
|
|
29178
|
+
default:
|
|
29179
|
+
return '·';
|
|
29180
|
+
}
|
|
29181
|
+
}
|
|
29182
|
+
/**
|
|
29183
|
+
* Rail-mode sidebar — shown on terminals < 100 columns when the
|
|
29184
|
+
* sidebar does not hold focus. Five vertically stacked tab glyphs
|
|
29185
|
+
* with optional counts; the active tab is bracketed. Pressing Tab to
|
|
29186
|
+
* focus the sidebar pops it back to the full accordion (the layout
|
|
29187
|
+
* un-rails it on focus, this renderer is never called in that case).
|
|
29188
|
+
*/
|
|
29189
|
+
function renderSidebarRail(h, components, state, context, width, theme, focused, tabs) {
|
|
29190
|
+
const { Box, Text } = components;
|
|
29191
|
+
return h(Box, {
|
|
29192
|
+
borderColor: focusBorderColor(theme, focused),
|
|
29193
|
+
borderStyle: theme.borderStyle,
|
|
29194
|
+
flexDirection: 'column',
|
|
29195
|
+
width,
|
|
29196
|
+
paddingX: 1,
|
|
29197
|
+
}, h(Text, { bold: true, dimColor: !focused }, 'Repo'), h(Text, { dimColor: true }, '────'), ...tabs.map((tab) => {
|
|
29198
|
+
const isActive = tab === state.sidebarTab;
|
|
29199
|
+
const glyph = sidebarTabRailGlyph(tab);
|
|
29200
|
+
const count = sidebarTabCount(tab, context);
|
|
29201
|
+
// Count fits in 2 cells (rail content area is ~4 cells); 99+
|
|
29202
|
+
// collapses to `+` so we never overflow.
|
|
29203
|
+
const countText = count === undefined
|
|
29204
|
+
? ''
|
|
29205
|
+
: count > 99
|
|
29206
|
+
? '+'
|
|
29207
|
+
: String(count);
|
|
29208
|
+
const body = isActive ? `[${glyph}]` : ` ${glyph} `;
|
|
29209
|
+
const text = countText ? `${body}${countText}` : body;
|
|
29210
|
+
return h(Text, {
|
|
29211
|
+
key: `rail-${tab}`,
|
|
29212
|
+
bold: isActive,
|
|
29213
|
+
dimColor: !isActive,
|
|
29214
|
+
}, text);
|
|
29215
|
+
}));
|
|
29216
|
+
}
|
|
29217
|
+
function renderSidebar(h, components, state, context, contextStatus, width, bodyRows, theme, railed = false) {
|
|
27536
29218
|
const { Box, Text } = components;
|
|
27537
29219
|
const focused = state.focus === 'sidebar';
|
|
27538
29220
|
const tabs = getLogInkSidebarTabs();
|
|
29221
|
+
if (railed) {
|
|
29222
|
+
return renderSidebarRail(h, components, state, context, width, theme, focused, tabs);
|
|
29223
|
+
}
|
|
27539
29224
|
// Accordion layout — every tab's title is visible on its own line, but
|
|
27540
29225
|
// only the active tab expands its content underneath. Switching tabs
|
|
27541
29226
|
// (1-5 / [/]) collapses the previous and expands the next.
|
|
@@ -27822,6 +29507,34 @@ function formatLogInkSubmodulesEmpty({ filter }) {
|
|
|
27822
29507
|
}
|
|
27823
29508
|
return 'No submodules registered. Add one with `git submodule add <url> <path>` from the shell.';
|
|
27824
29509
|
}
|
|
29510
|
+
function formatLogInkIssuesEmpty({ filter }) {
|
|
29511
|
+
if (filter.trim()) {
|
|
29512
|
+
return `No issues match filter '${filter}'. Press ctrl+u to clear.`;
|
|
29513
|
+
}
|
|
29514
|
+
return 'No issues match the current GitHub filter (default: open issues).';
|
|
29515
|
+
}
|
|
29516
|
+
function formatLogInkPullRequestTriageEmpty({ filter, }) {
|
|
29517
|
+
if (filter.trim()) {
|
|
29518
|
+
return `No pull requests match filter '${filter}'. Press ctrl+u to clear.`;
|
|
29519
|
+
}
|
|
29520
|
+
return 'No pull requests match the current GitHub filter (default: open PRs).';
|
|
29521
|
+
}
|
|
29522
|
+
/**
|
|
29523
|
+
* Surface-level fallback when the GitHub CLI is missing or not
|
|
29524
|
+
* authenticated. The triage views (#882) all share this empty-state
|
|
29525
|
+
* copy — the underlying problem is the same regardless of which
|
|
29526
|
+
* surface the user is on, and the recovery is identical.
|
|
29527
|
+
*/
|
|
29528
|
+
function formatLogInkGitHubUnauthenticated({ resource, }) {
|
|
29529
|
+
return `${resource} require the GitHub CLI. Install \`gh\` and run \`gh auth login\` to enable triage.`;
|
|
29530
|
+
}
|
|
29531
|
+
/**
|
|
29532
|
+
* Surface-level fallback when the repo has no GitHub remote. Same
|
|
29533
|
+
* shared message across the triage surfaces.
|
|
29534
|
+
*/
|
|
29535
|
+
function formatLogInkGitHubNoRemote({ resource, }) {
|
|
29536
|
+
return `${resource} require a GitHub remote (origin or fallback). None detected for this repo.`;
|
|
29537
|
+
}
|
|
27825
29538
|
|
|
27826
29539
|
/**
|
|
27827
29540
|
* Branches surface — promoted view listing local branches with sort,
|
|
@@ -28712,6 +30425,179 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
28712
30425
|
: []));
|
|
28713
30426
|
}
|
|
28714
30427
|
|
|
30428
|
+
/**
|
|
30429
|
+
* Strip refs that are already represented by a branch tip chip so the
|
|
30430
|
+
* trailing `[ref] [ref]` list doesn't repeat what the chip is already
|
|
30431
|
+
* showing. The chip carries the primary branch name; the trailing
|
|
30432
|
+
* list keeps everything else — including remote-tracking variants
|
|
30433
|
+
* (`origin/X`) and `origin/HEAD` — because those convey "remote is
|
|
30434
|
+
* also at this commit" info the chip alone doesn't.
|
|
30435
|
+
*
|
|
30436
|
+
* Removes:
|
|
30437
|
+
* - exact match of the chipped name (`main`, `feat/foo`)
|
|
30438
|
+
* - `HEAD -> <name>` for the chipped name
|
|
30439
|
+
* - bare `HEAD` when the chip is the HEAD branch (only paranoia;
|
|
30440
|
+
* git typically emits `HEAD -> name` not both, but a detached
|
|
30441
|
+
* fixup commit may have produced both)
|
|
30442
|
+
*/
|
|
30443
|
+
function filterChippedRefs(refs, chip) {
|
|
30444
|
+
if (!chip)
|
|
30445
|
+
return refs;
|
|
30446
|
+
const headDecoration = `HEAD -> ${chip.name}`;
|
|
30447
|
+
return refs.filter((ref) => {
|
|
30448
|
+
if (ref === chip.name)
|
|
30449
|
+
return false;
|
|
30450
|
+
if (ref === headDecoration)
|
|
30451
|
+
return false;
|
|
30452
|
+
if (chip.isHead && ref === 'HEAD')
|
|
30453
|
+
return false;
|
|
30454
|
+
return true;
|
|
30455
|
+
});
|
|
30456
|
+
}
|
|
30457
|
+
function getBranchTipChip(refs) {
|
|
30458
|
+
for (const ref of refs) {
|
|
30459
|
+
if (ref.startsWith('HEAD -> ')) {
|
|
30460
|
+
const name = ref.slice('HEAD -> '.length).trim();
|
|
30461
|
+
if (name)
|
|
30462
|
+
return { name, isHead: true };
|
|
30463
|
+
}
|
|
30464
|
+
}
|
|
30465
|
+
for (const ref of refs) {
|
|
30466
|
+
if (ref === 'HEAD' ||
|
|
30467
|
+
ref.startsWith('HEAD -> ') ||
|
|
30468
|
+
ref.startsWith('tag: ') ||
|
|
30469
|
+
ref.includes('/')) {
|
|
30470
|
+
continue;
|
|
30471
|
+
}
|
|
30472
|
+
if (ref.trim())
|
|
30473
|
+
return { name: ref.trim(), isHead: false };
|
|
30474
|
+
}
|
|
30475
|
+
for (const ref of refs) {
|
|
30476
|
+
if (ref.startsWith('tag: ') || ref === 'HEAD' || ref.startsWith('HEAD -> ')) {
|
|
30477
|
+
continue;
|
|
30478
|
+
}
|
|
30479
|
+
if (ref.includes('/') && ref.trim()) {
|
|
30480
|
+
return { name: ref.trim(), isHead: false };
|
|
30481
|
+
}
|
|
30482
|
+
}
|
|
30483
|
+
return undefined;
|
|
30484
|
+
}
|
|
30485
|
+
|
|
30486
|
+
const PREFIX_PATTERN = /^([a-z]+)(\(([^)]+)\))?(!)?:\s+/;
|
|
30487
|
+
function parseConventionalCommitPrefix(message) {
|
|
30488
|
+
const match = PREFIX_PATTERN.exec(message);
|
|
30489
|
+
if (!match)
|
|
30490
|
+
return undefined;
|
|
30491
|
+
const [whole, type, , scope, breakingMarker] = match;
|
|
30492
|
+
return {
|
|
30493
|
+
prefix: whole,
|
|
30494
|
+
rest: message.slice(whole.length),
|
|
30495
|
+
type,
|
|
30496
|
+
scope: scope || undefined,
|
|
30497
|
+
breaking: Boolean(breakingMarker),
|
|
30498
|
+
};
|
|
30499
|
+
}
|
|
30500
|
+
/**
|
|
30501
|
+
* Pick the theme color used to paint a conventional-commit prefix.
|
|
30502
|
+
*
|
|
30503
|
+
* Rough mapping intent:
|
|
30504
|
+
* - feat → success (new capability, growth)
|
|
30505
|
+
* - fix → warning (was a problem; eye-catch)
|
|
30506
|
+
* - docs / refactor / perf → info / accent (intent-bearing change)
|
|
30507
|
+
* - test / style / build / ci → muted (mechanical / housekeeping)
|
|
30508
|
+
* - chore → muted
|
|
30509
|
+
* - revert → danger (signals "this undid something")
|
|
30510
|
+
*
|
|
30511
|
+
* Unknown types fall through to `accent` so a project-specific
|
|
30512
|
+
* convention (`wip:`, `release:`, etc.) still reads as the typed
|
|
30513
|
+
* prefix rather than blending into the subject. Returns `undefined`
|
|
30514
|
+
* under `theme.noColor` so the prefix stays plain — the textual
|
|
30515
|
+
* `feat:` carries the meaning by itself.
|
|
30516
|
+
*
|
|
30517
|
+
* Breaking changes (`!:`) override the type color with `danger` so
|
|
30518
|
+
* the row reads as "stop and look at this" regardless of which type
|
|
30519
|
+
* it is.
|
|
30520
|
+
*/
|
|
30521
|
+
function getConventionalCommitColor(parsed, theme) {
|
|
30522
|
+
if (theme.noColor)
|
|
30523
|
+
return undefined;
|
|
30524
|
+
if (parsed.breaking)
|
|
30525
|
+
return theme.colors.danger;
|
|
30526
|
+
switch (parsed.type) {
|
|
30527
|
+
case 'feat':
|
|
30528
|
+
return theme.colors.success;
|
|
30529
|
+
case 'fix':
|
|
30530
|
+
return theme.colors.warning;
|
|
30531
|
+
case 'docs':
|
|
30532
|
+
case 'refactor':
|
|
30533
|
+
case 'perf':
|
|
30534
|
+
return theme.colors.info;
|
|
30535
|
+
case 'test':
|
|
30536
|
+
case 'style':
|
|
30537
|
+
case 'build':
|
|
30538
|
+
case 'ci':
|
|
30539
|
+
case 'chore':
|
|
30540
|
+
return theme.colors.muted;
|
|
30541
|
+
case 'revert':
|
|
30542
|
+
return theme.colors.danger;
|
|
30543
|
+
default:
|
|
30544
|
+
return theme.colors.accent;
|
|
30545
|
+
}
|
|
30546
|
+
}
|
|
30547
|
+
|
|
30548
|
+
/**
|
|
30549
|
+
* Date formatting helpers for the Ink TUI surfaces.
|
|
30550
|
+
*
|
|
30551
|
+
* The branch list already ships its own "X ago" formatter
|
|
30552
|
+
* (`formatBranchLastTouched` in iconography.ts) sized for a sidebar
|
|
30553
|
+
* row with room to breathe. The history surface needs a tighter
|
|
30554
|
+
* variant: the date column is fixed-width and competes with the
|
|
30555
|
+
* commit message for cells, so a 2-3 character form is the budget.
|
|
30556
|
+
*
|
|
30557
|
+
* Inputs match what `git log --date=short` produces:
|
|
30558
|
+
* `YYYY-MM-DD`. Caller passes `now` so tests can pin the reference
|
|
30559
|
+
* instant.
|
|
30560
|
+
*
|
|
30561
|
+
* Outputs (rounded toward the nearest unit, no `ago` suffix):
|
|
30562
|
+
* - `today` for same UTC day
|
|
30563
|
+
* - `1d` … `13d` for 1-13 days
|
|
30564
|
+
* - `2w` … `8w` for 2-8 weeks
|
|
30565
|
+
* - `2mo` … `11mo` for 2-11 months
|
|
30566
|
+
* - `2y`+ for older
|
|
30567
|
+
* - `''` for malformed inputs (caller renders nothing)
|
|
30568
|
+
*
|
|
30569
|
+
* Day comparison is in UTC so a commit dated "yesterday" never reads
|
|
30570
|
+
* "today" depending on the operator's timezone.
|
|
30571
|
+
*/
|
|
30572
|
+
function formatCompactRelativeDate(iso, now) {
|
|
30573
|
+
if (!iso)
|
|
30574
|
+
return '';
|
|
30575
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
|
30576
|
+
if (!match)
|
|
30577
|
+
return '';
|
|
30578
|
+
const year = Number.parseInt(match[1], 10);
|
|
30579
|
+
const month = Number.parseInt(match[2], 10);
|
|
30580
|
+
const day = Number.parseInt(match[3], 10);
|
|
30581
|
+
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day))
|
|
30582
|
+
return '';
|
|
30583
|
+
const commitUtc = Date.UTC(year, month - 1, day);
|
|
30584
|
+
const nowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
30585
|
+
const oneDay = 24 * 60 * 60 * 1000;
|
|
30586
|
+
const days = Math.floor((nowUtc - commitUtc) / oneDay);
|
|
30587
|
+
if (days <= 0)
|
|
30588
|
+
return 'today';
|
|
30589
|
+
if (days < 14)
|
|
30590
|
+
return `${days}d`;
|
|
30591
|
+
const weeks = Math.floor(days / 7);
|
|
30592
|
+
if (weeks < 9)
|
|
30593
|
+
return `${weeks}w`;
|
|
30594
|
+
const months = Math.floor(days / 30);
|
|
30595
|
+
if (months < 12)
|
|
30596
|
+
return `${months}mo`;
|
|
30597
|
+
const years = Math.floor(days / 365);
|
|
30598
|
+
return `${years}y`;
|
|
30599
|
+
}
|
|
30600
|
+
|
|
28715
30601
|
/**
|
|
28716
30602
|
* The chars `git log --graph` emits for branch topology — `*`, `|`, `\`,
|
|
28717
30603
|
* `/`, `_`, ` `. ASCII-only output is bulletproof for legacy terminals
|
|
@@ -28961,6 +30847,54 @@ function getLaneColor(laneId, theme) {
|
|
|
28961
30847
|
return palette[laneId % palette.length];
|
|
28962
30848
|
}
|
|
28963
30849
|
|
|
30850
|
+
const MONTH_NAMES = [
|
|
30851
|
+
'January', 'February', 'March', 'April', 'May', 'June',
|
|
30852
|
+
'July', 'August', 'September', 'October', 'November', 'December',
|
|
30853
|
+
];
|
|
30854
|
+
function getDateBucket(iso, now) {
|
|
30855
|
+
if (!iso)
|
|
30856
|
+
return { key: 'unknown', label: 'Unknown date' };
|
|
30857
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
|
30858
|
+
if (!match)
|
|
30859
|
+
return { key: 'unknown', label: 'Unknown date' };
|
|
30860
|
+
const year = Number.parseInt(match[1], 10);
|
|
30861
|
+
const month = Number.parseInt(match[2], 10);
|
|
30862
|
+
const day = Number.parseInt(match[3], 10);
|
|
30863
|
+
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
|
|
30864
|
+
return { key: 'unknown', label: 'Unknown date' };
|
|
30865
|
+
}
|
|
30866
|
+
const commitUtc = Date.UTC(year, month - 1, day);
|
|
30867
|
+
const nowUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
30868
|
+
const oneDay = 24 * 60 * 60 * 1000;
|
|
30869
|
+
const days = Math.floor((nowUtc - commitUtc) / oneDay);
|
|
30870
|
+
// Future-dated commits (clock skew, bad commit dates) collapse to
|
|
30871
|
+
// today rather than confusing the user with an "in the future"
|
|
30872
|
+
// bucket.
|
|
30873
|
+
if (days <= 0)
|
|
30874
|
+
return { key: 'today', label: 'Today' };
|
|
30875
|
+
if (days === 1)
|
|
30876
|
+
return { key: 'yesterday', label: 'Yesterday' };
|
|
30877
|
+
if (days < 7)
|
|
30878
|
+
return { key: 'this-week', label: 'This week' };
|
|
30879
|
+
if (days < 14)
|
|
30880
|
+
return { key: 'last-week', label: 'Last week' };
|
|
30881
|
+
// Inside the same calendar month → one "earlier this month" bucket
|
|
30882
|
+
// so the user sees a single section rather than per-day groupings
|
|
30883
|
+
// for a commit-heavy week.
|
|
30884
|
+
if (year === now.getUTCFullYear() && month - 1 === now.getUTCMonth()) {
|
|
30885
|
+
return { key: 'earlier-this-month', label: 'Earlier this month' };
|
|
30886
|
+
}
|
|
30887
|
+
// Older months use the calendar-month label so the bucket reads
|
|
30888
|
+
// naturally even years back (`April 2024`). The key embeds the
|
|
30889
|
+
// year+month so different months stay in distinct buckets without
|
|
30890
|
+
// colliding on month name alone.
|
|
30891
|
+
const monthLabel = MONTH_NAMES[month - 1] ?? `Month ${month}`;
|
|
30892
|
+
return {
|
|
30893
|
+
key: `month-${match[1]}-${match[2]}`,
|
|
30894
|
+
label: `${monthLabel} ${year}`,
|
|
30895
|
+
};
|
|
30896
|
+
}
|
|
30897
|
+
|
|
28964
30898
|
/**
|
|
28965
30899
|
* Pick the commit glyph based on parent count + HEAD-ness so the
|
|
28966
30900
|
* renderer can flag merges and the current head visually. HEAD wins
|
|
@@ -28989,9 +30923,8 @@ function commitKey(commit) {
|
|
|
28989
30923
|
function graphWidth(items) {
|
|
28990
30924
|
return Math.max(1, ...items.map((item) => item.graph.length));
|
|
28991
30925
|
}
|
|
28992
|
-
function
|
|
28993
|
-
|
|
28994
|
-
return state.filteredCommits.slice(start, start + visibleCount).map((commit, offset) => ({
|
|
30926
|
+
function makeCompactCommitItem(commit, selected) {
|
|
30927
|
+
return {
|
|
28995
30928
|
type: 'commit',
|
|
28996
30929
|
commit,
|
|
28997
30930
|
graph: '*',
|
|
@@ -29000,24 +30933,197 @@ function toCompactItems(state, visibleCount) {
|
|
|
29000
30933
|
// glance. Lane id stays undefined so the segment renders muted —
|
|
29001
30934
|
// matching the legacy compact appearance, just with a richer glyph.
|
|
29002
30935
|
laneSegments: [{ text: commitGlyphFor(commit), laneId: undefined }],
|
|
29003
|
-
selected
|
|
29004
|
-
}
|
|
30936
|
+
selected,
|
|
30937
|
+
};
|
|
30938
|
+
}
|
|
30939
|
+
function bucketHeaderItem(label) {
|
|
30940
|
+
return { type: 'bucket-header', graph: '', label };
|
|
30941
|
+
}
|
|
30942
|
+
function toCompactItems(state, visibleCount, bucketingNow) {
|
|
30943
|
+
const start = clampWindowStart(state.selectedIndex, state.filteredCommits.length, visibleCount);
|
|
30944
|
+
const slice = state.filteredCommits.slice(start, start + visibleCount);
|
|
30945
|
+
if (!bucketingNow) {
|
|
30946
|
+
return slice.map((commit, offset) => makeCompactCommitItem(commit, start + offset === state.selectedIndex));
|
|
30947
|
+
}
|
|
30948
|
+
// With bucketing on: emit a sticky header above the first visible
|
|
30949
|
+
// commit and an additional header each time the bucket changes. The
|
|
30950
|
+
// header occupies one row from the visibleCount budget every time
|
|
30951
|
+
// it fires, so the visible commit count drops slightly in exchange
|
|
30952
|
+
// for always-on temporal orientation.
|
|
30953
|
+
const items = [];
|
|
30954
|
+
let prevBucket = undefined;
|
|
30955
|
+
for (let offset = 0; offset < slice.length && items.length < visibleCount; offset += 1) {
|
|
30956
|
+
const commit = slice[offset];
|
|
30957
|
+
const bucket = getDateBucket(commit.date, bucketingNow);
|
|
30958
|
+
if (bucket.key !== prevBucket) {
|
|
30959
|
+
items.push(bucketHeaderItem(bucket.label));
|
|
30960
|
+
prevBucket = bucket.key;
|
|
30961
|
+
if (items.length >= visibleCount)
|
|
30962
|
+
break;
|
|
30963
|
+
}
|
|
30964
|
+
items.push(makeCompactCommitItem(commit, start + offset === state.selectedIndex));
|
|
30965
|
+
}
|
|
30966
|
+
return items;
|
|
29005
30967
|
}
|
|
29006
30968
|
function isSelectedCommit(row, selected) {
|
|
29007
30969
|
return row.type === 'commit' && selected ? commitKey(row) === commitKey(selected) : false;
|
|
29008
30970
|
}
|
|
29009
|
-
|
|
30971
|
+
/**
|
|
30972
|
+
* Build the vertical-only graph string that follows a commit row when
|
|
30973
|
+
* `withSpacers` is enabled. Every commit-cell glyph (`*`) is rewritten
|
|
30974
|
+
* to a lane bar (`|`) so the synthetic row continues every open lane
|
|
30975
|
+
* without re-rendering the commit dot. All other graph chars pass
|
|
30976
|
+
* through unchanged, so a commit graph like `* | |` becomes `| | |`.
|
|
30977
|
+
*/
|
|
30978
|
+
function buildSpacerGraph(commitGraph) {
|
|
30979
|
+
return commitGraph.replace(/\*/g, '|');
|
|
30980
|
+
}
|
|
30981
|
+
/**
|
|
30982
|
+
* Walk `state.rows` and inject a synthetic spacer entry after every
|
|
30983
|
+
* commit row when `withSpacers` is true. The spacer is a graph-only
|
|
30984
|
+
* row that renders as `|` per active lane so consecutive commits have
|
|
30985
|
+
* a clear vertical rhythm without losing topology continuity.
|
|
30986
|
+
*
|
|
30987
|
+
* The spacer is suppressed in two cases where it would create visible
|
|
30988
|
+
* "tearing" on the graph column:
|
|
30989
|
+
*
|
|
30990
|
+
* 1. The next row is git's own graph-only topology row (`|\` /
|
|
30991
|
+
* `|/` / `| |`). That row already provides vertical breathing
|
|
30992
|
+
* AND draws the lane transition; sandwiching our spacer between
|
|
30993
|
+
* the commit and the transition produces an extra all-pipes row
|
|
30994
|
+
* that reads as misalignment.
|
|
30995
|
+
*
|
|
30996
|
+
* 2. The current commit's graph contains a backslash or forward
|
|
30997
|
+
* slash (the compressed forms git uses for `*\` / `*` followed
|
|
30998
|
+
* by slash, when it draws the fork on the same row as the
|
|
30999
|
+
* commit). The spacer's commit-glyph → lane-bar rewrite would
|
|
31000
|
+
* leave the diagonal intact, rendering a second corner glyph
|
|
31001
|
+
* immediately below the merge — a duplicate that looks like a
|
|
31002
|
+
* glyph stutter.
|
|
31003
|
+
*
|
|
31004
|
+
* When `withSpacers` is false the list is identity-mapped from
|
|
31005
|
+
* source rows, preserving the legacy zero-padding behavior for any
|
|
31006
|
+
* caller that wants raw git topology (filters, tests, etc.).
|
|
31007
|
+
*/
|
|
31008
|
+
function commitGraphIsSimple(graph) {
|
|
31009
|
+
return !/[\\/]/.test(graph);
|
|
31010
|
+
}
|
|
31011
|
+
function expandRowsWithSpacers(rows, withSpacers) {
|
|
31012
|
+
const out = [];
|
|
31013
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
31014
|
+
const row = rows[i];
|
|
31015
|
+
out.push({ kind: 'source', row });
|
|
31016
|
+
if (!withSpacers || row.type !== 'commit')
|
|
31017
|
+
continue;
|
|
31018
|
+
if (!commitGraphIsSimple(row.graph || '*'))
|
|
31019
|
+
continue;
|
|
31020
|
+
const next = rows[i + 1];
|
|
31021
|
+
if (next && next.type === 'graph')
|
|
31022
|
+
continue;
|
|
31023
|
+
out.push({ kind: 'spacer', sourceCommit: row });
|
|
31024
|
+
}
|
|
31025
|
+
return out;
|
|
31026
|
+
}
|
|
31027
|
+
/**
|
|
31028
|
+
* Walk an already-expanded row list and inject `bucket-header`
|
|
31029
|
+
* entries immediately before each commit whose date bucket differs
|
|
31030
|
+
* from the previous commit's. The very first commit always gets a
|
|
31031
|
+
* header so the user lands inside a labeled section regardless of
|
|
31032
|
+
* where the scroll window starts. Non-commit entries (spacers, git
|
|
31033
|
+
* topology rows) pass through unchanged.
|
|
31034
|
+
*/
|
|
31035
|
+
function injectBucketHeaders(rows, now) {
|
|
31036
|
+
const out = [];
|
|
31037
|
+
let prevBucket = undefined;
|
|
31038
|
+
for (const entry of rows) {
|
|
31039
|
+
if (entry.kind === 'source' && entry.row.type === 'commit') {
|
|
31040
|
+
const bucket = getDateBucket(entry.row.date, now);
|
|
31041
|
+
if (bucket.key !== prevBucket) {
|
|
31042
|
+
out.push({ kind: 'bucket-header', label: bucket.label });
|
|
31043
|
+
prevBucket = bucket.key;
|
|
31044
|
+
}
|
|
31045
|
+
}
|
|
31046
|
+
out.push(entry);
|
|
31047
|
+
}
|
|
31048
|
+
return out;
|
|
31049
|
+
}
|
|
31050
|
+
/**
|
|
31051
|
+
* Find the most recent bucket header at or above `start` so a slice
|
|
31052
|
+
* that begins mid-bucket can still surface its section label. Used
|
|
31053
|
+
* for the "sticky header" behavior — when the window scrolls past
|
|
31054
|
+
* the natural header position, prepend the header to the slice so
|
|
31055
|
+
* the user always sees which bucket they're in. Returns the label
|
|
31056
|
+
* to prepend, or `undefined` when no prepend is needed (either
|
|
31057
|
+
* `expanded[start]` is already a header, or there is no earlier
|
|
31058
|
+
* header in the list).
|
|
31059
|
+
*/
|
|
31060
|
+
function findStickyBucketLabel(expanded, start) {
|
|
31061
|
+
if (start < expanded.length && expanded[start].kind === 'bucket-header')
|
|
31062
|
+
return undefined;
|
|
31063
|
+
for (let i = start - 1; i >= 0; i -= 1) {
|
|
31064
|
+
const entry = expanded[i];
|
|
31065
|
+
if (entry.kind === 'bucket-header')
|
|
31066
|
+
return entry.label;
|
|
31067
|
+
}
|
|
31068
|
+
return undefined;
|
|
31069
|
+
}
|
|
31070
|
+
function toFullGraphItems(state, visibleCount, options = {
|
|
31071
|
+
withSpacers: false,
|
|
31072
|
+
bucketingNow: undefined,
|
|
31073
|
+
}) {
|
|
29010
31074
|
const selected = state.filteredCommits[state.selectedIndex];
|
|
29011
|
-
const
|
|
29012
|
-
const
|
|
31075
|
+
const withSpacers = expandRowsWithSpacers(state.rows, options.withSpacers);
|
|
31076
|
+
const expanded = options.bucketingNow
|
|
31077
|
+
? injectBucketHeaders(withSpacers, options.bucketingNow)
|
|
31078
|
+
: withSpacers;
|
|
31079
|
+
const selectedExpandedIndex = expanded.findIndex((entry) => entry.kind === 'source' && isSelectedCommit(entry.row, selected));
|
|
31080
|
+
const start = clampWindowStart(selectedExpandedIndex >= 0 ? selectedExpandedIndex : 0, expanded.length, visibleCount);
|
|
29013
31081
|
// Lane tracking is order-dependent — fast-forward the tracker through
|
|
29014
31082
|
// every row above the visible window so lane ids stay stable as the
|
|
29015
31083
|
// user scrolls. Without this, scrolling would re-color lanes from a
|
|
29016
|
-
// fresh tracker each time.
|
|
31084
|
+
// fresh tracker each time. Spacers contribute their vertical-only
|
|
31085
|
+
// graph to the prefix so the tracker sees a no-op advance and lane
|
|
31086
|
+
// state stays consistent at the window boundary. Bucket headers
|
|
31087
|
+
// skip the tracker entirely since they have no graph string.
|
|
29017
31088
|
const tracker = createLaneTrackerState();
|
|
29018
|
-
const
|
|
29019
|
-
|
|
29020
|
-
|
|
31089
|
+
const prefixGraphs = [];
|
|
31090
|
+
for (let k = 0; k < start; k += 1) {
|
|
31091
|
+
const entry = expanded[k];
|
|
31092
|
+
if (entry.kind === 'bucket-header')
|
|
31093
|
+
continue;
|
|
31094
|
+
if (entry.kind === 'spacer') {
|
|
31095
|
+
prefixGraphs.push(buildSpacerGraph(entry.sourceCommit.graph || '*'));
|
|
31096
|
+
continue;
|
|
31097
|
+
}
|
|
31098
|
+
prefixGraphs.push(entry.row.type === 'commit' ? entry.row.graph || '*' : entry.row.graph);
|
|
31099
|
+
}
|
|
31100
|
+
advanceTrackerThrough(prefixGraphs, tracker, prefixGraphs.length);
|
|
31101
|
+
// Sticky header — if the slice would start partway into a bucket
|
|
31102
|
+
// (most commonly when scrolling), prepend the bucket label so the
|
|
31103
|
+
// user keeps temporal context. The prepend costs one row from the
|
|
31104
|
+
// visible budget, so the slice itself shrinks by 1.
|
|
31105
|
+
const stickyLabel = options.bucketingNow
|
|
31106
|
+
? findStickyBucketLabel(expanded, start)
|
|
31107
|
+
: undefined;
|
|
31108
|
+
const sliceCount = stickyLabel ? visibleCount - 1 : visibleCount;
|
|
31109
|
+
const sliced = expanded.slice(start, start + sliceCount);
|
|
31110
|
+
const finalEntries = stickyLabel
|
|
31111
|
+
? [{ kind: 'bucket-header', label: stickyLabel }, ...sliced]
|
|
31112
|
+
: sliced;
|
|
31113
|
+
return finalEntries.map((entry) => {
|
|
31114
|
+
if (entry.kind === 'bucket-header') {
|
|
31115
|
+
return bucketHeaderItem(entry.label);
|
|
31116
|
+
}
|
|
31117
|
+
if (entry.kind === 'spacer') {
|
|
31118
|
+
const graph = buildSpacerGraph(entry.sourceCommit.graph || '*');
|
|
31119
|
+
return {
|
|
31120
|
+
type: 'graph',
|
|
31121
|
+
graph,
|
|
31122
|
+
laneSegments: renderGraphRowSegments(graph, tracker, { ascii: false }),
|
|
31123
|
+
spacer: true,
|
|
31124
|
+
};
|
|
31125
|
+
}
|
|
31126
|
+
const { row } = entry;
|
|
29021
31127
|
if (row.type === 'graph') {
|
|
29022
31128
|
return {
|
|
29023
31129
|
type: 'graph',
|
|
@@ -29036,10 +31142,18 @@ function toFullGraphItems(state, visibleCount) {
|
|
|
29036
31142
|
};
|
|
29037
31143
|
});
|
|
29038
31144
|
}
|
|
29039
|
-
function getVisibleLogInkHistory(state, visibleCount) {
|
|
31145
|
+
function getVisibleLogInkHistory(state, visibleCount, options = {}) {
|
|
31146
|
+
// Bucketing only makes sense for chronologically ordered output —
|
|
31147
|
+
// an active search filter shuffles commits by relevance, so the
|
|
31148
|
+
// adjacent-bucket invariant breaks down and the divider would
|
|
31149
|
+
// read as noise.
|
|
31150
|
+
const bucketingNow = state.filter ? undefined : options.dateBucketingNow;
|
|
29040
31151
|
const items = state.fullGraph && !state.filter
|
|
29041
|
-
? toFullGraphItems(state, visibleCount
|
|
29042
|
-
|
|
31152
|
+
? toFullGraphItems(state, visibleCount, {
|
|
31153
|
+
withSpacers: Boolean(options.fullGraphSpacing),
|
|
31154
|
+
bucketingNow,
|
|
31155
|
+
})
|
|
31156
|
+
: toCompactItems(state, visibleCount, bucketingNow);
|
|
29043
31157
|
return {
|
|
29044
31158
|
graphWidth: graphWidth(items),
|
|
29045
31159
|
items,
|
|
@@ -29065,6 +31179,96 @@ function formatInkRefLabels(refs) {
|
|
|
29065
31179
|
* `formatHistoryFetchArgs`) lived in inkRuntime.ts only to support
|
|
29066
31180
|
* this surface; they migrate together.
|
|
29067
31181
|
*/
|
|
31182
|
+
/**
|
|
31183
|
+
* How the date column should render for a given density tier:
|
|
31184
|
+
* - wide → absolute `YYYY-MM-DD`
|
|
31185
|
+
* - normal → compact relative form (`2d`, `3w`, `2mo`)
|
|
31186
|
+
* - tight → hidden entirely (column dropped)
|
|
31187
|
+
* - rail → caller picks `rowMode='stacked'`; this fn isn't consulted
|
|
31188
|
+
*
|
|
31189
|
+
* Compact mode (the user toggling away from the full graph) forces
|
|
31190
|
+
* the tight behavior regardless of density. Compact is the "scan
|
|
31191
|
+
* mode" — the date is the first thing the user is willing to drop in
|
|
31192
|
+
* exchange for more visible commits per screen.
|
|
31193
|
+
*
|
|
31194
|
+
* When `bucketed` is true the surface is rendering section dividers
|
|
31195
|
+
* (`── Today ──`) above commits, so the per-row date column would be
|
|
31196
|
+
* redundant. We drop it entirely and let the message column expand.
|
|
31197
|
+
*/
|
|
31198
|
+
function pickDateText(commit, density, fullGraph, bucketed, now) {
|
|
31199
|
+
if (bucketed)
|
|
31200
|
+
return '';
|
|
31201
|
+
if (!fullGraph)
|
|
31202
|
+
return '';
|
|
31203
|
+
if (density === 'wide')
|
|
31204
|
+
return commit.date;
|
|
31205
|
+
if (density === 'normal')
|
|
31206
|
+
return formatCompactRelativeDate(commit.date, now);
|
|
31207
|
+
return '';
|
|
31208
|
+
}
|
|
31209
|
+
/**
|
|
31210
|
+
* Maximum cells the chip body (between the brackets) is allowed to
|
|
31211
|
+
* occupy. Anything longer is truncated with an ellipsis so a
|
|
31212
|
+
* `[origin/claude/issues-prs-cache]` chip — 32 cells of chrome —
|
|
31213
|
+
* doesn't eat the whole subject column on a narrow terminal. Picked
|
|
31214
|
+
* empirically: 20 cells fits common branch shapes (`feat/foo`,
|
|
31215
|
+
* `claude/graph-fidelity`, `main`) without truncation.
|
|
31216
|
+
*/
|
|
31217
|
+
const BRANCH_CHIP_MAX_NAME_WIDTH = 20;
|
|
31218
|
+
/**
|
|
31219
|
+
* Render a pill-style chip for a branch tip — colored background
|
|
31220
|
+
* with the branch name reverse-printed inside, so the chip reads as
|
|
31221
|
+
* a distinct visual category (block) rather than colored text (which
|
|
31222
|
+
* collides with `docs:`/`refactor:`/`perf:` conventional-commit
|
|
31223
|
+
* prefixes that also use `info`). Current branch (HEAD -> X) uses
|
|
31224
|
+
* success-green; other branch tips use info-blue.
|
|
31225
|
+
*
|
|
31226
|
+
* Implementation: `inverse: true` + `color: <accent>` is the
|
|
31227
|
+
* portable way to render "colored background with terminal-default
|
|
31228
|
+
* foreground" — it adapts to dark vs light terminals without
|
|
31229
|
+
* hardcoding a black/white fg. Tags are never chipped; they stay in
|
|
31230
|
+
* the trailing ref list. The chip emits its own trailing space so
|
|
31231
|
+
* callers concatenate it directly into the row without a separator.
|
|
31232
|
+
*
|
|
31233
|
+
* Selection styling is opt-out: when the row is selected, the outer
|
|
31234
|
+
* `inverse: true` + selection background already covers everything,
|
|
31235
|
+
* and a second `inverse` on the chip would flip it back to plain. We
|
|
31236
|
+
* drop the pill styling for selected rows and let the row's own
|
|
31237
|
+
* inverse highlight carry through cleanly.
|
|
31238
|
+
*
|
|
31239
|
+
* Returns the rendered node alongside its cell width AND the chip
|
|
31240
|
+
* descriptor so the caller can pass it to `filterChippedRefs` and
|
|
31241
|
+
* avoid emitting the same branch a second time in the trailing list.
|
|
31242
|
+
*/
|
|
31243
|
+
function renderBranchTipChip(h, Text, commit, theme, key, selected) {
|
|
31244
|
+
const chip = getBranchTipChip(commit.refs);
|
|
31245
|
+
if (!chip)
|
|
31246
|
+
return { node: null, width: 0, chip };
|
|
31247
|
+
const truncated = truncateCells(chip.name, BRANCH_CHIP_MAX_NAME_WIDTH);
|
|
31248
|
+
// Inner pill body is `name`; the trailing space sits OUTSIDE the
|
|
31249
|
+
// colored block so the bg doesn't bleed into the message column.
|
|
31250
|
+
// The brackets are gone — the colored block is its own visual
|
|
31251
|
+
// affordance and the brackets would add 2 cells of chrome that
|
|
31252
|
+
// duplicate the affordance.
|
|
31253
|
+
const body = ` ${truncated} `;
|
|
31254
|
+
// Selected row OR noColor mode → drop pill styling. Selected rows
|
|
31255
|
+
// get the row-level inverse highlight; noColor terminals fall
|
|
31256
|
+
// back to bracketed text so the chip still parses visually.
|
|
31257
|
+
if (selected || theme.noColor) {
|
|
31258
|
+
const fallbackLabel = `[${truncated}] `;
|
|
31259
|
+
return {
|
|
31260
|
+
node: h(Text, { key, bold: chip.isHead }, fallbackLabel),
|
|
31261
|
+
width: cellWidth(fallbackLabel),
|
|
31262
|
+
chip,
|
|
31263
|
+
};
|
|
31264
|
+
}
|
|
31265
|
+
const accent = chip.isHead ? theme.colors.success : theme.colors.info;
|
|
31266
|
+
return {
|
|
31267
|
+
node: h(Text, {}, h(Text, { key, inverse: true, color: accent, bold: chip.isHead }, body), h(Text, { key: `${key}-pad` }, ' ')),
|
|
31268
|
+
width: cellWidth(body) + 1,
|
|
31269
|
+
chip,
|
|
31270
|
+
};
|
|
31271
|
+
}
|
|
29068
31272
|
function formatHistoryFetchArgs(args) {
|
|
29069
31273
|
const parts = [];
|
|
29070
31274
|
if (args.author)
|
|
@@ -29073,6 +31277,36 @@ function formatHistoryFetchArgs(args) {
|
|
|
29073
31277
|
parts.push(`-- ${args.path}`);
|
|
29074
31278
|
return parts.join(' ') || 'none';
|
|
29075
31279
|
}
|
|
31280
|
+
/**
|
|
31281
|
+
* Render a commit subject with the conventional-commit prefix
|
|
31282
|
+
* (`feat:`, `fix(scope)!:`, …) painted in a type-specific color so
|
|
31283
|
+
* the eye can bucket commits by type while scanning.
|
|
31284
|
+
*
|
|
31285
|
+
* Truncation lives at the message level above this helper — the
|
|
31286
|
+
* caller has already shortened `text` to the available room. We just
|
|
31287
|
+
* split on the parsed prefix length and emit two spans. If the
|
|
31288
|
+
* shortened text is too narrow to include the full prefix (e.g. a
|
|
31289
|
+
* tight panel that cut into `feat`), we fall back to a single plain
|
|
31290
|
+
* span so the partial prefix doesn't read as a malformed colored
|
|
31291
|
+
* fragment.
|
|
31292
|
+
*
|
|
31293
|
+
* Returns the spans flat so the caller can splat them into the row's
|
|
31294
|
+
* outer Text alongside other segments without an extra wrapper.
|
|
31295
|
+
*/
|
|
31296
|
+
function renderTypedSubject(h, Text, text, theme, key) {
|
|
31297
|
+
const parsed = parseConventionalCommitPrefix(text);
|
|
31298
|
+
if (!parsed) {
|
|
31299
|
+
return [h(Text, { key: `${key}-msg` }, text)];
|
|
31300
|
+
}
|
|
31301
|
+
if (text.length < parsed.prefix.length) {
|
|
31302
|
+
return [h(Text, { key: `${key}-msg` }, text)];
|
|
31303
|
+
}
|
|
31304
|
+
const color = getConventionalCommitColor(parsed, theme);
|
|
31305
|
+
return [
|
|
31306
|
+
h(Text, { key: `${key}-type`, color, bold: parsed.breaking }, parsed.prefix),
|
|
31307
|
+
h(Text, { key: `${key}-rest` }, text.slice(parsed.prefix.length)),
|
|
31308
|
+
];
|
|
31309
|
+
}
|
|
29076
31310
|
/**
|
|
29077
31311
|
* Render `LaneSegment[]` as a flat list of Text spans, one per lane
|
|
29078
31312
|
* (#791 stage 2). Each segment paints in its lane's palette color so
|
|
@@ -29118,8 +31352,7 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, opti
|
|
|
29118
31352
|
* Truncation is per-segment so the variable-length message field gets
|
|
29119
31353
|
* the leftover budget after fixed segments are accounted for.
|
|
29120
31354
|
*/
|
|
29121
|
-
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, laneSegments, isRecent = false) {
|
|
29122
|
-
const refs = formatInkRefLabels(commit.refs);
|
|
31355
|
+
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, density, fullGraph, bucketed, now, laneSegments, isRecent = false) {
|
|
29123
31356
|
// Total cells available to the row content. Earlier revisions used a
|
|
29124
31357
|
// hardcoded 140 here, which let row content overflow whenever the
|
|
29125
31358
|
// panel was narrower than that — Ink would wrap onto a second visual
|
|
@@ -29127,7 +31360,19 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
|
|
|
29127
31360
|
// continuation rather than its own commit (#830). Subtracting 4
|
|
29128
31361
|
// accounts for the panel's left + right border + 1-cell padding.
|
|
29129
31362
|
const totalWidth = Math.max(20, panelWidth - 4);
|
|
29130
|
-
const
|
|
31363
|
+
const dateText = pickDateText(commit, density, fullGraph, bucketed, now);
|
|
31364
|
+
const dateSegmentWidth = dateText ? dateText.length + 1 : 0;
|
|
31365
|
+
// Branch chip prefix — only renders in full-graph mode so compact
|
|
31366
|
+
// (scan) mode stays minimal. Chip occupies cells immediately after
|
|
31367
|
+
// the shortHash and before the message; truncation math reserves
|
|
31368
|
+
// its width before sizing the message column. Trailing refs filter
|
|
31369
|
+
// out whatever the chip already shows so the row doesn't print
|
|
31370
|
+
// `[main] feat: x [HEAD -> main]` with the same info on both ends.
|
|
31371
|
+
const chip = fullGraph
|
|
31372
|
+
? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-chip`, selected)
|
|
31373
|
+
: { node: null, width: 0, chip: undefined };
|
|
31374
|
+
const refs = formatInkRefLabels(filterChippedRefs(commit.refs, chip.chip));
|
|
31375
|
+
const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + dateSegmentWidth + chip.width;
|
|
29131
31376
|
// Refs trail the message and shrink first when the row is narrow:
|
|
29132
31377
|
// the user can always see the full ref list in the inspector, so
|
|
29133
31378
|
// the headline subject keeps priority over decoration.
|
|
@@ -29165,7 +31410,81 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
|
|
|
29165
31410
|
// the primary cue but boldness makes the row read as "this is
|
|
29166
31411
|
// worth looking at" even without color.
|
|
29167
31412
|
bold: selected || isRecent,
|
|
29168
|
-
}, commit.shortHash), ' ',
|
|
31413
|
+
}, commit.shortHash), ' ',
|
|
31414
|
+
// Date column drops out entirely at `tight` density — no spacer
|
|
31415
|
+
// either, so the message column slides left into the freed cells.
|
|
31416
|
+
dateText
|
|
31417
|
+
? h(Text, { key: `${commit.hash}-${index}-date`, dimColor: true }, dateText, ' ')
|
|
31418
|
+
: null,
|
|
31419
|
+
// Branch chip prefix (full-graph mode only) lands right before the
|
|
31420
|
+
// message so the eye reads "branch · subject" as a unit.
|
|
31421
|
+
chip.node, ...renderTypedSubject(h, Text, message, theme, `${commit.hash}-${index}-subj`), refsTrunc ? h(Text, { color: accent }, refsTrunc) : null);
|
|
31422
|
+
}
|
|
31423
|
+
/**
|
|
31424
|
+
* Stacked variant used at `rowMode='stacked'` (rail tier). Each
|
|
31425
|
+
* commit takes two lines so the message never has to share its row
|
|
31426
|
+
* with the date / refs / hash on a sub-90-cell terminal:
|
|
31427
|
+
* line 1: graph · shortHash · subject
|
|
31428
|
+
* line 2: dim padding · date · refs
|
|
31429
|
+
*
|
|
31430
|
+
* Selection styling lives on the line-1 outer span; the secondary
|
|
31431
|
+
* line stays dim regardless of selection so it doesn't pull the eye
|
|
31432
|
+
* away from the subject.
|
|
31433
|
+
*/
|
|
31434
|
+
function renderStackedCommitHistoryRow(h, Text, Box, commit, graph, graphWidth, selected, theme, index, panelWidth, fullGraph, now, laneSegments, isRecent = false) {
|
|
31435
|
+
const totalWidth = Math.max(20, panelWidth - 4);
|
|
31436
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
31437
|
+
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
31438
|
+
const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
|
|
31439
|
+
// Line 1 — subject row. Mostly mirrors the single-line layout but
|
|
31440
|
+
// skips the date and refs so the message has the whole tail to
|
|
31441
|
+
// itself. Branch chip rides between the hash and the subject the
|
|
31442
|
+
// same way as the single-line variant, but only in full-graph mode.
|
|
31443
|
+
const recentMarkerWidth = isRecent ? 2 : 0;
|
|
31444
|
+
const chip = fullGraph
|
|
31445
|
+
? renderBranchTipChip(h, Text, commit, theme, `${commit.hash}-${index}-stk-chip`, selected)
|
|
31446
|
+
: { node: null, width: 0, chip: undefined };
|
|
31447
|
+
const lineOneFixed = graphWidth + 1 + commit.shortHash.length + 1 + recentMarkerWidth + chip.width;
|
|
31448
|
+
const subject = truncateCells(commit.message, Math.max(8, totalWidth - lineOneFixed));
|
|
31449
|
+
const graphChildren = laneSegments && !theme.ascii
|
|
31450
|
+
? renderLaneSegmentSpans(h, Text, laneSegments, theme, graphWidth, `cs${index}`)
|
|
31451
|
+
: [h(Text, { color: muted, dimColor: theme.noColor }, substituteGraphChars(graph.padEnd(graphWidth), { ascii: theme.ascii }))];
|
|
31452
|
+
const lineOne = h(Text, {
|
|
31453
|
+
key: `${commit.hash}-${index}-l1`,
|
|
31454
|
+
backgroundColor: selectedBg,
|
|
31455
|
+
inverse: selected,
|
|
31456
|
+
}, ...graphChildren, ' ', isRecent
|
|
31457
|
+
? h(Text, { color: accent, bold: true }, theme.ascii ? '* ' : '▎ ')
|
|
31458
|
+
: null, h(Text, { color: accent, bold: selected || isRecent }, commit.shortHash), ' ', chip.node, ...renderTypedSubject(h, Text, subject, theme, `${commit.hash}-${index}-stk-subj`));
|
|
31459
|
+
// Line 2 — metadata row, padded to align with the start of the
|
|
31460
|
+
// shortHash on line 1 so the eye still groups them as one commit.
|
|
31461
|
+
// Selection background does not extend here so we don't get a thick
|
|
31462
|
+
// double-row highlight on a tight terminal. Trailing refs are
|
|
31463
|
+
// filtered against the chip so we don't repeat the branch tip both
|
|
31464
|
+
// as a leading chip and a trailing label.
|
|
31465
|
+
const indent = ' '.repeat(graphWidth + 1);
|
|
31466
|
+
const dateText = formatCompactRelativeDate(commit.date, now);
|
|
31467
|
+
const refs = formatInkRefLabels(filterChippedRefs(commit.refs, chip.chip));
|
|
31468
|
+
const metaRoom = Math.max(8, totalWidth - indent.length - (dateText ? dateText.length + 1 : 0));
|
|
31469
|
+
const refsTrunc = refs ? truncateCells(refs, metaRoom) : '';
|
|
31470
|
+
// If both pieces are empty (date unparseable + no refs), show a
|
|
31471
|
+
// bullet so the row's structure still reads as two-line and the
|
|
31472
|
+
// user doesn't think they hit a render bug.
|
|
31473
|
+
const metaContent = dateText || refsTrunc
|
|
31474
|
+
? [
|
|
31475
|
+
dateText ? h(Text, { key: `${commit.hash}-${index}-l2-date` }, dateText) : null,
|
|
31476
|
+
dateText && refsTrunc ? h(Text, { key: `${commit.hash}-${index}-l2-sep` }, ' ') : null,
|
|
31477
|
+
refsTrunc ? h(Text, { key: `${commit.hash}-${index}-l2-refs` }, refsTrunc) : null,
|
|
31478
|
+
].filter(Boolean)
|
|
31479
|
+
: [h(Text, { key: `${commit.hash}-${index}-l2-empty` }, '·')];
|
|
31480
|
+
const lineTwo = h(Text, {
|
|
31481
|
+
key: `${commit.hash}-${index}-l2`,
|
|
31482
|
+
dimColor: true,
|
|
31483
|
+
}, indent, ...metaContent);
|
|
31484
|
+
return h(Box, {
|
|
31485
|
+
key: `${commit.hash}-${index}-stack`,
|
|
31486
|
+
flexDirection: 'column',
|
|
31487
|
+
}, lineOne, lineTwo);
|
|
29169
31488
|
}
|
|
29170
31489
|
/**
|
|
29171
31490
|
* Render the synthetic "(+) new commit" affordance shown above the real
|
|
@@ -29194,7 +31513,7 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
|
|
|
29194
31513
|
backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
|
|
29195
31514
|
}, truncateCells(label, 140));
|
|
29196
31515
|
}
|
|
29197
|
-
function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits) {
|
|
31516
|
+
function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = new Date()) {
|
|
29198
31517
|
const { Box, Text } = components;
|
|
29199
31518
|
const focused = state.focus === 'commits';
|
|
29200
31519
|
const worktree = context.worktree;
|
|
@@ -29210,8 +31529,26 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
29210
31529
|
const showPendingRow = worktreeDirty &&
|
|
29211
31530
|
!state.filter &&
|
|
29212
31531
|
state.selectedIndex === 0;
|
|
29213
|
-
|
|
29214
|
-
|
|
31532
|
+
// Stacked rows take two terminal lines each, so the visible item
|
|
31533
|
+
// budget is halved before the pending-row / chrome subtraction.
|
|
31534
|
+
// Full-graph mode injects a spacer row after every commit for
|
|
31535
|
+
// comfortable rhythm — the data-layer items still count 1 per row,
|
|
31536
|
+
// so the listRows budget passes straight through; the spacer rows
|
|
31537
|
+
// just consume some of that budget instead of additional commits.
|
|
31538
|
+
// Date bucketing is the new way the surface communicates "when" —
|
|
31539
|
+
// headers replace the per-row date column whenever the result set
|
|
31540
|
+
// is chronological (no active filter) AND the user has bucketing
|
|
31541
|
+
// enabled in `logTui.dateBucketing`. The filter check is the second
|
|
31542
|
+
// guardrail: even with bucketing enabled, an active search filter
|
|
31543
|
+
// shuffles commits by relevance so the adjacent-bucket invariant
|
|
31544
|
+
// breaks down and the dividers would read as noise.
|
|
31545
|
+
const fullGraphSpacing = state.fullGraph && !state.filter;
|
|
31546
|
+
const dateBucketingNow = !dateBucketingEnabled || state.filter ? undefined : now;
|
|
31547
|
+
const chromeRows = showPendingRow ? 5 : 4;
|
|
31548
|
+
const listRows = rowMode === 'stacked'
|
|
31549
|
+
? Math.max(2, Math.floor((bodyRows - chromeRows) / 2))
|
|
31550
|
+
: Math.max(3, bodyRows - chromeRows);
|
|
31551
|
+
const visible = getVisibleLogInkHistory(state, listRows, { fullGraphSpacing, dateBucketingNow });
|
|
29215
31552
|
const loadState = loadingMoreCommits
|
|
29216
31553
|
? 'loading older commits'
|
|
29217
31554
|
: hasMoreCommits
|
|
@@ -29246,23 +31583,51 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
29246
31583
|
totalCommits: state.commits.length,
|
|
29247
31584
|
}))
|
|
29248
31585
|
: visible.items.map((item, index) => {
|
|
31586
|
+
if (item.type === 'bucket-header') {
|
|
31587
|
+
// Section divider — `── Today ────────────`. The label is
|
|
31588
|
+
// bold to anchor the eye, the surrounding rule is dim so
|
|
31589
|
+
// the divider reads as chrome rather than competing with
|
|
31590
|
+
// commit content. Rule fills the panel's interior width
|
|
31591
|
+
// (minus border + padding); the label rides inside it.
|
|
31592
|
+
const contentWidth = Math.max(10, width - 4);
|
|
31593
|
+
const labelCells = cellWidth(item.label) + 2; // pad the label with surrounding spaces
|
|
31594
|
+
const ruleAfter = Math.max(0, contentWidth - 3 - labelCells);
|
|
31595
|
+
return h(Text, {
|
|
31596
|
+
key: `bucket-${index}-${item.label}`,
|
|
31597
|
+
dimColor: true,
|
|
31598
|
+
}, h(Text, undefined, '── '), h(Text, { bold: true }, item.label), h(Text, undefined, ' '), h(Text, undefined, '─'.repeat(ruleAfter)));
|
|
31599
|
+
}
|
|
29249
31600
|
if (item.type === 'graph') {
|
|
29250
|
-
// Graph-only rows
|
|
29251
|
-
//
|
|
29252
|
-
//
|
|
29253
|
-
//
|
|
29254
|
-
//
|
|
29255
|
-
//
|
|
31601
|
+
// Graph-only rows split into two visual categories:
|
|
31602
|
+
//
|
|
31603
|
+
// - git's own lane-closure scaffolding (`|/`, `|\`, etc.)
|
|
31604
|
+
// stays dim-on-dim so it reads as connector chrome that
|
|
31605
|
+
// recedes behind the commits it joins (#831). The eye
|
|
31606
|
+
// should never confuse a fork/close row for a commit
|
|
31607
|
+
// somebody accidentally skipped.
|
|
31608
|
+
//
|
|
31609
|
+
// - synthetic spacers we inject between linear commits
|
|
31610
|
+
// (`spacer: true`) render at FULL lane brightness so the
|
|
31611
|
+
// trunk lane bar visibly connects consecutive commits.
|
|
31612
|
+
// They are explicitly NOT scaffolding — they exist to
|
|
31613
|
+
// make linear-history rhythm read as one continuous lane.
|
|
31614
|
+
const isSpacer = item.spacer === true;
|
|
29256
31615
|
if (item.laneSegments && !theme.ascii) {
|
|
29257
|
-
return h(Text, {
|
|
31616
|
+
return h(Text, {
|
|
31617
|
+
key: `graph-${index}-${item.graph}`,
|
|
31618
|
+
dimColor: !isSpacer,
|
|
31619
|
+
}, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`, { forceDim: !isSpacer }));
|
|
29258
31620
|
}
|
|
29259
31621
|
return h(Text, {
|
|
29260
31622
|
key: `graph-${index}-${item.graph}`,
|
|
29261
31623
|
color: theme.noColor ? undefined : theme.colors.muted,
|
|
29262
|
-
dimColor:
|
|
31624
|
+
dimColor: !isSpacer,
|
|
29263
31625
|
}, truncateCells(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), Math.max(8, width - 4)));
|
|
29264
31626
|
}
|
|
29265
|
-
|
|
31627
|
+
if (rowMode === 'stacked') {
|
|
31628
|
+
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));
|
|
31629
|
+
}
|
|
31630
|
+
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));
|
|
29266
31631
|
}));
|
|
29267
31632
|
}
|
|
29268
31633
|
|
|
@@ -29637,6 +32002,138 @@ function wrapErrorMessage(message, maxWidth) {
|
|
|
29637
32002
|
return lines;
|
|
29638
32003
|
}
|
|
29639
32004
|
|
|
32005
|
+
/**
|
|
32006
|
+
* Issues triage surface (#882 phase 3). Read-only list view rendered
|
|
32007
|
+
* in the main panel when `state.activeView === 'issues'`. Mirrors the
|
|
32008
|
+
* branches / tags surface pattern: pure renderer, no hooks, no async.
|
|
32009
|
+
* Data flows in via `context.issueList`; the cursor position lives at
|
|
32010
|
+
* `state.selectedIssueIndex`.
|
|
32011
|
+
*
|
|
32012
|
+
* Per-row actions (assign, label, comment, close) and AI summarize
|
|
32013
|
+
* land in phase 4-6. This phase ships navigation only.
|
|
32014
|
+
*/
|
|
32015
|
+
function stateColor$1(theme, state) {
|
|
32016
|
+
if (theme.noColor)
|
|
32017
|
+
return undefined;
|
|
32018
|
+
switch (state.toUpperCase()) {
|
|
32019
|
+
case 'OPEN':
|
|
32020
|
+
return theme.colors.success;
|
|
32021
|
+
case 'CLOSED':
|
|
32022
|
+
return theme.colors.danger;
|
|
32023
|
+
default:
|
|
32024
|
+
return theme.colors.muted;
|
|
32025
|
+
}
|
|
32026
|
+
}
|
|
32027
|
+
function matchesIssueFilter(issue, filter) {
|
|
32028
|
+
if (!filter)
|
|
32029
|
+
return true;
|
|
32030
|
+
return matchesPromotedFilter([
|
|
32031
|
+
`#${issue.number}`,
|
|
32032
|
+
issue.title,
|
|
32033
|
+
issue.author || '',
|
|
32034
|
+
...(issue.labels || []),
|
|
32035
|
+
...(issue.assignees || []),
|
|
32036
|
+
], filter);
|
|
32037
|
+
}
|
|
32038
|
+
function renderIssuesTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
32039
|
+
const { Box, Text } = components;
|
|
32040
|
+
const focused = state.focus === 'commits';
|
|
32041
|
+
const overview = context.issueList;
|
|
32042
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'issueList');
|
|
32043
|
+
// Resolve the "what should the panel say" headline first, then fan
|
|
32044
|
+
// out to the row body. The chrome (border + title + headerRight) is
|
|
32045
|
+
// identical across loading / unavailable / unauthenticated / empty
|
|
32046
|
+
// / populated states; only the body changes.
|
|
32047
|
+
let headerRight = '';
|
|
32048
|
+
let bodyLines = [];
|
|
32049
|
+
if (loading || !overview) {
|
|
32050
|
+
headerRight = 'loading issues';
|
|
32051
|
+
bodyLines = [
|
|
32052
|
+
h(Text, { key: 'issues-loading', dimColor: true }, formatLogInkLoading({ resource: 'issues' })),
|
|
32053
|
+
];
|
|
32054
|
+
}
|
|
32055
|
+
else if (!overview.available) {
|
|
32056
|
+
headerRight = 'unavailable';
|
|
32057
|
+
bodyLines = [
|
|
32058
|
+
h(Text, { key: 'issues-no-remote', dimColor: true }, formatLogInkGitHubNoRemote({ resource: 'Issues' })),
|
|
32059
|
+
];
|
|
32060
|
+
}
|
|
32061
|
+
else if (!overview.authenticated) {
|
|
32062
|
+
headerRight = 'gh not authenticated';
|
|
32063
|
+
bodyLines = [
|
|
32064
|
+
h(Text, { key: 'issues-unauth', dimColor: true }, formatLogInkGitHubUnauthenticated({ resource: 'Issues' })),
|
|
32065
|
+
];
|
|
32066
|
+
}
|
|
32067
|
+
else if (overview.message && !overview.issues) {
|
|
32068
|
+
headerRight = 'error';
|
|
32069
|
+
bodyLines = [
|
|
32070
|
+
h(Text, { key: 'issues-error', dimColor: true, color: theme.noColor ? undefined : theme.colors.danger }, overview.message),
|
|
32071
|
+
];
|
|
32072
|
+
}
|
|
32073
|
+
else {
|
|
32074
|
+
const all = overview.issues || [];
|
|
32075
|
+
const visible = state.filter
|
|
32076
|
+
? all.filter((issue) => matchesIssueFilter(issue, state.filter))
|
|
32077
|
+
: all;
|
|
32078
|
+
const selected = Math.max(0, Math.min(state.selectedIssueIndex, Math.max(0, visible.length - 1)));
|
|
32079
|
+
const listRows = Math.max(4, bodyRows - 4);
|
|
32080
|
+
const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
|
|
32081
|
+
const windowed = visible.slice(startIndex, startIndex + listRows);
|
|
32082
|
+
const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
|
|
32083
|
+
const presetLabel = `▼ ${ISSUE_FILTER_LABELS[state.selectedIssueFilter]}`;
|
|
32084
|
+
const repoLabel = overview.repository
|
|
32085
|
+
? `${overview.repository.owner}/${overview.repository.name}`
|
|
32086
|
+
: '';
|
|
32087
|
+
headerRight = `${repoLabel ? `${repoLabel} · ` : ''}${visible.length}/${all.length} | ${presetLabel}${filterLabel}`;
|
|
32088
|
+
if (visible.length === 0) {
|
|
32089
|
+
bodyLines = [
|
|
32090
|
+
h(Text, { key: 'issues-empty', dimColor: true }, formatLogInkIssuesEmpty({ filter: state.filter })),
|
|
32091
|
+
];
|
|
32092
|
+
}
|
|
32093
|
+
else {
|
|
32094
|
+
// Column widths derived from the visible window so columns stay
|
|
32095
|
+
// aligned without one outlier title pushing the rest sideways.
|
|
32096
|
+
// Capped to keep the title column from being squeezed out on
|
|
32097
|
+
// narrow terminals.
|
|
32098
|
+
const numberColWidth = Math.min(6, Math.max(...windowed.map((i) => `#${i.number}`.length), 3));
|
|
32099
|
+
const authorColWidth = Math.min(16, Math.max(...windowed.map((i) => (i.author || '').length), 4));
|
|
32100
|
+
bodyLines = windowed.map((issue, offset) => {
|
|
32101
|
+
const index = startIndex + offset;
|
|
32102
|
+
const isSelected = index === selected;
|
|
32103
|
+
const cursor = isSelected ? '>' : ' ';
|
|
32104
|
+
const numStr = `#${issue.number}`.padEnd(numberColWidth);
|
|
32105
|
+
const stateStr = issue.state.toLowerCase().padEnd(6);
|
|
32106
|
+
const authorStr = (issue.author || '').padEnd(authorColWidth);
|
|
32107
|
+
const commentsStr = typeof issue.comments === 'number' && issue.comments > 0
|
|
32108
|
+
? ` ${issue.comments}c`
|
|
32109
|
+
: '';
|
|
32110
|
+
// The title column gets whatever is left after the prefix
|
|
32111
|
+
// columns. Truncate so the row stays a single visual line.
|
|
32112
|
+
const head = `${cursor} `;
|
|
32113
|
+
const prefix = `${numStr} ${stateStr} ${authorStr} `;
|
|
32114
|
+
const titleBudget = Math.max(8, width - 4 - head.length - prefix.length - commentsStr.length);
|
|
32115
|
+
const titleStr = truncateCells(issue.title, titleBudget);
|
|
32116
|
+
const labelStr = (issue.labels || []).length
|
|
32117
|
+
? ` [${(issue.labels || []).join(' ')}]`
|
|
32118
|
+
: '';
|
|
32119
|
+
return h(Text, {
|
|
32120
|
+
key: `issue-${index}`,
|
|
32121
|
+
bold: isSelected,
|
|
32122
|
+
dimColor: !isSelected,
|
|
32123
|
+
}, 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));
|
|
32124
|
+
});
|
|
32125
|
+
}
|
|
32126
|
+
}
|
|
32127
|
+
return h(Box, {
|
|
32128
|
+
borderColor: focusBorderColor(theme, focused),
|
|
32129
|
+
borderStyle: theme.borderStyle,
|
|
32130
|
+
flexDirection: 'column',
|
|
32131
|
+
flexShrink: 0,
|
|
32132
|
+
paddingX: 1,
|
|
32133
|
+
width,
|
|
32134
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Issues', focused)), h(Text, { dimColor: true }, headerRight)), ...bodyLines);
|
|
32135
|
+
}
|
|
32136
|
+
|
|
29640
32137
|
/**
|
|
29641
32138
|
* Normalize gh's two parallel signals (`status` for in-flight check
|
|
29642
32139
|
* runs, `conclusion` for completed runs and status contexts) into a
|
|
@@ -29901,6 +32398,205 @@ function renderPullRequestSurface(h, components, state, context, contextStatus,
|
|
|
29901
32398
|
: []));
|
|
29902
32399
|
}
|
|
29903
32400
|
|
|
32401
|
+
/**
|
|
32402
|
+
* Pull-request triage surface (#882 phase 3). Read-only list view
|
|
32403
|
+
* rendered in the main panel when `state.activeView ===
|
|
32404
|
+
* 'pull-request-triage'`. Distinct from the existing single-PR action
|
|
32405
|
+
* panel (`'pull-request'`, chord `gp`) — this is the multi-PR list
|
|
32406
|
+
* surface (chord `gP`).
|
|
32407
|
+
*
|
|
32408
|
+
* Pure renderer; data flows in via `context.pullRequestList`. Per-row
|
|
32409
|
+
* actions (merge, approve, request-changes, close, comment) and AI
|
|
32410
|
+
* summarize land in phase 4-6.
|
|
32411
|
+
*/
|
|
32412
|
+
function stateColor(theme, state, isDraft) {
|
|
32413
|
+
if (theme.noColor)
|
|
32414
|
+
return undefined;
|
|
32415
|
+
if (isDraft)
|
|
32416
|
+
return theme.colors.muted;
|
|
32417
|
+
switch (state.toUpperCase()) {
|
|
32418
|
+
case 'OPEN':
|
|
32419
|
+
return theme.colors.success;
|
|
32420
|
+
case 'CLOSED':
|
|
32421
|
+
return theme.colors.danger;
|
|
32422
|
+
case 'MERGED':
|
|
32423
|
+
return theme.colors.accent;
|
|
32424
|
+
default:
|
|
32425
|
+
return theme.colors.muted;
|
|
32426
|
+
}
|
|
32427
|
+
}
|
|
32428
|
+
function reviewGlyph(decision) {
|
|
32429
|
+
switch (decision) {
|
|
32430
|
+
case 'APPROVED':
|
|
32431
|
+
return '✓';
|
|
32432
|
+
case 'CHANGES_REQUESTED':
|
|
32433
|
+
return '✗';
|
|
32434
|
+
case 'REVIEW_REQUIRED':
|
|
32435
|
+
return '?';
|
|
32436
|
+
default:
|
|
32437
|
+
return ' ';
|
|
32438
|
+
}
|
|
32439
|
+
}
|
|
32440
|
+
function mergeableGlyph(mergeStateStatus, mergeable) {
|
|
32441
|
+
if (mergeStateStatus === 'CLEAN')
|
|
32442
|
+
return '●';
|
|
32443
|
+
if (mergeStateStatus === 'BLOCKED')
|
|
32444
|
+
return '●';
|
|
32445
|
+
if (mergeStateStatus === 'DIRTY' || mergeable === 'CONFLICTING')
|
|
32446
|
+
return '●';
|
|
32447
|
+
if (mergeStateStatus === 'BEHIND')
|
|
32448
|
+
return '●';
|
|
32449
|
+
if (mergeStateStatus === 'UNSTABLE')
|
|
32450
|
+
return '●';
|
|
32451
|
+
return '·';
|
|
32452
|
+
}
|
|
32453
|
+
function mergeableColor(theme, mergeStateStatus, mergeable) {
|
|
32454
|
+
if (theme.noColor)
|
|
32455
|
+
return undefined;
|
|
32456
|
+
if (mergeStateStatus === 'CLEAN')
|
|
32457
|
+
return theme.colors.success;
|
|
32458
|
+
if (mergeStateStatus === 'BLOCKED' || mergeStateStatus === 'UNSTABLE')
|
|
32459
|
+
return theme.colors.warning;
|
|
32460
|
+
if (mergeStateStatus === 'DIRTY' || mergeable === 'CONFLICTING')
|
|
32461
|
+
return theme.colors.danger;
|
|
32462
|
+
if (mergeStateStatus === 'BEHIND')
|
|
32463
|
+
return theme.colors.accent;
|
|
32464
|
+
return theme.colors.muted;
|
|
32465
|
+
}
|
|
32466
|
+
function reviewColor(theme, decision) {
|
|
32467
|
+
if (theme.noColor)
|
|
32468
|
+
return undefined;
|
|
32469
|
+
switch (decision) {
|
|
32470
|
+
case 'APPROVED':
|
|
32471
|
+
return theme.colors.success;
|
|
32472
|
+
case 'CHANGES_REQUESTED':
|
|
32473
|
+
return theme.colors.danger;
|
|
32474
|
+
case 'REVIEW_REQUIRED':
|
|
32475
|
+
return theme.colors.warning;
|
|
32476
|
+
default:
|
|
32477
|
+
return theme.colors.muted;
|
|
32478
|
+
}
|
|
32479
|
+
}
|
|
32480
|
+
function matchesPullRequestFilter(pr, filter) {
|
|
32481
|
+
if (!filter)
|
|
32482
|
+
return true;
|
|
32483
|
+
return matchesPromotedFilter([
|
|
32484
|
+
`#${pr.number}`,
|
|
32485
|
+
pr.title,
|
|
32486
|
+
pr.author || '',
|
|
32487
|
+
pr.headRefName,
|
|
32488
|
+
pr.baseRefName,
|
|
32489
|
+
...(pr.labels || []),
|
|
32490
|
+
...(pr.assignees || []),
|
|
32491
|
+
], filter);
|
|
32492
|
+
}
|
|
32493
|
+
function renderPullRequestTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme) {
|
|
32494
|
+
const { Box, Text } = components;
|
|
32495
|
+
const focused = state.focus === 'commits';
|
|
32496
|
+
const overview = context.pullRequestList;
|
|
32497
|
+
const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequestList');
|
|
32498
|
+
let headerRight = '';
|
|
32499
|
+
let bodyLines = [];
|
|
32500
|
+
if (loading || !overview) {
|
|
32501
|
+
headerRight = 'loading pull requests';
|
|
32502
|
+
bodyLines = [
|
|
32503
|
+
h(Text, { key: 'pr-triage-loading', dimColor: true }, formatLogInkLoading({ resource: 'pull requests' })),
|
|
32504
|
+
];
|
|
32505
|
+
}
|
|
32506
|
+
else if (!overview.available) {
|
|
32507
|
+
headerRight = 'unavailable';
|
|
32508
|
+
bodyLines = [
|
|
32509
|
+
h(Text, { key: 'pr-triage-no-remote', dimColor: true }, formatLogInkGitHubNoRemote({ resource: 'Pull requests' })),
|
|
32510
|
+
];
|
|
32511
|
+
}
|
|
32512
|
+
else if (!overview.authenticated) {
|
|
32513
|
+
headerRight = 'gh not authenticated';
|
|
32514
|
+
bodyLines = [
|
|
32515
|
+
h(Text, { key: 'pr-triage-unauth', dimColor: true }, formatLogInkGitHubUnauthenticated({ resource: 'Pull requests' })),
|
|
32516
|
+
];
|
|
32517
|
+
}
|
|
32518
|
+
else if (overview.message && !overview.pullRequests) {
|
|
32519
|
+
headerRight = 'error';
|
|
32520
|
+
bodyLines = [
|
|
32521
|
+
h(Text, {
|
|
32522
|
+
key: 'pr-triage-error',
|
|
32523
|
+
dimColor: true,
|
|
32524
|
+
color: theme.noColor ? undefined : theme.colors.danger,
|
|
32525
|
+
}, overview.message),
|
|
32526
|
+
];
|
|
32527
|
+
}
|
|
32528
|
+
else {
|
|
32529
|
+
const all = overview.pullRequests || [];
|
|
32530
|
+
const visible = state.filter
|
|
32531
|
+
? all.filter((pr) => matchesPullRequestFilter(pr, state.filter))
|
|
32532
|
+
: all;
|
|
32533
|
+
const selected = Math.max(0, Math.min(state.selectedPullRequestTriageIndex, Math.max(0, visible.length - 1)));
|
|
32534
|
+
const listRows = Math.max(4, bodyRows - 4);
|
|
32535
|
+
const startIndex = Math.max(0, selected - Math.floor(listRows / 2));
|
|
32536
|
+
const windowed = visible.slice(startIndex, startIndex + listRows);
|
|
32537
|
+
const filterLabel = state.filter ? ` | filter: ${state.filter}` : '';
|
|
32538
|
+
const presetLabel = `▼ ${PULL_REQUEST_FILTER_LABELS[state.selectedPullRequestFilter]}`;
|
|
32539
|
+
const repoLabel = overview.repository
|
|
32540
|
+
? `${overview.repository.owner}/${overview.repository.name}`
|
|
32541
|
+
: '';
|
|
32542
|
+
headerRight = `${repoLabel ? `${repoLabel} · ` : ''}${visible.length}/${all.length} | ${presetLabel}${filterLabel}`;
|
|
32543
|
+
if (visible.length === 0) {
|
|
32544
|
+
bodyLines = [
|
|
32545
|
+
h(Text, { key: 'pr-triage-empty', dimColor: true }, formatLogInkPullRequestTriageEmpty({ filter: state.filter })),
|
|
32546
|
+
];
|
|
32547
|
+
}
|
|
32548
|
+
else {
|
|
32549
|
+
const numberColWidth = Math.min(6, Math.max(...windowed.map((p) => `#${p.number}`.length), 3));
|
|
32550
|
+
const authorColWidth = Math.min(16, Math.max(...windowed.map((p) => (p.author || '').length), 4));
|
|
32551
|
+
const branchColWidth = Math.min(24, Math.max(...windowed.map((p) => p.headRefName.length), 6));
|
|
32552
|
+
bodyLines = windowed.map((pr, offset) => {
|
|
32553
|
+
const index = startIndex + offset;
|
|
32554
|
+
const isSelected = index === selected;
|
|
32555
|
+
const cursor = isSelected ? '>' : ' ';
|
|
32556
|
+
const numStr = `#${pr.number}`.padEnd(numberColWidth);
|
|
32557
|
+
const stateLabel = pr.isDraft ? 'draft' : pr.state.toLowerCase();
|
|
32558
|
+
const stateStr = stateLabel.padEnd(6);
|
|
32559
|
+
const mergeStr = mergeableGlyph(pr.mergeStateStatus, pr.mergeable);
|
|
32560
|
+
const reviewStr = reviewGlyph(pr.reviewDecision);
|
|
32561
|
+
const authorStr = (pr.author || '').padEnd(authorColWidth);
|
|
32562
|
+
const branchStr = truncateCells(pr.headRefName, branchColWidth).padEnd(branchColWidth);
|
|
32563
|
+
const labelStr = (pr.labels || []).length
|
|
32564
|
+
? ` [${(pr.labels || []).join(' ')}]`
|
|
32565
|
+
: '';
|
|
32566
|
+
const head = `${cursor} `;
|
|
32567
|
+
const prefix = `${numStr} ${stateStr} ${mergeStr} ${reviewStr} ${authorStr} ${branchStr} `;
|
|
32568
|
+
const titleBudget = Math.max(8, width - 4 - head.length - prefix.length);
|
|
32569
|
+
const titleStr = truncateCells(pr.title, titleBudget);
|
|
32570
|
+
return h(Text, {
|
|
32571
|
+
key: `pr-triage-${index}`,
|
|
32572
|
+
bold: isSelected,
|
|
32573
|
+
dimColor: !isSelected,
|
|
32574
|
+
}, head, h(Text, { dimColor: true }, numStr + ' '), h(Text, {
|
|
32575
|
+
color: stateColor(theme, pr.state, pr.isDraft),
|
|
32576
|
+
dimColor: !isSelected,
|
|
32577
|
+
}, stateStr + ' '), h(Text, {
|
|
32578
|
+
color: mergeableColor(theme, pr.mergeStateStatus, pr.mergeable),
|
|
32579
|
+
dimColor: !isSelected,
|
|
32580
|
+
}, mergeStr + ' '), h(Text, {
|
|
32581
|
+
color: reviewColor(theme, pr.reviewDecision),
|
|
32582
|
+
dimColor: !isSelected,
|
|
32583
|
+
}, reviewStr + ' '), h(Text, {
|
|
32584
|
+
color: theme.noColor ? undefined : theme.colors.accent,
|
|
32585
|
+
dimColor: !isSelected,
|
|
32586
|
+
}, authorStr + ' '), h(Text, { dimColor: true }, branchStr + ' '), titleStr, h(Text, { dimColor: true }, labelStr));
|
|
32587
|
+
});
|
|
32588
|
+
}
|
|
32589
|
+
}
|
|
32590
|
+
return h(Box, {
|
|
32591
|
+
borderColor: focusBorderColor(theme, focused),
|
|
32592
|
+
borderStyle: theme.borderStyle,
|
|
32593
|
+
flexDirection: 'column',
|
|
32594
|
+
flexShrink: 0,
|
|
32595
|
+
paddingX: 1,
|
|
32596
|
+
width,
|
|
32597
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Pull requests', focused)), h(Text, { dimColor: true }, headerRight)), ...bodyLines);
|
|
32598
|
+
}
|
|
32599
|
+
|
|
29904
32600
|
/**
|
|
29905
32601
|
* Reflog surface (#781). Renders `git reflog` chronologically — every
|
|
29906
32602
|
* HEAD movement (commit, checkout, merge, reset, …) with relative time,
|
|
@@ -30480,7 +33176,7 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
30480
33176
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
|
|
30481
33177
|
* of #890. No behavior change.
|
|
30482
33178
|
*/
|
|
30483
|
-
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) {
|
|
33179
|
+
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) {
|
|
30484
33180
|
// Split-plan overlay (#907 polish): renders in the MAIN panel (not
|
|
30485
33181
|
// detail) when active, because the content — multiple commit groups
|
|
30486
33182
|
// with file lists, rationale, hunks — needs the full center width
|
|
@@ -30524,13 +33220,19 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
30524
33220
|
if (state.activeView === 'pull-request') {
|
|
30525
33221
|
return renderPullRequestSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
30526
33222
|
}
|
|
33223
|
+
if (state.activeView === 'pull-request-triage') {
|
|
33224
|
+
return renderPullRequestTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
33225
|
+
}
|
|
33226
|
+
if (state.activeView === 'issues') {
|
|
33227
|
+
return renderIssuesTriageSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
33228
|
+
}
|
|
30527
33229
|
if (state.activeView === 'conflicts') {
|
|
30528
33230
|
return renderConflictsSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
30529
33231
|
}
|
|
30530
33232
|
if (state.activeView === 'changelog') {
|
|
30531
33233
|
return renderChangelogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
30532
33234
|
}
|
|
30533
|
-
return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits);
|
|
33235
|
+
return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled);
|
|
30534
33236
|
}
|
|
30535
33237
|
|
|
30536
33238
|
/**
|
|
@@ -30628,6 +33330,230 @@ function formatStashPreview(stash, options = {}) {
|
|
|
30628
33330
|
}
|
|
30629
33331
|
return out;
|
|
30630
33332
|
}
|
|
33333
|
+
/* ------------------------- detail-section helpers ----------------------- */
|
|
33334
|
+
/**
|
|
33335
|
+
* Render the first `maxLines` non-empty lines of an issue / PR body
|
|
33336
|
+
* as preview lines. Returns an empty array when the body itself is
|
|
33337
|
+
* empty (or whitespace only) so callers can `out.push(...body(...))`
|
|
33338
|
+
* without an extra guard. Trailer appears only when content was
|
|
33339
|
+
* actually truncated.
|
|
33340
|
+
*/
|
|
33341
|
+
function bodyExcerptLines(body, maxLines) {
|
|
33342
|
+
if (!body.trim())
|
|
33343
|
+
return [];
|
|
33344
|
+
const lines = body.replace(/\r\n/g, '\n').split('\n');
|
|
33345
|
+
// Drop leading blanks so the excerpt opens on the first real line
|
|
33346
|
+
// rather than rendering an awkward "blank line, then body".
|
|
33347
|
+
while (lines.length > 0 && !lines[0].trim())
|
|
33348
|
+
lines.shift();
|
|
33349
|
+
const shown = lines.slice(0, maxLines);
|
|
33350
|
+
const truncated = lines.length > maxLines;
|
|
33351
|
+
const out = [
|
|
33352
|
+
heading('Body'),
|
|
33353
|
+
...shown.map((l) => line(l)),
|
|
33354
|
+
];
|
|
33355
|
+
if (truncated) {
|
|
33356
|
+
out.push(dim(`… ${lines.length - maxLines} more line(s)`));
|
|
33357
|
+
}
|
|
33358
|
+
return out;
|
|
33359
|
+
}
|
|
33360
|
+
function shortenLine(value, maxLength) {
|
|
33361
|
+
const flattened = value.replace(/\s+/g, ' ').trim();
|
|
33362
|
+
if (flattened.length <= maxLength)
|
|
33363
|
+
return flattened;
|
|
33364
|
+
return `${flattened.slice(0, Math.max(0, maxLength - 1))}…`;
|
|
33365
|
+
}
|
|
33366
|
+
function commentsSection(comments, maxShown) {
|
|
33367
|
+
if (comments.length === 0)
|
|
33368
|
+
return [];
|
|
33369
|
+
const recent = comments.slice(-3);
|
|
33370
|
+
const out = [heading(`Comments (${comments.length})`)];
|
|
33371
|
+
for (const comment of recent) {
|
|
33372
|
+
const who = comment.author || 'anonymous';
|
|
33373
|
+
out.push(line(`@${who}: ${shortenLine(comment.body, 80)}`));
|
|
33374
|
+
}
|
|
33375
|
+
if (comments.length > recent.length) {
|
|
33376
|
+
out.push(dim(`… ${comments.length - recent.length} earlier comment(s)`));
|
|
33377
|
+
}
|
|
33378
|
+
return out;
|
|
33379
|
+
}
|
|
33380
|
+
function reviewsSection(reviews) {
|
|
33381
|
+
if (reviews.length === 0)
|
|
33382
|
+
return [];
|
|
33383
|
+
const out = [heading(`Reviews (${reviews.length})`)];
|
|
33384
|
+
for (const review of reviews) {
|
|
33385
|
+
const who = review.author || 'anonymous';
|
|
33386
|
+
const stateLabel = (review.state || 'commented').toLowerCase().replace(/_/g, ' ');
|
|
33387
|
+
const inlineBody = review.body ? ` — ${shortenLine(review.body, 60)}` : '';
|
|
33388
|
+
out.push(line(`@${who} (${stateLabel})${inlineBody}`));
|
|
33389
|
+
}
|
|
33390
|
+
return out;
|
|
33391
|
+
}
|
|
33392
|
+
function statusChecksSection(checks) {
|
|
33393
|
+
if (checks.length === 0)
|
|
33394
|
+
return [];
|
|
33395
|
+
const grouped = {
|
|
33396
|
+
success: 0,
|
|
33397
|
+
failure: 0,
|
|
33398
|
+
pending: 0,
|
|
33399
|
+
other: 0,
|
|
33400
|
+
};
|
|
33401
|
+
for (const check of checks) {
|
|
33402
|
+
const result = check.conclusion?.toLowerCase() ?? check.status?.toLowerCase() ?? '';
|
|
33403
|
+
if (result === 'success')
|
|
33404
|
+
grouped.success++;
|
|
33405
|
+
else if (result === 'failure' || result === 'cancelled' || result === 'timed_out')
|
|
33406
|
+
grouped.failure++;
|
|
33407
|
+
else if (result === 'pending' || result === 'queued' || result === 'in_progress')
|
|
33408
|
+
grouped.pending++;
|
|
33409
|
+
else
|
|
33410
|
+
grouped.other++;
|
|
33411
|
+
}
|
|
33412
|
+
const parts = [];
|
|
33413
|
+
if (grouped.success)
|
|
33414
|
+
parts.push(`${grouped.success} pass`);
|
|
33415
|
+
if (grouped.failure)
|
|
33416
|
+
parts.push(`${grouped.failure} fail`);
|
|
33417
|
+
if (grouped.pending)
|
|
33418
|
+
parts.push(`${grouped.pending} pending`);
|
|
33419
|
+
if (grouped.other)
|
|
33420
|
+
parts.push(`${grouped.other} other`);
|
|
33421
|
+
return [
|
|
33422
|
+
heading(`Checks (${checks.length})`),
|
|
33423
|
+
line(parts.join(' · ')),
|
|
33424
|
+
];
|
|
33425
|
+
}
|
|
33426
|
+
/* -------------------------------- issue -------------------------------- */
|
|
33427
|
+
/**
|
|
33428
|
+
* Format an issue triage entry into preview lines (#882 phase 3,
|
|
33429
|
+
* body + comments added in the inspector-hydration follow-up).
|
|
33430
|
+
* The list payload from `gh issue list --json` carries metadata
|
|
33431
|
+
* only; the optional `detail` argument is filled by the runtime's
|
|
33432
|
+
* debounced hydration effect when the cursor rests on a row, and
|
|
33433
|
+
* unlocks the body / comments sections.
|
|
33434
|
+
*/
|
|
33435
|
+
function formatIssueTriagePreview(issue, detail) {
|
|
33436
|
+
if (!issue) {
|
|
33437
|
+
return [dim('Select an issue to preview.')];
|
|
33438
|
+
}
|
|
33439
|
+
const out = [
|
|
33440
|
+
heading(`#${issue.number} · ${issue.title}`),
|
|
33441
|
+
blank(),
|
|
33442
|
+
line(`State: ${issue.state.toLowerCase()}`),
|
|
33443
|
+
];
|
|
33444
|
+
if (issue.author)
|
|
33445
|
+
out.push(line(`Author: ${issue.author}`));
|
|
33446
|
+
if (issue.assignees && issue.assignees.length > 0) {
|
|
33447
|
+
out.push(line(`Assigned: ${issue.assignees.join(', ')}`));
|
|
33448
|
+
}
|
|
33449
|
+
if (issue.labels && issue.labels.length > 0) {
|
|
33450
|
+
out.push(line(`Labels: ${issue.labels.join(', ')}`));
|
|
33451
|
+
}
|
|
33452
|
+
if (typeof issue.comments === 'number') {
|
|
33453
|
+
out.push(line(`Comments: ${issue.comments}`));
|
|
33454
|
+
}
|
|
33455
|
+
out.push(blank());
|
|
33456
|
+
if (issue.createdAt)
|
|
33457
|
+
out.push(line(`Created: ${issue.createdAt}`));
|
|
33458
|
+
if (issue.updatedAt)
|
|
33459
|
+
out.push(line(`Updated: ${issue.updatedAt}`));
|
|
33460
|
+
out.push(blank());
|
|
33461
|
+
out.push(dim(issue.url));
|
|
33462
|
+
// Hydrated sections (body + recent comments). Inserted only when
|
|
33463
|
+
// the runtime has finished the per-cursor-rest detail fetch and
|
|
33464
|
+
// populated the cache.
|
|
33465
|
+
if (detail) {
|
|
33466
|
+
const body = bodyExcerptLines(detail.body, 6);
|
|
33467
|
+
if (body.length > 0) {
|
|
33468
|
+
out.push(blank());
|
|
33469
|
+
out.push(...body);
|
|
33470
|
+
}
|
|
33471
|
+
const comments = commentsSection(detail.comments);
|
|
33472
|
+
if (comments.length > 0) {
|
|
33473
|
+
out.push(blank());
|
|
33474
|
+
out.push(...comments);
|
|
33475
|
+
}
|
|
33476
|
+
}
|
|
33477
|
+
else if (typeof issue.comments === 'number' && issue.comments > 0) {
|
|
33478
|
+
// Pre-hydration affordance — tell the user the body / comments
|
|
33479
|
+
// section is coming, so a 250ms wait doesn't look like a bug.
|
|
33480
|
+
out.push(blank());
|
|
33481
|
+
out.push(dim('Loading body + comments…'));
|
|
33482
|
+
}
|
|
33483
|
+
return out;
|
|
33484
|
+
}
|
|
33485
|
+
/* ----------------------------- pull request ---------------------------- */
|
|
33486
|
+
/**
|
|
33487
|
+
* Format a pull-request triage entry into preview lines (#882 phase 3,
|
|
33488
|
+
* body / comments / reviews / checks added in the inspector-hydration
|
|
33489
|
+
* follow-up). Optional `detail` argument is filled by the runtime's
|
|
33490
|
+
* debounced hydration effect when the cursor rests on a row.
|
|
33491
|
+
*/
|
|
33492
|
+
function formatPullRequestTriagePreview(pr, detail) {
|
|
33493
|
+
if (!pr) {
|
|
33494
|
+
return [dim('Select a pull request to preview.')];
|
|
33495
|
+
}
|
|
33496
|
+
const out = [
|
|
33497
|
+
heading(`#${pr.number} · ${pr.title}`),
|
|
33498
|
+
blank(),
|
|
33499
|
+
line(`State: ${pr.isDraft ? 'draft' : pr.state.toLowerCase()}`),
|
|
33500
|
+
];
|
|
33501
|
+
if (pr.author)
|
|
33502
|
+
out.push(line(`Author: ${pr.author}`));
|
|
33503
|
+
out.push(line(`Branches: ${pr.headRefName} → ${pr.baseRefName}`));
|
|
33504
|
+
if (pr.mergeable || pr.mergeStateStatus) {
|
|
33505
|
+
const merge = [pr.mergeable, pr.mergeStateStatus].filter(Boolean).join(' / ');
|
|
33506
|
+
out.push(line(`Mergeable: ${merge.toLowerCase()}`));
|
|
33507
|
+
}
|
|
33508
|
+
if (pr.reviewDecision) {
|
|
33509
|
+
out.push(line(`Review: ${pr.reviewDecision.toLowerCase().replace(/_/g, ' ')}`));
|
|
33510
|
+
}
|
|
33511
|
+
if (pr.assignees && pr.assignees.length > 0) {
|
|
33512
|
+
out.push(line(`Assigned: ${pr.assignees.join(', ')}`));
|
|
33513
|
+
}
|
|
33514
|
+
if (pr.labels && pr.labels.length > 0) {
|
|
33515
|
+
out.push(line(`Labels: ${pr.labels.join(', ')}`));
|
|
33516
|
+
}
|
|
33517
|
+
out.push(blank());
|
|
33518
|
+
if (pr.createdAt)
|
|
33519
|
+
out.push(line(`Created: ${pr.createdAt}`));
|
|
33520
|
+
if (pr.updatedAt)
|
|
33521
|
+
out.push(line(`Updated: ${pr.updatedAt}`));
|
|
33522
|
+
out.push(blank());
|
|
33523
|
+
out.push(dim(pr.url));
|
|
33524
|
+
// Hydrated sections — body, status checks, reviews, comments.
|
|
33525
|
+
// Status checks come BEFORE reviews because failing CI is usually
|
|
33526
|
+
// what a triager wants to see first; reviews come second because
|
|
33527
|
+
// they're the human-judgment layer on top.
|
|
33528
|
+
if (detail) {
|
|
33529
|
+
const body = bodyExcerptLines(detail.body, 6);
|
|
33530
|
+
if (body.length > 0) {
|
|
33531
|
+
out.push(blank());
|
|
33532
|
+
out.push(...body);
|
|
33533
|
+
}
|
|
33534
|
+
const checks = statusChecksSection(detail.statusCheckRollup);
|
|
33535
|
+
if (checks.length > 0) {
|
|
33536
|
+
out.push(blank());
|
|
33537
|
+
out.push(...checks);
|
|
33538
|
+
}
|
|
33539
|
+
const reviews = reviewsSection(detail.reviews);
|
|
33540
|
+
if (reviews.length > 0) {
|
|
33541
|
+
out.push(blank());
|
|
33542
|
+
out.push(...reviews);
|
|
33543
|
+
}
|
|
33544
|
+
const comments = commentsSection(detail.comments);
|
|
33545
|
+
if (comments.length > 0) {
|
|
33546
|
+
out.push(blank());
|
|
33547
|
+
out.push(...comments);
|
|
33548
|
+
}
|
|
33549
|
+
}
|
|
33550
|
+
else {
|
|
33551
|
+
// Pre-hydration affordance — same as the issue preview.
|
|
33552
|
+
out.push(blank());
|
|
33553
|
+
out.push(dim('Loading body + reviews + comments…'));
|
|
33554
|
+
}
|
|
33555
|
+
return out;
|
|
33556
|
+
}
|
|
30631
33557
|
|
|
30632
33558
|
/**
|
|
30633
33559
|
* Detail / inspector / preview surface family.
|
|
@@ -30666,6 +33592,15 @@ function formatStashPreview(stash, options = {}) {
|
|
|
30666
33592
|
* minimum from `getLogInkLayout`) so an overflowing label never wraps and
|
|
30667
33593
|
* collides with the next row.
|
|
30668
33594
|
*/
|
|
33595
|
+
/**
|
|
33596
|
+
* Format the file-count portion of the inspector stats line. Pluralize
|
|
33597
|
+
* "files" only when the count is not 1 so `1 file +12/-22` reads
|
|
33598
|
+
* naturally instead of `1 files`.
|
|
33599
|
+
*/
|
|
33600
|
+
function formatCommitStatLine(stats) {
|
|
33601
|
+
const label = stats.filesChanged === 1 ? 'file' : 'files';
|
|
33602
|
+
return `${stats.filesChanged} ${label} +${stats.insertions}/-${stats.deletions}`;
|
|
33603
|
+
}
|
|
30669
33604
|
function renderInspectorActionsSection(h, Text, context, width, theme, options = {}) {
|
|
30670
33605
|
const actions = getInspectorActions(context);
|
|
30671
33606
|
if (!actions.length)
|
|
@@ -30840,7 +33775,7 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
30840
33775
|
cursorActive: focused && state.inspectorTab === 'actions',
|
|
30841
33776
|
}));
|
|
30842
33777
|
}
|
|
30843
|
-
const statLine =
|
|
33778
|
+
const statLine = formatCommitStatLine(detail.stats);
|
|
30844
33779
|
// P5.1 — link the commit hash and each ref out to GitHub when we know
|
|
30845
33780
|
// the remote. OSC 8 escapes embed inline; supportsHyperlinks() decides
|
|
30846
33781
|
// whether to wrap or fall through to plain text.
|
|
@@ -30960,7 +33895,7 @@ function renderCommitDiffDetail(h, components, state, detail, loading, width, th
|
|
|
30960
33895
|
dimColor: index > 1,
|
|
30961
33896
|
}, truncateCells(line, width - 4))));
|
|
30962
33897
|
}
|
|
30963
|
-
const statLine =
|
|
33898
|
+
const statLine = formatCommitStatLine(detail.stats);
|
|
30964
33899
|
const headerLines = [
|
|
30965
33900
|
detail.message,
|
|
30966
33901
|
'',
|
|
@@ -31189,6 +34124,64 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
|
|
|
31189
34124
|
? null
|
|
31190
34125
|
: h(Text, { key: 'commit-state', dimColor: true }, truncateCells(stateLine, width - 4)));
|
|
31191
34126
|
}
|
|
34127
|
+
/**
|
|
34128
|
+
* Issue triage preview pane (#882 phase 3). Mirrors the branch / tag /
|
|
34129
|
+
* stash preview pattern — `renderPreviewPanel` chrome, formatter pulls
|
|
34130
|
+
* the cursored item via `state.selectedIssueIndex`. Shown when
|
|
34131
|
+
* `state.activeView === 'issues'`.
|
|
34132
|
+
*/
|
|
34133
|
+
function renderIssueTriagePreviewPanel(h, components, state, context, contextStatus, width, theme, focused) {
|
|
34134
|
+
const { Box, Text } = components;
|
|
34135
|
+
if (isLogInkContextKeyLoading(contextStatus, 'issueList')) {
|
|
34136
|
+
return renderPreviewPanel(h, { Box, Text }, 'Issue preview', [{ text: formatLogInkLoading({ resource: 'issues' }), emphasis: 'dim' }], width, theme, focused);
|
|
34137
|
+
}
|
|
34138
|
+
const all = context.issueList?.issues || [];
|
|
34139
|
+
const visible = state.filter
|
|
34140
|
+
? all.filter((issue) => matchesPromotedFilter([
|
|
34141
|
+
`#${issue.number}`,
|
|
34142
|
+
issue.title,
|
|
34143
|
+
issue.author || '',
|
|
34144
|
+
...(issue.labels || []),
|
|
34145
|
+
...(issue.assignees || []),
|
|
34146
|
+
], state.filter))
|
|
34147
|
+
: all;
|
|
34148
|
+
const index = Math.max(0, Math.min(state.selectedIssueIndex, Math.max(0, visible.length - 1)));
|
|
34149
|
+
const issue = visible[index];
|
|
34150
|
+
const detail = issue
|
|
34151
|
+
? context.issueDetailByNumber?.get(issue.number)
|
|
34152
|
+
: undefined;
|
|
34153
|
+
return renderPreviewPanel(h, { Box, Text }, 'Issue preview', formatIssueTriagePreview(issue, detail), width, theme, focused);
|
|
34154
|
+
}
|
|
34155
|
+
/**
|
|
34156
|
+
* Pull-request triage preview pane (#882 phase 3). Shown when
|
|
34157
|
+
* `state.activeView === 'pull-request-triage'`. Distinct from the
|
|
34158
|
+
* single-PR action panel's right pane (which renders the full
|
|
34159
|
+
* inspector with status checks, reviews, and action keys).
|
|
34160
|
+
*/
|
|
34161
|
+
function renderPullRequestTriagePreviewPanel(h, components, state, context, contextStatus, width, theme, focused) {
|
|
34162
|
+
const { Box, Text } = components;
|
|
34163
|
+
if (isLogInkContextKeyLoading(contextStatus, 'pullRequestList')) {
|
|
34164
|
+
return renderPreviewPanel(h, { Box, Text }, 'Pull request preview', [{ text: formatLogInkLoading({ resource: 'pull requests' }), emphasis: 'dim' }], width, theme, focused);
|
|
34165
|
+
}
|
|
34166
|
+
const all = context.pullRequestList?.pullRequests || [];
|
|
34167
|
+
const visible = state.filter
|
|
34168
|
+
? all.filter((pr) => matchesPromotedFilter([
|
|
34169
|
+
`#${pr.number}`,
|
|
34170
|
+
pr.title,
|
|
34171
|
+
pr.author || '',
|
|
34172
|
+
pr.headRefName,
|
|
34173
|
+
pr.baseRefName,
|
|
34174
|
+
...(pr.labels || []),
|
|
34175
|
+
...(pr.assignees || []),
|
|
34176
|
+
], state.filter))
|
|
34177
|
+
: all;
|
|
34178
|
+
const index = Math.max(0, Math.min(state.selectedPullRequestTriageIndex, Math.max(0, visible.length - 1)));
|
|
34179
|
+
const pr = visible[index];
|
|
34180
|
+
const detail = pr
|
|
34181
|
+
? context.pullRequestDetailByNumber?.get(pr.number)
|
|
34182
|
+
: undefined;
|
|
34183
|
+
return renderPreviewPanel(h, { Box, Text }, 'Pull request preview', formatPullRequestTriagePreview(pr, detail), width, theme, focused);
|
|
34184
|
+
}
|
|
31192
34185
|
|
|
31193
34186
|
/**
|
|
31194
34187
|
* Detail-panel dispatcher. Routes to the right detail / overlay
|
|
@@ -31204,8 +34197,41 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
|
|
|
31204
34197
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
|
|
31205
34198
|
* of #890. No behavior change.
|
|
31206
34199
|
*/
|
|
31207
|
-
|
|
34200
|
+
/**
|
|
34201
|
+
* Rail-mode inspector — shown on terminals < 100 columns when the
|
|
34202
|
+
* detail panel does not hold focus. The full inspector (commit body,
|
|
34203
|
+
* file list, actions) does not survive truncation to ~4 content cells
|
|
34204
|
+
* so we collapse to a stack with the panel label and the selected
|
|
34205
|
+
* commit's shortHash. Focus pops the panel back to its expanded
|
|
34206
|
+
* widths via the layout, so this renderer is only reached at rest.
|
|
34207
|
+
*
|
|
34208
|
+
* Help / overlay states are still handled by their own renderers
|
|
34209
|
+
* above; this short-circuit only kicks in for the regular "view the
|
|
34210
|
+
* commit" cases.
|
|
34211
|
+
*/
|
|
34212
|
+
function renderInspectorRail(h, components, state, detail, width, theme, focused) {
|
|
34213
|
+
const { Box, Text } = components;
|
|
34214
|
+
// Prefer the loaded detail's hash (canonical) but fall back to the
|
|
34215
|
+
// selected list row's shortHash so the rail isn't blank on the
|
|
34216
|
+
// first render before getCommitDetail resolves.
|
|
34217
|
+
const selectedRow = getSelectedInkCommit(state);
|
|
34218
|
+
const hashText = detail?.hash.slice(0, 4)
|
|
34219
|
+
?? selectedRow?.shortHash.slice(0, 4)
|
|
34220
|
+
?? '····';
|
|
34221
|
+
return h(Box, {
|
|
34222
|
+
borderColor: focusBorderColor(theme, focused),
|
|
34223
|
+
borderStyle: theme.borderStyle,
|
|
34224
|
+
flexDirection: 'column',
|
|
34225
|
+
width,
|
|
34226
|
+
paddingX: 1,
|
|
34227
|
+
}, h(Text, { bold: true, dimColor: !focused }, panelTitle('Insp', focused)), h(Text, { dimColor: true }, '────'), h(Text, { color: theme.noColor ? undefined : theme.colors.accent }, hashText));
|
|
34228
|
+
}
|
|
34229
|
+
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, railed = false) {
|
|
31208
34230
|
const focused = state.focus === 'detail';
|
|
34231
|
+
// Overlays (help / palette / input / confirmation / chord) take
|
|
34232
|
+
// precedence over rail because they always claim the panel's width
|
|
34233
|
+
// via the help-overlay layout branch — and railing those would
|
|
34234
|
+
// defeat their whole purpose (the user is reading them).
|
|
31209
34235
|
if (state.showHelp) {
|
|
31210
34236
|
return renderHelpPanel(h, components, state, width, theme, focused);
|
|
31211
34237
|
}
|
|
@@ -31231,6 +34257,15 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
31231
34257
|
if (state.pendingKey && !state.splitPlan) {
|
|
31232
34258
|
return renderChordOverlay(h, components, state, width, theme, focused);
|
|
31233
34259
|
}
|
|
34260
|
+
// Rail mode applies only after every overlay above has had its say
|
|
34261
|
+
// — those would all be unreadable at 4 cells of content. The layout
|
|
34262
|
+
// also clears `railed` whenever the inspector takes focus, so we
|
|
34263
|
+
// can safely short-circuit the per-view dispatch here without
|
|
34264
|
+
// worrying about hiding the panel from a user who's actively
|
|
34265
|
+
// reading it.
|
|
34266
|
+
if (railed) {
|
|
34267
|
+
return renderInspectorRail(h, components, state, detail, width, theme, focused);
|
|
34268
|
+
}
|
|
31234
34269
|
// The synthetic "(+) new commit" row routes the inspector through the
|
|
31235
34270
|
// worktree summary so the user sees what's staged / unstaged at a glance
|
|
31236
34271
|
// — same surface as the compose view's right panel.
|
|
@@ -31271,6 +34306,12 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
31271
34306
|
if (state.activeView === 'submodules') {
|
|
31272
34307
|
return renderSubmodulePreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
|
|
31273
34308
|
}
|
|
34309
|
+
if (state.activeView === 'issues') {
|
|
34310
|
+
return renderIssueTriagePreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
|
|
34311
|
+
}
|
|
34312
|
+
if (state.activeView === 'pull-request-triage') {
|
|
34313
|
+
return renderPullRequestTriagePreviewPanel(h, components, state, context, contextStatus, width, theme, focused);
|
|
34314
|
+
}
|
|
31274
34315
|
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
|
|
31275
34316
|
}
|
|
31276
34317
|
|
|
@@ -31491,7 +34532,7 @@ function enrichFilterActionWithRectification(action, state, context) {
|
|
|
31491
34532
|
}
|
|
31492
34533
|
}
|
|
31493
34534
|
function LogInkApp(deps) {
|
|
31494
|
-
const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
|
|
34535
|
+
const { appLabel, clipboardRunner, dateBucketingEnabled, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
|
|
31495
34536
|
const { Box, Text, useApp, useInput, useWindowSize } = ink;
|
|
31496
34537
|
const h = React.createElement;
|
|
31497
34538
|
const { exit } = useApp();
|
|
@@ -31685,6 +34726,35 @@ function LogInkApp(deps) {
|
|
|
31685
34726
|
return all;
|
|
31686
34727
|
return all.filter((entry) => matchesPromotedFilter([entry.name, entry.path, entry.trackingBranch || '', entry.url || ''], state.filter));
|
|
31687
34728
|
}, [context.submodules?.entries, state.filter]);
|
|
34729
|
+
// Issues + PR triage filtered lists (#882 phase 3). Same memo
|
|
34730
|
+
// pattern as the other promoted views — collapses per-keystroke
|
|
34731
|
+
// filter work to one pass per (data, filter) change.
|
|
34732
|
+
const filteredIssueList = React.useMemo(() => {
|
|
34733
|
+
const all = context.issueList?.issues || [];
|
|
34734
|
+
if (!state.filter)
|
|
34735
|
+
return all;
|
|
34736
|
+
return all.filter((issue) => matchesPromotedFilter([
|
|
34737
|
+
`#${issue.number}`,
|
|
34738
|
+
issue.title,
|
|
34739
|
+
issue.author || '',
|
|
34740
|
+
...(issue.labels || []),
|
|
34741
|
+
...(issue.assignees || []),
|
|
34742
|
+
], state.filter));
|
|
34743
|
+
}, [context.issueList?.issues, state.filter]);
|
|
34744
|
+
const filteredPullRequestTriageList = React.useMemo(() => {
|
|
34745
|
+
const all = context.pullRequestList?.pullRequests || [];
|
|
34746
|
+
if (!state.filter)
|
|
34747
|
+
return all;
|
|
34748
|
+
return all.filter((pr) => matchesPromotedFilter([
|
|
34749
|
+
`#${pr.number}`,
|
|
34750
|
+
pr.title,
|
|
34751
|
+
pr.author || '',
|
|
34752
|
+
pr.headRefName,
|
|
34753
|
+
pr.baseRefName,
|
|
34754
|
+
...(pr.labels || []),
|
|
34755
|
+
...(pr.assignees || []),
|
|
34756
|
+
], state.filter));
|
|
34757
|
+
}, [context.pullRequestList?.pullRequests, state.filter]);
|
|
31688
34758
|
const dispatch = React.useCallback((action) => {
|
|
31689
34759
|
setState((current) => applyLogInkAction(current, action));
|
|
31690
34760
|
}, []);
|
|
@@ -32068,6 +35138,149 @@ function LogInkApp(deps) {
|
|
|
32068
35138
|
active = false;
|
|
32069
35139
|
};
|
|
32070
35140
|
}, [git, state.activeView, context.pullRequest]);
|
|
35141
|
+
// Lazy-load the issue triage list (#882 phase 3, filter-aware
|
|
35142
|
+
// since phase 6). Fires on entry to the view AND on filter
|
|
35143
|
+
// preset changes (`f` cycles the preset; the dep on
|
|
35144
|
+
// `state.selectedIssueFilter` triggers the refetch). The
|
|
35145
|
+
// existing `context.issueList` guard collapses to a no-op when
|
|
35146
|
+
// the preset hasn't changed and data is already loaded.
|
|
35147
|
+
React.useEffect(() => {
|
|
35148
|
+
if (state.activeView !== 'issues')
|
|
35149
|
+
return;
|
|
35150
|
+
if (context.issueList)
|
|
35151
|
+
return;
|
|
35152
|
+
let active = true;
|
|
35153
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'loading'));
|
|
35154
|
+
const filter = issueFilterForPreset(state.selectedIssueFilter);
|
|
35155
|
+
void safe(getIssueList(git, filter)).then((value) => {
|
|
35156
|
+
if (!active)
|
|
35157
|
+
return;
|
|
35158
|
+
setContext((current) => ({
|
|
35159
|
+
...current,
|
|
35160
|
+
issueList: value,
|
|
35161
|
+
}));
|
|
35162
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'ready'));
|
|
35163
|
+
});
|
|
35164
|
+
return () => {
|
|
35165
|
+
active = false;
|
|
35166
|
+
};
|
|
35167
|
+
}, [git, state.activeView, context.issueList, state.selectedIssueFilter]);
|
|
35168
|
+
// Filter cycling: when the preset changes, drop the cached list
|
|
35169
|
+
// so the effect above re-fires with the new filter. Done as a
|
|
35170
|
+
// separate effect (rather than folded into the cycle reducer)
|
|
35171
|
+
// because the reducer is pure — fs / network side-effects live
|
|
35172
|
+
// in `useEffect`.
|
|
35173
|
+
React.useEffect(() => {
|
|
35174
|
+
if (state.activeView !== 'issues')
|
|
35175
|
+
return;
|
|
35176
|
+
setContext((current) => (current.issueList ? { ...current, issueList: undefined } : current));
|
|
35177
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'idle'));
|
|
35178
|
+
// We deliberately depend ONLY on the preset — not on
|
|
35179
|
+
// activeView — so re-entering the view doesn't re-fire and
|
|
35180
|
+
// discard the just-loaded data. The activeView guard above
|
|
35181
|
+
// keeps us from clearing data while the user is on a
|
|
35182
|
+
// different surface.
|
|
35183
|
+
}, [state.selectedIssueFilter]);
|
|
35184
|
+
// Lazy-load the PR triage list (#882 phase 3, filter-aware
|
|
35185
|
+
// since phase 6). Same pattern as the issue effect above.
|
|
35186
|
+
React.useEffect(() => {
|
|
35187
|
+
if (state.activeView !== 'pull-request-triage')
|
|
35188
|
+
return;
|
|
35189
|
+
if (context.pullRequestList)
|
|
35190
|
+
return;
|
|
35191
|
+
let active = true;
|
|
35192
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'loading'));
|
|
35193
|
+
const filter = pullRequestFilterForPreset(state.selectedPullRequestFilter);
|
|
35194
|
+
void safe(getPullRequestList(git, filter)).then((value) => {
|
|
35195
|
+
if (!active)
|
|
35196
|
+
return;
|
|
35197
|
+
setContext((current) => ({
|
|
35198
|
+
...current,
|
|
35199
|
+
pullRequestList: value,
|
|
35200
|
+
}));
|
|
35201
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'ready'));
|
|
35202
|
+
});
|
|
35203
|
+
return () => {
|
|
35204
|
+
active = false;
|
|
35205
|
+
};
|
|
35206
|
+
}, [git, state.activeView, context.pullRequestList, state.selectedPullRequestFilter]);
|
|
35207
|
+
React.useEffect(() => {
|
|
35208
|
+
if (state.activeView !== 'pull-request-triage')
|
|
35209
|
+
return;
|
|
35210
|
+
setContext((current) => current.pullRequestList ? { ...current, pullRequestList: undefined } : current);
|
|
35211
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'idle'));
|
|
35212
|
+
}, [state.selectedPullRequestFilter]);
|
|
35213
|
+
// Per-item inspector hydration (#882 follow-up to phase 6). When
|
|
35214
|
+
// the user rests the cursor on an issue / PR row for ~250ms, fetch
|
|
35215
|
+
// the body + comments (+ reviews + status checks for PRs) and
|
|
35216
|
+
// cache the result keyed by number. Cursoring back to a previously-
|
|
35217
|
+
// fetched item shows the cached entry instantly; rapid j/k
|
|
35218
|
+
// navigation never fires a `gh` call because the debounce timer
|
|
35219
|
+
// resets on every cursor move.
|
|
35220
|
+
//
|
|
35221
|
+
// The cache lives on `context.{issueDetailByNumber,
|
|
35222
|
+
// pullRequestDetailByNumber}` so it survives the per-keystroke
|
|
35223
|
+
// re-renders. It's intentionally Maps — `new Map(prev).set(k, v)`
|
|
35224
|
+
// keeps the immutable update story simple, and entries persist
|
|
35225
|
+
// until either the list is invalidated (post-mutation) or the
|
|
35226
|
+
// process exits.
|
|
35227
|
+
const DETAIL_HYDRATION_DELAY_MS = 250;
|
|
35228
|
+
React.useEffect(() => {
|
|
35229
|
+
if (state.activeView !== 'issues')
|
|
35230
|
+
return;
|
|
35231
|
+
const cursored = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
|
|
35232
|
+
if (!cursored)
|
|
35233
|
+
return;
|
|
35234
|
+
if (context.issueDetailByNumber?.has(cursored.number))
|
|
35235
|
+
return;
|
|
35236
|
+
let active = true;
|
|
35237
|
+
const timer = setTimeout(async () => {
|
|
35238
|
+
const result = await getIssueDetail(cursored.number);
|
|
35239
|
+
if (!active || !result.ok)
|
|
35240
|
+
return;
|
|
35241
|
+
setContext((current) => ({
|
|
35242
|
+
...current,
|
|
35243
|
+
issueDetailByNumber: new Map(current.issueDetailByNumber || []).set(result.detail.number, result.detail),
|
|
35244
|
+
}));
|
|
35245
|
+
}, DETAIL_HYDRATION_DELAY_MS);
|
|
35246
|
+
return () => {
|
|
35247
|
+
active = false;
|
|
35248
|
+
clearTimeout(timer);
|
|
35249
|
+
};
|
|
35250
|
+
}, [
|
|
35251
|
+
state.activeView,
|
|
35252
|
+
state.selectedIssueIndex,
|
|
35253
|
+
filteredIssueList,
|
|
35254
|
+
context.issueDetailByNumber,
|
|
35255
|
+
]);
|
|
35256
|
+
React.useEffect(() => {
|
|
35257
|
+
if (state.activeView !== 'pull-request-triage')
|
|
35258
|
+
return;
|
|
35259
|
+
const cursored = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
35260
|
+
if (!cursored)
|
|
35261
|
+
return;
|
|
35262
|
+
if (context.pullRequestDetailByNumber?.has(cursored.number))
|
|
35263
|
+
return;
|
|
35264
|
+
let active = true;
|
|
35265
|
+
const timer = setTimeout(async () => {
|
|
35266
|
+
const result = await getPullRequestDetail(cursored.number);
|
|
35267
|
+
if (!active || !result.ok)
|
|
35268
|
+
return;
|
|
35269
|
+
setContext((current) => ({
|
|
35270
|
+
...current,
|
|
35271
|
+
pullRequestDetailByNumber: new Map(current.pullRequestDetailByNumber || []).set(result.detail.number, result.detail),
|
|
35272
|
+
}));
|
|
35273
|
+
}, DETAIL_HYDRATION_DELAY_MS);
|
|
35274
|
+
return () => {
|
|
35275
|
+
active = false;
|
|
35276
|
+
clearTimeout(timer);
|
|
35277
|
+
};
|
|
35278
|
+
}, [
|
|
35279
|
+
state.activeView,
|
|
35280
|
+
state.selectedPullRequestTriageIndex,
|
|
35281
|
+
filteredPullRequestTriageList,
|
|
35282
|
+
context.pullRequestDetailByNumber,
|
|
35283
|
+
]);
|
|
32071
35284
|
React.useEffect(() => {
|
|
32072
35285
|
let active = true;
|
|
32073
35286
|
async function loadDetail() {
|
|
@@ -33014,6 +36227,56 @@ function LogInkApp(deps) {
|
|
|
33014
36227
|
const effectiveTarget = expectedTarget || target;
|
|
33015
36228
|
return applyHunkPatch(git, patchText, { target: effectiveTarget });
|
|
33016
36229
|
};
|
|
36230
|
+
// #882 phase 4 — post-mutation cache invalidation for the
|
|
36231
|
+
// issue / PR triage views. Each helper does two things:
|
|
36232
|
+
// 1. Clears the in-memory `context.issueList` /
|
|
36233
|
+
// `context.pullRequestList` entry so the view's `useEffect`
|
|
36234
|
+
// retriggers on the next render and the user sees their
|
|
36235
|
+
// change reflected immediately.
|
|
36236
|
+
// 2. Wipes the disk cache so a follow-up `coco issues` /
|
|
36237
|
+
// `coco prs` CLI call doesn't serve stale data from the
|
|
36238
|
+
// 5-minute TTL window. Sledgehammer rather than scalpel —
|
|
36239
|
+
// clearing per (repo, filter) tuple would require more
|
|
36240
|
+
// bookkeeping than the cache is worth.
|
|
36241
|
+
const invalidateIssueListCaches = (issueNumber) => {
|
|
36242
|
+
setContext((current) => {
|
|
36243
|
+
const next = { ...current, issueList: undefined };
|
|
36244
|
+
// Drop only the mutated issue's detail entry so other
|
|
36245
|
+
// hydrated entries survive — they're still accurate. When
|
|
36246
|
+
// no number is given (rare), wipe the whole detail map.
|
|
36247
|
+
if (current.issueDetailByNumber) {
|
|
36248
|
+
if (typeof issueNumber === 'number') {
|
|
36249
|
+
const trimmed = new Map(current.issueDetailByNumber);
|
|
36250
|
+
trimmed.delete(issueNumber);
|
|
36251
|
+
next.issueDetailByNumber = trimmed;
|
|
36252
|
+
}
|
|
36253
|
+
else {
|
|
36254
|
+
next.issueDetailByNumber = undefined;
|
|
36255
|
+
}
|
|
36256
|
+
}
|
|
36257
|
+
return next;
|
|
36258
|
+
});
|
|
36259
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'issueList', 'idle'));
|
|
36260
|
+
clearGitHubListCache();
|
|
36261
|
+
};
|
|
36262
|
+
const invalidatePullRequestListCaches = (pullRequestNumber) => {
|
|
36263
|
+
setContext((current) => {
|
|
36264
|
+
const next = { ...current, pullRequestList: undefined };
|
|
36265
|
+
if (current.pullRequestDetailByNumber) {
|
|
36266
|
+
if (typeof pullRequestNumber === 'number') {
|
|
36267
|
+
const trimmed = new Map(current.pullRequestDetailByNumber);
|
|
36268
|
+
trimmed.delete(pullRequestNumber);
|
|
36269
|
+
next.pullRequestDetailByNumber = trimmed;
|
|
36270
|
+
}
|
|
36271
|
+
else {
|
|
36272
|
+
next.pullRequestDetailByNumber = undefined;
|
|
36273
|
+
}
|
|
36274
|
+
}
|
|
36275
|
+
return next;
|
|
36276
|
+
});
|
|
36277
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequestList', 'idle'));
|
|
36278
|
+
clearGitHubListCache();
|
|
36279
|
+
};
|
|
33017
36280
|
const handlers = {
|
|
33018
36281
|
'create-branch': async () => {
|
|
33019
36282
|
const name = payload?.trim();
|
|
@@ -33531,6 +36794,181 @@ function LogInkApp(deps) {
|
|
|
33531
36794
|
return { ok: false, message: 'Comment body required' };
|
|
33532
36795
|
return commentPullRequest(body);
|
|
33533
36796
|
},
|
|
36797
|
+
// #882 phase 4 — triage-view low-risk mutations. Each picks
|
|
36798
|
+
// the cursored item from the *filtered* list (matching what
|
|
36799
|
+
// the user sees on screen), runs the corresponding `gh` action,
|
|
36800
|
+
// and on success clears both the in-memory context entry and
|
|
36801
|
+
// the disk cache so the next view entry refetches. Comment
|
|
36802
|
+
// is additive; label / assign are toggleable via re-invocation
|
|
36803
|
+
// with --remove-* (deferred to phase 5 as part of the y-confirm
|
|
36804
|
+
// suite). Open / yank don't mutate so they skip the
|
|
36805
|
+
// invalidation step entirely.
|
|
36806
|
+
'triage-issue-open': async () => {
|
|
36807
|
+
const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
|
|
36808
|
+
if (!issue)
|
|
36809
|
+
return { ok: false, message: 'No issue under cursor' };
|
|
36810
|
+
try {
|
|
36811
|
+
await defaultOpenUrlRunner(issue.url);
|
|
36812
|
+
return { ok: true, message: `Opened ${issue.url}` };
|
|
36813
|
+
}
|
|
36814
|
+
catch (error) {
|
|
36815
|
+
return { ok: false, message: error.message };
|
|
36816
|
+
}
|
|
36817
|
+
},
|
|
36818
|
+
'triage-issue-comment': async () => {
|
|
36819
|
+
const body = payload?.trim();
|
|
36820
|
+
if (!body)
|
|
36821
|
+
return { ok: false, message: 'Comment body required' };
|
|
36822
|
+
const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
|
|
36823
|
+
if (!issue)
|
|
36824
|
+
return { ok: false, message: 'No issue under cursor' };
|
|
36825
|
+
const result = await commentIssue(issue.number, body);
|
|
36826
|
+
if (result.ok)
|
|
36827
|
+
invalidateIssueListCaches(issue.number);
|
|
36828
|
+
return result;
|
|
36829
|
+
},
|
|
36830
|
+
'triage-issue-label': async () => {
|
|
36831
|
+
const label = payload?.trim();
|
|
36832
|
+
if (!label)
|
|
36833
|
+
return { ok: false, message: 'Label name required' };
|
|
36834
|
+
const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
|
|
36835
|
+
if (!issue)
|
|
36836
|
+
return { ok: false, message: 'No issue under cursor' };
|
|
36837
|
+
const result = await addIssueLabel(issue.number, label);
|
|
36838
|
+
if (result.ok)
|
|
36839
|
+
invalidateIssueListCaches(issue.number);
|
|
36840
|
+
return result;
|
|
36841
|
+
},
|
|
36842
|
+
'triage-issue-assign': async () => {
|
|
36843
|
+
const assignee = payload?.trim();
|
|
36844
|
+
if (!assignee)
|
|
36845
|
+
return { ok: false, message: 'Assignee login required' };
|
|
36846
|
+
const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
|
|
36847
|
+
if (!issue)
|
|
36848
|
+
return { ok: false, message: 'No issue under cursor' };
|
|
36849
|
+
const result = await addIssueAssignee(issue.number, assignee);
|
|
36850
|
+
if (result.ok)
|
|
36851
|
+
invalidateIssueListCaches(issue.number);
|
|
36852
|
+
return result;
|
|
36853
|
+
},
|
|
36854
|
+
'triage-pr-open': async () => {
|
|
36855
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
36856
|
+
if (!pr)
|
|
36857
|
+
return { ok: false, message: 'No pull request under cursor' };
|
|
36858
|
+
try {
|
|
36859
|
+
await defaultOpenUrlRunner(pr.url);
|
|
36860
|
+
return { ok: true, message: `Opened ${pr.url}` };
|
|
36861
|
+
}
|
|
36862
|
+
catch (error) {
|
|
36863
|
+
return { ok: false, message: error.message };
|
|
36864
|
+
}
|
|
36865
|
+
},
|
|
36866
|
+
'triage-pr-comment': async () => {
|
|
36867
|
+
const body = payload?.trim();
|
|
36868
|
+
if (!body)
|
|
36869
|
+
return { ok: false, message: 'Comment body required' };
|
|
36870
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
36871
|
+
if (!pr)
|
|
36872
|
+
return { ok: false, message: 'No pull request under cursor' };
|
|
36873
|
+
const result = await commentPullRequestByNumber(pr.number, body);
|
|
36874
|
+
if (result.ok)
|
|
36875
|
+
invalidatePullRequestListCaches(pr.number);
|
|
36876
|
+
return result;
|
|
36877
|
+
},
|
|
36878
|
+
'triage-pr-label': async () => {
|
|
36879
|
+
const label = payload?.trim();
|
|
36880
|
+
if (!label)
|
|
36881
|
+
return { ok: false, message: 'Label name required' };
|
|
36882
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
36883
|
+
if (!pr)
|
|
36884
|
+
return { ok: false, message: 'No pull request under cursor' };
|
|
36885
|
+
const result = await addPullRequestLabel(pr.number, label);
|
|
36886
|
+
if (result.ok)
|
|
36887
|
+
invalidatePullRequestListCaches(pr.number);
|
|
36888
|
+
return result;
|
|
36889
|
+
},
|
|
36890
|
+
'triage-pr-assign': async () => {
|
|
36891
|
+
const assignee = payload?.trim();
|
|
36892
|
+
if (!assignee)
|
|
36893
|
+
return { ok: false, message: 'Assignee login required' };
|
|
36894
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
36895
|
+
if (!pr)
|
|
36896
|
+
return { ok: false, message: 'No pull request under cursor' };
|
|
36897
|
+
const result = await addPullRequestAssignee(pr.number, assignee);
|
|
36898
|
+
if (result.ok)
|
|
36899
|
+
invalidatePullRequestListCaches(pr.number);
|
|
36900
|
+
return result;
|
|
36901
|
+
},
|
|
36902
|
+
// #882 phase 5 — destructive triage mutations. Each is gated
|
|
36903
|
+
// through the y-confirm path so the user sees a prompt before
|
|
36904
|
+
// anything ships. The runner reads the cursored item from the
|
|
36905
|
+
// filtered list at confirm-time; the cursor can't move while
|
|
36906
|
+
// the confirmation overlay is up so there's no stale-target
|
|
36907
|
+
// window. Cache invalidation matches the phase-4 pattern.
|
|
36908
|
+
'triage-issue-close': async () => {
|
|
36909
|
+
const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
|
|
36910
|
+
if (!issue)
|
|
36911
|
+
return { ok: false, message: 'No issue under cursor' };
|
|
36912
|
+
const result = await closeIssue(issue.number);
|
|
36913
|
+
if (result.ok)
|
|
36914
|
+
invalidateIssueListCaches(issue.number);
|
|
36915
|
+
return result;
|
|
36916
|
+
},
|
|
36917
|
+
'triage-issue-reopen': async () => {
|
|
36918
|
+
const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
|
|
36919
|
+
if (!issue)
|
|
36920
|
+
return { ok: false, message: 'No issue under cursor' };
|
|
36921
|
+
const result = await reopenIssue(issue.number);
|
|
36922
|
+
if (result.ok)
|
|
36923
|
+
invalidateIssueListCaches(issue.number);
|
|
36924
|
+
return result;
|
|
36925
|
+
},
|
|
36926
|
+
'triage-pr-merge': async () => {
|
|
36927
|
+
const strategy = payload?.trim();
|
|
36928
|
+
if (!strategy || !isPullRequestMergeStrategy(strategy)) {
|
|
36929
|
+
return {
|
|
36930
|
+
ok: false,
|
|
36931
|
+
message: `Unknown merge strategy: ${strategy}. Use merge, squash, or rebase.`,
|
|
36932
|
+
};
|
|
36933
|
+
}
|
|
36934
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
36935
|
+
if (!pr)
|
|
36936
|
+
return { ok: false, message: 'No pull request under cursor' };
|
|
36937
|
+
const result = await mergePullRequestByNumber(pr.number, strategy);
|
|
36938
|
+
if (result.ok)
|
|
36939
|
+
invalidatePullRequestListCaches(pr.number);
|
|
36940
|
+
return result;
|
|
36941
|
+
},
|
|
36942
|
+
'triage-pr-close': async () => {
|
|
36943
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
36944
|
+
if (!pr)
|
|
36945
|
+
return { ok: false, message: 'No pull request under cursor' };
|
|
36946
|
+
const result = await closePullRequestByNumber(pr.number);
|
|
36947
|
+
if (result.ok)
|
|
36948
|
+
invalidatePullRequestListCaches(pr.number);
|
|
36949
|
+
return result;
|
|
36950
|
+
},
|
|
36951
|
+
'triage-pr-approve': async () => {
|
|
36952
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
36953
|
+
if (!pr)
|
|
36954
|
+
return { ok: false, message: 'No pull request under cursor' };
|
|
36955
|
+
const result = await approvePullRequestByNumber(pr.number);
|
|
36956
|
+
if (result.ok)
|
|
36957
|
+
invalidatePullRequestListCaches(pr.number);
|
|
36958
|
+
return result;
|
|
36959
|
+
},
|
|
36960
|
+
'triage-pr-request-changes': async () => {
|
|
36961
|
+
const body = payload?.trim();
|
|
36962
|
+
if (!body)
|
|
36963
|
+
return { ok: false, message: 'Review body required for change-request' };
|
|
36964
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
36965
|
+
if (!pr)
|
|
36966
|
+
return { ok: false, message: 'No pull request under cursor' };
|
|
36967
|
+
const result = await requestChangesPullRequestByNumber(pr.number, body);
|
|
36968
|
+
if (result.ok)
|
|
36969
|
+
invalidatePullRequestListCaches(pr.number);
|
|
36970
|
+
return result;
|
|
36971
|
+
},
|
|
33534
36972
|
// Status surface group-level batch ops (#791 follow-up). The
|
|
33535
36973
|
// input handler dispatches these when the user presses Enter on a
|
|
33536
36974
|
// group header. We re-derive the file list from the live
|
|
@@ -33686,6 +37124,29 @@ function LogInkApp(deps) {
|
|
|
33686
37124
|
}
|
|
33687
37125
|
}
|
|
33688
37126
|
}
|
|
37127
|
+
else if (view === 'issues') {
|
|
37128
|
+
// #882 phase 4 — y yanks the cursored issue's URL so the user
|
|
37129
|
+
// can paste it into Slack / a PR description / etc. without
|
|
37130
|
+
// dropping back to the browser. Short form (`Y`) is a no-op
|
|
37131
|
+
// here — there's no compact identifier worth a second key.
|
|
37132
|
+
const issue = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))];
|
|
37133
|
+
if (issue) {
|
|
37134
|
+
value = issue.url;
|
|
37135
|
+
label = `issue #${issue.number} URL`;
|
|
37136
|
+
}
|
|
37137
|
+
}
|
|
37138
|
+
else if (view === 'pull-request-triage') {
|
|
37139
|
+
// #882 phase 4 — same URL-yank pattern for the multi-PR list.
|
|
37140
|
+
// Distinct from `pull-request` (single, current-branch); that
|
|
37141
|
+
// view falls through to the generic "Nothing to yank" path
|
|
37142
|
+
// below since the action panel already exposes O for browser
|
|
37143
|
+
// open.
|
|
37144
|
+
const pr = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))];
|
|
37145
|
+
if (pr) {
|
|
37146
|
+
value = pr.url;
|
|
37147
|
+
label = `pull request #${pr.number} URL`;
|
|
37148
|
+
}
|
|
37149
|
+
}
|
|
33689
37150
|
else if (view === 'bisect') {
|
|
33690
37151
|
// #879 item 3 — yank the first-bad commit sha from the
|
|
33691
37152
|
// completion panel. The headline answer is what the user
|
|
@@ -33756,6 +37217,8 @@ function LogInkApp(deps) {
|
|
|
33756
37217
|
context.submodules,
|
|
33757
37218
|
context.tags,
|
|
33758
37219
|
dispatch,
|
|
37220
|
+
filteredIssueList,
|
|
37221
|
+
filteredPullRequestTriageList,
|
|
33759
37222
|
selected,
|
|
33760
37223
|
selectedDetailFile,
|
|
33761
37224
|
stashDiffLines,
|
|
@@ -33768,6 +37231,8 @@ function LogInkApp(deps) {
|
|
|
33768
37231
|
state.filteredCommits,
|
|
33769
37232
|
state.selectedBranchIndex,
|
|
33770
37233
|
state.selectedIndex,
|
|
37234
|
+
state.selectedIssueIndex,
|
|
37235
|
+
state.selectedPullRequestTriageIndex,
|
|
33771
37236
|
state.selectedStashIndex,
|
|
33772
37237
|
state.selectedSubmoduleIndex,
|
|
33773
37238
|
state.selectedTagIndex,
|
|
@@ -33988,6 +37453,10 @@ function LogInkApp(deps) {
|
|
|
33988
37453
|
const reflogSelectedHash = filteredReflogList[Math.min(state.selectedReflogIndex, Math.max(0, filteredReflogList.length - 1))]?.hash;
|
|
33989
37454
|
const submoduleVisibleCount = filteredSubmoduleList.length;
|
|
33990
37455
|
filteredSubmoduleList[Math.min(state.selectedSubmoduleIndex, Math.max(0, filteredSubmoduleList.length - 1))]?.path;
|
|
37456
|
+
const issueVisibleCount = filteredIssueList.length;
|
|
37457
|
+
const issueSelectedUrl = filteredIssueList[Math.min(state.selectedIssueIndex, Math.max(0, filteredIssueList.length - 1))]?.url;
|
|
37458
|
+
const pullRequestTriageVisibleCount = filteredPullRequestTriageList.length;
|
|
37459
|
+
const pullRequestTriageSelectedUrl = filteredPullRequestTriageList[Math.min(state.selectedPullRequestTriageIndex, Math.max(0, filteredPullRequestTriageList.length - 1))]?.url;
|
|
33991
37460
|
const worktreeVisibleCount = filteredWorktreeList.length;
|
|
33992
37461
|
// When the diff view is showing a stash patch, swap the previewLineCount
|
|
33993
37462
|
// to the stash diff length so the existing pageDetailPreview path
|
|
@@ -34019,6 +37488,10 @@ function LogInkApp(deps) {
|
|
|
34019
37488
|
reflogCount: reflogVisibleCount,
|
|
34020
37489
|
reflogSelectedHash,
|
|
34021
37490
|
submoduleCount: submoduleVisibleCount,
|
|
37491
|
+
issueCount: issueVisibleCount,
|
|
37492
|
+
issueSelectedUrl,
|
|
37493
|
+
pullRequestTriageCount: pullRequestTriageVisibleCount,
|
|
37494
|
+
pullRequestTriageSelectedUrl,
|
|
34022
37495
|
stashSelectedRef,
|
|
34023
37496
|
stashDiffFileOffsets: stashDiffFileOffsets.length ? stashDiffFileOffsets : undefined,
|
|
34024
37497
|
stashDiffSelectedPath,
|
|
@@ -34186,7 +37659,7 @@ function LogInkApp(deps) {
|
|
|
34186
37659
|
if (showOnboarding) {
|
|
34187
37660
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
34188
37661
|
}
|
|
34189
|
-
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));
|
|
37662
|
+
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));
|
|
34190
37663
|
}
|
|
34191
37664
|
|
|
34192
37665
|
/**
|
|
@@ -34495,6 +37968,9 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
|
34495
37968
|
appLabel: options.appLabel || 'coco log',
|
|
34496
37969
|
git,
|
|
34497
37970
|
idleTipsEnabled: Boolean(options.idleTips),
|
|
37971
|
+
// Resolve undefined → true so the default flips on automatically.
|
|
37972
|
+
// An explicit `false` from config opts out.
|
|
37973
|
+
dateBucketingEnabled: options.dateBucketing !== false,
|
|
34498
37974
|
ink,
|
|
34499
37975
|
initialView: options.initialView || 'history',
|
|
34500
37976
|
logArgv: options.logArgv,
|
|
@@ -34659,6 +38135,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
|
34659
38135
|
await startInkInteractiveLog(git, initialRows, {}, {
|
|
34660
38136
|
appLabel: 'coco',
|
|
34661
38137
|
idleTips: config.logTui?.idleTips,
|
|
38138
|
+
dateBucketing: config.logTui?.dateBucketing,
|
|
34662
38139
|
initialView: 'history',
|
|
34663
38140
|
loadRows,
|
|
34664
38141
|
logArgv,
|
|
@@ -34680,13 +38157,14 @@ async function startCocoUi(argv) {
|
|
|
34680
38157
|
await startInkInteractiveLog(git, cachedRows || [], {}, {
|
|
34681
38158
|
appLabel: 'coco',
|
|
34682
38159
|
idleTips: config.logTui?.idleTips,
|
|
38160
|
+
dateBucketing: config.logTui?.dateBucketing,
|
|
34683
38161
|
initialView: argv.view || 'history',
|
|
34684
38162
|
loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
|
|
34685
38163
|
logArgv,
|
|
34686
38164
|
theme: createUiTheme(config, argv),
|
|
34687
38165
|
});
|
|
34688
38166
|
}
|
|
34689
|
-
const handler$
|
|
38167
|
+
const handler$4 = async (argv) => {
|
|
34690
38168
|
await startCocoUi(argv);
|
|
34691
38169
|
};
|
|
34692
38170
|
|
|
@@ -34779,7 +38257,7 @@ function formatCommitDetail(detail, format) {
|
|
|
34779
38257
|
].join('\n');
|
|
34780
38258
|
}
|
|
34781
38259
|
|
|
34782
|
-
const handler$
|
|
38260
|
+
const handler$3 = async (argv) => {
|
|
34783
38261
|
// `--repo <dir>` (alias `--cwd`) — apply the global flag via the
|
|
34784
38262
|
// shared helper. After this returns, `process.cwd()` and the git
|
|
34785
38263
|
// instance are both bound to the targeted repo.
|
|
@@ -34814,11 +38292,169 @@ const handler$2 = async (argv) => {
|
|
|
34814
38292
|
};
|
|
34815
38293
|
|
|
34816
38294
|
var log = {
|
|
34817
|
-
command: command$
|
|
38295
|
+
command: command$4,
|
|
34818
38296
|
desc: 'Explore commit history with a branch graph, filters, and commit details.',
|
|
38297
|
+
builder: builder$4,
|
|
38298
|
+
handler: commandExecutor(handler$3),
|
|
38299
|
+
options: options$4,
|
|
38300
|
+
};
|
|
38301
|
+
|
|
38302
|
+
const command$3 = 'prs';
|
|
38303
|
+
const options$3 = {
|
|
38304
|
+
state: {
|
|
38305
|
+
type: 'string',
|
|
38306
|
+
choices: ['open', 'closed', 'merged', 'all'],
|
|
38307
|
+
description: 'Filter by PR state.',
|
|
38308
|
+
default: 'open',
|
|
38309
|
+
},
|
|
38310
|
+
assignee: {
|
|
38311
|
+
type: 'string',
|
|
38312
|
+
description: 'Filter by assignee GitHub login (or `@me`).',
|
|
38313
|
+
},
|
|
38314
|
+
author: {
|
|
38315
|
+
type: 'string',
|
|
38316
|
+
description: 'Filter by author GitHub login.',
|
|
38317
|
+
},
|
|
38318
|
+
label: {
|
|
38319
|
+
type: 'string',
|
|
38320
|
+
description: 'Filter by label name (comma-separated for AND).',
|
|
38321
|
+
},
|
|
38322
|
+
search: {
|
|
38323
|
+
type: 'string',
|
|
38324
|
+
description: 'Free-form GitHub PR search query.',
|
|
38325
|
+
},
|
|
38326
|
+
base: {
|
|
38327
|
+
type: 'string',
|
|
38328
|
+
description: 'Filter to PRs targeting a specific base branch.',
|
|
38329
|
+
},
|
|
38330
|
+
head: {
|
|
38331
|
+
type: 'string',
|
|
38332
|
+
description: 'Filter to PRs originating from a specific head branch.',
|
|
38333
|
+
},
|
|
38334
|
+
draft: {
|
|
38335
|
+
type: 'boolean',
|
|
38336
|
+
description: 'Limit to draft PRs only.',
|
|
38337
|
+
default: false,
|
|
38338
|
+
},
|
|
38339
|
+
mine: {
|
|
38340
|
+
type: 'boolean',
|
|
38341
|
+
description: 'Shorthand for `--assignee @me`.',
|
|
38342
|
+
default: false,
|
|
38343
|
+
},
|
|
38344
|
+
limit: {
|
|
38345
|
+
type: 'number',
|
|
38346
|
+
description: 'Maximum rows to fetch. Defaults to `gh`\'s own default.',
|
|
38347
|
+
},
|
|
38348
|
+
json: {
|
|
38349
|
+
type: 'boolean',
|
|
38350
|
+
description: 'Print machine-readable JSON instead of a formatted table.',
|
|
38351
|
+
default: false,
|
|
38352
|
+
},
|
|
38353
|
+
refresh: {
|
|
38354
|
+
type: 'boolean',
|
|
38355
|
+
description: 'Force fresh `gh` call (writes through to cache).',
|
|
38356
|
+
default: false,
|
|
38357
|
+
},
|
|
38358
|
+
'no-cache': {
|
|
38359
|
+
type: 'boolean',
|
|
38360
|
+
description: 'Skip the disk cache entirely (no read, no write).',
|
|
38361
|
+
default: false,
|
|
38362
|
+
},
|
|
38363
|
+
};
|
|
38364
|
+
const builder$3 = (yargs) => {
|
|
38365
|
+
return yargs.options(options$3).usage(getCommandUsageHeader(command$3));
|
|
38366
|
+
};
|
|
38367
|
+
|
|
38368
|
+
const handler$2 = async (argv, logger) => {
|
|
38369
|
+
const git = applyRepoFlag(argv);
|
|
38370
|
+
const repoPath = process.cwd();
|
|
38371
|
+
const filter = {
|
|
38372
|
+
state: argv.state,
|
|
38373
|
+
assignee: argv.mine ? '@me' : argv.assignee,
|
|
38374
|
+
author: argv.author,
|
|
38375
|
+
label: argv.label,
|
|
38376
|
+
search: argv.search,
|
|
38377
|
+
base: argv.base,
|
|
38378
|
+
head: argv.head,
|
|
38379
|
+
draft: argv.draft,
|
|
38380
|
+
limit: argv.limit,
|
|
38381
|
+
};
|
|
38382
|
+
const cacheEnabled = !argv.noCache;
|
|
38383
|
+
let prs;
|
|
38384
|
+
let fromCache = false;
|
|
38385
|
+
let cacheAgeMs;
|
|
38386
|
+
const repository = await getGitHubRepository(git);
|
|
38387
|
+
if (cacheEnabled && !argv.refresh) {
|
|
38388
|
+
const cached = readCachedList('prs', repoPath, filter);
|
|
38389
|
+
if (cached?.fresh) {
|
|
38390
|
+
prs = cached.payload.items;
|
|
38391
|
+
fromCache = true;
|
|
38392
|
+
cacheAgeMs = cached.ageMs;
|
|
38393
|
+
}
|
|
38394
|
+
}
|
|
38395
|
+
if (!prs) {
|
|
38396
|
+
const overview = await getPullRequestList(git, filter);
|
|
38397
|
+
if (!overview.available) {
|
|
38398
|
+
logger.log(chalk.red(overview.message || 'No GitHub remote detected.'));
|
|
38399
|
+
commandExit(1);
|
|
38400
|
+
return;
|
|
38401
|
+
}
|
|
38402
|
+
if (!overview.authenticated) {
|
|
38403
|
+
logger.log(chalk.yellow(overview.message || 'GitHub CLI is missing or not authenticated.'));
|
|
38404
|
+
logger.log(chalk.dim('Install `gh` and run `gh auth login` to enable PR triage.'));
|
|
38405
|
+
commandExit(1);
|
|
38406
|
+
return;
|
|
38407
|
+
}
|
|
38408
|
+
if (overview.message) {
|
|
38409
|
+
logger.log(chalk.red(overview.message));
|
|
38410
|
+
commandExit(1);
|
|
38411
|
+
return;
|
|
38412
|
+
}
|
|
38413
|
+
prs = overview.pullRequests || [];
|
|
38414
|
+
if (cacheEnabled) {
|
|
38415
|
+
writeCachedList(repoPath, filter, { kind: 'prs', items: prs });
|
|
38416
|
+
}
|
|
38417
|
+
}
|
|
38418
|
+
if (argv.json) {
|
|
38419
|
+
logger.log(JSON.stringify(prs, null, 2));
|
|
38420
|
+
return;
|
|
38421
|
+
}
|
|
38422
|
+
if (repository) {
|
|
38423
|
+
const filterParts = [];
|
|
38424
|
+
if (filter.state && filter.state !== 'open')
|
|
38425
|
+
filterParts.push(`state=${filter.state}`);
|
|
38426
|
+
if (filter.assignee)
|
|
38427
|
+
filterParts.push(`assignee=${filter.assignee}`);
|
|
38428
|
+
if (filter.author)
|
|
38429
|
+
filterParts.push(`author=${filter.author}`);
|
|
38430
|
+
if (filter.label)
|
|
38431
|
+
filterParts.push(`label=${filter.label}`);
|
|
38432
|
+
if (filter.search)
|
|
38433
|
+
filterParts.push(`search=${JSON.stringify(filter.search)}`);
|
|
38434
|
+
if (filter.base)
|
|
38435
|
+
filterParts.push(`base=${filter.base}`);
|
|
38436
|
+
if (filter.head)
|
|
38437
|
+
filterParts.push(`head=${filter.head}`);
|
|
38438
|
+
if (filter.draft)
|
|
38439
|
+
filterParts.push('draft');
|
|
38440
|
+
const suffix = filterParts.length ? chalk.dim(` (${filterParts.join(', ')})`) : '';
|
|
38441
|
+
const cacheTag = fromCache && typeof cacheAgeMs === 'number'
|
|
38442
|
+
? chalk.dim(` · cached ${Math.round(cacheAgeMs / 1000)}s ago`)
|
|
38443
|
+
: '';
|
|
38444
|
+
logger.log(chalk.bold(`${repository.owner}/${repository.name}`) +
|
|
38445
|
+
chalk.dim(` · ${prs.length} pull request${prs.length === 1 ? '' : 's'}`) +
|
|
38446
|
+
suffix +
|
|
38447
|
+
cacheTag);
|
|
38448
|
+
logger.log('');
|
|
38449
|
+
}
|
|
38450
|
+
logger.log(formatPullRequestList(prs));
|
|
38451
|
+
};
|
|
38452
|
+
|
|
38453
|
+
var prs = {
|
|
38454
|
+
command: command$3,
|
|
38455
|
+
desc: 'List GitHub pull requests for the current repository (read-only triage)',
|
|
34819
38456
|
builder: builder$3,
|
|
34820
38457
|
handler: commandExecutor(handler$2),
|
|
34821
|
-
options: options$3,
|
|
34822
38458
|
};
|
|
34823
38459
|
|
|
34824
38460
|
const RecapLlmResponseSchema = objectType({
|
|
@@ -35720,7 +39356,7 @@ var ui = {
|
|
|
35720
39356
|
command,
|
|
35721
39357
|
desc: 'Open the Coco Git workstation TUI.',
|
|
35722
39358
|
builder,
|
|
35723
|
-
handler: commandExecutor(handler$
|
|
39359
|
+
handler: commandExecutor(handler$4),
|
|
35724
39360
|
options,
|
|
35725
39361
|
};
|
|
35726
39362
|
|
|
@@ -35751,6 +39387,8 @@ y.command(doctor.command, doctor.desc, doctor.builder, doctor.handler);
|
|
|
35751
39387
|
y.command(log.command, log.desc, log.builder, log.handler);
|
|
35752
39388
|
y.command(ui.command, ui.desc, ui.builder, ui.handler);
|
|
35753
39389
|
y.command(cache.command, cache.desc, cache.builder, cache.handler);
|
|
39390
|
+
y.command(issues.command, issues.desc, issues.builder, issues.handler);
|
|
39391
|
+
y.command(prs.command, prs.desc, prs.builder, prs.handler);
|
|
35754
39392
|
async function main() {
|
|
35755
39393
|
await runPrefetchFromEnv();
|
|
35756
39394
|
y.help().parse(process.argv.slice(2));
|
|
@@ -36209,4 +39847,4 @@ var commitValidationHandler = /*#__PURE__*/Object.freeze({
|
|
|
36209
39847
|
handleValidationErrors: handleValidationErrors
|
|
36210
39848
|
});
|
|
36211
39849
|
|
|
36212
|
-
export { cache, changelog, commit, doctor, init, log, recap, types, ui };
|
|
39850
|
+
export { cache, changelog, commit, doctor, init, issues, log, prs, recap, types, ui };
|