git-coco 0.59.1 → 0.61.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 +9 -23
- package/dist/index.esm.mjs +1851 -343
- package/dist/index.js +1851 -343
- package/dist/tree-sitter/web-tree-sitter.wasm +0 -0
- 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.61.0";
|
|
65
65
|
|
|
66
66
|
const isInteractive = (config) => {
|
|
67
67
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -292,10 +292,19 @@ class LangChainExecutionError extends LangChainError {
|
|
|
292
292
|
}
|
|
293
293
|
/**
|
|
294
294
|
* Authentication-related errors (missing API keys, invalid credentials, etc.)
|
|
295
|
+
*
|
|
296
|
+
* Carries `provider` + `endpoint` context so the formatter (in
|
|
297
|
+
* `commandExecutor`) can render provider-specific recovery hints
|
|
298
|
+
* ("set OPENAI_API_KEY", "run `gh auth login`", etc.) instead of the
|
|
299
|
+
* generic "verify your API key" copy. Mirrors the shape of
|
|
300
|
+
* `LangChainNetworkError` so call sites can hand the same fields to
|
|
301
|
+
* either constructor depending on which condition fired.
|
|
295
302
|
*/
|
|
296
303
|
class LangChainAuthenticationError extends LangChainError {
|
|
297
|
-
constructor(message, context) {
|
|
298
|
-
super(message, context);
|
|
304
|
+
constructor(message, provider, endpoint, context) {
|
|
305
|
+
super(message, { ...context, provider, endpoint });
|
|
306
|
+
this.provider = provider;
|
|
307
|
+
this.endpoint = endpoint;
|
|
299
308
|
}
|
|
300
309
|
}
|
|
301
310
|
/**
|
|
@@ -433,21 +442,27 @@ function getDefaultServiceApiKey(config) {
|
|
|
433
442
|
const requiresAuth = provider === 'openai' || provider === 'anthropic';
|
|
434
443
|
if (service.authentication.type === 'APIKey') {
|
|
435
444
|
const apiKey = service.authentication.credentials?.apiKey;
|
|
445
|
+
// `endpoint` is optional on some service variants (Ollama / OpenAI-
|
|
446
|
+
// compatible) and absent on others (managed OpenAI / Anthropic).
|
|
447
|
+
// Read defensively so we still attach it when present.
|
|
448
|
+
const endpoint = service.endpoint;
|
|
436
449
|
if (requiresAuth && (!apiKey || apiKey.trim() === '')) {
|
|
437
|
-
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: API key is required for ${provider} provider but not provided`,
|
|
450
|
+
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: API key is required for ${provider} provider but not provided`, provider, endpoint, { authenticationType: service.authentication.type });
|
|
438
451
|
}
|
|
439
452
|
return apiKey || '';
|
|
440
453
|
}
|
|
441
454
|
if (service.authentication.type === 'OAuth') {
|
|
442
455
|
const token = service.authentication.credentials?.token;
|
|
456
|
+
const endpoint = service.endpoint;
|
|
443
457
|
if (requiresAuth && (!token || token.trim() === '')) {
|
|
444
|
-
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: OAuth token is required for ${provider} provider but not provided`,
|
|
458
|
+
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: OAuth token is required for ${provider} provider but not provided`, provider, endpoint, { authenticationType: service.authentication.type });
|
|
445
459
|
}
|
|
446
460
|
return token || '';
|
|
447
461
|
}
|
|
448
462
|
if (service.authentication.type === 'None') {
|
|
449
463
|
if (requiresAuth) {
|
|
450
|
-
|
|
464
|
+
const endpoint = service.endpoint;
|
|
465
|
+
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: ${provider} provider requires authentication but 'None' was configured`, provider, endpoint, { authenticationType: service.authentication.type });
|
|
451
466
|
}
|
|
452
467
|
return '';
|
|
453
468
|
}
|
|
@@ -2580,18 +2595,48 @@ function formatNetworkError(error, logger) {
|
|
|
2580
2595
|
logger.log(' • Verify the service endpoint is correct', { color: 'white' });
|
|
2581
2596
|
logger.log(' • Ensure the LLM service is running and accessible', { color: 'white' });
|
|
2582
2597
|
}
|
|
2598
|
+
logger.log(' • Run `coco doctor` to verify your configured provider + endpoint', { color: 'white' });
|
|
2583
2599
|
logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
|
|
2584
2600
|
}
|
|
2585
2601
|
/**
|
|
2586
|
-
* Formats an authentication error with
|
|
2602
|
+
* Formats an authentication error with provider-aware troubleshooting.
|
|
2603
|
+
*
|
|
2604
|
+
* Pre-MEDIUM-8 the formatter was generic — "verify your API key,
|
|
2605
|
+
* check it hasn't expired" — because the error class didn't carry
|
|
2606
|
+
* any provider context. Now that `LangChainAuthenticationError`
|
|
2607
|
+
* carries `provider` + `endpoint` (mirroring `LangChainNetworkError`),
|
|
2608
|
+
* we can name the env var the user actually needs to set and route
|
|
2609
|
+
* Ollama / OpenAI-compatible / managed-provider users through the
|
|
2610
|
+
* right next step.
|
|
2587
2611
|
*/
|
|
2588
2612
|
function formatAuthenticationError(error, logger) {
|
|
2613
|
+
const provider = error.provider || 'LLM service';
|
|
2614
|
+
const endpoint = error.endpoint;
|
|
2589
2615
|
logger.log('\nFailed to execute command', { color: 'yellow' });
|
|
2590
|
-
logger.log(
|
|
2616
|
+
logger.log(`\nError: Authentication failed${error.provider ? ` for ${provider}` : ''}`, { color: 'red' });
|
|
2617
|
+
if (endpoint) {
|
|
2618
|
+
logger.log(` Endpoint: ${endpoint}`, { color: 'red' });
|
|
2619
|
+
}
|
|
2591
2620
|
logger.log('\nTroubleshooting:', { color: 'cyan' });
|
|
2592
|
-
logger.log(' • Verify your API key is correct', { color: 'white' });
|
|
2593
|
-
|
|
2594
|
-
|
|
2621
|
+
logger.log(' • Verify your API key is correct and has not expired', { color: 'white' });
|
|
2622
|
+
// Provider-specific env var hint when we know the provider.
|
|
2623
|
+
if (provider === 'openai' || provider === 'OpenAI') {
|
|
2624
|
+
logger.log(' • Set `OPENAI_API_KEY` in your shell or `service.authentication.credentials.apiKey` in config', { color: 'white' });
|
|
2625
|
+
}
|
|
2626
|
+
else if (provider === 'anthropic' || provider === 'Anthropic') {
|
|
2627
|
+
logger.log(' • Set `ANTHROPIC_API_KEY` in your shell or `service.authentication.credentials.apiKey` in config', { color: 'white' });
|
|
2628
|
+
}
|
|
2629
|
+
else if (provider === 'ollama' || provider === 'Ollama') {
|
|
2630
|
+
logger.log(' • Ollama usually does not need a key — check `service.endpoint` and that `ollama serve` is running', { color: 'white' });
|
|
2631
|
+
}
|
|
2632
|
+
else if (provider === 'openai-compatible') {
|
|
2633
|
+
logger.log(' • OpenAI-compatible endpoints need both `service.endpoint` and a valid API key', { color: 'white' });
|
|
2634
|
+
}
|
|
2635
|
+
else {
|
|
2636
|
+
logger.log(' • Ensure the API key is set in your environment or config', { color: 'white' });
|
|
2637
|
+
}
|
|
2638
|
+
logger.log(' • Run `coco init` to (re)configure your provider + key', { color: 'white' });
|
|
2639
|
+
logger.log(' • Run `coco doctor` to inspect the active config sources', { color: 'white' });
|
|
2595
2640
|
logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
|
|
2596
2641
|
}
|
|
2597
2642
|
/**
|
|
@@ -3561,8 +3606,7 @@ const handler$b = async (argv, logger) => {
|
|
|
3561
3606
|
const result = clearDiffSummaryCache(repoPath);
|
|
3562
3607
|
if (!result.ok) {
|
|
3563
3608
|
logger.log(chalk.red(`Failed to clear diff-summary cache at ${cachePath}`));
|
|
3564
|
-
|
|
3565
|
-
return;
|
|
3609
|
+
commandExit(1, 'cache clear failed');
|
|
3566
3610
|
}
|
|
3567
3611
|
if (result.removed) {
|
|
3568
3612
|
logger.log(chalk.green(`Cleared diff-summary cache at ${cachePath}`));
|
|
@@ -3602,8 +3646,7 @@ const handler$b = async (argv, logger) => {
|
|
|
3602
3646
|
if (interactive) {
|
|
3603
3647
|
const picked = await promptLanguageSelection(logger);
|
|
3604
3648
|
if (!picked) {
|
|
3605
|
-
|
|
3606
|
-
return;
|
|
3649
|
+
commandExit(1, 'cache prefetch cancelled');
|
|
3607
3650
|
}
|
|
3608
3651
|
resolved = picked;
|
|
3609
3652
|
}
|
|
@@ -3620,7 +3663,7 @@ const handler$b = async (argv, logger) => {
|
|
|
3620
3663
|
`${chalk.dim(`${result.alreadyCached.length} already cached`)} · ` +
|
|
3621
3664
|
`${chalk.red(`${result.failed.length} failed`)}`);
|
|
3622
3665
|
if (result.failed.length > 0) {
|
|
3623
|
-
|
|
3666
|
+
commandExit(1, `cache prefetch failed for ${result.failed.length} language(s)`);
|
|
3624
3667
|
}
|
|
3625
3668
|
return;
|
|
3626
3669
|
}
|
|
@@ -3653,12 +3696,12 @@ const handler$b = async (argv, logger) => {
|
|
|
3653
3696
|
}
|
|
3654
3697
|
logger.log(chalk.red(`Unknown cache subcommand: ${subcommand}`));
|
|
3655
3698
|
logger.log(chalk.dim('Use one of: clear, info, parsers, prefetch, clear-parsers, clear-github'));
|
|
3656
|
-
|
|
3699
|
+
commandExit(1, `unknown cache subcommand: ${subcommand}`);
|
|
3657
3700
|
};
|
|
3658
3701
|
|
|
3659
3702
|
var cache = {
|
|
3660
3703
|
command: command$b,
|
|
3661
|
-
desc: 'Manage
|
|
3704
|
+
desc: 'Manage coco caches (clear, info, parsers, prefetch, github)',
|
|
3662
3705
|
builder: builder$b,
|
|
3663
3706
|
handler: commandExecutor(handler$b),
|
|
3664
3707
|
};
|
|
@@ -8927,6 +8970,124 @@ async function generateAndReviewLoop({ label, factory, parser, noResult, agent,
|
|
|
8927
8970
|
return result;
|
|
8928
8971
|
}
|
|
8929
8972
|
|
|
8973
|
+
/**
|
|
8974
|
+
* Centralised glyph + label vocabulary for diagnostic / status copy.
|
|
8975
|
+
*
|
|
8976
|
+
* Before this module each surface (commandExecutor, doctor, footer,
|
|
8977
|
+
* cache, issues, prs, commit-hook flow) picked its own marks for
|
|
8978
|
+
* pass / warn / fail / info — `✓` here, `✔` there, `✖` vs `✗`. Users
|
|
8979
|
+
* couldn't lean on a consistent visual signal to scan output, and the
|
|
8980
|
+
* audit flagged it as one of the bigger inconsistencies in the
|
|
8981
|
+
* codebase.
|
|
8982
|
+
*
|
|
8983
|
+
* The vocabulary mirrors what Linux package managers + git-aware
|
|
8984
|
+
* tools converge on (`pacman`, `apt`, `nala`, `npm doctor`, etc.) —
|
|
8985
|
+
* green check / red fail / yellow warn / blue info. ASCII fallbacks
|
|
8986
|
+
* are first-class so dumb terminals (TERM=dumb / vt100) still render
|
|
8987
|
+
* a meaningful prefix.
|
|
8988
|
+
*
|
|
8989
|
+
* Conventions:
|
|
8990
|
+
* - Status glyphs (PASS / FAIL / WARN / INFO) — for diagnostic
|
|
8991
|
+
* output, command exit, doctor severity, footer message kinds.
|
|
8992
|
+
* Colour-coded variants live alongside as `*_COLORED` helpers
|
|
8993
|
+
* so callers can use either depending on context.
|
|
8994
|
+
* - Action glyphs (BULLET, ARROW) — for indented hint lines and
|
|
8995
|
+
* "next step" callouts.
|
|
8996
|
+
* - Domain glyphs (CHECK_RUN_*, DECISION_*) — keep their own
|
|
8997
|
+
* vocabularies (PR reviews, status checks) because their
|
|
8998
|
+
* semantic shape doesn't map cleanly onto pass/fail/warn/info.
|
|
8999
|
+
*
|
|
9000
|
+
* Use `pickGlyph(unicode, ascii, isAscii)` when you need to honor
|
|
9001
|
+
* `theme.ascii` mode in a single call site.
|
|
9002
|
+
*/
|
|
9003
|
+
/**
|
|
9004
|
+
* Status-severity glyph set. Same vocabulary as the workstation
|
|
9005
|
+
* footer's `kind` field (info / warning / error / success / loading)
|
|
9006
|
+
* plus `pass` for the doctor / "no problem" case.
|
|
9007
|
+
*/
|
|
9008
|
+
const GLYPHS = {
|
|
9009
|
+
pass: '✓',
|
|
9010
|
+
fail: '✖',
|
|
9011
|
+
warn: '⚠',
|
|
9012
|
+
info: 'ℹ',
|
|
9013
|
+
bullet: '•'};
|
|
9014
|
+
/**
|
|
9015
|
+
* Theme-tinted helpers for terminal output. These return chalk-wrapped
|
|
9016
|
+
* strings so callers don't repeat the `chalk.<color>(GLYPHS.<key>)`
|
|
9017
|
+
* pattern. Each maps to the canonical colour the codebase uses for
|
|
9018
|
+
* that severity:
|
|
9019
|
+
*
|
|
9020
|
+
* - PASS → green
|
|
9021
|
+
* - FAIL → red
|
|
9022
|
+
* - WARN → yellow
|
|
9023
|
+
* - INFO → blue
|
|
9024
|
+
*
|
|
9025
|
+
* Doctor's `SEVERITY_ICON` lookup is the canonical example — it now
|
|
9026
|
+
* delegates here so the colours stay in sync if the theme palette
|
|
9027
|
+
* shifts in the future.
|
|
9028
|
+
*/
|
|
9029
|
+
const PASS = () => chalk.green(GLYPHS.pass);
|
|
9030
|
+
const FAIL = () => chalk.red(GLYPHS.fail);
|
|
9031
|
+
const WARN = () => chalk.yellow(GLYPHS.warn);
|
|
9032
|
+
const INFO = () => chalk.blue(GLYPHS.info);
|
|
9033
|
+
|
|
9034
|
+
/**
|
|
9035
|
+
* Maps each provider to the env var users should set + the kebab-case
|
|
9036
|
+
* provider label used in the recovery copy. `coco init` and `coco
|
|
9037
|
+
* doctor` both reference these names; keeping the lookup in one place
|
|
9038
|
+
* makes the messages stay aligned when a new provider lands.
|
|
9039
|
+
*/
|
|
9040
|
+
const PROVIDER_ENV_VARS = {
|
|
9041
|
+
openai: { envVar: 'OPENAI_API_KEY', label: 'OpenAI' },
|
|
9042
|
+
anthropic: { envVar: 'ANTHROPIC_API_KEY', label: 'Anthropic' },
|
|
9043
|
+
ollama: { envVar: 'OLLAMA_API_KEY', label: 'Ollama' },
|
|
9044
|
+
'openai-compatible': { envVar: 'OPENAI_API_KEY', label: 'OpenAI-compatible' },
|
|
9045
|
+
};
|
|
9046
|
+
/**
|
|
9047
|
+
* Print a structured "missing API key" message + exit non-zero.
|
|
9048
|
+
*
|
|
9049
|
+
* Replaces the old `No API Key found. 🗝️🚪` one-liner that used to live
|
|
9050
|
+
* inline in commit / changelog / recap / review handlers. Centralised
|
|
9051
|
+
* because:
|
|
9052
|
+
*
|
|
9053
|
+
* 1. The message names the env var the user actually needs to set
|
|
9054
|
+
* (different per provider) — that was the single biggest gap in
|
|
9055
|
+
* the prior message.
|
|
9056
|
+
* 2. It surfaces the configured provider + model so the user can tell
|
|
9057
|
+
* which of their providers tripped the check (useful when running
|
|
9058
|
+
* with dynamic model routing).
|
|
9059
|
+
* 3. It points at `coco init` and `coco doctor` as the recovery
|
|
9060
|
+
* paths, mirroring the discoverability cue every other modern CLI
|
|
9061
|
+
* uses for first-run config errors.
|
|
9062
|
+
*
|
|
9063
|
+
* Throws `CommandExitError(1)` via `commandExit` — callers do NOT need
|
|
9064
|
+
* to handle the return value.
|
|
9065
|
+
*/
|
|
9066
|
+
function handleMissingApiKey(logger, config, options) {
|
|
9067
|
+
const provider = config.service?.provider || 'unknown';
|
|
9068
|
+
const model = config.service?.model || 'unknown';
|
|
9069
|
+
const providerInfo = PROVIDER_ENV_VARS[provider] || {
|
|
9070
|
+
envVar: 'PROVIDER_API_KEY',
|
|
9071
|
+
label: provider,
|
|
9072
|
+
};
|
|
9073
|
+
const lines = [
|
|
9074
|
+
`${FAIL()} ${chalk.bold('Missing API key')} for ${chalk.cyan(providerInfo.label)} (model: ${chalk.cyan(model)})`,
|
|
9075
|
+
'',
|
|
9076
|
+
`${chalk.bold('Next step')} — set up an API key one of these ways:`,
|
|
9077
|
+
` ${chalk.dim(GLYPHS.bullet)} Run ${chalk.cyan('coco init')} to walk through provider + key setup`,
|
|
9078
|
+
` ${chalk.dim(GLYPHS.bullet)} Export ${chalk.cyan(providerInfo.envVar)} in your shell`,
|
|
9079
|
+
` ${chalk.dim(GLYPHS.bullet)} Add the key to ${chalk.cyan('.coco.config.json')} or ${chalk.cyan('~/.gitconfig')} (under ${chalk.cyan('[coco]')})`,
|
|
9080
|
+
'',
|
|
9081
|
+
`${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('to diagnose the active config sources.')}`,
|
|
9082
|
+
];
|
|
9083
|
+
for (const line of lines) {
|
|
9084
|
+
logger.log(line);
|
|
9085
|
+
}
|
|
9086
|
+
// Tag the exit message with the failing command so process supervisors
|
|
9087
|
+
// / CI logs can grep for it without parsing the full body.
|
|
9088
|
+
commandExit(1, `${options.command}: missing API key for ${providerInfo.label}`);
|
|
9089
|
+
}
|
|
9090
|
+
|
|
8930
9091
|
const logSuccess = () => {
|
|
8931
9092
|
console.log(chalk.green(chalk.bold('\nAll set! 🦾🤖')));
|
|
8932
9093
|
};
|
|
@@ -14419,8 +14580,7 @@ const handler$a = async (argv, logger) => {
|
|
|
14419
14580
|
commandExit(1);
|
|
14420
14581
|
}
|
|
14421
14582
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
14422
|
-
logger
|
|
14423
|
-
commandExit(1);
|
|
14583
|
+
handleMissingApiKey(logger, config, { command: 'changelog' });
|
|
14424
14584
|
}
|
|
14425
14585
|
const llm = getLlm(provider, model, { ...config, service: changelogService });
|
|
14426
14586
|
const summaryLlm = getLlm(provider, summaryService.model, { ...config, service: summaryService });
|
|
@@ -16316,8 +16476,7 @@ const handler$9 = async (argv, logger) => {
|
|
|
16316
16476
|
const splitService = resolveDynamicService(config, 'commitSplit');
|
|
16317
16477
|
const model = commitService.model;
|
|
16318
16478
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
16319
|
-
logger
|
|
16320
|
-
commandExit(1);
|
|
16479
|
+
handleMissingApiKey(logger, config, { command: 'commit' });
|
|
16321
16480
|
}
|
|
16322
16481
|
const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
|
|
16323
16482
|
const llm = getLlm(provider, model, { ...config, service: commitService });
|
|
@@ -17047,9 +17206,9 @@ function checkProjectConfigFile(diagnostics) {
|
|
|
17047
17206
|
}
|
|
17048
17207
|
|
|
17049
17208
|
const SEVERITY_ICON = {
|
|
17050
|
-
error:
|
|
17051
|
-
warn:
|
|
17052
|
-
info:
|
|
17209
|
+
error: FAIL(),
|
|
17210
|
+
warn: WARN(),
|
|
17211
|
+
info: INFO(),
|
|
17053
17212
|
};
|
|
17054
17213
|
const SEVERITY_LABEL = {
|
|
17055
17214
|
error: chalk.red('error'),
|
|
@@ -17068,10 +17227,10 @@ function formatSourceInfo(sources) {
|
|
|
17068
17227
|
for (const source of sources) {
|
|
17069
17228
|
const label = SOURCE_LABELS[source.source] || source.source;
|
|
17070
17229
|
if (source.path) {
|
|
17071
|
-
lines.push(` ${
|
|
17230
|
+
lines.push(` ${PASS()} ${label} ${chalk.dim(`(${source.path})`)}`);
|
|
17072
17231
|
}
|
|
17073
17232
|
else {
|
|
17074
|
-
lines.push(` ${
|
|
17233
|
+
lines.push(` ${PASS()} ${label}`);
|
|
17075
17234
|
}
|
|
17076
17235
|
}
|
|
17077
17236
|
return lines;
|
|
@@ -17108,7 +17267,7 @@ const handler$8 = async (argv, logger) => {
|
|
|
17108
17267
|
// Run diagnostics
|
|
17109
17268
|
const diagnostics = runDiagnostics(config);
|
|
17110
17269
|
if (diagnostics.length === 0) {
|
|
17111
|
-
logger.log(chalk.green(
|
|
17270
|
+
logger.log(chalk.green(`${PASS()} No issues found. Your configuration looks good!`));
|
|
17112
17271
|
return;
|
|
17113
17272
|
}
|
|
17114
17273
|
const errors = diagnostics.filter((d) => d.severity === 'error');
|
|
@@ -17161,7 +17320,7 @@ const handler$8 = async (argv, logger) => {
|
|
|
17161
17320
|
const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
17162
17321
|
for (const diagnostic of fixable) {
|
|
17163
17322
|
diagnostic.autoFix(raw);
|
|
17164
|
-
logger.log(chalk.green(`
|
|
17323
|
+
logger.log(chalk.green(` ${PASS()} Fixed: ${diagnostic.message}`));
|
|
17165
17324
|
}
|
|
17166
17325
|
// Ensure $schema is present
|
|
17167
17326
|
if (!raw.$schema) {
|
|
@@ -17180,6 +17339,15 @@ const handler$8 = async (argv, logger) => {
|
|
|
17180
17339
|
logger.log(chalk.dim(`${fixable.length} issue(s) can be auto-fixed. Run \`coco doctor --fix\` to apply.`));
|
|
17181
17340
|
}
|
|
17182
17341
|
}
|
|
17342
|
+
// Exit non-zero when error-severity diagnostics were surfaced so CI
|
|
17343
|
+
// pipelines can gate on `coco doctor` without parsing its stdout.
|
|
17344
|
+
// Warnings + infos still exit clean — they're informational, not
|
|
17345
|
+
// blockers. Auto-fixed errors keep the non-zero exit so the CI run
|
|
17346
|
+
// surfaces "we patched something for you, please commit it" rather
|
|
17347
|
+
// than masquerading as a passing check.
|
|
17348
|
+
if (errors.length > 0) {
|
|
17349
|
+
commandExit(1, `${errors.length} doctor error(s)`);
|
|
17350
|
+
}
|
|
17183
17351
|
};
|
|
17184
17352
|
|
|
17185
17353
|
var doctor = {
|
|
@@ -17187,6 +17355,7 @@ var doctor = {
|
|
|
17187
17355
|
desc: 'Check your coco configuration for common issues and suggest fixes',
|
|
17188
17356
|
builder: builder$8,
|
|
17189
17357
|
handler: commandExecutor(handler$8),
|
|
17358
|
+
options: options$8,
|
|
17190
17359
|
};
|
|
17191
17360
|
|
|
17192
17361
|
const command$7 = 'init';
|
|
@@ -17566,11 +17735,7 @@ const handler$7 = async (argv, logger) => {
|
|
|
17566
17735
|
// writes the project config to X, not the launcher's cwd. The
|
|
17567
17736
|
// chdir has to happen before getProjectConfigFilePath resolves
|
|
17568
17737
|
// its target path (it reads process.cwd).
|
|
17569
|
-
|
|
17570
|
-
// `InitArgv` is `Argv<InitOptions>['argv']` which yargs types as a
|
|
17571
|
-
// union including Promise — pass just the `repo` field as a plain
|
|
17572
|
-
// object so the helper's narrow signature stays clean.
|
|
17573
|
-
applyRepoCwd({ repo: argv.repo });
|
|
17738
|
+
applyRepoCwd(argv);
|
|
17574
17739
|
const options = loadConfig(argv);
|
|
17575
17740
|
logger.log(LOGO);
|
|
17576
17741
|
let scope = options?.scope;
|
|
@@ -17705,6 +17870,44 @@ const handler$7 = async (argv, logger) => {
|
|
|
17705
17870
|
await installCommitlintPackages(scope, logger);
|
|
17706
17871
|
}
|
|
17707
17872
|
logger.log(`\ninit successful! 🦾🤖🎉`, { color: 'green' });
|
|
17873
|
+
// Post-write verification — run the same check `coco doctor` runs
|
|
17874
|
+
// so the user finds out about typos / structural issues now,
|
|
17875
|
+
// before their first `coco commit`. Re-load from disk so we
|
|
17876
|
+
// verify the persisted config (not the in-memory shape we just
|
|
17877
|
+
// built), which catches transcription bugs in the appenders.
|
|
17878
|
+
try {
|
|
17879
|
+
const persistedConfig = loadConfig({});
|
|
17880
|
+
const diagnostics = runDiagnostics(persistedConfig);
|
|
17881
|
+
const errors = diagnostics.filter((d) => d.severity === 'error');
|
|
17882
|
+
const warnings = diagnostics.filter((d) => d.severity === 'warn');
|
|
17883
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
17884
|
+
logger.log(`${PASS()} Verified: no issues found in your new config.`, { color: 'green' });
|
|
17885
|
+
}
|
|
17886
|
+
else {
|
|
17887
|
+
if (errors.length > 0) {
|
|
17888
|
+
logger.log(`${FAIL()} ${errors.length} error(s) found in the persisted config:`, { color: 'red' });
|
|
17889
|
+
for (const diagnostic of errors) {
|
|
17890
|
+
logger.log(` ${chalk.red(diagnostic.message)}`);
|
|
17891
|
+
}
|
|
17892
|
+
}
|
|
17893
|
+
if (warnings.length > 0) {
|
|
17894
|
+
logger.log(`${WARN()} ${warnings.length} warning(s) found in the persisted config:`, { color: 'yellow' });
|
|
17895
|
+
for (const diagnostic of warnings) {
|
|
17896
|
+
logger.log(` ${chalk.yellow(diagnostic.message)}`);
|
|
17897
|
+
}
|
|
17898
|
+
}
|
|
17899
|
+
logger.log(`${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('for the full diagnostic report.')}`);
|
|
17900
|
+
}
|
|
17901
|
+
}
|
|
17902
|
+
catch (verifyError) {
|
|
17903
|
+
// Verification is a polish step, not a blocker. If it crashes
|
|
17904
|
+
// (e.g. config file written to a path the loader can't reach
|
|
17905
|
+
// from the current cwd), fall through to a hint instead of
|
|
17906
|
+
// failing the whole init flow — the config is on disk and
|
|
17907
|
+
// the user can run `coco doctor` themselves.
|
|
17908
|
+
logger.log(`${chalk.dim('Skipped post-init verification:')} ${verifyError.message}`, { color: 'gray' });
|
|
17909
|
+
logger.log(`${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('to verify your config manually.')}`);
|
|
17910
|
+
}
|
|
17708
17911
|
}
|
|
17709
17912
|
else {
|
|
17710
17913
|
logger.log('\ninit cancelled.', { color: 'yellow' });
|
|
@@ -17746,7 +17949,7 @@ async function installCommitlintPackages(scope, logger) {
|
|
|
17746
17949
|
|
|
17747
17950
|
var init = {
|
|
17748
17951
|
command: command$7,
|
|
17749
|
-
desc: '
|
|
17952
|
+
desc: 'Install & configure coco globally or for the current project',
|
|
17750
17953
|
builder: builder$7,
|
|
17751
17954
|
handler: commandExecutor(handler$7),
|
|
17752
17955
|
options: options$7,
|
|
@@ -17830,19 +18033,76 @@ async function getGitHubRepository(git) {
|
|
|
17830
18033
|
return url ? parseGitHubRemoteUrl$1(url) : undefined;
|
|
17831
18034
|
}
|
|
17832
18035
|
/**
|
|
17833
|
-
* Probe `gh auth status` and return
|
|
17834
|
-
*
|
|
17835
|
-
* circuit before issuing real API calls —
|
|
17836
|
-
*
|
|
17837
|
-
* of
|
|
18036
|
+
* Probe `gh auth status` and return a structured status describing
|
|
18037
|
+
* exactly which of the failure modes is in play. Used by every data
|
|
18038
|
+
* fetcher to short-circuit before issuing real API calls — and now
|
|
18039
|
+
* lets the caller surface a tailored recovery hint per failure mode
|
|
18040
|
+
* instead of one catch-all message.
|
|
18041
|
+
*
|
|
18042
|
+
* Distinguishing the modes:
|
|
18043
|
+
* - ENOENT (`gh: command not found`) → `not-installed`
|
|
18044
|
+
* - `gh auth status` exits non-zero with stderr matching the
|
|
18045
|
+
* "not logged into" / "authentication required" pattern →
|
|
18046
|
+
* `not-authenticated`
|
|
18047
|
+
* - Anything else (permission denied on the binary, timeout, etc.)
|
|
18048
|
+
* → `unknown` with the underlying error message attached for
|
|
18049
|
+
* diagnostic display.
|
|
17838
18050
|
*/
|
|
17839
|
-
async function
|
|
18051
|
+
async function getGhStatus(runner) {
|
|
17840
18052
|
try {
|
|
17841
18053
|
await runner(['auth', 'status', '--hostname', 'github.com']);
|
|
17842
|
-
return
|
|
18054
|
+
return { kind: 'ok' };
|
|
17843
18055
|
}
|
|
17844
|
-
catch {
|
|
17845
|
-
|
|
18056
|
+
catch (error) {
|
|
18057
|
+
const err = error;
|
|
18058
|
+
// ENOENT = the binary itself is missing. exec/spawn surfaces this
|
|
18059
|
+
// as either `code === 'ENOENT'` (Node's spawn error code) or a
|
|
18060
|
+
// message containing "ENOENT". Either form is unambiguous.
|
|
18061
|
+
if (err.code === 'ENOENT' || (err.message && err.message.includes('ENOENT'))) {
|
|
18062
|
+
return { kind: 'not-installed' };
|
|
18063
|
+
}
|
|
18064
|
+
// gh exits non-zero from `auth status` when the user isn't logged
|
|
18065
|
+
// in. The message body contains "not logged into" or "logged in
|
|
18066
|
+
// failed" depending on the gh version. Both patterns are stable
|
|
18067
|
+
// enough to gate on without scope-locking to a specific gh
|
|
18068
|
+
// release.
|
|
18069
|
+
const stderr = err.stderr || err.message || '';
|
|
18070
|
+
if (/not logged into|authentication.*required|you are not/i.test(stderr)) {
|
|
18071
|
+
return { kind: 'not-authenticated', detail: stderr.trim().split('\n')[0] };
|
|
18072
|
+
}
|
|
18073
|
+
// Anything else — permission denied, timeout, etc. Surface the
|
|
18074
|
+
// raw message so the user can read it; treat as unavailable.
|
|
18075
|
+
return { kind: 'unknown', detail: err.message || 'gh auth status failed' };
|
|
18076
|
+
}
|
|
18077
|
+
}
|
|
18078
|
+
/**
|
|
18079
|
+
* Backwards-compatible boolean wrapper around `getGhStatus`. Kept so
|
|
18080
|
+
* existing callers (data loaders, sidebar fetchers) don't all have to
|
|
18081
|
+
* migrate at once. New call sites should use `getGhStatus` directly
|
|
18082
|
+
* to access the discriminated failure modes.
|
|
18083
|
+
*/
|
|
18084
|
+
async function isGhAuthenticated(runner) {
|
|
18085
|
+
const status = await getGhStatus(runner);
|
|
18086
|
+
return status.kind === 'ok';
|
|
18087
|
+
}
|
|
18088
|
+
/**
|
|
18089
|
+
* Render a user-facing recovery hint for a non-`ok` gh status. Used by
|
|
18090
|
+
* `commands/issues` / `commands/prs` / pull-request workflow surfaces
|
|
18091
|
+
* so every "gh is unavailable" message tells the user the exact next
|
|
18092
|
+
* step. Keeps the wording in sync across surfaces — if a user runs
|
|
18093
|
+
* `coco prs` and `coco issues` back to back, the same broken state
|
|
18094
|
+
* surfaces the same fix.
|
|
18095
|
+
*/
|
|
18096
|
+
function describeGhStatus(status) {
|
|
18097
|
+
switch (status.kind) {
|
|
18098
|
+
case 'ok':
|
|
18099
|
+
return 'GitHub CLI is installed and authenticated.';
|
|
18100
|
+
case 'not-installed':
|
|
18101
|
+
return 'GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com/ and run `gh auth login`.';
|
|
18102
|
+
case 'not-authenticated':
|
|
18103
|
+
return `GitHub CLI is installed but not authenticated. Run \`gh auth login\` (scopes: \`repo\`, \`read:org\`).${status.detail ? ` Details: ${status.detail}` : ''}`;
|
|
18104
|
+
case 'unknown':
|
|
18105
|
+
return `GitHub CLI returned an unexpected error: ${status.detail}. Try \`gh auth status\` directly to diagnose.`;
|
|
17846
18106
|
}
|
|
17847
18107
|
}
|
|
17848
18108
|
|
|
@@ -18034,13 +18294,14 @@ async function getIssueList(git, filter = {}, runner = defaultGhRunner) {
|
|
|
18034
18294
|
message: 'No GitHub remote detected.',
|
|
18035
18295
|
};
|
|
18036
18296
|
}
|
|
18037
|
-
|
|
18297
|
+
const ghStatus = await getGhStatus(runner);
|
|
18298
|
+
if (ghStatus.kind !== 'ok') {
|
|
18038
18299
|
return {
|
|
18039
18300
|
available: true,
|
|
18040
18301
|
authenticated: false,
|
|
18041
18302
|
repository,
|
|
18042
18303
|
filter,
|
|
18043
|
-
message:
|
|
18304
|
+
message: describeGhStatus(ghStatus),
|
|
18044
18305
|
};
|
|
18045
18306
|
}
|
|
18046
18307
|
try {
|
|
@@ -18152,6 +18413,7 @@ var issues = {
|
|
|
18152
18413
|
desc: 'List GitHub issues for the current repository (read-only triage)',
|
|
18153
18414
|
builder: builder$6,
|
|
18154
18415
|
handler: commandExecutor(handler$6),
|
|
18416
|
+
options: options$6,
|
|
18155
18417
|
};
|
|
18156
18418
|
|
|
18157
18419
|
const command$5 = 'log';
|
|
@@ -18903,9 +19165,17 @@ async function getStashOverview(git) {
|
|
|
18903
19165
|
// %gd — stash reflog selector (stash@{N})
|
|
18904
19166
|
// %H — stash commit hash
|
|
18905
19167
|
// %P — space-separated parent hashes (first = base, see StashEntry.baseHash)
|
|
18906
|
-
// %
|
|
19168
|
+
// %cI — committer date, strict ISO 8601
|
|
18907
19169
|
// %gs — reflog subject ("WIP on main: <subject>")
|
|
18908
|
-
|
|
19170
|
+
//
|
|
19171
|
+
// NOTE: we deliberately do NOT pass `--date=iso`. That flag rewrites the
|
|
19172
|
+
// `%gd` selector from the index form (`stash@{0}`) into a timestamp
|
|
19173
|
+
// (`stash@{2026-06-03 17:29:23 -0400}`), which is noisy in the list, eats
|
|
19174
|
+
// row width, and — critically — breaks `renameStash`, which parses the
|
|
19175
|
+
// `stash@{N}` index out of the ref. `%cI` gives a strict-ISO date that's
|
|
19176
|
+
// independent of `--date`, so we get both a clean index ref and a
|
|
19177
|
+
// parseable date.
|
|
19178
|
+
const stashes = parseStashList(await git.raw(['stash', 'list', '--format=%gd%x1f%H%x1f%P%x1f%cI%x1f%gs']));
|
|
18909
19179
|
return {
|
|
18910
19180
|
stashes: await Promise.all(stashes.map(async (stash) => ({
|
|
18911
19181
|
...stash,
|
|
@@ -20574,7 +20844,7 @@ function applyCommitComposeAction(state, action) {
|
|
|
20574
20844
|
loading: false,
|
|
20575
20845
|
streamingPreview: undefined,
|
|
20576
20846
|
pendingAiDraft: action.value,
|
|
20577
|
-
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20847
|
+
message: 'AI draft ready. Press Enter (or R) to replace your text, or Esc to keep what you have.',
|
|
20578
20848
|
details: undefined,
|
|
20579
20849
|
};
|
|
20580
20850
|
}
|
|
@@ -20623,7 +20893,7 @@ function applyCommitComposeAction(state, action) {
|
|
|
20623
20893
|
loading: false,
|
|
20624
20894
|
streamingPreview: undefined,
|
|
20625
20895
|
pendingAiDraft: action.value,
|
|
20626
|
-
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20896
|
+
message: 'AI draft ready. Press Enter (or R) to replace your text, or Esc to keep what you have.',
|
|
20627
20897
|
details: undefined,
|
|
20628
20898
|
};
|
|
20629
20899
|
case 'acceptPendingAiDraft':
|
|
@@ -22179,6 +22449,25 @@ function getLogInkWorkflowActions() {
|
|
|
22179
22449
|
kind: 'destructive',
|
|
22180
22450
|
requiresConfirmation: true,
|
|
22181
22451
|
},
|
|
22452
|
+
{
|
|
22453
|
+
// Palette-only create variants (empty `key`): no global hotkey to
|
|
22454
|
+
// collide with `S` / `gZ`, reachable from `:`. Both stash a quick
|
|
22455
|
+
// WIP entry with the requested scope.
|
|
22456
|
+
id: 'stash-staged',
|
|
22457
|
+
key: '',
|
|
22458
|
+
label: 'Stash staged only',
|
|
22459
|
+
description: 'Stash just the staged (index) changes — `git stash push --staged`.',
|
|
22460
|
+
kind: 'normal',
|
|
22461
|
+
requiresConfirmation: false,
|
|
22462
|
+
},
|
|
22463
|
+
{
|
|
22464
|
+
id: 'stash-keep-index',
|
|
22465
|
+
key: '',
|
|
22466
|
+
label: 'Stash keeping index',
|
|
22467
|
+
description: 'Stash everything but leave the index intact for an immediate commit — `git stash push --keep-index`.',
|
|
22468
|
+
kind: 'normal',
|
|
22469
|
+
requiresConfirmation: false,
|
|
22470
|
+
},
|
|
22182
22471
|
{
|
|
22183
22472
|
id: 'remove-worktree',
|
|
22184
22473
|
key: 'W',
|
|
@@ -22690,6 +22979,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
22690
22979
|
description: 'Push the stash view (gz; gs is reserved for status).',
|
|
22691
22980
|
contexts: ['normal'],
|
|
22692
22981
|
},
|
|
22982
|
+
{
|
|
22983
|
+
id: 'createStash',
|
|
22984
|
+
keys: ['gZ'],
|
|
22985
|
+
label: 'stash changes',
|
|
22986
|
+
description: 'Stash all changes (tracked + untracked) with an optional message — works from any view, including status/diff/compose. Empty message creates a quick WIP stash.',
|
|
22987
|
+
contexts: ['normal'],
|
|
22988
|
+
},
|
|
22693
22989
|
{
|
|
22694
22990
|
id: 'navigateWorktrees',
|
|
22695
22991
|
keys: ['gw'],
|
|
@@ -22934,6 +23230,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
22934
23230
|
description: 'Create a lightweight tag at the cursored commit.',
|
|
22935
23231
|
contexts: ['history'],
|
|
22936
23232
|
},
|
|
23233
|
+
{
|
|
23234
|
+
id: 'viewKeys',
|
|
23235
|
+
keys: ['g?'],
|
|
23236
|
+
label: 'keys',
|
|
23237
|
+
description: 'Show the single-key actions available in the current view (which-key strip).',
|
|
23238
|
+
contexts: ['normal'],
|
|
23239
|
+
},
|
|
22937
23240
|
{
|
|
22938
23241
|
id: 'themePicker',
|
|
22939
23242
|
keys: ['gC'],
|
|
@@ -22962,6 +23265,20 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
22962
23265
|
description: 'Add the cursored file or folder to .gitignore (pick a pattern).',
|
|
22963
23266
|
contexts: ['status'],
|
|
22964
23267
|
},
|
|
23268
|
+
{
|
|
23269
|
+
id: 'stageAll',
|
|
23270
|
+
keys: ['A'],
|
|
23271
|
+
label: 'stage all',
|
|
23272
|
+
description: 'Stage every change in the worktree (git add -A).',
|
|
23273
|
+
contexts: ['status', 'compose'],
|
|
23274
|
+
},
|
|
23275
|
+
{
|
|
23276
|
+
id: 'stagePathspec',
|
|
23277
|
+
keys: ['+'],
|
|
23278
|
+
label: 'stage paths',
|
|
23279
|
+
description: 'Stage files matching a typed pathspec (. / src/ / *.ts / a list).',
|
|
23280
|
+
contexts: ['status', 'compose'],
|
|
23281
|
+
},
|
|
22965
23282
|
{
|
|
22966
23283
|
id: 'viewChangelog',
|
|
22967
23284
|
keys: ['L'],
|
|
@@ -23035,6 +23352,19 @@ const GLOBAL_BINDING_IDS = [
|
|
|
23035
23352
|
'navigateBack',
|
|
23036
23353
|
];
|
|
23037
23354
|
const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
|
|
23355
|
+
/**
|
|
23356
|
+
* Narrow single-pane footer budget (#1135). On terminals below the
|
|
23357
|
+
* single-pane breakpoint the pane switcher (`tab: …`, ~29 cells) plus
|
|
23358
|
+
* the snap-back / peek affordance already claim most of an 80-cell row,
|
|
23359
|
+
* so the per-view hint tail and the global cluster are trimmed to what
|
|
23360
|
+
* fits without clipping — the switcher is the orientation anchor and
|
|
23361
|
+
* must stay whole. The dropped bindings remain one `?` (help) away.
|
|
23362
|
+
*
|
|
23363
|
+
* - keep only the first view hint (the most actionable for the view)
|
|
23364
|
+
* - shrink the global cluster to the two recovery essentials
|
|
23365
|
+
*/
|
|
23366
|
+
const SINGLE_PANE_GLOBAL_HINTS = ['? help', 'q quit'];
|
|
23367
|
+
const SINGLE_PANE_VIEW_HINT_LIMIT = 1;
|
|
23038
23368
|
/**
|
|
23039
23369
|
* Per-binding category mapping. Used to subdivide the help overlay's
|
|
23040
23370
|
* Global and view sections into named clusters so users don't face a
|
|
@@ -23055,6 +23385,9 @@ const BINDING_CATEGORY_BY_ID = {
|
|
|
23055
23385
|
openProjectConfig: 'view',
|
|
23056
23386
|
openGlobalConfig: 'view',
|
|
23057
23387
|
gitignoreFile: 'mutate',
|
|
23388
|
+
stageAll: 'mutate',
|
|
23389
|
+
stagePathspec: 'mutate',
|
|
23390
|
+
createStash: 'mutate',
|
|
23058
23391
|
quit: 'essentials',
|
|
23059
23392
|
refresh: 'essentials',
|
|
23060
23393
|
navigateBack: 'essentials',
|
|
@@ -23206,18 +23539,20 @@ function formatLogInkBreadcrumb(viewStack) {
|
|
|
23206
23539
|
if (viewStack.length === 1 && viewStack[0] === 'history') {
|
|
23207
23540
|
return '';
|
|
23208
23541
|
}
|
|
23209
|
-
//
|
|
23210
|
-
//
|
|
23211
|
-
|
|
23542
|
+
// Pure location breadcrumb — no trailing back-hint. The footer's
|
|
23543
|
+
// global `< back` hint already names the walk-back key, so repeating
|
|
23544
|
+
// `← <` on every nested view was redundant header chrome (TUI audit).
|
|
23545
|
+
return viewStack.join(' › ');
|
|
23212
23546
|
}
|
|
23213
23547
|
/**
|
|
23214
23548
|
* Render the nested-repo navigation stack (#931) as a breadcrumb suitable
|
|
23215
23549
|
* for the chrome header. Returns an empty string for a root-only stack
|
|
23216
23550
|
* so the header stays compact when nothing has been pushed.
|
|
23217
23551
|
*
|
|
23218
|
-
* The trailing `← esc` reminds the user that Esc
|
|
23219
|
-
*
|
|
23220
|
-
*
|
|
23552
|
+
* The trailing `← esc` reminds the user that Esc (not `<`) pops the
|
|
23553
|
+
* repo stack — a distinct key from the footer's global `< back`, so
|
|
23554
|
+
* unlike the view breadcrumb (pure location) the repo crumb keeps its
|
|
23555
|
+
* hint. The repo breadcrumb shows in addition to the view breadcrumb when
|
|
23221
23556
|
* both stacks are non-trivial; the chrome layer is responsible for
|
|
23222
23557
|
* laying them out side by side.
|
|
23223
23558
|
*
|
|
@@ -23260,7 +23595,53 @@ function combineLogInkBreadcrumbSegments(repoCrumb, viewCrumb) {
|
|
|
23260
23595
|
}
|
|
23261
23596
|
return '';
|
|
23262
23597
|
}
|
|
23598
|
+
/**
|
|
23599
|
+
* Single-pane pane switcher hint, e.g. `tab: [sidebar] main inspector`.
|
|
23600
|
+
* The active pane (derived from focus: sidebar → sidebar, detail →
|
|
23601
|
+
* inspector, otherwise main) is bracketed so the user can see which of
|
|
23602
|
+
* the three panes Tab will move them away from. Surfaced only on narrow
|
|
23603
|
+
* terminals where the other two panes aren't on screen.
|
|
23604
|
+
*/
|
|
23605
|
+
function singlePaneSwitcherHint(focus) {
|
|
23606
|
+
const active = focus === 'sidebar' ? 'sidebar' : focus === 'detail' ? 'inspector' : 'main';
|
|
23607
|
+
const label = (pane) => (pane === active ? `[${pane}]` : pane);
|
|
23608
|
+
return `tab: ${label('sidebar')} ${label('main')} ${label('inspector')}`;
|
|
23609
|
+
}
|
|
23263
23610
|
function getLogInkFooterHints(options) {
|
|
23611
|
+
const hints = computeLogInkFooterHints(options);
|
|
23612
|
+
// While peeking the sidebar (#1135 v2) the footer shows the snap-back
|
|
23613
|
+
// affordance instead of the switcher — the user is mid-glance, not
|
|
23614
|
+
// navigating, so `v`/Esc returning to main is the relevant action. The
|
|
23615
|
+
// view-hint tail + globals are trimmed to fit the narrow row (see
|
|
23616
|
+
// SINGLE_PANE_GLOBAL_HINTS).
|
|
23617
|
+
if (options.peeking) {
|
|
23618
|
+
return {
|
|
23619
|
+
contextual: ['v/esc → main', ...hints.contextual.slice(0, SINGLE_PANE_VIEW_HINT_LIMIT)],
|
|
23620
|
+
global: SINGLE_PANE_GLOBAL_HINTS,
|
|
23621
|
+
};
|
|
23622
|
+
}
|
|
23623
|
+
// On narrow terminals only one pane is on screen, so prepend a Tab
|
|
23624
|
+
// pane switcher for orientation. The caller (footer) only sets
|
|
23625
|
+
// `singlePane` in the plain per-pane states — while an overlay or
|
|
23626
|
+
// filter owns the screen the visible pane is forced (or input is
|
|
23627
|
+
// captured) and Tab does something else, so the switcher is
|
|
23628
|
+
// suppressed there to avoid showing a pane that isn't active. From the
|
|
23629
|
+
// main / inspector pane we also surface `v peek` so the momentary
|
|
23630
|
+
// sidebar glance is discoverable. The full per-view hint cluster +
|
|
23631
|
+
// global cluster don't fit alongside the switcher at the 80-col floor,
|
|
23632
|
+
// so both are trimmed (the dropped keys stay reachable via `?`).
|
|
23633
|
+
if (options.singlePane) {
|
|
23634
|
+
const lead = options.focus === 'sidebar'
|
|
23635
|
+
? [singlePaneSwitcherHint(options.focus)]
|
|
23636
|
+
: [singlePaneSwitcherHint(options.focus), 'v peek'];
|
|
23637
|
+
return {
|
|
23638
|
+
contextual: [...lead, ...hints.contextual.slice(0, SINGLE_PANE_VIEW_HINT_LIMIT)],
|
|
23639
|
+
global: SINGLE_PANE_GLOBAL_HINTS,
|
|
23640
|
+
};
|
|
23641
|
+
}
|
|
23642
|
+
return hints;
|
|
23643
|
+
}
|
|
23644
|
+
function computeLogInkFooterHints(options) {
|
|
23264
23645
|
if (options.pendingKey) {
|
|
23265
23646
|
const continuations = getLogInkChordContinuations(options.pendingKey);
|
|
23266
23647
|
if (continuations.length > 0) {
|
|
@@ -23377,7 +23758,7 @@ function getLogInkFooterHints(options) {
|
|
|
23377
23758
|
}
|
|
23378
23759
|
if (options.activeView === 'status') {
|
|
23379
23760
|
return {
|
|
23380
|
-
contextual: ['↑/↓ files', 'enter
|
|
23761
|
+
contextual: ['↑/↓ files', 'enter hunks', 'space stage', 'A stage all', 'z revert', 'e/c compose'],
|
|
23381
23762
|
global: NORMAL_GLOBAL_HINTS,
|
|
23382
23763
|
};
|
|
23383
23764
|
}
|
|
@@ -23389,16 +23770,19 @@ function getLogInkFooterHints(options) {
|
|
|
23389
23770
|
const splitToggleHint = options.diffViewMode === 'split' ? 'd unified' : 'd split';
|
|
23390
23771
|
if (options.diffSource === 'stash') {
|
|
23391
23772
|
return {
|
|
23392
|
-
contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk',
|
|
23773
|
+
contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'esc back'],
|
|
23393
23774
|
global: NORMAL_GLOBAL_HINTS,
|
|
23394
23775
|
};
|
|
23395
23776
|
}
|
|
23396
23777
|
if (options.diffSource === 'commit') {
|
|
23397
23778
|
// Commit-diff explore: read-only diff, but `c` cherry-picks the
|
|
23398
23779
|
// cursored file from the commit into the worktree, and `H`
|
|
23399
|
-
// (or `gH` for index) applies just the cursored hunk.
|
|
23780
|
+
// (or `gH` for index) applies just the cursored hunk. `j/k`
|
|
23781
|
+
// line-scroll the diff body; `[`/`]` jump between hunks — the
|
|
23782
|
+
// footer labels match the actual handlers (commit diff has no
|
|
23783
|
+
// per-file `[/]` jump; that's the stash diff).
|
|
23400
23784
|
return {
|
|
23401
|
-
contextual: ['j/k
|
|
23785
|
+
contextual: ['j/k lines', '[/] hunk', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'esc back'],
|
|
23402
23786
|
global: NORMAL_GLOBAL_HINTS,
|
|
23403
23787
|
};
|
|
23404
23788
|
}
|
|
@@ -23411,14 +23795,17 @@ function getLogInkFooterHints(options) {
|
|
|
23411
23795
|
global: NORMAL_GLOBAL_HINTS,
|
|
23412
23796
|
};
|
|
23413
23797
|
}
|
|
23798
|
+
// Worktree (staging) diff. The hunk is the unit of action: ↑/↓ walk
|
|
23799
|
+
// hunks, space stages/unstages the selected one, a stages the whole
|
|
23800
|
+
// file, z discards the hunk.
|
|
23414
23801
|
return {
|
|
23415
|
-
contextual: ['
|
|
23802
|
+
contextual: ['↑/↓ hunk', 'space stage', 'a stage file', 'z discard', 'o edit', 'esc back'],
|
|
23416
23803
|
global: NORMAL_GLOBAL_HINTS,
|
|
23417
23804
|
};
|
|
23418
23805
|
}
|
|
23419
23806
|
if (options.activeView === 'compose') {
|
|
23420
23807
|
return {
|
|
23421
|
-
contextual: ['e edit', '
|
|
23808
|
+
contextual: ['e edit', 'c commit', 'A stage all', '+ stage…', 'S split', 'I AI draft', 'esc back'],
|
|
23422
23809
|
global: NORMAL_GLOBAL_HINTS,
|
|
23423
23810
|
};
|
|
23424
23811
|
}
|
|
@@ -23448,7 +23835,7 @@ function getLogInkFooterHints(options) {
|
|
|
23448
23835
|
}
|
|
23449
23836
|
if (options.activeView === 'stash') {
|
|
23450
23837
|
return {
|
|
23451
|
-
contextual: ['↑/↓ stashes', 'enter diff', 'a apply', 'p pop', '
|
|
23838
|
+
contextual: ['↑/↓ stashes', 'enter diff', 'a/A apply', 'p pop', 'R rename', 'b branch', 'X drop · u undo'],
|
|
23452
23839
|
global: NORMAL_GLOBAL_HINTS,
|
|
23453
23840
|
};
|
|
23454
23841
|
}
|
|
@@ -23577,6 +23964,48 @@ function getLogInkHelpSections(options) {
|
|
|
23577
23964
|
},
|
|
23578
23965
|
];
|
|
23579
23966
|
}
|
|
23967
|
+
/**
|
|
23968
|
+
* True when a key string is a single, bare printable key (e.g. `c`, `R`,
|
|
23969
|
+
* `[`) rather than a chord (`gh`, `gg`) or a named special key (`up`,
|
|
23970
|
+
* `page down`). Used by the which-key view-keys strip, which surfaces only
|
|
23971
|
+
* the single-key overloads — the chord set already has its own overlay.
|
|
23972
|
+
*/
|
|
23973
|
+
function isBareSingleKey(key) {
|
|
23974
|
+
return key.length === 1 && key !== ' ';
|
|
23975
|
+
}
|
|
23976
|
+
/**
|
|
23977
|
+
* Single-key bindings available in the current view (#1137). Powers the
|
|
23978
|
+
* `g?` which-key strip: the per-view counterpart to the `g`-chord overlay.
|
|
23979
|
+
*
|
|
23980
|
+
* Sourced entirely from `LOG_INK_KEY_BINDINGS` (no duplicated key data) and
|
|
23981
|
+
* filtered the same way the help overlay's "This view" section is — by
|
|
23982
|
+
* `contexts` against the active view + focus — then narrowed to bindings
|
|
23983
|
+
* that expose at least one bare single key. Globals (`q`, `?`, `/`, `:`, …)
|
|
23984
|
+
* are excluded: they're always available and already live in the footer and
|
|
23985
|
+
* onboarding tour, so the strip stays focused on the deliberate per-view
|
|
23986
|
+
* overloads (`c`, `R`, `a`, `m`, `S`, `[`/`]`, …) the keymap guard protects.
|
|
23987
|
+
*
|
|
23988
|
+
* Sorted by the first bare key for stable, scannable output.
|
|
23989
|
+
*/
|
|
23990
|
+
function getLogInkViewKeyBindings(options) {
|
|
23991
|
+
return LOG_INK_KEY_BINDINGS
|
|
23992
|
+
.filter((binding) => !GLOBAL_BINDING_IDS.includes(binding.id) &&
|
|
23993
|
+
bindingMatchesViewContext(binding, options) &&
|
|
23994
|
+
binding.keys.some(isBareSingleKey))
|
|
23995
|
+
.sort((a, b) => {
|
|
23996
|
+
const aKey = a.keys.find(isBareSingleKey) ?? '';
|
|
23997
|
+
const bKey = b.keys.find(isBareSingleKey) ?? '';
|
|
23998
|
+
return aKey.localeCompare(bKey);
|
|
23999
|
+
});
|
|
24000
|
+
}
|
|
24001
|
+
/**
|
|
24002
|
+
* Format only the bare single keys of a binding for the view-keys strip
|
|
24003
|
+
* (e.g. `['up', 'k']` → `k`). Named/chord keys are dropped — the strip is
|
|
24004
|
+
* about the single-key affordance, and the full key list lives in `?` help.
|
|
24005
|
+
*/
|
|
24006
|
+
function formatBindingBareKeys(binding) {
|
|
24007
|
+
return binding.keys.filter(isBareSingleKey).join(' / ');
|
|
24008
|
+
}
|
|
23580
24009
|
function bindingToPaletteCommand(binding) {
|
|
23581
24010
|
return {
|
|
23582
24011
|
id: binding.id,
|
|
@@ -24839,7 +25268,7 @@ function topOfStack(stack) {
|
|
|
24839
25268
|
}
|
|
24840
25269
|
function withPushedView(state, value) {
|
|
24841
25270
|
if (topOfStack(state.viewStack) === value) {
|
|
24842
|
-
return { ...state, pendingKey: undefined };
|
|
25271
|
+
return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
|
|
24843
25272
|
}
|
|
24844
25273
|
const viewStack = [...state.viewStack, value];
|
|
24845
25274
|
return {
|
|
@@ -24862,12 +25291,15 @@ function withPushedView(state, value) {
|
|
|
24862
25291
|
compareHead: value === 'diff' ? state.compareHead : undefined,
|
|
24863
25292
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
24864
25293
|
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
25294
|
+
// Changing the view is a deliberate destination — cancel any pending
|
|
25295
|
+
// peek return so the user isn't snapped back afterward.
|
|
25296
|
+
peekReturnFocus: undefined,
|
|
24865
25297
|
pendingKey: undefined,
|
|
24866
25298
|
};
|
|
24867
25299
|
}
|
|
24868
25300
|
function withPoppedView(state) {
|
|
24869
25301
|
if (state.viewStack.length <= 1) {
|
|
24870
|
-
return { ...state, pendingKey: undefined };
|
|
25302
|
+
return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
|
|
24871
25303
|
}
|
|
24872
25304
|
const viewStack = state.viewStack.slice(0, -1);
|
|
24873
25305
|
const next = topOfStack(viewStack);
|
|
@@ -24894,6 +25326,8 @@ function withPoppedView(state) {
|
|
|
24894
25326
|
compareHead: next === 'diff' ? state.compareHead : undefined,
|
|
24895
25327
|
pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
|
|
24896
25328
|
statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
|
|
25329
|
+
// Backing out is a deliberate navigation — cancel any peek return.
|
|
25330
|
+
peekReturnFocus: undefined,
|
|
24897
25331
|
pendingKey: undefined,
|
|
24898
25332
|
};
|
|
24899
25333
|
}
|
|
@@ -25006,7 +25440,7 @@ function withPoppedRepoFrame(state) {
|
|
|
25006
25440
|
}
|
|
25007
25441
|
function withReplacedView(state, value) {
|
|
25008
25442
|
if (topOfStack(state.viewStack) === value) {
|
|
25009
|
-
return { ...state, pendingKey: undefined };
|
|
25443
|
+
return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
|
|
25010
25444
|
}
|
|
25011
25445
|
const viewStack = [...state.viewStack.slice(0, -1), value];
|
|
25012
25446
|
return {
|
|
@@ -25020,6 +25454,9 @@ function withReplacedView(state, value) {
|
|
|
25020
25454
|
compareHead: value === 'diff' ? state.compareHead : undefined,
|
|
25021
25455
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
25022
25456
|
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
25457
|
+
// Changing the view is a deliberate destination — cancel any pending
|
|
25458
|
+
// peek return so the user isn't snapped back afterward.
|
|
25459
|
+
peekReturnFocus: undefined,
|
|
25023
25460
|
pendingKey: undefined,
|
|
25024
25461
|
};
|
|
25025
25462
|
}
|
|
@@ -25082,7 +25519,39 @@ function replaceRows(state, rows) {
|
|
|
25082
25519
|
}
|
|
25083
25520
|
function appendRows(state, rows) {
|
|
25084
25521
|
const selected = getSelectedInkCommit(state);
|
|
25085
|
-
|
|
25522
|
+
// Dedup the merged row list by commit hash so the graph renderer —
|
|
25523
|
+
// which windows directly over `state.rows` (toFullGraphItems →
|
|
25524
|
+
// expandRowsWithSpacers) — and the selection list (deduped commits)
|
|
25525
|
+
// agree on one canonical, duplicate-free row order. Overlapping
|
|
25526
|
+
// appends, notably the anchored `loadCommitContext` page that
|
|
25527
|
+
// re-walks history from the tip, otherwise stack the newest commits
|
|
25528
|
+
// below the oldest ones already loaded. The renderer then shows the
|
|
25529
|
+
// initial commit directly above HEAD and the cursor can scroll
|
|
25530
|
+
// forever through the duplicated tail — the history graph "looping
|
|
25531
|
+
// back on itself". Drop graph-only topology rows that trail a dropped
|
|
25532
|
+
// duplicate commit too, since they describe that duplicate's lanes
|
|
25533
|
+
// and would otherwise dangle.
|
|
25534
|
+
const seenHashes = new Set();
|
|
25535
|
+
const nextRows = [];
|
|
25536
|
+
let droppingTrailingGraph = false;
|
|
25537
|
+
for (const row of [...state.rows, ...rows]) {
|
|
25538
|
+
if (row.type === 'commit') {
|
|
25539
|
+
if (seenHashes.has(row.hash)) {
|
|
25540
|
+
droppingTrailingGraph = true;
|
|
25541
|
+
continue;
|
|
25542
|
+
}
|
|
25543
|
+
seenHashes.add(row.hash);
|
|
25544
|
+
droppingTrailingGraph = false;
|
|
25545
|
+
nextRows.push(row);
|
|
25546
|
+
continue;
|
|
25547
|
+
}
|
|
25548
|
+
// Graph-only topology row: keep it unless it trails a just-dropped
|
|
25549
|
+
// duplicate commit (then it belongs to the duplicate page's lanes).
|
|
25550
|
+
if (droppingTrailingGraph) {
|
|
25551
|
+
continue;
|
|
25552
|
+
}
|
|
25553
|
+
nextRows.push(row);
|
|
25554
|
+
}
|
|
25086
25555
|
const seen = new Set();
|
|
25087
25556
|
const commits = getCommitRows(nextRows).filter((commit) => {
|
|
25088
25557
|
if (seen.has(commit.hash)) {
|
|
@@ -25174,6 +25643,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
25174
25643
|
fullGraph: options.fullGraph ?? true,
|
|
25175
25644
|
showHelp: false,
|
|
25176
25645
|
helpScrollOffset: 0,
|
|
25646
|
+
showViewKeys: false,
|
|
25177
25647
|
showCommandPalette: false,
|
|
25178
25648
|
workflowActionId: undefined,
|
|
25179
25649
|
pendingConfirmationId: undefined,
|
|
@@ -25253,6 +25723,9 @@ function applyLogInkAction(state, action) {
|
|
|
25253
25723
|
// from 'commits' should always land back on a real file when
|
|
25254
25724
|
// the user returns.
|
|
25255
25725
|
statusGroupHeaderFocused: false,
|
|
25726
|
+
// Explicit focus cycle cancels a pending peek return — the
|
|
25727
|
+
// user has taken manual control of the focus.
|
|
25728
|
+
peekReturnFocus: undefined,
|
|
25256
25729
|
pendingKey: undefined,
|
|
25257
25730
|
};
|
|
25258
25731
|
case 'focusPrevious':
|
|
@@ -25261,6 +25734,7 @@ function applyLogInkAction(state, action) {
|
|
|
25261
25734
|
focus: cycleValue(FOCUS_ORDER, state.focus, -1),
|
|
25262
25735
|
sidebarHeaderFocused: false,
|
|
25263
25736
|
statusGroupHeaderFocused: false,
|
|
25737
|
+
peekReturnFocus: undefined,
|
|
25264
25738
|
pendingKey: undefined,
|
|
25265
25739
|
};
|
|
25266
25740
|
case 'move':
|
|
@@ -25675,6 +26149,22 @@ function applyLogInkAction(state, action) {
|
|
|
25675
26149
|
pendingKey: undefined,
|
|
25676
26150
|
};
|
|
25677
26151
|
}
|
|
26152
|
+
case 'returnFromCommit': {
|
|
26153
|
+
// After a successful commit we leave the compose view automatically.
|
|
26154
|
+
// Where to: a still-dirty tree the user was staging from returns to
|
|
26155
|
+
// the Status view so they can finish the rest; an otherwise-complete
|
|
26156
|
+
// commit returns to the History view, where the new commit now shows.
|
|
26157
|
+
// We pop frames one at a time (reusing withPoppedView) so sidebar-tab
|
|
26158
|
+
// and diff-state restoration stays identical to manual Esc/back —
|
|
26159
|
+
// this also unwinds an intermediate `diff` frame (status → diff →
|
|
26160
|
+
// compose) back to the status frame it sits under.
|
|
26161
|
+
const target = action.stillDirty && state.viewStack.includes('status') ? 'status' : HOME_VIEW;
|
|
26162
|
+
let next = state;
|
|
26163
|
+
while (next.viewStack.length > 1 && topOfStack(next.viewStack) !== target) {
|
|
26164
|
+
next = withPoppedView(next);
|
|
26165
|
+
}
|
|
26166
|
+
return { ...next, pendingKey: undefined };
|
|
26167
|
+
}
|
|
25678
26168
|
case 'navigateOpenDiffForCommit': {
|
|
25679
26169
|
const next = withPushedView(state, 'diff');
|
|
25680
26170
|
const filteredCommits = state.filteredCommits;
|
|
@@ -25758,8 +26248,35 @@ function applyLogInkAction(state, action) {
|
|
|
25758
26248
|
// the status view — clear when focus moves away so a
|
|
25759
26249
|
// re-entry starts on a real file.
|
|
25760
26250
|
statusGroupHeaderFocused: action.value === 'commits' ? state.statusGroupHeaderFocused : false,
|
|
26251
|
+
// An explicit focus set cancels a pending peek return.
|
|
26252
|
+
peekReturnFocus: undefined,
|
|
25761
26253
|
pendingKey: undefined,
|
|
25762
26254
|
};
|
|
26255
|
+
case 'togglePeek': {
|
|
26256
|
+
// Peek = "focus the sidebar with a return ticket." Closing returns
|
|
26257
|
+
// to the stashed focus; opening (only from a non-sidebar pane)
|
|
26258
|
+
// stashes the current focus and jumps to the sidebar. The render
|
|
26259
|
+
// layer needs no special case — `focus: 'sidebar'` already drives
|
|
26260
|
+
// the single-pane layout to show the sidebar full-width.
|
|
26261
|
+
if (state.peekReturnFocus !== undefined) {
|
|
26262
|
+
return {
|
|
26263
|
+
...state,
|
|
26264
|
+
focus: state.peekReturnFocus,
|
|
26265
|
+
peekReturnFocus: undefined,
|
|
26266
|
+
sidebarHeaderFocused: false,
|
|
26267
|
+
pendingKey: undefined,
|
|
26268
|
+
};
|
|
26269
|
+
}
|
|
26270
|
+
if (state.focus === 'sidebar') {
|
|
26271
|
+
return state;
|
|
26272
|
+
}
|
|
26273
|
+
return {
|
|
26274
|
+
...state,
|
|
26275
|
+
focus: 'sidebar',
|
|
26276
|
+
peekReturnFocus: state.focus,
|
|
26277
|
+
pendingKey: undefined,
|
|
26278
|
+
};
|
|
26279
|
+
}
|
|
25763
26280
|
case 'setPendingKey':
|
|
25764
26281
|
return {
|
|
25765
26282
|
...state,
|
|
@@ -25844,6 +26361,7 @@ function applyLogInkAction(state, action) {
|
|
|
25844
26361
|
showCommandPalette: false,
|
|
25845
26362
|
showHelp: false,
|
|
25846
26363
|
helpScrollOffset: 0,
|
|
26364
|
+
showViewKeys: false,
|
|
25847
26365
|
pendingKey: undefined,
|
|
25848
26366
|
};
|
|
25849
26367
|
case 'toggleGraph':
|
|
@@ -25862,9 +26380,24 @@ function applyLogInkAction(state, action) {
|
|
|
25862
26380
|
// than picking up where the user last scrolled.
|
|
25863
26381
|
helpScrollOffset: 0,
|
|
25864
26382
|
showCommandPalette: false,
|
|
26383
|
+
// Opening full help supersedes the compact view-keys strip — this
|
|
26384
|
+
// is the progressive-disclosure step (`?` from the strip expands
|
|
26385
|
+
// to the full categorized help, #1137).
|
|
26386
|
+
showViewKeys: false,
|
|
25865
26387
|
pendingKey: undefined,
|
|
25866
26388
|
};
|
|
25867
26389
|
}
|
|
26390
|
+
case 'toggleViewKeys':
|
|
26391
|
+
return {
|
|
26392
|
+
...state,
|
|
26393
|
+
showViewKeys: !state.showViewKeys,
|
|
26394
|
+
// The view-keys strip is mutually exclusive with the other
|
|
26395
|
+
// overlays; opening it closes anything else that was showing.
|
|
26396
|
+
showHelp: false,
|
|
26397
|
+
helpScrollOffset: 0,
|
|
26398
|
+
showCommandPalette: false,
|
|
26399
|
+
pendingKey: undefined,
|
|
26400
|
+
};
|
|
25868
26401
|
case 'scrollHelp':
|
|
25869
26402
|
// No upper-bound clamp here — the renderer caps the offset
|
|
25870
26403
|
// against the actual content height at render time. The
|
|
@@ -25881,6 +26414,7 @@ function applyLogInkAction(state, action) {
|
|
|
25881
26414
|
showCommandPalette: opening,
|
|
25882
26415
|
showHelp: false,
|
|
25883
26416
|
helpScrollOffset: 0,
|
|
26417
|
+
showViewKeys: false,
|
|
25884
26418
|
// Reset palette interaction state on every open/close so the next
|
|
25885
26419
|
// session starts from a clean slate.
|
|
25886
26420
|
paletteFilter: '',
|
|
@@ -25928,8 +26462,9 @@ function applyLogInkAction(state, action) {
|
|
|
25928
26462
|
return {
|
|
25929
26463
|
...state,
|
|
25930
26464
|
showThemePicker: opening,
|
|
25931
|
-
// Only one overlay at a time — close help / palette on open.
|
|
26465
|
+
// Only one overlay at a time — close help / palette / view-keys on open.
|
|
25932
26466
|
showHelp: false,
|
|
26467
|
+
showViewKeys: false,
|
|
25933
26468
|
showCommandPalette: false,
|
|
25934
26469
|
themePickerFilter: '',
|
|
25935
26470
|
themePickerIndex: 0,
|
|
@@ -26626,6 +27161,12 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
26626
27161
|
return [action({ type: 'toggleGraph' })];
|
|
26627
27162
|
case 'navigateHome':
|
|
26628
27163
|
return [action({ type: 'navigateHome' })];
|
|
27164
|
+
case 'createStash':
|
|
27165
|
+
return [action({
|
|
27166
|
+
type: 'openInputPrompt',
|
|
27167
|
+
kind: 'create-stash',
|
|
27168
|
+
label: 'Stash message (empty = WIP)',
|
|
27169
|
+
})];
|
|
26629
27170
|
case 'navigateStatus':
|
|
26630
27171
|
return [action({ type: 'pushView', value: 'status' })];
|
|
26631
27172
|
case 'navigateDiff':
|
|
@@ -26723,6 +27264,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
26723
27264
|
// Palette closes on execute (toggleCommandPalette runs first), then
|
|
26724
27265
|
// this opens the theme picker.
|
|
26725
27266
|
return [action({ type: 'toggleThemePicker' })];
|
|
27267
|
+
case 'viewKeys':
|
|
27268
|
+
// Palette closes on execute (toggleCommandPalette runs first), then
|
|
27269
|
+
// this opens the per-view which-key strip (#1137).
|
|
27270
|
+
return [action({ type: 'toggleViewKeys' })];
|
|
26726
27271
|
case 'openProjectConfig':
|
|
26727
27272
|
return [{ type: 'openConfigInEditor', scope: 'project' }];
|
|
26728
27273
|
case 'openGlobalConfig':
|
|
@@ -26731,6 +27276,14 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
26731
27276
|
// Runtime resolves the cursored worktree file and opens the picker
|
|
26732
27277
|
// (no-ops with a warning when there's no file under the cursor).
|
|
26733
27278
|
return [{ type: 'openGitignorePicker' }];
|
|
27279
|
+
case 'stageAll':
|
|
27280
|
+
return [{ type: 'runWorkflowAction', id: 'stage-all' }];
|
|
27281
|
+
case 'stagePathspec':
|
|
27282
|
+
return [action({
|
|
27283
|
+
type: 'openInputPrompt',
|
|
27284
|
+
kind: 'stage-pathspec',
|
|
27285
|
+
label: 'Stage pathspec (e.g. `.`, `src/`, `*.ts`, or a space-separated list)',
|
|
27286
|
+
})];
|
|
26734
27287
|
case 'workflowDeleteBranch':
|
|
26735
27288
|
case 'workflowDeleteTag':
|
|
26736
27289
|
case 'workflowDropStash':
|
|
@@ -26821,6 +27374,15 @@ function submitInputPrompt(state) {
|
|
|
26821
27374
|
if (!state.inputPrompt)
|
|
26822
27375
|
return [];
|
|
26823
27376
|
const value = state.inputPrompt.value.trim();
|
|
27377
|
+
// create-stash allows an EMPTY value → quick WIP stash (git supplies its
|
|
27378
|
+
// own "WIP on <branch>" subject). Handled before the generic empty guard
|
|
27379
|
+
// so an empty stash prompt commits a WIP stash instead of bouncing.
|
|
27380
|
+
if (state.inputPrompt.kind === 'create-stash') {
|
|
27381
|
+
return [
|
|
27382
|
+
{ type: 'runWorkflowAction', id: 'create-stash', payload: value },
|
|
27383
|
+
action({ type: 'closeInputPrompt' }),
|
|
27384
|
+
];
|
|
27385
|
+
}
|
|
26824
27386
|
if (!value) {
|
|
26825
27387
|
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
|
|
26826
27388
|
}
|
|
@@ -26830,6 +27392,12 @@ function submitInputPrompt(state) {
|
|
|
26830
27392
|
action({ type: 'closeInputPrompt' }),
|
|
26831
27393
|
];
|
|
26832
27394
|
}
|
|
27395
|
+
if (state.inputPrompt.kind === 'stage-pathspec') {
|
|
27396
|
+
return [
|
|
27397
|
+
{ type: 'runWorkflowAction', id: 'stage-pathspec', payload: value },
|
|
27398
|
+
action({ type: 'closeInputPrompt' }),
|
|
27399
|
+
];
|
|
27400
|
+
}
|
|
26833
27401
|
if (state.inputPrompt.kind === 'reset-mode') {
|
|
26834
27402
|
const mode = value.toLowerCase();
|
|
26835
27403
|
if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
|
|
@@ -27056,7 +27624,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27056
27624
|
// draft was pending should see the original `R` / Esc semantics of
|
|
27057
27625
|
// wherever they are now.
|
|
27058
27626
|
if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
|
|
27059
|
-
|
|
27627
|
+
// `R` or `Enter` accept the swap (the AI draft becomes the new
|
|
27628
|
+
// content); `Enter` is the natural "yes, use it" confirmation.
|
|
27629
|
+
if ((inputValue === 'R' && !key.ctrl && !key.meta) || key.return) {
|
|
27060
27630
|
return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
|
|
27061
27631
|
}
|
|
27062
27632
|
if (key.escape) {
|
|
@@ -27426,6 +27996,26 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27426
27996
|
}
|
|
27427
27997
|
return [];
|
|
27428
27998
|
}
|
|
27999
|
+
// #1137 — the `g?` which-key strip. While it's open the keyboard is
|
|
28000
|
+
// claimed (mirrors the help overlay) so a stray keystroke can't drop
|
|
28001
|
+
// the user into a per-view action they didn't mean to trigger. Esc
|
|
28002
|
+
// closes; `?` is the progressive-disclosure step up to the full
|
|
28003
|
+
// categorized help; `q` still quits. Everything else is swallowed —
|
|
28004
|
+
// the user peeks, dismisses, then presses the key they came for.
|
|
28005
|
+
if (state.showViewKeys) {
|
|
28006
|
+
if (key.escape) {
|
|
28007
|
+
return [action({ type: 'toggleViewKeys' })];
|
|
28008
|
+
}
|
|
28009
|
+
if (inputValue === '?') {
|
|
28010
|
+
// Expand the compact strip into the full help overlay. `toggleHelp`
|
|
28011
|
+
// clears `showViewKeys` so the two never render at once.
|
|
28012
|
+
return [action({ type: 'toggleHelp' })];
|
|
28013
|
+
}
|
|
28014
|
+
if (inputValue === 'q') {
|
|
28015
|
+
return [{ type: 'exit' }];
|
|
28016
|
+
}
|
|
28017
|
+
return [];
|
|
28018
|
+
}
|
|
27429
28019
|
// #879 item 4 — Esc cancels an in-flight bisect-start wizard. Runs
|
|
27430
28020
|
// BEFORE the generic `popView` so we both clear the wizard state
|
|
27431
28021
|
// and walk back to the bisect view in one keystroke. Without this
|
|
@@ -27442,6 +28032,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27442
28032
|
}
|
|
27443
28033
|
return events;
|
|
27444
28034
|
}
|
|
28035
|
+
// #1135 v2 — while peeking the sidebar, Esc or the peek key (`v`)
|
|
28036
|
+
// snaps back to the pane the user came from. Placed before the
|
|
28037
|
+
// generic Esc → popView so a peek glance returns to main rather than
|
|
28038
|
+
// walking the view stack. Every other key falls through to normal
|
|
28039
|
+
// handling (focus is on the sidebar during a peek), so ←/→ and ↑/↓
|
|
28040
|
+
// browse the sidebar and keep the peek open until an explicit exit.
|
|
28041
|
+
if (state.peekReturnFocus !== undefined && (key.escape || inputValue === 'v')) {
|
|
28042
|
+
return [action({ type: 'togglePeek' })];
|
|
28043
|
+
}
|
|
27445
28044
|
if (key.escape && state.viewStack.length > 1) {
|
|
27446
28045
|
return [action({ type: 'popView' })];
|
|
27447
28046
|
}
|
|
@@ -27460,6 +28059,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27460
28059
|
}
|
|
27461
28060
|
return [{ type: 'exit' }];
|
|
27462
28061
|
}
|
|
28062
|
+
// `g?` chord (#1137) — open the per-view which-key strip. Placed
|
|
28063
|
+
// BEFORE the bare `?` (full help) check below so the chord is read as
|
|
28064
|
+
// a unit: with `g` pending, `?` opens the view-keys strip rather than
|
|
28065
|
+
// toggling full help. Surfaces automatically in the `g` which-key menu
|
|
28066
|
+
// because its key is a two-char `g`-prefixed binding.
|
|
28067
|
+
if (state.pendingKey === 'g' && inputValue === '?') {
|
|
28068
|
+
return [
|
|
28069
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
28070
|
+
action({ type: 'toggleViewKeys' }),
|
|
28071
|
+
];
|
|
28072
|
+
}
|
|
27463
28073
|
if (inputValue === '?') {
|
|
27464
28074
|
return [action({ type: 'toggleHelp' })];
|
|
27465
28075
|
}
|
|
@@ -27508,6 +28118,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27508
28118
|
action({ type: 'setStatus', value: 'jumped to stash' }),
|
|
27509
28119
|
];
|
|
27510
28120
|
}
|
|
28121
|
+
// `gZ` chord: stash all changes from ANY view — including status / diff /
|
|
28122
|
+
// compose, where bare `S` is claimed by the commit-split flow. Mnemonic
|
|
28123
|
+
// pair with `gz` (jump to the stash *view*). Opens the same message
|
|
28124
|
+
// prompt; an empty message creates a quick WIP stash.
|
|
28125
|
+
if (state.pendingKey === 'g' && inputValue === 'Z') {
|
|
28126
|
+
return [action({
|
|
28127
|
+
type: 'openInputPrompt',
|
|
28128
|
+
kind: 'create-stash',
|
|
28129
|
+
label: 'Stash message (empty = WIP)',
|
|
28130
|
+
})];
|
|
28131
|
+
}
|
|
27511
28132
|
if (state.pendingKey === 'g' && inputValue === 'w') {
|
|
27512
28133
|
return [
|
|
27513
28134
|
action({ type: 'pushView', value: 'worktrees' }),
|
|
@@ -27871,6 +28492,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27871
28492
|
if (key.tab) {
|
|
27872
28493
|
return [action({ type: key.shift ? 'focusPrevious' : 'focusNext' })];
|
|
27873
28494
|
}
|
|
28495
|
+
// #1135 v2 — `v` peeks the sidebar from the main / inspector pane on
|
|
28496
|
+
// narrow (single-pane) terminals: a momentary glance that snaps back
|
|
28497
|
+
// with `v` / Esc (handled above once peeking). No-op in the three-pane
|
|
28498
|
+
// layout (every pane is already on screen) and from the sidebar itself.
|
|
28499
|
+
if (inputValue === 'v' && context.singlePane && state.focus !== 'sidebar') {
|
|
28500
|
+
return [action({ type: 'togglePeek' })];
|
|
28501
|
+
}
|
|
27874
28502
|
// ←/→ on the sidebar switch tabs (Status ↔ Branches ↔ Tags ↔
|
|
27875
28503
|
// Stashes ↔ Worktrees) — the horizontal axis is "between tabs", the
|
|
27876
28504
|
// vertical axis (↑/↓ below) is "within the active tab's items".
|
|
@@ -27956,10 +28584,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27956
28584
|
fileCount: context.worktreeFileCount,
|
|
27957
28585
|
})];
|
|
27958
28586
|
}
|
|
27959
|
-
//
|
|
27960
|
-
//
|
|
27961
|
-
//
|
|
27962
|
-
//
|
|
28587
|
+
// Worktree (staging) diff: ↑/↓ move between hunks — the hunk is the
|
|
28588
|
+
// unit you stage, so the cursor walks hunks (auto-scrolling to the
|
|
28589
|
+
// selected one). Single-hunk files fall through to line-scroll so a
|
|
28590
|
+
// long lone hunk stays readable; `[`/`]` remain hunk-jump aliases.
|
|
28591
|
+
if (state.activeView === 'diff' && (context.worktreeHunkOffsets?.length ?? 0) > 1) {
|
|
28592
|
+
return [action({
|
|
28593
|
+
type: 'jumpWorktreeHunk',
|
|
28594
|
+
delta: -1,
|
|
28595
|
+
hunkOffsets: context.worktreeHunkOffsets,
|
|
28596
|
+
})];
|
|
28597
|
+
}
|
|
27963
28598
|
if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
|
|
27964
28599
|
return [action({
|
|
27965
28600
|
type: 'pageWorktreeDiff',
|
|
@@ -28074,6 +28709,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28074
28709
|
fileCount: context.worktreeFileCount,
|
|
28075
28710
|
})];
|
|
28076
28711
|
}
|
|
28712
|
+
// Worktree (staging) diff: ↓ walks to the next hunk (see the ↑
|
|
28713
|
+
// handler). Multi-hunk only; single-hunk files line-scroll.
|
|
28714
|
+
if (state.activeView === 'diff' && (context.worktreeHunkOffsets?.length ?? 0) > 1) {
|
|
28715
|
+
return [action({
|
|
28716
|
+
type: 'jumpWorktreeHunk',
|
|
28717
|
+
delta: 1,
|
|
28718
|
+
hunkOffsets: context.worktreeHunkOffsets,
|
|
28719
|
+
})];
|
|
28720
|
+
}
|
|
28077
28721
|
if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
|
|
28078
28722
|
return [action({
|
|
28079
28723
|
type: 'pageWorktreeDiff',
|
|
@@ -28480,6 +29124,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28480
29124
|
if (inputValue === 'p' && isStashActionTarget(state) && context.stashCount) {
|
|
28481
29125
|
return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
|
|
28482
29126
|
}
|
|
29127
|
+
// `A` applies restoring the staged/unstaged split (`git stash apply
|
|
29128
|
+
// --index`) — distinct from `a` (plain apply).
|
|
29129
|
+
if (inputValue === 'A' && isStashActionTarget(state) && context.stashCount) {
|
|
29130
|
+
return [{ type: 'runWorkflowAction', id: 'apply-stash-index' }];
|
|
29131
|
+
}
|
|
29132
|
+
// `b` turns the cursored stash into a new branch (`git stash branch`).
|
|
29133
|
+
if (inputValue === 'b' && isStashActionTarget(state) && context.stashCount) {
|
|
29134
|
+
return [action({ type: 'openInputPrompt', kind: 'stash-branch', label: 'New branch from stash' })];
|
|
29135
|
+
}
|
|
29136
|
+
// `R` renames the cursored stash (store-under-new-message + drop old).
|
|
29137
|
+
if (inputValue === 'R' && isStashActionTarget(state) && context.stashCount) {
|
|
29138
|
+
return [action({ type: 'openInputPrompt', kind: 'rename-stash', label: 'Rename stash' })];
|
|
29139
|
+
}
|
|
29140
|
+
// `u` undoes the last drop. Gated on the view, NOT the count, so it
|
|
29141
|
+
// still works right after you drop your only stash (the list is empty
|
|
29142
|
+
// but the dropped commit is recoverable by hash).
|
|
29143
|
+
if (inputValue === 'u' && isStashActionTarget(state)) {
|
|
29144
|
+
return [{ type: 'runWorkflowAction', id: 'undo-drop-stash' }];
|
|
29145
|
+
}
|
|
28483
29146
|
// Per-view tag action: `P` pushes the selected tag to origin. Letter
|
|
28484
29147
|
// is scoped to the tags target so it doesn't collide with `p` for
|
|
28485
29148
|
// pop-stash. Note: this also takes precedence over the global
|
|
@@ -28708,7 +29371,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28708
29371
|
return [action({
|
|
28709
29372
|
type: 'openInputPrompt',
|
|
28710
29373
|
kind: 'create-stash',
|
|
28711
|
-
label: 'Stash message',
|
|
29374
|
+
label: 'Stash message (empty = WIP)',
|
|
28712
29375
|
})];
|
|
28713
29376
|
}
|
|
28714
29377
|
// `o` opens the file under the cursor in $EDITOR. Available on the
|
|
@@ -28977,9 +29640,35 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28977
29640
|
if (inputValue === ' ' && state.activeView === 'status' && context.worktreeFileCount) {
|
|
28978
29641
|
return [{ type: 'toggleSelectedFileStage' }];
|
|
28979
29642
|
}
|
|
29643
|
+
// `A` — stage everything (git add -A); `+` — stage by typed pathspec.
|
|
29644
|
+
// Both available from the status AND compose views so you can stage
|
|
29645
|
+
// without leaving the message editor.
|
|
29646
|
+
if (inputValue === 'A' && (state.activeView === 'status' || state.activeView === 'compose')) {
|
|
29647
|
+
return [{ type: 'runWorkflowAction', id: 'stage-all' }];
|
|
29648
|
+
}
|
|
29649
|
+
if (inputValue === '+' && (state.activeView === 'status' || state.activeView === 'compose')) {
|
|
29650
|
+
return [action({
|
|
29651
|
+
type: 'openInputPrompt',
|
|
29652
|
+
kind: 'stage-pathspec',
|
|
29653
|
+
label: 'Stage pathspec (e.g. `.`, `src/`, `*.ts`, or a space-separated list)',
|
|
29654
|
+
})];
|
|
29655
|
+
}
|
|
28980
29656
|
if (inputValue === ' ' && state.activeView === 'diff' && context.worktreeHunkOffsets?.length) {
|
|
28981
29657
|
return [{ type: 'toggleSelectedHunkStage' }];
|
|
28982
29658
|
}
|
|
29659
|
+
// Worktree diff with no hunks (a new/untracked file) — `space` stages
|
|
29660
|
+
// the whole file, since there's nothing to partial-stage.
|
|
29661
|
+
if (inputValue === ' ' &&
|
|
29662
|
+
state.activeView === 'diff' &&
|
|
29663
|
+
state.diffSource === 'worktree' &&
|
|
29664
|
+
!context.worktreeHunkOffsets?.length) {
|
|
29665
|
+
return [{ type: 'toggleSelectedFileStage' }];
|
|
29666
|
+
}
|
|
29667
|
+
// `a` stages/unstages the WHOLE current file from the staging diff —
|
|
29668
|
+
// an escape hatch out of hunk-by-hunk back to all-or-nothing.
|
|
29669
|
+
if (inputValue === 'a' && state.activeView === 'diff' && state.diffSource === 'worktree') {
|
|
29670
|
+
return [{ type: 'toggleSelectedFileStage' }];
|
|
29671
|
+
}
|
|
28983
29672
|
if (inputValue === 'z' && state.activeView === 'status' && context.worktreeFileCount) {
|
|
28984
29673
|
return [action({ type: 'setPendingMutationConfirmation', value: 'revert-file' })];
|
|
28985
29674
|
}
|
|
@@ -29754,21 +30443,22 @@ const INSPECTOR_TABBED_BELOW_ROWS = 28;
|
|
|
29754
30443
|
* wide >= 160 — plenty of room; keep absolute dates
|
|
29755
30444
|
* normal >= 120 — relative dates save 8-ish cells without hiding info
|
|
29756
30445
|
* tight >= 100 — drop date entirely; subject + refs are the priority
|
|
29757
|
-
* rail < 100 —
|
|
29758
|
-
*
|
|
30446
|
+
* rail < 100 — history rows stack to two lines; the UI also drops
|
|
30447
|
+
* to single-pane mode (see `LAYOUT_SINGLE_PANE_BELOW`)
|
|
29759
30448
|
*/
|
|
29760
30449
|
const LAYOUT_TIGHT_BELOW = 120;
|
|
29761
30450
|
const LAYOUT_NORMAL_BELOW = 160;
|
|
29762
30451
|
const LAYOUT_RAIL_BELOW = 100;
|
|
29763
30452
|
/**
|
|
29764
|
-
*
|
|
29765
|
-
*
|
|
29766
|
-
*
|
|
29767
|
-
*
|
|
30453
|
+
* Width below which the three-panel layout can't tile without starving
|
|
30454
|
+
* every pane, so the UI shows exactly one full-width pane (the focused
|
|
30455
|
+
* one) and Tab cycles which pane is visible. Coincides with the `rail`
|
|
30456
|
+
* density breakpoint — single-pane mode replaces the old 8-cell icon
|
|
30457
|
+
* rails that used to render at this width.
|
|
29768
30458
|
*/
|
|
29769
|
-
const
|
|
30459
|
+
const LAYOUT_SINGLE_PANE_BELOW = LAYOUT_RAIL_BELOW;
|
|
29770
30460
|
const SIDEBAR_AT_REST_BY_TIER = {
|
|
29771
|
-
rail: { min: 22, max: 28, fraction: 0.24 }, // unused —
|
|
30461
|
+
rail: { min: 22, max: 28, fraction: 0.24 }, // unused at rest — single-pane mode overrides the width
|
|
29772
30462
|
tight: { min: 22, max: 28, fraction: 0.24 },
|
|
29773
30463
|
normal: { min: 22, max: 30, fraction: 0.22 },
|
|
29774
30464
|
wide: { min: 28, max: 32, fraction: 0.20 },
|
|
@@ -29787,14 +30477,25 @@ function getLogInkLayout(input) {
|
|
|
29787
30477
|
: columns >= LAYOUT_RAIL_BELOW
|
|
29788
30478
|
? 'tight'
|
|
29789
30479
|
: 'rail';
|
|
29790
|
-
//
|
|
29791
|
-
//
|
|
29792
|
-
//
|
|
29793
|
-
//
|
|
29794
|
-
//
|
|
29795
|
-
|
|
29796
|
-
|
|
29797
|
-
|
|
30480
|
+
// Below the single-pane breakpoint the three-panel layout can't tile
|
|
30481
|
+
// without starving every pane, so we show exactly one full-width pane
|
|
30482
|
+
// — the focused one — and Tab cycles which pane is visible. This
|
|
30483
|
+
// replaces the retired 8-cell icon rails (an 8-cell stub showed a tab
|
|
30484
|
+
// glyph + count and nothing actionable).
|
|
30485
|
+
const singlePane = columns < LAYOUT_SINGLE_PANE_BELOW;
|
|
30486
|
+
// Which pane shows in single-pane mode. Defaults to the focused pane
|
|
30487
|
+
// (focus and visibility coalesce, so the existing Tab focus cycle
|
|
30488
|
+
// drives it). An active overlay can force a specific pane via
|
|
30489
|
+
// `forcedPane` so its surface isn't hidden behind whatever pane focus
|
|
30490
|
+
// points at.
|
|
30491
|
+
const focusPane = input.sidebarFocused
|
|
30492
|
+
? 'sidebar'
|
|
30493
|
+
: input.inspectorFocused
|
|
30494
|
+
? 'inspector'
|
|
30495
|
+
: 'main';
|
|
30496
|
+
const visiblePane = singlePane
|
|
30497
|
+
? input.forcedPane ?? focusPane
|
|
30498
|
+
: focusPane;
|
|
29798
30499
|
// Inspector width — at rest 20-32 cells (~22% of width), focused
|
|
29799
30500
|
// 36-60 cells (~40% of width). Narrow rest state keeps the commit
|
|
29800
30501
|
// graph dominant; focus expansion gives the inspector room for long
|
|
@@ -29806,42 +30507,48 @@ function getLogInkLayout(input) {
|
|
|
29806
30507
|
// "Move focus...". Capped at 100 cells so a wide terminal doesn't
|
|
29807
30508
|
// waste an absurd amount of horizontal space on the cheat sheet.
|
|
29808
30509
|
//
|
|
29809
|
-
//
|
|
29810
|
-
//
|
|
29811
|
-
// intent to read the panel.
|
|
30510
|
+
// (In single-pane mode these three-panel widths are recomputed below
|
|
30511
|
+
// so the visible pane gets the full terminal.)
|
|
29812
30512
|
const detailWidth = input.helpOverlayActive
|
|
29813
30513
|
? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
|
|
29814
30514
|
: input.inspectorFocused
|
|
29815
30515
|
? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
|
|
29816
|
-
:
|
|
29817
|
-
? LAYOUT_RAIL_PANEL_WIDTH
|
|
29818
|
-
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
30516
|
+
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
29819
30517
|
// Sidebar at rest is tier-aware (see `SIDEBAR_AT_REST_BY_TIER`):
|
|
29820
30518
|
// tight stays compact (22-28), normal shrinks slightly (22-30),
|
|
29821
30519
|
// wide grows naturally (28-48) so the side panel doesn't get pinned
|
|
29822
30520
|
// at an arbitrary cap on big terminals while the main panel hogs
|
|
29823
30521
|
// 80% of the width. Focused: 32-50 cells (~36% of width),
|
|
29824
30522
|
// regardless of tier — deliberate user intent to read the sidebar
|
|
29825
|
-
// deserves the extra width.
|
|
29826
|
-
// collapses to a fixed 8-cell strip with tab glyphs only.
|
|
30523
|
+
// deserves the extra width.
|
|
29827
30524
|
const sidebarWidth = input.sidebarFocused
|
|
29828
30525
|
? Math.max(32, Math.min(50, Math.floor(columns * 0.36)))
|
|
29829
|
-
:
|
|
29830
|
-
|
|
29831
|
-
|
|
30526
|
+
: calcSidebarAtRestWidth(columns, density);
|
|
30527
|
+
// Single-pane mode: exactly one pane renders, full-width; the other
|
|
30528
|
+
// two are hidden (width 0), not railed. Above the breakpoint the
|
|
30529
|
+
// three panels tile flush across the terminal.
|
|
30530
|
+
const paneWidths = singlePane
|
|
30531
|
+
? {
|
|
30532
|
+
sidebarWidth: visiblePane === 'sidebar' ? columns : 0,
|
|
30533
|
+
mainPanelWidth: visiblePane === 'main' ? columns : 0,
|
|
30534
|
+
detailWidth: visiblePane === 'inspector' ? columns : 0,
|
|
30535
|
+
}
|
|
30536
|
+
: {
|
|
30537
|
+
sidebarWidth,
|
|
30538
|
+
mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
|
|
30539
|
+
detailWidth,
|
|
30540
|
+
};
|
|
29832
30541
|
return {
|
|
29833
30542
|
bodyRows: Math.max(8, rows - 5),
|
|
29834
30543
|
columns,
|
|
29835
|
-
detailWidth,
|
|
29836
|
-
mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
|
|
29837
30544
|
rows,
|
|
29838
|
-
sidebarWidth,
|
|
29839
30545
|
tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
|
|
29840
30546
|
inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
|
|
29841
30547
|
density,
|
|
29842
|
-
|
|
29843
|
-
|
|
30548
|
+
singlePane,
|
|
30549
|
+
visiblePane,
|
|
29844
30550
|
historyRowMode: density === 'rail' ? 'stacked' : 'single',
|
|
30551
|
+
...paneWidths,
|
|
29845
30552
|
};
|
|
29846
30553
|
}
|
|
29847
30554
|
|
|
@@ -30345,7 +31052,7 @@ function fetchBranch(git, branch) {
|
|
|
30345
31052
|
if (!branch.upstream || !branch.remote) {
|
|
30346
31053
|
return Promise.resolve({
|
|
30347
31054
|
ok: false,
|
|
30348
|
-
message: `${branch.shortName} has no upstream —
|
|
31055
|
+
message: `${branch.shortName} has no upstream — set one with \`git push -u <remote> ${branch.shortName}\` to enable fetch.`,
|
|
30349
31056
|
});
|
|
30350
31057
|
}
|
|
30351
31058
|
// `branch.upstream` is the short form (e.g. `origin/main`); the
|
|
@@ -30383,7 +31090,7 @@ function pullBranch(git, branch, currentBranchName) {
|
|
|
30383
31090
|
if (!branch.upstream || !branch.remote) {
|
|
30384
31091
|
return Promise.resolve({
|
|
30385
31092
|
ok: false,
|
|
30386
|
-
message: `${branch.shortName} has no upstream —
|
|
31093
|
+
message: `${branch.shortName} has no upstream — set one with \`git push -u <remote> ${branch.shortName}\` to enable pull.`,
|
|
30387
31094
|
});
|
|
30388
31095
|
}
|
|
30389
31096
|
// Current branch — defer to the in-place workflow.
|
|
@@ -30890,6 +31597,45 @@ async function highlightDiffCode(filePath, lines) {
|
|
|
30890
31597
|
return result;
|
|
30891
31598
|
}
|
|
30892
31599
|
|
|
31600
|
+
/**
|
|
31601
|
+
* Humanize raw AI-provider / LangChain error strings into a short,
|
|
31602
|
+
* actionable line for the compose surface.
|
|
31603
|
+
*
|
|
31604
|
+
* The underlying errors are verbose and developer-facing — e.g.
|
|
31605
|
+
* `executeChain: Chain execution failed: 429 You exceeded your current
|
|
31606
|
+
* quota …`. We classify the common failure modes (rate limit, auth,
|
|
31607
|
+
* network, context length) into a concise message that tells the user
|
|
31608
|
+
* what happened and what to do, and fall back to the original (trimmed)
|
|
31609
|
+
* text for anything we don't recognize. Pure + tested.
|
|
31610
|
+
*/
|
|
31611
|
+
function humanizeAiError(raw) {
|
|
31612
|
+
const message = (raw || '').trim();
|
|
31613
|
+
if (!message)
|
|
31614
|
+
return 'AI request failed.';
|
|
31615
|
+
const lower = message.toLowerCase();
|
|
31616
|
+
// Rate limit / quota — the 429 in the screenshot.
|
|
31617
|
+
if (/\b429\b/.test(message) || /rate.?limit|too many requests|exceeded your current quota|quota/i.test(lower)) {
|
|
31618
|
+
return 'Rate limited by your AI provider (429) — too many requests or quota exceeded. Wait a moment, then press I to retry.';
|
|
31619
|
+
}
|
|
31620
|
+
// Auth / API key problems.
|
|
31621
|
+
if (/\b401\b|\b403\b/.test(message) || /unauthor|forbidden|invalid api key|incorrect api key|no api key|authentication/i.test(lower)) {
|
|
31622
|
+
return 'AI provider rejected the request — check your API key (run `coco init`, or press gK to edit the global config).';
|
|
31623
|
+
}
|
|
31624
|
+
// Context window overflow.
|
|
31625
|
+
if (/context length|maximum context|too many tokens|reduce the length|context_length_exceeded/i.test(lower)) {
|
|
31626
|
+
return 'The staged diff is too large for the model’s context window — stage fewer changes (or split the commit) and retry with I.';
|
|
31627
|
+
}
|
|
31628
|
+
// Network / connectivity.
|
|
31629
|
+
if (/etimedout|econnreset|enotfound|econnrefused|network error|fetch failed|socket hang up|timeout/i.test(lower)) {
|
|
31630
|
+
return 'Network error reaching the AI provider — check your connection, then press I to retry.';
|
|
31631
|
+
}
|
|
31632
|
+
// Unknown: strip the noisy `executeChain: Chain execution failed:`
|
|
31633
|
+
// prefix if present so the meaningful part leads, and keep it to one
|
|
31634
|
+
// line so it doesn't blow out the panel.
|
|
31635
|
+
const stripped = message.replace(/^.*?chain execution failed:\s*/i, '').trim() || message;
|
|
31636
|
+
return stripped.split('\n')[0];
|
|
31637
|
+
}
|
|
31638
|
+
|
|
30893
31639
|
async function runAction$4(action, successMessage) {
|
|
30894
31640
|
try {
|
|
30895
31641
|
await action();
|
|
@@ -30933,15 +31679,104 @@ async function runAction$3(action, successMessage) {
|
|
|
30933
31679
|
};
|
|
30934
31680
|
}
|
|
30935
31681
|
}
|
|
30936
|
-
function createStash(git, message) {
|
|
31682
|
+
function createStash(git, message, options = {}) {
|
|
30937
31683
|
const trimmedMessage = message.trim();
|
|
30938
|
-
|
|
30939
|
-
|
|
30940
|
-
|
|
30941
|
-
|
|
30942
|
-
|
|
31684
|
+
const args = ['stash', 'push'];
|
|
31685
|
+
// `--staged` is index-only, so untracked / `--keep-index` don't apply;
|
|
31686
|
+
// every other mode includes untracked (`-u`). `--keep-index` leaves the
|
|
31687
|
+
// index populated for an immediate follow-up commit.
|
|
31688
|
+
if (options.stagedOnly) {
|
|
31689
|
+
args.push('--staged');
|
|
30943
31690
|
}
|
|
30944
|
-
|
|
31691
|
+
else {
|
|
31692
|
+
args.push('-u');
|
|
31693
|
+
if (options.keepIndex)
|
|
31694
|
+
args.push('--keep-index');
|
|
31695
|
+
}
|
|
31696
|
+
if (trimmedMessage)
|
|
31697
|
+
args.push('-m', trimmedMessage);
|
|
31698
|
+
const paths = options.pathspec?.trim();
|
|
31699
|
+
if (paths)
|
|
31700
|
+
args.push('--', ...paths.split(/\s+/));
|
|
31701
|
+
const what = options.stagedOnly
|
|
31702
|
+
? 'staged changes'
|
|
31703
|
+
: paths
|
|
31704
|
+
? `“${paths}”`
|
|
31705
|
+
: options.keepIndex
|
|
31706
|
+
? 'changes (index kept)'
|
|
31707
|
+
: '';
|
|
31708
|
+
const success = trimmedMessage
|
|
31709
|
+
? `Created stash: ${trimmedMessage}`
|
|
31710
|
+
: what
|
|
31711
|
+
? `Stashed ${what}`
|
|
31712
|
+
: 'Created WIP stash';
|
|
31713
|
+
return runAction$3(() => git.raw(args), success);
|
|
31714
|
+
}
|
|
31715
|
+
/**
|
|
31716
|
+
* Apply a stash while restoring the original staged/unstaged split via
|
|
31717
|
+
* `--index`. Faithfully reinstates what was staged at stash time; git
|
|
31718
|
+
* errors (surfaced to the user) if the index can no longer be replayed,
|
|
31719
|
+
* in which case plain `applyStash` is the fallback.
|
|
31720
|
+
*/
|
|
31721
|
+
function applyStashKeepIndex(git, stash) {
|
|
31722
|
+
return runAction$3(() => git.raw(['stash', 'apply', '--index', stash.ref]), `Applied ${stash.ref} (index restored)`);
|
|
31723
|
+
}
|
|
31724
|
+
/**
|
|
31725
|
+
* Create a new branch from a stash's base commit, apply the stash onto
|
|
31726
|
+
* it, and drop the stash on success — `git stash branch`. The canonical
|
|
31727
|
+
* recovery when a stash no longer applies cleanly onto the current
|
|
31728
|
+
* branch (the branch starts at the exact commit the stash was made on).
|
|
31729
|
+
*/
|
|
31730
|
+
function stashBranch(git, stash, branchName) {
|
|
31731
|
+
const trimmed = branchName.trim();
|
|
31732
|
+
if (!trimmed) {
|
|
31733
|
+
return Promise.resolve({ ok: false, message: 'Cancelled: empty branch name.' });
|
|
31734
|
+
}
|
|
31735
|
+
return runAction$3(() => git.raw(['stash', 'branch', trimmed, stash.ref]), `Created branch ${trimmed} from ${stash.ref}`);
|
|
31736
|
+
}
|
|
31737
|
+
/**
|
|
31738
|
+
* Rename a stash. Git has no native rename, so: drop the original entry,
|
|
31739
|
+
* then re-store the SAME commit under the new message.
|
|
31740
|
+
*
|
|
31741
|
+
* Order matters — and it's the OPPOSITE of what you'd guess. `git stash
|
|
31742
|
+
* store` SILENTLY NO-OPS when the commit is already referenced in the
|
|
31743
|
+
* stash reflog (verified empirically), so storing first does nothing and
|
|
31744
|
+
* a follow-up drop removes the wrong entry. Dropping first removes the
|
|
31745
|
+
* reflog reference (the commit object survives), so the subsequent
|
|
31746
|
+
* `store` actually re-adds it — landing at `stash@{0}` with the new
|
|
31747
|
+
* message. The commit is captured by hash beforehand, so the drop→store
|
|
31748
|
+
* window can't lose it.
|
|
31749
|
+
*/
|
|
31750
|
+
function renameStash(git, stash, newMessage) {
|
|
31751
|
+
const trimmed = newMessage.trim();
|
|
31752
|
+
if (!trimmed) {
|
|
31753
|
+
return Promise.resolve({ ok: false, message: 'Rename cancelled: empty message.' });
|
|
31754
|
+
}
|
|
31755
|
+
if (!stash.hash) {
|
|
31756
|
+
return Promise.resolve({ ok: false, message: 'Cannot rename: stash commit hash unavailable.' });
|
|
31757
|
+
}
|
|
31758
|
+
// Preserve git's `On <branch>: <subject>` convention so the renamed
|
|
31759
|
+
// stash keeps its origin-branch context. The list + inspector parse the
|
|
31760
|
+
// branch out of that prefix (`parseStashSubject`); a bare message would
|
|
31761
|
+
// render `on <unknown>`. Falls back to the bare message when the branch
|
|
31762
|
+
// is unknown so we never store a misleading `On <unknown>:`.
|
|
31763
|
+
const branch = stash.branch && stash.branch !== '<unknown>' ? stash.branch : '';
|
|
31764
|
+
const storedMessage = branch ? `On ${branch}: ${trimmed}` : trimmed;
|
|
31765
|
+
return runAction$3(async () => {
|
|
31766
|
+
await git.raw(['stash', 'drop', stash.ref]);
|
|
31767
|
+
await git.raw(['stash', 'store', '-m', storedMessage, stash.hash]);
|
|
31768
|
+
}, `Renamed ${stash.ref} → ${trimmed}`);
|
|
31769
|
+
}
|
|
31770
|
+
/**
|
|
31771
|
+
* Re-store a previously dropped stash by its commit hash — the undo for
|
|
31772
|
+
* a `dropStash`. The dropped stash's commit stays in the object database
|
|
31773
|
+
* until git gc, so storing it back recreates the entry (at `stash@{0}`).
|
|
31774
|
+
*/
|
|
31775
|
+
function restoreStash(git, hash, message) {
|
|
31776
|
+
if (!hash) {
|
|
31777
|
+
return Promise.resolve({ ok: false, message: 'Nothing to restore.' });
|
|
31778
|
+
}
|
|
31779
|
+
return runAction$3(() => git.raw(['stash', 'store', '-m', message || 'restored stash', hash]), 'Restored dropped stash');
|
|
30945
31780
|
}
|
|
30946
31781
|
function applyStash(git, stash) {
|
|
30947
31782
|
return runAction$3(() => git.raw(['stash', 'apply', stash.ref]), `Applied ${stash.ref}`);
|
|
@@ -31563,13 +32398,14 @@ async function getPullRequestList(git, filter = {}, runner = defaultGhRunner) {
|
|
|
31563
32398
|
message: 'No GitHub remote detected.',
|
|
31564
32399
|
};
|
|
31565
32400
|
}
|
|
31566
|
-
|
|
32401
|
+
const ghStatus = await getGhStatus(runner);
|
|
32402
|
+
if (ghStatus.kind !== 'ok') {
|
|
31567
32403
|
return {
|
|
31568
32404
|
available: true,
|
|
31569
32405
|
authenticated: false,
|
|
31570
32406
|
repository,
|
|
31571
32407
|
filter,
|
|
31572
|
-
message:
|
|
32408
|
+
message: describeGhStatus(ghStatus),
|
|
31573
32409
|
};
|
|
31574
32410
|
}
|
|
31575
32411
|
try {
|
|
@@ -31814,6 +32650,28 @@ function unstageAllFiles(git, files) {
|
|
|
31814
32650
|
}
|
|
31815
32651
|
return runAction(() => git.raw(['restore', '--staged', '--', ...files.map((file) => file.path)]), `Unstaged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
|
|
31816
32652
|
}
|
|
32653
|
+
/**
|
|
32654
|
+
* Stage everything in the worktree — modifications, new files, and
|
|
32655
|
+
* deletions — in one shot (`git add -A`). The `A` hotkey + the `:`
|
|
32656
|
+
* palette's "stage all" both route here.
|
|
32657
|
+
*/
|
|
32658
|
+
function stageAll(git) {
|
|
32659
|
+
return runAction(() => git.raw(['add', '-A']), 'Staged all changes');
|
|
32660
|
+
}
|
|
32661
|
+
/**
|
|
32662
|
+
* Stage files matching one or more git pathspecs (`git add -- <spec…>`).
|
|
32663
|
+
* Powers the typed "stage…" prompt (`+`): the user types a path, a
|
|
32664
|
+
* directory, a glob like `*.ts`, or a space-separated list, and git's
|
|
32665
|
+
* own pathspec matching does the rest. Args are passed directly (no
|
|
32666
|
+
* shell), so the globs are interpreted by git, not the shell.
|
|
32667
|
+
*/
|
|
32668
|
+
function stagePathspec(git, pathspec) {
|
|
32669
|
+
const specs = pathspec.trim().split(/\s+/).filter(Boolean);
|
|
32670
|
+
if (specs.length === 0) {
|
|
32671
|
+
return Promise.resolve({ ok: false, message: 'Enter a pathspec to stage (e.g. . or src/ or *.ts).' });
|
|
32672
|
+
}
|
|
32673
|
+
return runAction(() => git.raw(['add', '--', ...specs]), `Staged ${specs.join(' ')}`);
|
|
32674
|
+
}
|
|
31817
32675
|
|
|
31818
32676
|
function hunkHeader(hunk) {
|
|
31819
32677
|
return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
|
|
@@ -32390,7 +33248,7 @@ function buildLoadedHashSet(commits) {
|
|
|
32390
33248
|
* 5a.7 of #890. Two-row layout introduced post-0.54.2; per-kind
|
|
32391
33249
|
* colors + glyphs added in the same pass.
|
|
32392
33250
|
*/
|
|
32393
|
-
function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFrame = 0) {
|
|
33251
|
+
function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFrame = 0, singlePane = false) {
|
|
32394
33252
|
const { Box, Text } = components;
|
|
32395
33253
|
// Sidebar item count drives the per-tab footer hints — when items are
|
|
32396
33254
|
// present the footer surfaces in-sidebar ops (checkout / apply / pop /
|
|
@@ -32404,6 +33262,24 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
|
|
|
32404
33262
|
default: return undefined;
|
|
32405
33263
|
}
|
|
32406
33264
|
})();
|
|
33265
|
+
// The single-pane pane switcher only makes sense in the plain
|
|
33266
|
+
// per-pane states. While an overlay or filter owns the screen the
|
|
33267
|
+
// visible pane is forced (split-plan → main; help / palette / theme /
|
|
33268
|
+
// gitignore / input prompt / confirmation / chord → inspector) or
|
|
33269
|
+
// input is captured, and Tab does something else — so the switcher
|
|
33270
|
+
// would point at a pane that isn't on screen. Suppress it then. Mirror
|
|
33271
|
+
// of the runtime's `forcedPane` derivation in `app.ts`.
|
|
33272
|
+
const overlayForcesPane = Boolean(state.splitPlan ||
|
|
33273
|
+
state.showHelp ||
|
|
33274
|
+
state.showViewKeys ||
|
|
33275
|
+
state.showCommandPalette ||
|
|
33276
|
+
state.showThemePicker ||
|
|
33277
|
+
state.gitignorePicker ||
|
|
33278
|
+
state.inputPrompt ||
|
|
33279
|
+
state.pendingConfirmationId ||
|
|
33280
|
+
state.pendingMutationConfirmation ||
|
|
33281
|
+
state.pendingKey ||
|
|
33282
|
+
state.filterMode);
|
|
32407
33283
|
const hints = getLogInkFooterHints({
|
|
32408
33284
|
activeView: state.activeView,
|
|
32409
33285
|
diffSource: state.diffSource,
|
|
@@ -32417,6 +33293,12 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
|
|
|
32417
33293
|
sidebarItemCount,
|
|
32418
33294
|
compareBaseSet: Boolean(state.compareBase),
|
|
32419
33295
|
splitPlanStatus: state.splitPlan?.status,
|
|
33296
|
+
singlePane: singlePane && !overlayForcesPane,
|
|
33297
|
+
// Peeking (#1135 v2) is a single-pane glance with focus on the
|
|
33298
|
+
// sidebar; the footer shows `v/esc → main` instead of the switcher.
|
|
33299
|
+
// Suppressed under an overlay (which owns the footer) just like the
|
|
33300
|
+
// switcher.
|
|
33301
|
+
peeking: Boolean(state.peekReturnFocus) && singlePane && !overlayForcesPane,
|
|
32420
33302
|
});
|
|
32421
33303
|
// Real status messages always win; idle tips only fill the slot when it
|
|
32422
33304
|
// would otherwise be empty.
|
|
@@ -32912,7 +33794,10 @@ function sidebarTabCount(tab, context) {
|
|
|
32912
33794
|
* Header chip builder. Turns the workstation's title-bar state into an
|
|
32913
33795
|
* ordered list of small visually-distinct chips:
|
|
32914
33796
|
*
|
|
32915
|
-
* coco · gfargo/coco · ⎇ main · ✓ clean ·
|
|
33797
|
+
* coco · gfargo/coco · ⎇ main · ✓ clean · [NORMAL]
|
|
33798
|
+
*
|
|
33799
|
+
* The PR chip is appended only when a pull request exists (#1133); there
|
|
33800
|
+
* is no "no PR" placeholder chip.
|
|
32916
33801
|
*
|
|
32917
33802
|
* Pre-refactor the title bar concatenated every segment into a single
|
|
32918
33803
|
* Text span, which made the eye read the whole thing as one run of
|
|
@@ -33008,10 +33893,11 @@ function buildHeaderChips(input) {
|
|
|
33008
33893
|
bold: true,
|
|
33009
33894
|
});
|
|
33010
33895
|
}
|
|
33011
|
-
// PR state.
|
|
33012
|
-
// label ("PR #1234 OPEN" / "PR #1234 DRAFT").
|
|
33013
|
-
// "no PR" chip
|
|
33014
|
-
//
|
|
33896
|
+
// PR state. Shown only when a PR actually exists — the chip uses the
|
|
33897
|
+
// PR-state glyph + a short label ("PR #1234 OPEN" / "PR #1234 DRAFT").
|
|
33898
|
+
// The old always-on "no PR" chip spent a permanent header segment to
|
|
33899
|
+
// report a negative default state on every screen; dropping it keeps
|
|
33900
|
+
// the state cluster about what *is* true (TUI audit).
|
|
33015
33901
|
if (input.pullRequest) {
|
|
33016
33902
|
const prGlyph = getPullRequestStateGlyph({ ...input.pullRequest, isDraft: Boolean(input.pullRequest.isDraft) }, theme);
|
|
33017
33903
|
const stateLabel = input.pullRequest.isDraft
|
|
@@ -33028,15 +33914,6 @@ function buildHeaderChips(input) {
|
|
|
33028
33914
|
bold: false,
|
|
33029
33915
|
});
|
|
33030
33916
|
}
|
|
33031
|
-
else {
|
|
33032
|
-
chips.push({
|
|
33033
|
-
id: 'pr',
|
|
33034
|
-
label: theme.ascii ? '- no PR' : '⊘ no PR',
|
|
33035
|
-
color: theme.colors.muted,
|
|
33036
|
-
dim: true,
|
|
33037
|
-
bold: false,
|
|
33038
|
-
});
|
|
33039
|
-
}
|
|
33040
33917
|
// View breadcrumb. Rendered only when there's content (`coco ui`
|
|
33041
33918
|
// root view → no breadcrumb chip; pushed into a sub-view → chip
|
|
33042
33919
|
// appears). Comes AFTER PR so the "state" group (app/repo/branch/
|
|
@@ -33107,7 +33984,10 @@ function measureHeaderChipsWidth(chips) {
|
|
|
33107
33984
|
* Title-bar renderer. Surfaces the workstation's identity + navigation
|
|
33108
33985
|
* state as a row of small visually-distinct chips:
|
|
33109
33986
|
*
|
|
33110
|
-
* coco · gfargo/coco · ⎇ main · ✓ clean ·
|
|
33987
|
+
* coco · gfargo/coco · ⎇ main · ✓ clean · [NORMAL]
|
|
33988
|
+
*
|
|
33989
|
+
* The PR chip is appended only when a pull request exists (e.g.
|
|
33990
|
+
* `· ⊠ PR #1234 OPEN`); there's no "no PR" placeholder chip.
|
|
33111
33991
|
*
|
|
33112
33992
|
* Per-chip color/glyph treatment lets the user scan in chunks ("what
|
|
33113
33993
|
* app, what repo, what branch, how clean, what PR state, what mode")
|
|
@@ -33547,70 +34427,10 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
33547
34427
|
return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
|
|
33548
34428
|
}, 'tab-worktrees', visibleListCount);
|
|
33549
34429
|
}
|
|
33550
|
-
|
|
33551
|
-
* Single-letter glyph for a sidebar tab in rail mode. Letters always
|
|
33552
|
-
* carry the meaning so this stays useful under ASCII; the rail is too
|
|
33553
|
-
* narrow to fit the full tab label. Pairs with `sidebarTabCount` for
|
|
33554
|
-
* the trailing count.
|
|
33555
|
-
*/
|
|
33556
|
-
function sidebarTabRailGlyph(tab) {
|
|
33557
|
-
switch (tab) {
|
|
33558
|
-
case 'status':
|
|
33559
|
-
return 'S';
|
|
33560
|
-
case 'branches':
|
|
33561
|
-
return 'B';
|
|
33562
|
-
case 'tags':
|
|
33563
|
-
return 'T';
|
|
33564
|
-
case 'stashes':
|
|
33565
|
-
return '$';
|
|
33566
|
-
case 'worktrees':
|
|
33567
|
-
return 'W';
|
|
33568
|
-
default:
|
|
33569
|
-
return '·';
|
|
33570
|
-
}
|
|
33571
|
-
}
|
|
33572
|
-
/**
|
|
33573
|
-
* Rail-mode sidebar — shown on terminals < 100 columns when the
|
|
33574
|
-
* sidebar does not hold focus. Five vertically stacked tab glyphs
|
|
33575
|
-
* with optional counts; the active tab is bracketed. Pressing Tab to
|
|
33576
|
-
* focus the sidebar pops it back to the full accordion (the layout
|
|
33577
|
-
* un-rails it on focus, this renderer is never called in that case).
|
|
33578
|
-
*/
|
|
33579
|
-
function renderSidebarRail$1(h, components, state, context, width, theme, focused, tabs) {
|
|
33580
|
-
const { Box, Text } = components;
|
|
33581
|
-
return h(Box, {
|
|
33582
|
-
borderColor: focusBorderColor(theme, focused),
|
|
33583
|
-
borderStyle: theme.borderStyle,
|
|
33584
|
-
flexDirection: 'column',
|
|
33585
|
-
width,
|
|
33586
|
-
paddingX: 1,
|
|
33587
|
-
}, h(Text, { bold: true, dimColor: !focused }, 'Repo'), h(Text, { dimColor: true }, '────'), ...tabs.map((tab) => {
|
|
33588
|
-
const isActive = tab === state.sidebarTab;
|
|
33589
|
-
const glyph = sidebarTabRailGlyph(tab);
|
|
33590
|
-
const count = sidebarTabCount(tab, context);
|
|
33591
|
-
// Count fits in 2 cells (rail content area is ~4 cells); 99+
|
|
33592
|
-
// collapses to `+` so we never overflow.
|
|
33593
|
-
const countText = count === undefined
|
|
33594
|
-
? ''
|
|
33595
|
-
: count > 99
|
|
33596
|
-
? '+'
|
|
33597
|
-
: String(count);
|
|
33598
|
-
const body = isActive ? `[${glyph}]` : ` ${glyph} `;
|
|
33599
|
-
const text = countText ? `${body}${countText}` : body;
|
|
33600
|
-
return h(Text, {
|
|
33601
|
-
key: `rail-${tab}`,
|
|
33602
|
-
bold: isActive,
|
|
33603
|
-
dimColor: !isActive,
|
|
33604
|
-
}, text);
|
|
33605
|
-
}));
|
|
33606
|
-
}
|
|
33607
|
-
function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme, railed = false) {
|
|
34430
|
+
function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme) {
|
|
33608
34431
|
const { Box, Text } = components;
|
|
33609
34432
|
const focused = state.focus === 'sidebar';
|
|
33610
34433
|
const tabs = getLogInkSidebarTabs();
|
|
33611
|
-
if (railed) {
|
|
33612
|
-
return renderSidebarRail$1(h, components, state, context, width, theme, focused, tabs);
|
|
33613
|
-
}
|
|
33614
34434
|
// Accordion layout — every tab's title is visible on its own line, but
|
|
33615
34435
|
// only the active tab expands its content underneath. Switching tabs
|
|
33616
34436
|
// (1-5 / [/]) collapses the previous and expands the next.
|
|
@@ -33669,7 +34489,8 @@ function renderSidebar$1(h, components, state, context, contextStatus, width, bo
|
|
|
33669
34489
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
33670
34490
|
* of #890. No behavior change.
|
|
33671
34491
|
*/
|
|
33672
|
-
function renderBisectSurface(
|
|
34492
|
+
function renderBisectSurface(ctx, candidateDetail, candidateLoading) {
|
|
34493
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
33673
34494
|
const { Box, Text } = components;
|
|
33674
34495
|
const focused = state.focus === 'commits';
|
|
33675
34496
|
const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
|
|
@@ -33993,7 +34814,8 @@ function formatLogInkGitHubNoRemote({ resource, }) {
|
|
|
33993
34814
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
33994
34815
|
* of #890. No behavior change.
|
|
33995
34816
|
*/
|
|
33996
|
-
function renderBranchesSurface(
|
|
34817
|
+
function renderBranchesSurface(ctx) {
|
|
34818
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
33997
34819
|
const { Box, Text } = components;
|
|
33998
34820
|
const focused = state.focus === 'commits';
|
|
33999
34821
|
const branches = context.branches;
|
|
@@ -34139,7 +34961,8 @@ function formatCacheAge(generatedAt, now) {
|
|
|
34139
34961
|
const day = Math.floor(hr / 24);
|
|
34140
34962
|
return `${day}d ago`;
|
|
34141
34963
|
}
|
|
34142
|
-
function renderChangelogSurface(
|
|
34964
|
+
function renderChangelogSurface(ctx) {
|
|
34965
|
+
const { h, components, state, bodyRows, width, theme } = ctx;
|
|
34143
34966
|
const { Box, Text } = components;
|
|
34144
34967
|
const focused = state.focus === 'commits';
|
|
34145
34968
|
const view = state.changelogView;
|
|
@@ -34331,7 +35154,8 @@ function renderStreamingPreviewLines(h, components, preview, width, theme) {
|
|
|
34331
35154
|
}, `${prefix}${line}`);
|
|
34332
35155
|
});
|
|
34333
35156
|
}
|
|
34334
|
-
function renderComposeSurface(
|
|
35157
|
+
function renderComposeSurface(ctx, spinnerFrame = 0) {
|
|
35158
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
34335
35159
|
const { Box, Text } = components;
|
|
34336
35160
|
const compose = state.commitCompose;
|
|
34337
35161
|
const focused = state.focus === 'commits';
|
|
@@ -34352,8 +35176,16 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
|
|
|
34352
35176
|
const bodyVisualLines = compose.body
|
|
34353
35177
|
? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, bodyRowsAvailable)
|
|
34354
35178
|
: ['<empty>'];
|
|
34355
|
-
|
|
34356
|
-
)
|
|
35179
|
+
// Summary now renders on its own indented line under the label (like the
|
|
35180
|
+
// body), so it wraps at the full content width instead of the cramped
|
|
35181
|
+
// "Summary " (9) + chrome budget it had when label and value shared a row.
|
|
35182
|
+
const summaryVisualLines = compose.summary
|
|
35183
|
+
? compose.summary.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth))
|
|
35184
|
+
: ['<empty>'];
|
|
35185
|
+
// Subject length drives a subtle counter on the Summary label: dim under
|
|
35186
|
+
// 50, warning past the conventional 50-char soft limit, danger past 72.
|
|
35187
|
+
// Counted in code points so multibyte subjects aren't over-counted.
|
|
35188
|
+
const summaryLength = [...compose.summary].length;
|
|
34357
35189
|
// State-line cycles through three modes (#881 phase 3 added the
|
|
34358
35190
|
// loading variant): editing copy when the user is typing, cancel
|
|
34359
35191
|
// hint when an AI draft is generating, default guidance otherwise.
|
|
@@ -34373,6 +35205,52 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
|
|
|
34373
35205
|
const noStagedHint = !isLogInkContextKeyLoading(contextStatus, 'worktree')
|
|
34374
35206
|
? formatLogInkComposeEmpty({ hasStaged: hasStagedFiles })
|
|
34375
35207
|
: undefined;
|
|
35208
|
+
// Section header for a field (Summary / Body). The active field's label
|
|
35209
|
+
// carries an arrow marker + the repo's selection highlight (matching the
|
|
35210
|
+
// status surface, see status/index.ts) so the user can see which field
|
|
35211
|
+
// their keystrokes target — even before entering edit mode, and even
|
|
35212
|
+
// under NO_COLOR where the marker + bold/dim carry the signal alone. An
|
|
35213
|
+
// optional length counter (Summary only) trails the label outside the
|
|
35214
|
+
// highlight so its own warning/danger color stays legible.
|
|
35215
|
+
const renderSectionHeader = (name, field, count) => {
|
|
35216
|
+
const active = compose.field === field;
|
|
35217
|
+
const highlight = active && focused && !theme.noColor;
|
|
35218
|
+
const marker = active ? (theme.ascii ? '> ' : '▸ ') : ' ';
|
|
35219
|
+
const badge = active && compose.editing ? ' EDITING' : '';
|
|
35220
|
+
const children = [
|
|
35221
|
+
h(Text, {
|
|
35222
|
+
key: `compose-${field}-label`,
|
|
35223
|
+
bold: active,
|
|
35224
|
+
dimColor: !active,
|
|
35225
|
+
backgroundColor: highlight ? theme.colors.selection : undefined,
|
|
35226
|
+
color: highlight ? theme.colors.selectionForeground : undefined,
|
|
35227
|
+
}, `${marker}${name}${badge}`),
|
|
35228
|
+
];
|
|
35229
|
+
if (count !== undefined) {
|
|
35230
|
+
const countColor = theme.noColor
|
|
35231
|
+
? undefined
|
|
35232
|
+
: count > 72
|
|
35233
|
+
? theme.colors.danger
|
|
35234
|
+
: count > 50
|
|
35235
|
+
? theme.colors.warning
|
|
35236
|
+
: undefined;
|
|
35237
|
+
children.push(h(Text, {
|
|
35238
|
+
key: `compose-${field}-count`,
|
|
35239
|
+
color: countColor,
|
|
35240
|
+
dimColor: countColor === undefined,
|
|
35241
|
+
}, ` ${count}`));
|
|
35242
|
+
}
|
|
35243
|
+
return h(Box, { key: `compose-${field}-header` }, ...children);
|
|
35244
|
+
};
|
|
35245
|
+
// Content lines for a field — indented two cells under the header, with
|
|
35246
|
+
// the edit cursor parked on the final line when this field is active.
|
|
35247
|
+
const renderSectionContent = (lines, field, cursor) => lines.map((line, index) => {
|
|
35248
|
+
const isLast = index === lines.length - 1;
|
|
35249
|
+
return h(Text, {
|
|
35250
|
+
key: `compose-${field}-${index}`,
|
|
35251
|
+
dimColor: line === '<empty>',
|
|
35252
|
+
}, ` ${line}${cursor && isLast ? cursor : ''}`);
|
|
35253
|
+
});
|
|
34376
35254
|
return h(Box, {
|
|
34377
35255
|
borderColor: focusBorderColor(theme, focused),
|
|
34378
35256
|
borderStyle: theme.borderStyle,
|
|
@@ -34380,20 +35258,7 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
|
|
|
34380
35258
|
flexShrink: 0,
|
|
34381
35259
|
paddingX: 1,
|
|
34382
35260
|
width,
|
|
34383
|
-
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Compose commit', focused)), h(Text, { dimColor: true }, statusLine)), h(Text, undefined, ''), h(Text,
|
|
34384
|
-
bold: compose.field === 'summary' && compose.editing,
|
|
34385
|
-
}, `Summary ${summaryVisualLines[0] || ''}`), ...summaryVisualLines.slice(1).map((line, index) => h(Text, {
|
|
34386
|
-
key: `compose-summary-${index}`,
|
|
34387
|
-
bold: compose.field === 'summary' && compose.editing,
|
|
34388
|
-
}, ` ${line}`)), h(Text, undefined, ''), h(Text, {
|
|
34389
|
-
bold: compose.field === 'body' && compose.editing,
|
|
34390
|
-
}, 'Body'), ...bodyVisualLines.map((line, index) => {
|
|
34391
|
-
const isLast = index === bodyVisualLines.length - 1;
|
|
34392
|
-
return h(Text, {
|
|
34393
|
-
key: `compose-body-${index}`,
|
|
34394
|
-
dimColor: line === '<empty>',
|
|
34395
|
-
}, ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`);
|
|
34396
|
-
}),
|
|
35261
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Compose commit', focused)), h(Text, { dimColor: true }, statusLine)), h(Text, undefined, ''), renderSectionHeader('Summary', 'summary', summaryLength > 0 ? summaryLength : undefined), ...renderSectionContent(summaryVisualLines, 'summary', summaryCursor), h(Text, undefined, ''), renderSectionHeader('Body', 'body'), ...renderSectionContent(bodyVisualLines, 'body', bodyCursor),
|
|
34397
35262
|
// Loading indicator + post-action message belong inline with the draft
|
|
34398
35263
|
// (they describe what just happened to the fields above). The state-
|
|
34399
35264
|
// line ("Editing — Enter switches summary↔body…" / "Press e to edit
|
|
@@ -34444,7 +35309,8 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
|
|
|
34444
35309
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.3
|
|
34445
35310
|
* of #890. No behavior change.
|
|
34446
35311
|
*/
|
|
34447
|
-
function renderConflictsSurface(
|
|
35312
|
+
function renderConflictsSurface(ctx) {
|
|
35313
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
34448
35314
|
const { Box, Text } = components;
|
|
34449
35315
|
const focused = state.focus === 'commits';
|
|
34450
35316
|
const loading = isLogInkContextKeyLoading(contextStatus, 'operation');
|
|
@@ -34913,6 +35779,50 @@ function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
|
|
|
34913
35779
|
return h(Text, { key }, h(Text, { key: `${key}-m`, color: markerColor }, marker), ...children);
|
|
34914
35780
|
}
|
|
34915
35781
|
|
|
35782
|
+
/** The hunk index owning `absLine`, or -1 for pre-hunk header/label rows. */
|
|
35783
|
+
function hunkIndexForLine(absLine, hunkOffsets) {
|
|
35784
|
+
let index = -1;
|
|
35785
|
+
for (let k = 0; k < hunkOffsets.length; k++) {
|
|
35786
|
+
if (hunkOffsets[k] <= absLine)
|
|
35787
|
+
index = k;
|
|
35788
|
+
else
|
|
35789
|
+
break;
|
|
35790
|
+
}
|
|
35791
|
+
return index;
|
|
35792
|
+
}
|
|
35793
|
+
function renderWorktreeDiffBody(h, components, params) {
|
|
35794
|
+
const { Box, Text } = components;
|
|
35795
|
+
const { lines, offset, visibleRows, width, theme, syntaxSpans, hunkOffsets, hunks, selectedIndex, keyPrefix } = params;
|
|
35796
|
+
const headerSet = new Set(hunkOffsets);
|
|
35797
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
35798
|
+
const added = theme.noColor ? undefined : theme.colors.gitAdded;
|
|
35799
|
+
const codeWidth = Math.max(8, width - 5); // 2 chrome + 1 gutter + slack
|
|
35800
|
+
const visible = lines.slice(offset, offset + visibleRows);
|
|
35801
|
+
return visible.map((line, i) => {
|
|
35802
|
+
const abs = offset + i;
|
|
35803
|
+
const key = `${keyPrefix}-${abs}`;
|
|
35804
|
+
const hunkIndex = hunkIndexForLine(abs, hunkOffsets);
|
|
35805
|
+
const hunk = hunkIndex >= 0 ? hunks[hunkIndex] : undefined;
|
|
35806
|
+
const isSelected = hunkIndex >= 0 && hunkIndex === selectedIndex;
|
|
35807
|
+
const isStaged = hunk?.state === 'staged';
|
|
35808
|
+
const bar = isSelected ? '▎' : ' ';
|
|
35809
|
+
// `@@` header row — badge + (dim) hunk position, emphasized when selected.
|
|
35810
|
+
if (headerSet.has(abs)) {
|
|
35811
|
+
const badge = theme.ascii ? (isStaged ? '[x] ' : '[ ] ') : (isStaged ? '● ' : '○ ');
|
|
35812
|
+
const badgeColor = theme.noColor ? undefined : isStaged ? added : theme.colors.muted;
|
|
35813
|
+
return h(Box, { key, flexDirection: 'row' }, h(Text, { color: accent }, bar), h(Text, { color: badgeColor, bold: isSelected }, badge), h(Text, { bold: isSelected, color: isSelected ? accent : (theme.noColor ? undefined : theme.colors.muted) }, truncateCells(line, codeWidth)));
|
|
35814
|
+
}
|
|
35815
|
+
// Body / context / pre-hunk lines.
|
|
35816
|
+
// A staged hunk that ISN'T selected renders dim ("done", out of
|
|
35817
|
+
// focus); the selected hunk and unstaged hunks keep full diff +
|
|
35818
|
+
// syntax coloring via renderDiffLine so the focus stays vivid.
|
|
35819
|
+
const content = isStaged && !isSelected && hunkIndex >= 0
|
|
35820
|
+
? h(Text, { key: `${key}-c`, dimColor: true }, truncateCells(line, codeWidth))
|
|
35821
|
+
: renderDiffLine(h, Text, line, theme, syntaxSpans, codeWidth, `${key}-c`);
|
|
35822
|
+
return h(Box, { key, flexDirection: 'row' }, h(Text, { color: accent }, bar), content);
|
|
35823
|
+
});
|
|
35824
|
+
}
|
|
35825
|
+
|
|
34916
35826
|
/**
|
|
34917
35827
|
* Diff surface — the unified or side-by-side diff view. Four sources
|
|
34918
35828
|
* route through here, disambiguated by `state.diffSource`:
|
|
@@ -34935,7 +35845,9 @@ function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
|
|
|
34935
35845
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.4
|
|
34936
35846
|
* of #890. No behavior change.
|
|
34937
35847
|
*/
|
|
34938
|
-
function renderDiffSurface(
|
|
35848
|
+
function renderDiffSurface(ctx, diff) {
|
|
35849
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
35850
|
+
const { worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, syntaxSpans, } = diff;
|
|
34939
35851
|
const { Box, Text } = components;
|
|
34940
35852
|
const focused = state.focus === 'commits';
|
|
34941
35853
|
const worktree = context.worktree;
|
|
@@ -35135,7 +36047,22 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
35135
36047
|
}
|
|
35136
36048
|
const diffLines = worktreeDiff?.lines || [];
|
|
35137
36049
|
const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
|
|
36050
|
+
const totalHunks = worktreeHunks?.hunks.length ?? 0;
|
|
36051
|
+
const stagedHunks = worktreeHunks?.hunks.filter((hunk) => hunk.state === 'staged').length ?? 0;
|
|
35138
36052
|
const visibleDiffLines = diffLines.slice(state.worktreeDiffOffset, state.worktreeDiffOffset + visibleRows);
|
|
36053
|
+
// Hunk-position line: badge + selected hunk's state + a staged/total
|
|
36054
|
+
// progress count, so the user always sees how far through staging they
|
|
36055
|
+
// are. Untracked/new files have no hunks — point them at whole-file
|
|
36056
|
+
// staging instead of a dead-end "no hunks" message.
|
|
36057
|
+
const hunkHeaderLine = worktreeHunksLoading
|
|
36058
|
+
? 'Hunks loading…'
|
|
36059
|
+
: worktreeDiff?.untracked
|
|
36060
|
+
? (theme.ascii ? 'New file — press space to stage it whole.' : '✚ New file — press space to stage it whole.')
|
|
36061
|
+
: totalHunks
|
|
36062
|
+
? `Hunk ${state.selectedWorktreeHunkIndex + 1}/${totalHunks} · ${selectedHunk?.state === 'staged'
|
|
36063
|
+
? (theme.ascii ? '[x] staged' : '● staged')
|
|
36064
|
+
: (theme.ascii ? '[ ] unstaged' : '○ unstaged')} · ${stagedHunks}/${totalHunks} staged`
|
|
36065
|
+
: 'No stageable hunks for this file.';
|
|
35139
36066
|
const headerLines = isLogInkContextKeyLoading(contextStatus, 'worktree')
|
|
35140
36067
|
? ['Loading file context...']
|
|
35141
36068
|
: worktreeDiffLoading
|
|
@@ -35144,11 +36071,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
35144
36071
|
? [
|
|
35145
36072
|
// File path is already shown in the panel title bar (right) —
|
|
35146
36073
|
// no redundant "Selected file:" line here.
|
|
35147
|
-
|
|
35148
|
-
? 'Hunks loading...'
|
|
35149
|
-
: worktreeHunks?.hunks.length
|
|
35150
|
-
? `Hunk ${state.selectedWorktreeHunkIndex + 1}/${worktreeHunks.hunks.length} ${selectedHunk?.state || ''}`
|
|
35151
|
-
: 'No stageable hunks for this file.',
|
|
36074
|
+
hunkHeaderLine,
|
|
35152
36075
|
`Lines ${Math.min(state.worktreeDiffOffset + 1, diffLines.length || 1)}-${Math.min(state.worktreeDiffOffset + visibleDiffLines.length, diffLines.length)}/${diffLines.length}`,
|
|
35153
36076
|
'',
|
|
35154
36077
|
]
|
|
@@ -35163,11 +36086,26 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
35163
36086
|
flexShrink: 0,
|
|
35164
36087
|
paddingX: 1,
|
|
35165
36088
|
width,
|
|
35166
|
-
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)),
|
|
36089
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)),
|
|
36090
|
+
// Use the path of the file actually being diffed (the grouped/visible
|
|
36091
|
+
// selection feeds the loaded diff) — `worktreeFile` indexes the raw,
|
|
36092
|
+
// ungrouped file list and can name a different file than the diff body.
|
|
36093
|
+
h(Text, { dimColor: true }, worktreeDiff?.filePath || worktreeFile?.path || 'no file')), ...headerLines.map((line, index) => h(Text, {
|
|
35167
36094
|
key: `diff-surface-header-${index}`,
|
|
35168
36095
|
dimColor: index > 0,
|
|
35169
36096
|
}, truncateCells(line, 140))), ...(showDiffLines
|
|
35170
|
-
?
|
|
36097
|
+
? renderWorktreeDiffBody(h, components, {
|
|
36098
|
+
lines: diffLines,
|
|
36099
|
+
offset: state.worktreeDiffOffset,
|
|
36100
|
+
visibleRows,
|
|
36101
|
+
width,
|
|
36102
|
+
theme,
|
|
36103
|
+
syntaxSpans,
|
|
36104
|
+
hunkOffsets: worktreeDiff?.hunkOffsets || [],
|
|
36105
|
+
hunks: worktreeHunks?.hunks || [],
|
|
36106
|
+
selectedIndex: state.selectedWorktreeHunkIndex,
|
|
36107
|
+
keyPrefix: 'diff-surface-line',
|
|
36108
|
+
})
|
|
35171
36109
|
: []));
|
|
35172
36110
|
}
|
|
35173
36111
|
|
|
@@ -36351,7 +37289,8 @@ function renderRemoteOpLoader(h, components, state, width, bodyRows, theme, focu
|
|
|
36351
37289
|
height: innerHeight,
|
|
36352
37290
|
}, h(Text, { color: accent, bold: true }, `${spinner} ${op.label}`), h(Text, undefined, ''), h(Text, { color: accent }, track), h(Text, undefined, ''), h(Text, { dimColor: true }, 'Talking to the remote — history refreshes automatically.')));
|
|
36353
37291
|
}
|
|
36354
|
-
function renderHistoryPanel(
|
|
37292
|
+
function renderHistoryPanel(ctx, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = getRenderNow(), spinnerFrame = 0) {
|
|
37293
|
+
const { h, components, state, context, bodyRows, width, theme } = ctx;
|
|
36355
37294
|
const { Box, Text } = components;
|
|
36356
37295
|
const focused = state.focus === 'commits';
|
|
36357
37296
|
// Remote op in flight (fetch / pull / push) → swap the commit list
|
|
@@ -36657,6 +37596,54 @@ function renderChordOverlay(h, components, state, width, theme, focused) {
|
|
|
36657
37596
|
paddingX: 1,
|
|
36658
37597
|
}, ...lines);
|
|
36659
37598
|
}
|
|
37599
|
+
/**
|
|
37600
|
+
* Which-key view-keys strip (#1137). The per-view counterpart to the
|
|
37601
|
+
* `g`-chord overlay: opened by `g?`, it lists the single-key actions
|
|
37602
|
+
* available in the current view (the deliberate overloads — `c`, `R`,
|
|
37603
|
+
* `a`, `m`, `S`, `[`/`]`, …) with their labels, sourced from
|
|
37604
|
+
* `LOG_INK_KEY_BINDINGS` filtered by the active view + focus.
|
|
37605
|
+
*
|
|
37606
|
+
* Renders in the detail panel slot like the chord overlay. `?` steps up
|
|
37607
|
+
* to the full categorized help; Esc closes.
|
|
37608
|
+
*/
|
|
37609
|
+
function renderViewKeysOverlay(h, components, state, width, theme, focused) {
|
|
37610
|
+
const { Box, Text } = components;
|
|
37611
|
+
const bindings = getLogInkViewKeyBindings({
|
|
37612
|
+
activeView: state.activeView,
|
|
37613
|
+
focus: state.focus,
|
|
37614
|
+
});
|
|
37615
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
37616
|
+
const lines = [
|
|
37617
|
+
h(Text, { key: 'view-keys-title', bold: true }, panelTitle(`keys · ${state.activeView}`, focused)),
|
|
37618
|
+
h(Text, { key: 'view-keys-spacer' }, ''),
|
|
37619
|
+
];
|
|
37620
|
+
if (bindings.length === 0) {
|
|
37621
|
+
lines.push(h(Text, {
|
|
37622
|
+
key: 'view-keys-empty',
|
|
37623
|
+
dimColor: true,
|
|
37624
|
+
}, truncateCells('No single-key actions in this view — use ? for the full help.', width - 4)));
|
|
37625
|
+
}
|
|
37626
|
+
else {
|
|
37627
|
+
// Pad keys to the widest entry so labels align into a scannable column.
|
|
37628
|
+
const keyColumn = bindings.reduce((max, binding) => Math.max(max, formatBindingBareKeys(binding).length), 0);
|
|
37629
|
+
for (const binding of bindings) {
|
|
37630
|
+
const keys = formatBindingBareKeys(binding);
|
|
37631
|
+
lines.push(h(Text, { key: `view-keys-${binding.id}` }, h(Text, { color: accent, bold: true }, ` ${keys.padEnd(keyColumn)} `), h(Text, undefined, truncateCells(`${binding.label.padEnd(14)} ${binding.description}`, width - keyColumn - 7))));
|
|
37632
|
+
}
|
|
37633
|
+
}
|
|
37634
|
+
lines.push(h(Text, { key: 'view-keys-foot-spacer' }, ''));
|
|
37635
|
+
lines.push(h(Text, {
|
|
37636
|
+
key: 'view-keys-hint',
|
|
37637
|
+
dimColor: true,
|
|
37638
|
+
}, truncateCells('? full help · esc closes', width - 4)));
|
|
37639
|
+
return h(Box, {
|
|
37640
|
+
borderColor: focusBorderColor(theme, focused),
|
|
37641
|
+
borderStyle: theme.borderStyle,
|
|
37642
|
+
flexDirection: 'column',
|
|
37643
|
+
width,
|
|
37644
|
+
paddingX: 1,
|
|
37645
|
+
}, ...lines);
|
|
37646
|
+
}
|
|
36660
37647
|
function renderHelpPanel(h, components, state, width, theme, focused, bodyRows = 0) {
|
|
36661
37648
|
const { Box, Text } = components;
|
|
36662
37649
|
// Build the full list of body rows (everything below the title).
|
|
@@ -37053,7 +38040,8 @@ function matchesIssueFilter(issue, filter) {
|
|
|
37053
38040
|
...(issue.assignees || []),
|
|
37054
38041
|
], filter);
|
|
37055
38042
|
}
|
|
37056
|
-
function renderIssuesTriageSurface(
|
|
38043
|
+
function renderIssuesTriageSurface(ctx) {
|
|
38044
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37057
38045
|
const { Box, Text } = components;
|
|
37058
38046
|
const focused = state.focus === 'commits';
|
|
37059
38047
|
const overview = context.issueList;
|
|
@@ -37335,7 +38323,8 @@ function formatPullRequestStateLine(pr) {
|
|
|
37335
38323
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
37336
38324
|
* of #890. No behavior change.
|
|
37337
38325
|
*/
|
|
37338
|
-
function renderPullRequestSurface(
|
|
38326
|
+
function renderPullRequestSurface(ctx) {
|
|
38327
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37339
38328
|
const { Box, Text } = components;
|
|
37340
38329
|
const focused = state.focus === 'commits';
|
|
37341
38330
|
const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequest');
|
|
@@ -37508,7 +38497,8 @@ function matchesPullRequestFilter(pr, filter) {
|
|
|
37508
38497
|
...(pr.assignees || []),
|
|
37509
38498
|
], filter);
|
|
37510
38499
|
}
|
|
37511
|
-
function renderPullRequestTriageSurface(
|
|
38500
|
+
function renderPullRequestTriageSurface(ctx) {
|
|
38501
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37512
38502
|
const { Box, Text } = components;
|
|
37513
38503
|
const focused = state.focus === 'commits';
|
|
37514
38504
|
const overview = context.pullRequestList;
|
|
@@ -37624,7 +38614,8 @@ function renderPullRequestTriageSurface(h, components, state, context, contextSt
|
|
|
37624
38614
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
37625
38615
|
* of #890. No behavior change.
|
|
37626
38616
|
*/
|
|
37627
|
-
function renderReflogSurface(
|
|
38617
|
+
function renderReflogSurface(ctx) {
|
|
38618
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37628
38619
|
const { Box, Text } = components;
|
|
37629
38620
|
const focused = state.focus === 'commits';
|
|
37630
38621
|
const loading = isLogInkContextKeyLoading(contextStatus, 'reflog');
|
|
@@ -37695,7 +38686,8 @@ function renderReflogSurface(h, components, state, context, contextStatus, bodyR
|
|
|
37695
38686
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
37696
38687
|
* of #890. No behavior change.
|
|
37697
38688
|
*/
|
|
37698
|
-
function renderStashSurface(
|
|
38689
|
+
function renderStashSurface(ctx) {
|
|
38690
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37699
38691
|
const { Box, Text } = components;
|
|
37700
38692
|
const focused = state.focus === 'commits';
|
|
37701
38693
|
const loading = isLogInkContextKeyLoading(contextStatus, 'stashes');
|
|
@@ -37713,6 +38705,11 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
|
|
|
37713
38705
|
: `${stashes.length}/${allStashes.length} stashes${filterLabel}`;
|
|
37714
38706
|
const emptyLabel = formatLogInkStashEmpty({ filter: state.filter });
|
|
37715
38707
|
const loadingLabel = formatLogInkLoading({ resource: 'stashes' });
|
|
38708
|
+
const now = getRenderNow();
|
|
38709
|
+
// Available width for a row: box width minus the 2-cell horizontal
|
|
38710
|
+
// padding. Truncate to it (with a small floor) instead of a magic 140
|
|
38711
|
+
// so the richer meta degrades gracefully on narrow terminals.
|
|
38712
|
+
const rowWidth = Math.max(20, width - 2);
|
|
37716
38713
|
const lines = loading
|
|
37717
38714
|
? [h(Text, { key: 'stash-loading', dimColor: true }, loadingLabel)]
|
|
37718
38715
|
: stashes.length === 0
|
|
@@ -37721,11 +38718,25 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
|
|
|
37721
38718
|
const index = startIndex + offset;
|
|
37722
38719
|
const isSelected = index === selected;
|
|
37723
38720
|
const cursor = isSelected ? '>' : ' ';
|
|
38721
|
+
// Surface the metadata the StashEntry already carries — origin
|
|
38722
|
+
// branch, file count, and relative age — between the ref and the
|
|
38723
|
+
// message, so the list answers "which stash is this?" without an
|
|
38724
|
+
// Enter→diff round trip.
|
|
38725
|
+
const age = formatCompactRelativeDate(stash.date, now);
|
|
38726
|
+
const fileCount = stash.files.length;
|
|
38727
|
+
const meta = [
|
|
38728
|
+
stash.branch ? `on ${stash.branch}` : '',
|
|
38729
|
+
fileCount > 0 ? `${fileCount} file${fileCount === 1 ? '' : 's'}` : '',
|
|
38730
|
+
age,
|
|
38731
|
+
].filter(Boolean).join(' · ');
|
|
38732
|
+
const rowText = meta
|
|
38733
|
+
? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
|
|
38734
|
+
: `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
|
|
37724
38735
|
return h(Text, {
|
|
37725
38736
|
key: `stash-${index}`,
|
|
37726
38737
|
bold: isSelected,
|
|
37727
38738
|
dimColor: !isSelected,
|
|
37728
|
-
}, truncateCells(
|
|
38739
|
+
}, truncateCells(rowText, rowWidth));
|
|
37729
38740
|
});
|
|
37730
38741
|
const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
|
|
37731
38742
|
const stashHasMoreBelow = startIndex + listRows < stashes.length;
|
|
@@ -37785,7 +38796,8 @@ function formatStatusFilterMask(mask) {
|
|
|
37785
38796
|
active.push('untracked');
|
|
37786
38797
|
return active.join(' + ') || 'none';
|
|
37787
38798
|
}
|
|
37788
|
-
function renderStatusSurface(
|
|
38799
|
+
function renderStatusSurface(ctx) {
|
|
38800
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37789
38801
|
const { Box, Text } = components;
|
|
37790
38802
|
const focused = state.focus === 'commits';
|
|
37791
38803
|
const worktree = context.worktree;
|
|
@@ -37932,7 +38944,8 @@ function flagColor(flag, theme) {
|
|
|
37932
38944
|
return theme.colors.danger;
|
|
37933
38945
|
return undefined;
|
|
37934
38946
|
}
|
|
37935
|
-
function renderSubmodulesSurface(
|
|
38947
|
+
function renderSubmodulesSurface(ctx) {
|
|
38948
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37936
38949
|
const { Box, Text } = components;
|
|
37937
38950
|
const focused = state.focus === 'commits';
|
|
37938
38951
|
const loading = isLogInkContextKeyLoading(contextStatus, 'submodules');
|
|
@@ -38071,7 +39084,8 @@ function formatHyperlink(text, url, env = process.env) {
|
|
|
38071
39084
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
38072
39085
|
* of #890. No behavior change.
|
|
38073
39086
|
*/
|
|
38074
|
-
function renderTagsSurface(
|
|
39087
|
+
function renderTagsSurface(ctx) {
|
|
39088
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38075
39089
|
const { Box, Text } = components;
|
|
38076
39090
|
const focused = state.focus === 'commits';
|
|
38077
39091
|
const loading = isLogInkContextKeyLoading(contextStatus, 'tags');
|
|
@@ -38153,7 +39167,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
38153
39167
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
38154
39168
|
* of #890. No behavior change.
|
|
38155
39169
|
*/
|
|
38156
|
-
function renderWorktreesSurface(
|
|
39170
|
+
function renderWorktreesSurface(ctx) {
|
|
39171
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38157
39172
|
const { Box, Text } = components;
|
|
38158
39173
|
const focused = state.focus === 'commits';
|
|
38159
39174
|
const loading = isLogInkContextKeyLoading(contextStatus, 'worktreeList');
|
|
@@ -38217,7 +39232,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
38217
39232
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
|
|
38218
39233
|
* of #890. No behavior change.
|
|
38219
39234
|
*/
|
|
38220
|
-
function renderMainPanel(
|
|
39235
|
+
function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled, syntaxSpans) {
|
|
39236
|
+
// The universal render values now arrive bundled (#1136); only the
|
|
39237
|
+
// few raw values the dispatcher itself touches (split-plan overlay,
|
|
39238
|
+
// activeView switch) are destructured here. Surfaces receive `surface`
|
|
39239
|
+
// directly plus their own slices.
|
|
39240
|
+
const { h, components, state, bodyRows, width, theme } = surface;
|
|
38221
39241
|
// Split-plan overlay (#907 polish): renders in the MAIN panel (not
|
|
38222
39242
|
// detail) when active, because the content — multiple commit groups
|
|
38223
39243
|
// with file lists, rationale, hunks — needs the full center width
|
|
@@ -38229,51 +39249,66 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
38229
39249
|
return renderSplitPlanOverlay(h, components, state, width, bodyRows, theme, true, spinnerFrame);
|
|
38230
39250
|
}
|
|
38231
39251
|
if (state.activeView === 'status') {
|
|
38232
|
-
return renderStatusSurface(
|
|
39252
|
+
return renderStatusSurface(surface);
|
|
38233
39253
|
}
|
|
38234
39254
|
if (state.activeView === 'diff') {
|
|
38235
|
-
|
|
39255
|
+
const diffData = {
|
|
39256
|
+
worktreeDiff,
|
|
39257
|
+
worktreeDiffLoading,
|
|
39258
|
+
worktreeHunks,
|
|
39259
|
+
worktreeHunksLoading,
|
|
39260
|
+
filePreview,
|
|
39261
|
+
filePreviewLoading,
|
|
39262
|
+
commitDiffHunkOffsets,
|
|
39263
|
+
selectedDetailFile,
|
|
39264
|
+
stashDiffLines,
|
|
39265
|
+
stashDiffLoading,
|
|
39266
|
+
compareDiffLines,
|
|
39267
|
+
compareDiffLoading,
|
|
39268
|
+
syntaxSpans,
|
|
39269
|
+
};
|
|
39270
|
+
return renderDiffSurface(surface, diffData);
|
|
38236
39271
|
}
|
|
38237
39272
|
if (state.activeView === 'compose') {
|
|
38238
|
-
return renderComposeSurface(
|
|
39273
|
+
return renderComposeSurface(surface, spinnerFrame);
|
|
38239
39274
|
}
|
|
38240
39275
|
if (state.activeView === 'branches') {
|
|
38241
|
-
return renderBranchesSurface(
|
|
39276
|
+
return renderBranchesSurface(surface);
|
|
38242
39277
|
}
|
|
38243
39278
|
if (state.activeView === 'tags') {
|
|
38244
|
-
return renderTagsSurface(
|
|
39279
|
+
return renderTagsSurface(surface);
|
|
38245
39280
|
}
|
|
38246
39281
|
if (state.activeView === 'reflog') {
|
|
38247
|
-
return renderReflogSurface(
|
|
39282
|
+
return renderReflogSurface(surface);
|
|
38248
39283
|
}
|
|
38249
39284
|
if (state.activeView === 'bisect') {
|
|
38250
|
-
return renderBisectSurface(
|
|
39285
|
+
return renderBisectSurface(surface, bisectCandidateDetail, bisectCandidateLoading);
|
|
38251
39286
|
}
|
|
38252
39287
|
if (state.activeView === 'stash') {
|
|
38253
|
-
return renderStashSurface(
|
|
39288
|
+
return renderStashSurface(surface);
|
|
38254
39289
|
}
|
|
38255
39290
|
if (state.activeView === 'worktrees') {
|
|
38256
|
-
return renderWorktreesSurface(
|
|
39291
|
+
return renderWorktreesSurface(surface);
|
|
38257
39292
|
}
|
|
38258
39293
|
if (state.activeView === 'submodules') {
|
|
38259
|
-
return renderSubmodulesSurface(
|
|
39294
|
+
return renderSubmodulesSurface(surface);
|
|
38260
39295
|
}
|
|
38261
39296
|
if (state.activeView === 'pull-request') {
|
|
38262
|
-
return renderPullRequestSurface(
|
|
39297
|
+
return renderPullRequestSurface(surface);
|
|
38263
39298
|
}
|
|
38264
39299
|
if (state.activeView === 'pull-request-triage') {
|
|
38265
|
-
return renderPullRequestTriageSurface(
|
|
39300
|
+
return renderPullRequestTriageSurface(surface);
|
|
38266
39301
|
}
|
|
38267
39302
|
if (state.activeView === 'issues') {
|
|
38268
|
-
return renderIssuesTriageSurface(
|
|
39303
|
+
return renderIssuesTriageSurface(surface);
|
|
38269
39304
|
}
|
|
38270
39305
|
if (state.activeView === 'conflicts') {
|
|
38271
|
-
return renderConflictsSurface(
|
|
39306
|
+
return renderConflictsSurface(surface);
|
|
38272
39307
|
}
|
|
38273
39308
|
if (state.activeView === 'changelog') {
|
|
38274
|
-
return renderChangelogSurface(
|
|
39309
|
+
return renderChangelogSurface(surface);
|
|
38275
39310
|
}
|
|
38276
|
-
return renderHistoryPanel(
|
|
39311
|
+
return renderHistoryPanel(surface, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled, undefined, spinnerFrame);
|
|
38277
39312
|
}
|
|
38278
39313
|
|
|
38279
39314
|
/**
|
|
@@ -39146,21 +40181,16 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
|
|
|
39146
40181
|
const bodyVisualLines = bodyHasContent
|
|
39147
40182
|
? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, 12)
|
|
39148
40183
|
: ['<empty>'];
|
|
39149
|
-
const
|
|
39150
|
-
const
|
|
39151
|
-
const
|
|
39152
|
-
|
|
39153
|
-
|
|
39154
|
-
|
|
39155
|
-
|
|
39156
|
-
|
|
39157
|
-
|
|
39158
|
-
|
|
39159
|
-
const isLast = index === bodyVisualLines.length - 1;
|
|
39160
|
-
return ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`;
|
|
39161
|
-
}),
|
|
39162
|
-
'',
|
|
39163
|
-
];
|
|
40184
|
+
const hasSummary = Boolean(compose.summary);
|
|
40185
|
+
const summaryMarker = compose.field === 'summary' && compose.editing ? '>' : ' ';
|
|
40186
|
+
const bodyMarker = compose.field === 'body' && compose.editing ? '>' : ' ';
|
|
40187
|
+
// The generated subject is the thing the user is looking for — render
|
|
40188
|
+
// it bold + accent so it pops out of the inspector instead of blending
|
|
40189
|
+
// into the dim label/body text. The `Summary:` label stays dim.
|
|
40190
|
+
const summaryLabel = `${summaryMarker} Summary: `;
|
|
40191
|
+
const summaryColor = hasSummary && !theme.noColor ? theme.colors.accent : undefined;
|
|
40192
|
+
const summaryValueWidth = Math.max(4, width - 4 - cellWidth(summaryLabel));
|
|
40193
|
+
const summaryWrapped = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, summaryValueWidth);
|
|
39164
40194
|
const trailerLines = [
|
|
39165
40195
|
...(compose.message ? ['', compose.message] : []),
|
|
39166
40196
|
...(compose.details || []).map((line) => ` ${line}`),
|
|
@@ -39174,10 +40204,26 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
|
|
|
39174
40204
|
flexDirection: 'column',
|
|
39175
40205
|
width,
|
|
39176
40206
|
paddingX: 1,
|
|
39177
|
-
}, h(Text, { bold: true }, panelTitle('Commit', focused)),
|
|
39178
|
-
|
|
39179
|
-
|
|
39180
|
-
|
|
40207
|
+
}, h(Text, { bold: true }, panelTitle('Commit', focused)), h(Text, { key: 'commit-status', dimColor: true }, truncateCells(statusLine, width - 4)), h(Text, { key: 'commit-spacer-1' }, ''),
|
|
40208
|
+
// Summary: dim label + the subject value emphasized so it's easy to spot.
|
|
40209
|
+
h(Text, { key: 'commit-summary' }, h(Text, { dimColor: true }, summaryLabel), h(Text, {
|
|
40210
|
+
bold: hasSummary,
|
|
40211
|
+
color: summaryColor,
|
|
40212
|
+
dimColor: !hasSummary,
|
|
40213
|
+
}, summaryWrapped[0] || '<empty>')), ...summaryWrapped.slice(1).map((line, index) => h(Text, {
|
|
40214
|
+
key: `commit-summary-rest-${index}`,
|
|
40215
|
+
bold: true,
|
|
40216
|
+
color: summaryColor,
|
|
40217
|
+
}, truncateCells(`${' '.repeat(cellWidth(summaryLabel))}${line}`, width - 4))), h(Text, {
|
|
40218
|
+
key: 'commit-body-label',
|
|
40219
|
+
dimColor: !(compose.field === 'body' && compose.editing),
|
|
40220
|
+
}, truncateCells(`${bodyMarker} Body:`, width - 4)), ...bodyVisualLines.map((line, index) => {
|
|
40221
|
+
const isLast = index === bodyVisualLines.length - 1;
|
|
40222
|
+
return h(Text, {
|
|
40223
|
+
key: `commit-body-${index}`,
|
|
40224
|
+
dimColor: true,
|
|
40225
|
+
}, truncateCells(` ${line}${bodyCursor && isLast ? bodyCursor : ''}`, width - 4));
|
|
40226
|
+
}), h(Text, { key: 'commit-spacer-2' }, ''),
|
|
39181
40227
|
// Loading indicator + commit result/details stay inline with the body
|
|
39182
40228
|
// (they describe what just happened to the fields above). The action
|
|
39183
40229
|
// hint ("e edit | c commit | I AI draft") moves to the bottom of the
|
|
@@ -39268,44 +40314,20 @@ function renderPullRequestTriagePreviewPanel(h, components, state, context, cont
|
|
|
39268
40314
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
|
|
39269
40315
|
* of #890. No behavior change.
|
|
39270
40316
|
*/
|
|
39271
|
-
|
|
39272
|
-
* Rail-mode inspector — shown on terminals < 100 columns when the
|
|
39273
|
-
* detail panel does not hold focus. The full inspector (commit body,
|
|
39274
|
-
* file list, actions) does not survive truncation to ~4 content cells
|
|
39275
|
-
* so we collapse to a stack with the panel label and the selected
|
|
39276
|
-
* commit's shortHash. Focus pops the panel back to its expanded
|
|
39277
|
-
* widths via the layout, so this renderer is only reached at rest.
|
|
39278
|
-
*
|
|
39279
|
-
* Help / overlay states are still handled by their own renderers
|
|
39280
|
-
* above; this short-circuit only kicks in for the regular "view the
|
|
39281
|
-
* commit" cases.
|
|
39282
|
-
*/
|
|
39283
|
-
function renderInspectorRail(h, components, state, detail, width, theme, focused) {
|
|
39284
|
-
const { Box, Text } = components;
|
|
39285
|
-
// Prefer the loaded detail's hash (canonical) but fall back to the
|
|
39286
|
-
// selected list row's shortHash so the rail isn't blank on the
|
|
39287
|
-
// first render before getCommitDetail resolves.
|
|
39288
|
-
const selectedRow = getSelectedInkCommit(state);
|
|
39289
|
-
const hashText = detail?.hash.slice(0, 4)
|
|
39290
|
-
?? selectedRow?.shortHash.slice(0, 4)
|
|
39291
|
-
?? '····';
|
|
39292
|
-
return h(Box, {
|
|
39293
|
-
borderColor: focusBorderColor(theme, focused),
|
|
39294
|
-
borderStyle: theme.borderStyle,
|
|
39295
|
-
flexDirection: 'column',
|
|
39296
|
-
width,
|
|
39297
|
-
paddingX: 1,
|
|
39298
|
-
}, h(Text, { bold: true, dimColor: !focused }, panelTitle('Insp', focused)), h(Text, { dimColor: true }, '────'), h(Text, { color: theme.noColor ? undefined : theme.colors.accent }, hashText));
|
|
39299
|
-
}
|
|
39300
|
-
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, railed = false, bodyRows = 0) {
|
|
40317
|
+
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, bodyRows = 0) {
|
|
39301
40318
|
const focused = state.focus === 'detail';
|
|
39302
40319
|
// Overlays (help / palette / input / confirmation / chord) take
|
|
39303
|
-
// precedence over
|
|
39304
|
-
// via the help-overlay layout branch
|
|
39305
|
-
// defeat their whole purpose (the user is reading them).
|
|
40320
|
+
// precedence over every per-view surface because they claim the
|
|
40321
|
+
// panel's full width via the help-overlay layout branch.
|
|
39306
40322
|
if (state.showHelp) {
|
|
39307
40323
|
return renderHelpPanel(h, components, state, width, theme, focused, bodyRows);
|
|
39308
40324
|
}
|
|
40325
|
+
// #1137 — the `g?` which-key strip lists the current view's single-key
|
|
40326
|
+
// actions. Checked alongside the other overlays; the reducer keeps it
|
|
40327
|
+
// mutually exclusive with help / palette / pickers.
|
|
40328
|
+
if (state.showViewKeys) {
|
|
40329
|
+
return renderViewKeysOverlay(h, components, state, width, theme, focused);
|
|
40330
|
+
}
|
|
39309
40331
|
if (state.showCommandPalette) {
|
|
39310
40332
|
return renderCommandPalette(h, components, state, width, theme, focused);
|
|
39311
40333
|
}
|
|
@@ -39334,15 +40356,6 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
39334
40356
|
if (state.pendingKey && !state.splitPlan) {
|
|
39335
40357
|
return renderChordOverlay(h, components, state, width, theme, focused);
|
|
39336
40358
|
}
|
|
39337
|
-
// Rail mode applies only after every overlay above has had its say
|
|
39338
|
-
// — those would all be unreadable at 4 cells of content. The layout
|
|
39339
|
-
// also clears `railed` whenever the inspector takes focus, so we
|
|
39340
|
-
// can safely short-circuit the per-view dispatch here without
|
|
39341
|
-
// worrying about hiding the panel from a user who's actively
|
|
39342
|
-
// reading it.
|
|
39343
|
-
if (railed) {
|
|
39344
|
-
return renderInspectorRail(h, components, state, detail, width, theme, focused);
|
|
39345
|
-
}
|
|
39346
40359
|
// The synthetic "(+) new commit" row routes the inspector through the
|
|
39347
40360
|
// worktree summary so the user sees what's staged / unstaged at a glance
|
|
39348
40361
|
// — same surface as the compose view's right panel.
|
|
@@ -39392,6 +40405,43 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
39392
40405
|
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
|
|
39393
40406
|
}
|
|
39394
40407
|
|
|
40408
|
+
/**
|
|
40409
|
+
* Runtime React Context for the workstation (#1136).
|
|
40410
|
+
*
|
|
40411
|
+
* The render layer currently drills `state` / `dispatch` / `theme` /
|
|
40412
|
+
* `layout` / `context` through every `render*Surface` signature, so
|
|
40413
|
+
* adding a feature repeatedly means threading one more value through
|
|
40414
|
+
* `app → mainPanel → render<View>Surface`. This Context is the single
|
|
40415
|
+
* place those five values live; surfaces read what they need from it
|
|
40416
|
+
* instead of receiving 10–15 positional props.
|
|
40417
|
+
*
|
|
40418
|
+
* Why a factory (`getLogInkRuntimeContext(React)`) instead of a plain
|
|
40419
|
+
* module-level `React.createContext(...)`: the workstation never
|
|
40420
|
+
* statically imports React. `ink` + `react` are ESM-only and loaded via
|
|
40421
|
+
* dynamicImport at boot (see `inkRuntime.ts`), so the rest of the
|
|
40422
|
+
* codebase compiles without bundling them. The Context object must be
|
|
40423
|
+
* built from that same runtime React instance — the one that renders
|
|
40424
|
+
* the tree and the one a consumer's `useContext` reads from have to be
|
|
40425
|
+
* identical. There is exactly one React instance per process, so we
|
|
40426
|
+
* lazily create the Context on first use and cache it; `LogInkApp`'s
|
|
40427
|
+
* provider and (in later PRs) the surface consumers all share the one
|
|
40428
|
+
* identity.
|
|
40429
|
+
*/
|
|
40430
|
+
let cachedContext = null;
|
|
40431
|
+
/**
|
|
40432
|
+
* Lazily create (and thereafter return) the process-wide
|
|
40433
|
+
* `LogInkRuntimeContext`, bound to the runtime React instance. Pass the
|
|
40434
|
+
* same `React` the tree is rendered with — `LogInkApp` uses `deps.React`;
|
|
40435
|
+
* tests use the statically-imported `react`.
|
|
40436
|
+
*/
|
|
40437
|
+
function getLogInkRuntimeContext(React) {
|
|
40438
|
+
if (!cachedContext) {
|
|
40439
|
+
cachedContext = React.createContext(null);
|
|
40440
|
+
cachedContext.displayName = 'LogInkRuntimeContext';
|
|
40441
|
+
}
|
|
40442
|
+
return cachedContext;
|
|
40443
|
+
}
|
|
40444
|
+
|
|
39395
40445
|
/**
|
|
39396
40446
|
* Resolve + scaffold the coco config files the workstation can open in
|
|
39397
40447
|
* `$EDITOR` (the `gk` / `gK` chords and their command-palette entries).
|
|
@@ -39875,6 +40925,10 @@ function LogInkApp(deps) {
|
|
|
39875
40925
|
const loadingMoreCommitsRef = React.useRef(false);
|
|
39876
40926
|
const loadMoreRequestRef = React.useRef(0);
|
|
39877
40927
|
const mountedRef = React.useRef(true);
|
|
40928
|
+
// Last dropped stash {hash, message}, captured before `drop-stash` runs
|
|
40929
|
+
// so `undo-drop-stash` can re-store it. The dropped commit survives in
|
|
40930
|
+
// the object DB until gc, so the hash is enough to bring it back.
|
|
40931
|
+
const lastDroppedStashRef = React.useRef(null);
|
|
39878
40932
|
// P4.3 — idle tip rotation. tickIndex 0 ⇒ no tip; the hook bumps it after
|
|
39879
40933
|
// a grace window of empty statusMessage and then on a steady cadence, so
|
|
39880
40934
|
// the footer surfaces a different hint every interval until the user does
|
|
@@ -40179,6 +41233,10 @@ function LogInkApp(deps) {
|
|
|
40179
41233
|
worktree,
|
|
40180
41234
|
}), issuedAtDepth);
|
|
40181
41235
|
setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'), issuedAtDepth);
|
|
41236
|
+
// Returned so callers needing the *fresh* overview (e.g. post-commit
|
|
41237
|
+
// navigation) can read it directly instead of racing the async
|
|
41238
|
+
// `setContext` update, which won't be visible in their closure.
|
|
41239
|
+
return worktree;
|
|
40182
41240
|
}, [git, runtimes.length, setContext, setContextStatus]);
|
|
40183
41241
|
// Live refresh: watch .git metadata + the working tree root and reload
|
|
40184
41242
|
// context when something changes outside the TUI (editor save, external
|
|
@@ -41041,7 +42099,14 @@ function LogInkApp(deps) {
|
|
|
41041
42099
|
// and see the pre-commit log (same silent-failure shape as
|
|
41042
42100
|
// the split-apply case caught in this PR).
|
|
41043
42101
|
await refreshHistoryRows();
|
|
41044
|
-
await refreshWorktreeContext();
|
|
42102
|
+
const worktree = await refreshWorktreeContext();
|
|
42103
|
+
// Leave the compose view automatically: a still-dirty tree returns
|
|
42104
|
+
// to Status (so the user can keep staging), an otherwise-complete
|
|
42105
|
+
// commit returns to History (where the new commit now shows). The
|
|
42106
|
+
// reducer inspects the live viewStack to pick the destination.
|
|
42107
|
+
const stillDirty = Boolean(worktree &&
|
|
42108
|
+
worktree.stagedCount + worktree.unstagedCount + worktree.untrackedCount > 0);
|
|
42109
|
+
dispatch({ type: 'returnFromCommit', stillDirty });
|
|
41045
42110
|
}
|
|
41046
42111
|
}, [
|
|
41047
42112
|
context.worktree?.stagedCount,
|
|
@@ -41118,11 +42183,15 @@ function LogInkApp(deps) {
|
|
|
41118
42183
|
dispatch({ type: 'setStatus', value: 'AI draft ready for editing', kind: 'success' });
|
|
41119
42184
|
return;
|
|
41120
42185
|
}
|
|
42186
|
+
// Humanize provider errors (rate limit / auth / context / network)
|
|
42187
|
+
// into a short actionable line; success-but-no-draft keeps its
|
|
42188
|
+
// message as-is.
|
|
42189
|
+
const composeMessage = result.ok ? result.message : humanizeAiError(result.message);
|
|
41121
42190
|
dispatch({
|
|
41122
42191
|
type: 'commitCompose',
|
|
41123
|
-
action: { type: 'setResult', message:
|
|
42192
|
+
action: { type: 'setResult', message: composeMessage, details: result.details },
|
|
41124
42193
|
});
|
|
41125
|
-
dispatch({ type: 'setStatus', value: result.
|
|
42194
|
+
dispatch({ type: 'setStatus', value: composeMessage, kind: result.ok ? undefined : 'error' });
|
|
41126
42195
|
}
|
|
41127
42196
|
catch (error) {
|
|
41128
42197
|
// Audit finding #3: defensive recovery for unexpected throws
|
|
@@ -42093,8 +43162,20 @@ function LogInkApp(deps) {
|
|
|
42093
43162
|
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
42094
43163
|
if (!stash)
|
|
42095
43164
|
return { ok: false, message: 'No stash selected' };
|
|
43165
|
+
// Remember the dropped commit so `u` can undo it.
|
|
43166
|
+
if (stash.hash)
|
|
43167
|
+
lastDroppedStashRef.current = { hash: stash.hash, message: stash.message };
|
|
42096
43168
|
return dropStash(git, stash);
|
|
42097
43169
|
},
|
|
43170
|
+
'undo-drop-stash': async () => {
|
|
43171
|
+
const dropped = lastDroppedStashRef.current;
|
|
43172
|
+
if (!dropped)
|
|
43173
|
+
return { ok: false, message: 'Nothing to undo — no stash dropped this session' };
|
|
43174
|
+
const result = await restoreStash(git, dropped.hash, dropped.message);
|
|
43175
|
+
if (result.ok)
|
|
43176
|
+
lastDroppedStashRef.current = null;
|
|
43177
|
+
return result;
|
|
43178
|
+
},
|
|
42098
43179
|
'apply-stash': async () => {
|
|
42099
43180
|
const all = context.stashes?.stashes || [];
|
|
42100
43181
|
const visible = state.filter
|
|
@@ -42105,6 +43186,16 @@ function LogInkApp(deps) {
|
|
|
42105
43186
|
return { ok: false, message: 'No stash selected' };
|
|
42106
43187
|
return applyStash(git, stash);
|
|
42107
43188
|
},
|
|
43189
|
+
'apply-stash-index': async () => {
|
|
43190
|
+
const all = context.stashes?.stashes || [];
|
|
43191
|
+
const visible = state.filter
|
|
43192
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
|
|
43193
|
+
: all;
|
|
43194
|
+
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
43195
|
+
if (!stash)
|
|
43196
|
+
return { ok: false, message: 'No stash selected' };
|
|
43197
|
+
return applyStashKeepIndex(git, stash);
|
|
43198
|
+
},
|
|
42108
43199
|
'pop-stash': async () => {
|
|
42109
43200
|
const all = context.stashes?.stashes || [];
|
|
42110
43201
|
const visible = state.filter
|
|
@@ -42115,6 +43206,26 @@ function LogInkApp(deps) {
|
|
|
42115
43206
|
return { ok: false, message: 'No stash selected' };
|
|
42116
43207
|
return popStash(git, stash);
|
|
42117
43208
|
},
|
|
43209
|
+
'rename-stash': async () => {
|
|
43210
|
+
const all = context.stashes?.stashes || [];
|
|
43211
|
+
const visible = state.filter
|
|
43212
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
|
|
43213
|
+
: all;
|
|
43214
|
+
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
43215
|
+
if (!stash)
|
|
43216
|
+
return { ok: false, message: 'No stash selected' };
|
|
43217
|
+
return renameStash(git, stash, payload ?? '');
|
|
43218
|
+
},
|
|
43219
|
+
'stash-branch': async () => {
|
|
43220
|
+
const all = context.stashes?.stashes || [];
|
|
43221
|
+
const visible = state.filter
|
|
43222
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
|
|
43223
|
+
: all;
|
|
43224
|
+
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
43225
|
+
if (!stash)
|
|
43226
|
+
return { ok: false, message: 'No stash selected' };
|
|
43227
|
+
return stashBranch(git, stash, payload ?? '');
|
|
43228
|
+
},
|
|
42118
43229
|
'bisect-good': async () => {
|
|
42119
43230
|
if (!context.bisect?.active)
|
|
42120
43231
|
return { ok: false, message: 'No bisect in progress' };
|
|
@@ -42525,11 +43636,12 @@ function LogInkApp(deps) {
|
|
|
42525
43636
|
return deleteRemoteTag(git, tag.name);
|
|
42526
43637
|
},
|
|
42527
43638
|
'create-stash': async () => {
|
|
42528
|
-
|
|
42529
|
-
|
|
42530
|
-
|
|
42531
|
-
return createStash(git, message);
|
|
43639
|
+
// Empty is allowed — createStash turns it into a quick WIP stash
|
|
43640
|
+
// (git's own `WIP on <branch>` subject). Naming is optional.
|
|
43641
|
+
return createStash(git, payload ?? '');
|
|
42532
43642
|
},
|
|
43643
|
+
'stash-staged': async () => createStash(git, payload ?? '', { stagedOnly: true }),
|
|
43644
|
+
'stash-keep-index': async () => createStash(git, payload ?? '', { keepIndex: true }),
|
|
42533
43645
|
// #783 — full PR action panel handlers. Each wraps the matching
|
|
42534
43646
|
// pullRequestActions verb. Strategy / body arrives via `payload`
|
|
42535
43647
|
// — input prompts validate before they reach here, but the
|
|
@@ -42777,6 +43889,8 @@ function LogInkApp(deps) {
|
|
|
42777
43889
|
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'untracked');
|
|
42778
43890
|
return stageAllFiles(git, files);
|
|
42779
43891
|
},
|
|
43892
|
+
'stage-all': async () => stageAll(git),
|
|
43893
|
+
'stage-pathspec': async () => stagePathspec(git, payload || ''),
|
|
42780
43894
|
};
|
|
42781
43895
|
const handler = handlers[id];
|
|
42782
43896
|
if (!handler) {
|
|
@@ -42807,6 +43921,16 @@ function LogInkApp(deps) {
|
|
|
42807
43921
|
'checkout-branch',
|
|
42808
43922
|
'continue-operation',
|
|
42809
43923
|
'pull-current-branch',
|
|
43924
|
+
// Fetch / pull / push bring in new commits and move
|
|
43925
|
+
// remote-tracking refs (origin/main, ahead/behind) — refresh the
|
|
43926
|
+
// graph so they appear instead of staying pinned to the pre-sync
|
|
43927
|
+
// state. (A successful push advances the local origin/<branch>
|
|
43928
|
+
// ref, so the chip should hop to the pushed commit.)
|
|
43929
|
+
'fetch-remotes',
|
|
43930
|
+
'fetch-selected-branch',
|
|
43931
|
+
'pull-selected-branch',
|
|
43932
|
+
'push-current-branch',
|
|
43933
|
+
'push-selected-branch',
|
|
42810
43934
|
'cherry-pick-commit',
|
|
42811
43935
|
'revert-commit',
|
|
42812
43936
|
'reset-hard-to-commit',
|
|
@@ -42861,6 +43985,11 @@ function LogInkApp(deps) {
|
|
|
42861
43985
|
if (result?.ok && id === 'add-to-gitignore') {
|
|
42862
43986
|
await refreshWorktreeContext();
|
|
42863
43987
|
}
|
|
43988
|
+
// Stage-all / stage-pathspec change staged/unstaged counts — refresh
|
|
43989
|
+
// the worktree so the status list + compose summary reflect it.
|
|
43990
|
+
if (result?.ok && (id === 'stage-all' || id === 'stage-pathspec')) {
|
|
43991
|
+
await refreshWorktreeContext();
|
|
43992
|
+
}
|
|
42864
43993
|
if (result?.ok && id === 'drop-stash') {
|
|
42865
43994
|
// Explicit worktree refresh in case the dropped stash carried
|
|
42866
43995
|
// untracked-file state that's now collected.
|
|
@@ -43444,6 +44573,11 @@ function LogInkApp(deps) {
|
|
|
43444
44573
|
? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
|
|
43445
44574
|
: undefined;
|
|
43446
44575
|
getLogInkInputEvents(state, inputValue, key, {
|
|
44576
|
+
// Narrow terminals show one pane at a time (#1135) — gates the `v`
|
|
44577
|
+
// peek key. Derived the same way the layout does, since `layout`
|
|
44578
|
+
// is computed later in the render path (not in this callback).
|
|
44579
|
+
singlePane: (windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS) <
|
|
44580
|
+
LAYOUT_SINGLE_PANE_BELOW,
|
|
43447
44581
|
detailFileCount: detail?.files.length,
|
|
43448
44582
|
previewLineCount: diffPreviewLineCount,
|
|
43449
44583
|
worktreeDiffLineCount: worktreeDiff?.lines.length,
|
|
@@ -43568,7 +44702,14 @@ function LogInkApp(deps) {
|
|
|
43568
44702
|
exit();
|
|
43569
44703
|
}
|
|
43570
44704
|
else if (event.type === 'refreshContext') {
|
|
44705
|
+
// The user-initiated refresh (`r`) refreshes BOTH the metadata
|
|
44706
|
+
// context (branches/tags/worktree) AND the commit rows. Without
|
|
44707
|
+
// the row re-fetch the history graph stays pinned to whatever
|
|
44708
|
+
// commits existed at boot — new commits (made in another
|
|
44709
|
+
// terminal, or remote commits brought in by a fetch) never
|
|
44710
|
+
// appear until relaunch, which reads as "the history is stuck."
|
|
43571
44711
|
void refreshContext();
|
|
44712
|
+
void refreshHistoryRows();
|
|
43572
44713
|
}
|
|
43573
44714
|
else if (event.type === 'toggleSelectedFileStage') {
|
|
43574
44715
|
void toggleSelectedFileStage();
|
|
@@ -43667,6 +44808,25 @@ function LogInkApp(deps) {
|
|
|
43667
44808
|
}
|
|
43668
44809
|
});
|
|
43669
44810
|
});
|
|
44811
|
+
// In single-pane mode (narrow terminals) only one pane renders, so an
|
|
44812
|
+
// active overlay must pull its own pane into view rather than stay
|
|
44813
|
+
// hidden behind whatever pane focus points at. The split-plan overlay
|
|
44814
|
+
// lives in the main panel; every other overlay (help / palette / theme
|
|
44815
|
+
// / gitignore / input prompt / confirmation / chord) renders in the
|
|
44816
|
+
// inspector. Ignored above the single-pane breakpoint (all panes show).
|
|
44817
|
+
const forcedPane = state.splitPlan
|
|
44818
|
+
? 'main'
|
|
44819
|
+
: state.showHelp ||
|
|
44820
|
+
state.showViewKeys ||
|
|
44821
|
+
state.showCommandPalette ||
|
|
44822
|
+
state.showThemePicker ||
|
|
44823
|
+
state.gitignorePicker ||
|
|
44824
|
+
state.inputPrompt ||
|
|
44825
|
+
state.pendingConfirmationId ||
|
|
44826
|
+
state.pendingMutationConfirmation ||
|
|
44827
|
+
state.pendingKey
|
|
44828
|
+
? 'inspector'
|
|
44829
|
+
: undefined;
|
|
43670
44830
|
// Layout depends on focus (sidebar grows when focused), so it's
|
|
43671
44831
|
// computed here — after state is in scope but before the render path.
|
|
43672
44832
|
const layout = getLogInkLayout({
|
|
@@ -43675,7 +44835,22 @@ function LogInkApp(deps) {
|
|
|
43675
44835
|
sidebarFocused: state.focus === 'sidebar',
|
|
43676
44836
|
inspectorFocused: state.focus === 'detail',
|
|
43677
44837
|
helpOverlayActive: state.showHelp,
|
|
44838
|
+
forcedPane,
|
|
43678
44839
|
});
|
|
44840
|
+
// Runtime Context provider (#1136). Bundles the five most-drilled
|
|
44841
|
+
// values so surfaces can read them from context instead of receiving
|
|
44842
|
+
// them as positional props. No consumers yet — this PR only installs
|
|
44843
|
+
// the provider at the root; the surface families migrate in later PRs.
|
|
44844
|
+
// A Context.Provider renders its children transparently (no host
|
|
44845
|
+
// output), so wrapping the tree is behavior-preserving.
|
|
44846
|
+
const RuntimeContext = getLogInkRuntimeContext(React);
|
|
44847
|
+
const runtimeContextValue = {
|
|
44848
|
+
state,
|
|
44849
|
+
dispatch,
|
|
44850
|
+
theme,
|
|
44851
|
+
layout,
|
|
44852
|
+
context,
|
|
44853
|
+
};
|
|
43679
44854
|
if (layout.tooSmall) {
|
|
43680
44855
|
return h(Box, {
|
|
43681
44856
|
flexDirection: 'column',
|
|
@@ -43689,7 +44864,35 @@ function LogInkApp(deps) {
|
|
|
43689
44864
|
if (showOnboarding) {
|
|
43690
44865
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
43691
44866
|
}
|
|
43692
|
-
|
|
44867
|
+
// Panel renderers are thunks so single-pane mode can build only the
|
|
44868
|
+
// visible pane — the main-panel render in particular is expensive, so
|
|
44869
|
+
// we don't want to invoke the two hidden ones just to drop them.
|
|
44870
|
+
const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme);
|
|
44871
|
+
const mainSurface = {
|
|
44872
|
+
h,
|
|
44873
|
+
components: { Box, Text },
|
|
44874
|
+
state,
|
|
44875
|
+
context,
|
|
44876
|
+
contextStatus,
|
|
44877
|
+
bodyRows: layout.bodyRows,
|
|
44878
|
+
width: layout.mainPanelWidth,
|
|
44879
|
+
theme,
|
|
44880
|
+
};
|
|
44881
|
+
const mainPanel = () => renderMainPanel(mainSurface, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, hasMoreCommits, loadingMoreCommits, spinnerFrame, layout.density, layout.historyRowMode, Boolean(dateBucketingEnabled), diffSyntaxSpans);
|
|
44882
|
+
const detailPanel = () => renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.bodyRows);
|
|
44883
|
+
// Single-pane mode (narrow terminals): exactly one full-width pane,
|
|
44884
|
+
// chosen by `layout.visiblePane`; Tab cycles which one. Above the
|
|
44885
|
+
// breakpoint all three tile side by side as before.
|
|
44886
|
+
const bodyPanels = layout.singlePane
|
|
44887
|
+
? [
|
|
44888
|
+
layout.visiblePane === 'sidebar'
|
|
44889
|
+
? sidebarPanel()
|
|
44890
|
+
: layout.visiblePane === 'inspector'
|
|
44891
|
+
? detailPanel()
|
|
44892
|
+
: mainPanel(),
|
|
44893
|
+
]
|
|
44894
|
+
: [sidebarPanel(), mainPanel(), detailPanel()];
|
|
44895
|
+
return h(RuntimeContext.Provider, { value: runtimeContextValue }, h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader$1(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, ...bodyPanels), renderFooter$1(h, { Box, Text }, state, context, theme, idleTip, spinnerFrame, layout.singlePane)));
|
|
43693
44896
|
}
|
|
43694
44897
|
|
|
43695
44898
|
/**
|
|
@@ -44427,6 +45630,7 @@ var prs = {
|
|
|
44427
45630
|
desc: 'List GitHub pull requests for the current repository (read-only triage)',
|
|
44428
45631
|
builder: builder$4,
|
|
44429
45632
|
handler: commandExecutor(handler$3),
|
|
45633
|
+
options: options$4,
|
|
44430
45634
|
};
|
|
44431
45635
|
|
|
44432
45636
|
const RecapLlmResponseSchema = objectType({
|
|
@@ -44506,8 +45710,7 @@ const handler$2 = async (argv, logger) => {
|
|
|
44506
45710
|
const summaryService = resolveDynamicService(config, 'summarize');
|
|
44507
45711
|
const model = recapService.model;
|
|
44508
45712
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
44509
|
-
logger
|
|
44510
|
-
commandExit(1);
|
|
45713
|
+
handleMissingApiKey(logger, config, { command: 'recap' });
|
|
44511
45714
|
}
|
|
44512
45715
|
const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
|
|
44513
45716
|
const llm = getLlm(provider, model, { ...config, service: recapService });
|
|
@@ -45095,8 +46298,7 @@ const handler$1 = async (argv, logger) => {
|
|
|
45095
46298
|
const summaryService = resolveDynamicService(config, argv.branch ? 'largeDiff' : 'summarize');
|
|
45096
46299
|
const model = reviewService.model;
|
|
45097
46300
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
45098
|
-
logger
|
|
45099
|
-
commandExit(1);
|
|
46301
|
+
handleMissingApiKey(logger, config, { command: 'review' });
|
|
45100
46302
|
}
|
|
45101
46303
|
const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
|
|
45102
46304
|
const llm = getLlm(provider, model, { ...config, service: reviewService });
|
|
@@ -45809,6 +47011,54 @@ async function getWorkspacePullRequestCounts(repoPaths, options = {}) {
|
|
|
45809
47011
|
return { authenticated: true, counts };
|
|
45810
47012
|
}
|
|
45811
47013
|
|
|
47014
|
+
/**
|
|
47015
|
+
* Clone a remote repository into a local path — the runtime side of the
|
|
47016
|
+
* workspace surface's `c` (clone) flow.
|
|
47017
|
+
*
|
|
47018
|
+
* `deriveRepoName` is pure (and tested) so the UI can pre-fill the
|
|
47019
|
+
* destination as `<cwd>/<name>` the moment a URL is typed; `cloneRepo`
|
|
47020
|
+
* does the filesystem-touching work and reports a friendly result.
|
|
47021
|
+
*/
|
|
47022
|
+
/**
|
|
47023
|
+
* Infer the repository folder name from a clone URL or SSH spec:
|
|
47024
|
+
* git@github.com:gfargo/coco.git → coco
|
|
47025
|
+
* https://github.com/gfargo/coco → coco
|
|
47026
|
+
* https://example.com/a/b/c.git/ → c
|
|
47027
|
+
* Falls back to `repo` when nothing usable can be parsed.
|
|
47028
|
+
*/
|
|
47029
|
+
function deriveRepoName(url) {
|
|
47030
|
+
const trimmed = url.trim().replace(/\/+$/, '').replace(/\.git$/i, '');
|
|
47031
|
+
if (!trimmed)
|
|
47032
|
+
return 'repo';
|
|
47033
|
+
// Split on both `/` and `:` so `host:owner/name` SSH specs work.
|
|
47034
|
+
const segment = trimmed.split(/[/:]/).filter(Boolean).pop() || '';
|
|
47035
|
+
return segment || 'repo';
|
|
47036
|
+
}
|
|
47037
|
+
/**
|
|
47038
|
+
* Clone `url` into `targetPath`. Refuses to clobber an existing path so
|
|
47039
|
+
* a typo never overwrites a directory. Network / auth failures surface
|
|
47040
|
+
* git's own message (trimmed to one line).
|
|
47041
|
+
*/
|
|
47042
|
+
async function cloneRepo(url, targetPath) {
|
|
47043
|
+
const remote = url.trim();
|
|
47044
|
+
const dest = targetPath.trim();
|
|
47045
|
+
if (!remote)
|
|
47046
|
+
return { ok: false, message: 'Enter a remote URL to clone.' };
|
|
47047
|
+
if (!dest)
|
|
47048
|
+
return { ok: false, message: 'Enter a destination path.' };
|
|
47049
|
+
if (fs.existsSync(dest)) {
|
|
47050
|
+
return { ok: false, message: `${dest} already exists — choose another path.` };
|
|
47051
|
+
}
|
|
47052
|
+
try {
|
|
47053
|
+
await simpleGit().clone(remote, dest);
|
|
47054
|
+
return { ok: true, message: `Cloned into ${dest}` };
|
|
47055
|
+
}
|
|
47056
|
+
catch (error) {
|
|
47057
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
47058
|
+
return { ok: false, message: `Clone failed: ${raw.split('\n')[0]}` };
|
|
47059
|
+
}
|
|
47060
|
+
}
|
|
47061
|
+
|
|
45812
47062
|
function resolveStoreDir(subdir) {
|
|
45813
47063
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
45814
47064
|
const root = xdg && xdg.trim().length > 0 ? xdg : path$1.join(os$1.homedir(), '.cache');
|
|
@@ -46051,6 +47301,7 @@ function createWorkspaceState(init) {
|
|
|
46051
47301
|
showThemePicker: false,
|
|
46052
47302
|
themePickerFilter: '',
|
|
46053
47303
|
themePickerIndex: 0,
|
|
47304
|
+
helpScrollOffset: 0,
|
|
46054
47305
|
knownRepoPaths: init.knownRepoPaths ?? [],
|
|
46055
47306
|
pullRequestFetching: [],
|
|
46056
47307
|
};
|
|
@@ -46216,10 +47467,17 @@ function applyWorkspaceAction(state, action) {
|
|
|
46216
47467
|
return { ...state, status: action.status };
|
|
46217
47468
|
}
|
|
46218
47469
|
case 'toggle-help': {
|
|
46219
|
-
|
|
47470
|
+
// Always reopen at the top — picking up the last scroll position
|
|
47471
|
+
// is more surprising than predictable for a reference overlay.
|
|
47472
|
+
return { ...state, showHelp: !state.showHelp, helpScrollOffset: 0, showOnboarding: false };
|
|
46220
47473
|
}
|
|
46221
47474
|
case 'close-help': {
|
|
46222
|
-
return { ...state, showHelp: false };
|
|
47475
|
+
return { ...state, showHelp: false, helpScrollOffset: 0 };
|
|
47476
|
+
}
|
|
47477
|
+
case 'scroll-help': {
|
|
47478
|
+
// Floor-clamp at 0 only; the renderer ceiling-clamps against the
|
|
47479
|
+
// real content height so `j` past the end sticks at the last row.
|
|
47480
|
+
return { ...state, helpScrollOffset: Math.max(0, state.helpScrollOffset + action.delta) };
|
|
46223
47481
|
}
|
|
46224
47482
|
case 'toggle-theme-picker': {
|
|
46225
47483
|
return {
|
|
@@ -46549,6 +47807,7 @@ function buildWorkspaceListWindow(state, options = { rows: 20 }) {
|
|
|
46549
47807
|
const all = buildWorkspaceListRows(state, {
|
|
46550
47808
|
width: options.width,
|
|
46551
47809
|
spinnerTick: options.spinnerTick,
|
|
47810
|
+
now: options.now,
|
|
46552
47811
|
});
|
|
46553
47812
|
const visibleCount = Math.max(1, options.rows);
|
|
46554
47813
|
if (all.length <= visibleCount) {
|
|
@@ -46672,10 +47931,11 @@ function buildWorkspaceHeaderChips(state, options = { focusLabel: 'List' }) {
|
|
|
46672
47931
|
// The contextual slot drops bindings users can find via the help
|
|
46673
47932
|
// overlay (arrow keys, tab); the global slot is the safety net so
|
|
46674
47933
|
// `? help` and `q quit` never disappear.
|
|
46675
|
-
const LIST_CONTEXTUAL = ['s sort', '/ filter', 'r/R refresh', 'a add', 'd remove'];
|
|
47934
|
+
const LIST_CONTEXTUAL = ['s sort', '/ filter', 'r/R refresh', 'a add', 'c clone', 'd remove'];
|
|
46676
47935
|
const SIDEBAR_CONTEXTUAL = ['↑/↓ cycle tab', 'enter open'];
|
|
46677
47936
|
const FILTER_CONTEXTUAL = ['type to filter', 'enter apply', 'esc cancel'];
|
|
46678
47937
|
const ADD_REPO_CONTEXTUAL = ['type path', 'tab to complete', 'enter to add', 'esc to cancel'];
|
|
47938
|
+
const CLONE_REPO_CONTEXTUAL = ['enter URL', 'enter → destination', 'enter to clone', 'esc to cancel'];
|
|
46679
47939
|
const CONFIRM_DELETE_CONTEXTUAL = ['y confirm', 'any other key cancels'];
|
|
46680
47940
|
const GLOBAL_HINTS = ['? help', 'q quit'];
|
|
46681
47941
|
function contextualHintsFor(focus) {
|
|
@@ -46686,6 +47946,8 @@ function contextualHintsFor(focus) {
|
|
|
46686
47946
|
return FILTER_CONTEXTUAL;
|
|
46687
47947
|
case 'add-repo':
|
|
46688
47948
|
return ADD_REPO_CONTEXTUAL;
|
|
47949
|
+
case 'clone-repo':
|
|
47950
|
+
return CLONE_REPO_CONTEXTUAL;
|
|
46689
47951
|
case 'confirm-delete':
|
|
46690
47952
|
return CONFIRM_DELETE_CONTEXTUAL;
|
|
46691
47953
|
case 'list':
|
|
@@ -46700,6 +47962,7 @@ function buildWorkspaceFooter(state) {
|
|
|
46700
47962
|
// is open and showing them would be misleading.
|
|
46701
47963
|
const isModal = state.focus === 'filter' ||
|
|
46702
47964
|
state.focus === 'add-repo' ||
|
|
47965
|
+
state.focus === 'clone-repo' ||
|
|
46703
47966
|
state.focus === 'confirm-delete';
|
|
46704
47967
|
const global = isModal ? [] : GLOBAL_HINTS;
|
|
46705
47968
|
const allHints = [...contextual, ...global];
|
|
@@ -46761,6 +48024,7 @@ function buildWorkspaceHelpSections() {
|
|
|
46761
48024
|
{ glyph: '⟳', keys: 'r', description: 'Refresh all repos (discovery + PR counts)' },
|
|
46762
48025
|
{ glyph: '⟲', keys: 'R', description: 'Refresh just the cursored repo (faster)' },
|
|
46763
48026
|
{ glyph: '+', keys: 'a', description: 'Add a repo via path prompt (tab-completes)' },
|
|
48027
|
+
{ glyph: '⬇', keys: 'c', description: 'Clone a remote repo (defaults into the launch directory)' },
|
|
46764
48028
|
{ glyph: '✕', keys: 'd', description: 'Remove the cursored repo from the known-repos store' },
|
|
46765
48029
|
],
|
|
46766
48030
|
},
|
|
@@ -46778,7 +48042,7 @@ function buildWorkspaceOnboarding(state) {
|
|
|
46778
48042
|
: undefined,
|
|
46779
48043
|
populatedHint: empty
|
|
46780
48044
|
? undefined
|
|
46781
|
-
: 'Press `enter` to open a repo ·
|
|
48045
|
+
: 'Press `enter` to open a repo · `a` to add by path · `c` to clone · `?` for the full keymap.',
|
|
46782
48046
|
};
|
|
46783
48047
|
}
|
|
46784
48048
|
|
|
@@ -47074,6 +48338,7 @@ function renderListBody(deps, width, height) {
|
|
|
47074
48338
|
width,
|
|
47075
48339
|
rows: listRows,
|
|
47076
48340
|
spinnerTick: deps.spinnerTick,
|
|
48341
|
+
now: deps.now,
|
|
47077
48342
|
});
|
|
47078
48343
|
const visibleRepos = selectVisibleRepos(state);
|
|
47079
48344
|
const filterChip = state.filter
|
|
@@ -47109,7 +48374,7 @@ function renderListBody(deps, width, height) {
|
|
|
47109
48374
|
function renderHelpRow(deps, row, glyphWidth, keysWidth, key) {
|
|
47110
48375
|
const { React, ink, theme } = deps;
|
|
47111
48376
|
const { Box, Text } = ink;
|
|
47112
|
-
return React.createElement(Box, { key, flexDirection: 'row' }, React.createElement(Box, { width: glyphWidth, flexShrink: 0 }, React.createElement(Text, { color: theme.noColor ? undefined : theme.colors.accent, bold: true }, ` ${row.glyph ?? ' '} `)), React.createElement(Box, { width: keysWidth, flexShrink: 0 }, React.createElement(Text, { color: theme.noColor ? undefined : theme.colors.success, bold: true }, row.keys)), React.createElement(Text, null, row.description));
|
|
48377
|
+
return React.createElement(Box, { key, flexShrink: 0, flexDirection: 'row' }, React.createElement(Box, { width: glyphWidth, flexShrink: 0 }, React.createElement(Text, { color: theme.noColor ? undefined : theme.colors.accent, bold: true }, ` ${row.glyph ?? ' '} `)), React.createElement(Box, { width: keysWidth, flexShrink: 0 }, React.createElement(Text, { color: theme.noColor ? undefined : theme.colors.success, bold: true }, row.keys)), React.createElement(Text, null, row.description));
|
|
47113
48378
|
}
|
|
47114
48379
|
function renderHelpOverlay(deps) {
|
|
47115
48380
|
if (!deps.state.showHelp) {
|
|
@@ -47122,34 +48387,68 @@ function renderHelpOverlay(deps) {
|
|
|
47122
48387
|
// Columns: glyph cell (4 cells) · keys (padded to longest) · description.
|
|
47123
48388
|
const glyphWidth = 4;
|
|
47124
48389
|
const keysWidth = Math.max(14, allRows.reduce((acc, row) => Math.max(acc, row.keys.length), 0) + 4);
|
|
47125
|
-
|
|
47126
|
-
//
|
|
47127
|
-
//
|
|
47128
|
-
//
|
|
47129
|
-
|
|
47130
|
-
|
|
47131
|
-
|
|
47132
|
-
// then its rows, then a blank line.
|
|
48390
|
+
// Body lines — every scrollable row below the pinned title. Built as
|
|
48391
|
+
// a flat list (section title → optional subtitle → rows → inter-section
|
|
48392
|
+
// spacer) so we can window it against the available height. Each entry
|
|
48393
|
+
// is `flexShrink: 0` so Ink never crushes rows on top of each other
|
|
48394
|
+
// when the keymap is taller than the panel (which used to collapse the
|
|
48395
|
+
// title and the first category onto the same line).
|
|
48396
|
+
const body = [];
|
|
47133
48397
|
sections.forEach((section, sIndex) => {
|
|
47134
|
-
|
|
48398
|
+
body.push(React.createElement(Text, {
|
|
47135
48399
|
key: `section-${sIndex}-title`,
|
|
47136
48400
|
bold: true,
|
|
47137
48401
|
color: theme.noColor ? undefined : theme.colors.muted,
|
|
47138
48402
|
}, section.title.toUpperCase()));
|
|
47139
48403
|
if (section.subtitle) {
|
|
47140
|
-
|
|
48404
|
+
body.push(React.createElement(Text, { key: `section-${sIndex}-subtitle`, dimColor: true }, ` ${section.subtitle}`));
|
|
47141
48405
|
}
|
|
47142
48406
|
section.rows.forEach((row, rIndex) => {
|
|
47143
|
-
|
|
48407
|
+
body.push(renderHelpRow(deps, row, glyphWidth, keysWidth, `row-${sIndex}-${rIndex}`));
|
|
47144
48408
|
});
|
|
47145
48409
|
if (sIndex < sections.length - 1) {
|
|
47146
|
-
|
|
48410
|
+
body.push(React.createElement(Text, { key: `section-${sIndex}-spacer` }, ''));
|
|
47147
48411
|
}
|
|
47148
48412
|
});
|
|
48413
|
+
// Vertical budget: the overlay shares the column with the header
|
|
48414
|
+
// (3 rows) and footer (FOOTER_HEIGHT). Its own chrome eats the border
|
|
48415
|
+
// (2), the pinned title (1) and the title/body separator (1). Whatever
|
|
48416
|
+
// is left is the window we slide the body through.
|
|
48417
|
+
const HEADER_ROWS = 3;
|
|
48418
|
+
const overlayChromeRows = 4;
|
|
48419
|
+
const visibleRows = Math.max(4, deps.rows - HEADER_ROWS - FOOTER_HEIGHT - overlayChromeRows);
|
|
48420
|
+
// Ceiling-clamp the offset here (the reducer only floors at 0) so
|
|
48421
|
+
// scrolling past the end sticks at the last row instead of revealing
|
|
48422
|
+
// blank space.
|
|
48423
|
+
const maxOffset = Math.max(0, body.length - visibleRows);
|
|
48424
|
+
const offset = Math.min(deps.state.helpScrollOffset, maxOffset);
|
|
48425
|
+
const children = [];
|
|
48426
|
+
// Title bar — accent-tinged, matches the chip-style header on the
|
|
48427
|
+
// main surface so the help reads as the same app, just a different
|
|
48428
|
+
// panel. Pinned above the scrolling body.
|
|
48429
|
+
children.push(React.createElement(Box, { key: 'title', flexShrink: 0, flexDirection: 'row', justifyContent: 'space-between' }, React.createElement(Box, { key: 'title-left', flexDirection: 'row' }, React.createElement(Text, { bold: true, color: theme.noColor ? undefined : theme.colors.accent }, ' ? coco workspace'), React.createElement(Text, { dimColor: true }, ' keymap · '), React.createElement(Text, { dimColor: true }, `${allRows.length} bindings`)), React.createElement(Text, { dimColor: true }, 'esc / ? to close ')));
|
|
48430
|
+
children.push(React.createElement(Text, { key: 'title-sep', dimColor: true }, ''));
|
|
48431
|
+
// "more above" / "more below" hints each consume a window row so they
|
|
48432
|
+
// don't push body content off-screen. Mirrors the `coco ui` overlay.
|
|
48433
|
+
let windowSize = visibleRows;
|
|
48434
|
+
const hasMoreAbove = offset > 0;
|
|
48435
|
+
if (hasMoreAbove) {
|
|
48436
|
+
windowSize -= 1;
|
|
48437
|
+
children.push(React.createElement(Text, { key: 'more-above', dimColor: true }, ' ↑ more above (j/k or ↑/↓ to scroll)'));
|
|
48438
|
+
}
|
|
48439
|
+
const hasMoreBelow = offset + windowSize < body.length;
|
|
48440
|
+
if (hasMoreBelow) {
|
|
48441
|
+
windowSize -= 1;
|
|
48442
|
+
}
|
|
48443
|
+
children.push(...body.slice(offset, offset + windowSize));
|
|
48444
|
+
if (hasMoreBelow) {
|
|
48445
|
+
children.push(React.createElement(Text, { key: 'more-below', dimColor: true }, ' ↓ more below (j/k or ↑/↓ to scroll)'));
|
|
48446
|
+
}
|
|
47149
48447
|
return React.createElement(Box, {
|
|
47150
48448
|
borderColor: focusBorderColor(theme, true),
|
|
47151
48449
|
borderStyle: theme.borderStyle,
|
|
47152
48450
|
flexDirection: 'column',
|
|
48451
|
+
flexShrink: 0,
|
|
47153
48452
|
paddingX: 1,
|
|
47154
48453
|
}, ...children);
|
|
47155
48454
|
}
|
|
@@ -47200,6 +48499,29 @@ function renderAddRepoPrompt(deps) {
|
|
|
47200
48499
|
? React.createElement(Text, { color: toneColor('dim', theme) }, completionLine)
|
|
47201
48500
|
: null);
|
|
47202
48501
|
}
|
|
48502
|
+
function renderCloneRepoPrompt(deps) {
|
|
48503
|
+
if (deps.state.focus !== 'clone-repo') {
|
|
48504
|
+
return null;
|
|
48505
|
+
}
|
|
48506
|
+
const { React, ink, theme, cloneUrl, cloneTarget, cloneField, cloneCompletion, cloning } = deps;
|
|
48507
|
+
const { Box, Text } = ink;
|
|
48508
|
+
const urlActive = cloneField === 'url' && !cloning;
|
|
48509
|
+
const targetActive = cloneField === 'target' && !cloning;
|
|
48510
|
+
const completionLine = cloneCompletion.completions.slice(0, 8).join(' ');
|
|
48511
|
+
const hint = cloning
|
|
48512
|
+
? 'Cloning… this can take a moment for large repos.'
|
|
48513
|
+
: cloneField === 'url'
|
|
48514
|
+
? 'Paste a remote URL (https or git@…), then enter for the destination.'
|
|
48515
|
+
: 'Edit the destination · tab to complete · enter to clone · esc to cancel';
|
|
48516
|
+
return React.createElement(Box, {
|
|
48517
|
+
borderColor: focusBorderColor(theme, true),
|
|
48518
|
+
borderStyle: theme.borderStyle,
|
|
48519
|
+
flexDirection: 'column',
|
|
48520
|
+
paddingX: 1,
|
|
48521
|
+
}, React.createElement(Text, { bold: true }, 'Clone a repository'), React.createElement(Text, { color: urlActive ? undefined : toneColor('dim', theme) }, ` URL: ${cloneUrl}${urlActive ? '_' : ''}`), React.createElement(Text, { color: targetActive ? undefined : toneColor('dim', theme) }, ` Into: ${cloneTarget}${targetActive ? '_' : ''}`), React.createElement(Text, { dimColor: true }, hint), completionLine && targetActive
|
|
48522
|
+
? React.createElement(Text, { color: toneColor('dim', theme) }, completionLine)
|
|
48523
|
+
: null);
|
|
48524
|
+
}
|
|
47203
48525
|
const FOOTER_HEIGHT = 4; // 2 borders + hint row + status row
|
|
47204
48526
|
function renderFooter(deps) {
|
|
47205
48527
|
const { React, ink, state, theme } = deps;
|
|
@@ -47241,8 +48563,10 @@ function computeBodyHeight(deps) {
|
|
|
47241
48563
|
const FOOTER_ROWS = FOOTER_HEIGHT;
|
|
47242
48564
|
const onboardingRows = buildWorkspaceOnboarding(deps.state).show ? 5 : 0;
|
|
47243
48565
|
const addRepoRows = deps.state.focus === 'add-repo' ? 5 : 0;
|
|
48566
|
+
// Clone modal is one row taller (URL + Into + hint + completion).
|
|
48567
|
+
const cloneRows = deps.state.focus === 'clone-repo' ? 6 : 0;
|
|
47244
48568
|
const confirmRows = deps.state.focus === 'confirm-delete' ? 5 : 0;
|
|
47245
|
-
const reserved = HEADER_ROWS + FOOTER_ROWS + onboardingRows + addRepoRows + confirmRows;
|
|
48569
|
+
const reserved = HEADER_ROWS + FOOTER_ROWS + onboardingRows + addRepoRows + cloneRows + confirmRows;
|
|
47246
48570
|
return Math.max(8, deps.rows - reserved);
|
|
47247
48571
|
}
|
|
47248
48572
|
function renderWorkspaceApp(deps) {
|
|
@@ -47264,7 +48588,7 @@ function renderWorkspaceApp(deps) {
|
|
|
47264
48588
|
return React.createElement(Box, { flexDirection: 'column', height: rootHeight }, renderHeader(deps), renderThemePickerOverlay(React.createElement, { Box: ink.Box, Text: ink.Text }, deps.state.themePickerFilter, deps.state.themePickerIndex, bodyWidth, deps.theme, true), renderFooter(deps));
|
|
47265
48589
|
}
|
|
47266
48590
|
const bodyHeight = computeBodyHeight(deps);
|
|
47267
|
-
return React.createElement(Box, { flexDirection: 'column', height: rootHeight }, renderHeader(deps), React.createElement(Box, { flexDirection: 'row', height: bodyHeight }, renderSidebar(deps, bodyHeight), renderListBody(deps, bodyWidth - sidebarWidthFor(deps) - 2, bodyHeight)), renderOnboardingBanner(deps), renderAddRepoPrompt(deps), renderConfirmDelete(deps), renderFooter(deps));
|
|
48591
|
+
return React.createElement(Box, { flexDirection: 'column', height: rootHeight }, renderHeader(deps), React.createElement(Box, { flexDirection: 'row', height: bodyHeight }, renderSidebar(deps, bodyHeight), renderListBody(deps, bodyWidth - sidebarWidthFor(deps) - 2, bodyHeight)), renderOnboardingBanner(deps), renderAddRepoPrompt(deps), renderCloneRepoPrompt(deps), renderConfirmDelete(deps), renderFooter(deps));
|
|
47268
48592
|
}
|
|
47269
48593
|
|
|
47270
48594
|
/**
|
|
@@ -47301,6 +48625,21 @@ function resolveWorkspaceInput(input, key, state) {
|
|
|
47301
48625
|
if (key.escape || input === '?' || input === 'q') {
|
|
47302
48626
|
return { kind: 'action', action: { type: 'close-help' } };
|
|
47303
48627
|
}
|
|
48628
|
+
// The keymap is taller than the panel on short terminals — let
|
|
48629
|
+
// j/k/↑/↓ and ctrl+d/u scroll the windowed body. Mirrors the
|
|
48630
|
+
// `coco ui` help overlay.
|
|
48631
|
+
if (key.downArrow || input === 'j') {
|
|
48632
|
+
return { kind: 'action', action: { type: 'scroll-help', delta: 1 } };
|
|
48633
|
+
}
|
|
48634
|
+
if (key.upArrow || input === 'k') {
|
|
48635
|
+
return { kind: 'action', action: { type: 'scroll-help', delta: -1 } };
|
|
48636
|
+
}
|
|
48637
|
+
if (key.ctrl && input === 'd') {
|
|
48638
|
+
return { kind: 'action', action: { type: 'scroll-help', delta: 10 } };
|
|
48639
|
+
}
|
|
48640
|
+
if (key.ctrl && input === 'u') {
|
|
48641
|
+
return { kind: 'action', action: { type: 'scroll-help', delta: -10 } };
|
|
48642
|
+
}
|
|
47304
48643
|
return { kind: 'noop' };
|
|
47305
48644
|
}
|
|
47306
48645
|
// Theme picker is modal (like `coco ui`'s gC): type to filter, ↑/↓ to
|
|
@@ -47351,6 +48690,14 @@ function resolveWorkspaceInput(input, key, state) {
|
|
|
47351
48690
|
// can drive the path-completion prompt.
|
|
47352
48691
|
return { kind: 'noop' };
|
|
47353
48692
|
}
|
|
48693
|
+
if (state.focus === 'clone-repo') {
|
|
48694
|
+
if (key.escape) {
|
|
48695
|
+
return { kind: 'action', action: { type: 'set-focus', focus: 'list' } };
|
|
48696
|
+
}
|
|
48697
|
+
// Enter/Tab/printable keys drive the URL + destination prompt in the
|
|
48698
|
+
// runtime (it owns the two-field state + path completion).
|
|
48699
|
+
return { kind: 'noop' };
|
|
48700
|
+
}
|
|
47354
48701
|
// Confirm-delete is modal: only `y` confirms, anything else cancels.
|
|
47355
48702
|
if (state.focus === 'confirm-delete') {
|
|
47356
48703
|
if (input === 'y' || input === 'Y') {
|
|
@@ -47445,6 +48792,9 @@ function resolveWorkspaceInput(input, key, state) {
|
|
|
47445
48792
|
if (input === 'a') {
|
|
47446
48793
|
return { kind: 'add-repo' };
|
|
47447
48794
|
}
|
|
48795
|
+
if (input === 'c') {
|
|
48796
|
+
return { kind: 'clone-repo' };
|
|
48797
|
+
}
|
|
47448
48798
|
if (input === 'd') {
|
|
47449
48799
|
return { kind: 'request-delete' };
|
|
47450
48800
|
}
|
|
@@ -47999,6 +49349,19 @@ function WorkspaceInkApp(props) {
|
|
|
47999
49349
|
const [filterDraft, setFilterDraft] = React.useState('');
|
|
48000
49350
|
const [addRepoDraft, setAddRepoDraft] = React.useState('~/');
|
|
48001
49351
|
const [addRepoCompletion, setAddRepoCompletion] = React.useState(() => completePath('~/'));
|
|
49352
|
+
// Clone-repo modal (`c`). Two fields: the remote URL and the
|
|
49353
|
+
// destination path. `cloneField` tracks which is active; `cloneTarget`
|
|
49354
|
+
// auto-derives `<cwd>/<repo-name>` from the URL until the user edits it
|
|
49355
|
+
// (`cloneTargetEdited`). `cloning` blocks input + shows a spinner while
|
|
49356
|
+
// `git clone` runs. The boot cwd is captured once at mount so it stays
|
|
49357
|
+
// the directory the workspace launched in even after drill-in.
|
|
49358
|
+
const bootCwdRef = React.useRef(process.cwd());
|
|
49359
|
+
const [cloneUrl, setCloneUrl] = React.useState('');
|
|
49360
|
+
const [cloneTarget, setCloneTarget] = React.useState('');
|
|
49361
|
+
const [cloneField, setCloneField] = React.useState('url');
|
|
49362
|
+
const [cloneTargetEdited, setCloneTargetEdited] = React.useState(false);
|
|
49363
|
+
const [cloneCompletion, setCloneCompletion] = React.useState(() => completePath('~/'));
|
|
49364
|
+
const [cloning, setCloning] = React.useState(false);
|
|
48002
49365
|
// Tick counter for the per-row PR-fetch spinner. Bumped on a
|
|
48003
49366
|
// setInterval that only runs while at least one row is mid-fetch
|
|
48004
49367
|
// (see effect below) so idle workspaces don't burn CPU on animation
|
|
@@ -48048,6 +49411,18 @@ function WorkspaceInkApp(props) {
|
|
|
48048
49411
|
addRepoDraftRef.current = addRepoDraft;
|
|
48049
49412
|
const addRepoCompletionRef = React.useRef(addRepoCompletion);
|
|
48050
49413
|
addRepoCompletionRef.current = addRepoCompletion;
|
|
49414
|
+
const cloneUrlRef = React.useRef(cloneUrl);
|
|
49415
|
+
cloneUrlRef.current = cloneUrl;
|
|
49416
|
+
const cloneTargetRef = React.useRef(cloneTarget);
|
|
49417
|
+
cloneTargetRef.current = cloneTarget;
|
|
49418
|
+
const cloneFieldRef = React.useRef(cloneField);
|
|
49419
|
+
cloneFieldRef.current = cloneField;
|
|
49420
|
+
const cloneTargetEditedRef = React.useRef(cloneTargetEdited);
|
|
49421
|
+
cloneTargetEditedRef.current = cloneTargetEdited;
|
|
49422
|
+
const cloneCompletionRef = React.useRef(cloneCompletion);
|
|
49423
|
+
cloneCompletionRef.current = cloneCompletion;
|
|
49424
|
+
const cloningRef = React.useRef(cloning);
|
|
49425
|
+
cloningRef.current = cloning;
|
|
48051
49426
|
// Background discovery + PR-count refresh on mount.
|
|
48052
49427
|
React.useEffect(() => {
|
|
48053
49428
|
let cancelled = false;
|
|
@@ -48280,6 +49655,60 @@ function WorkspaceInkApp(props) {
|
|
|
48280
49655
|
});
|
|
48281
49656
|
}
|
|
48282
49657
|
}, [addRepoDraft, dispatch, props]);
|
|
49658
|
+
// Default destination for a clone URL: `<bootCwd>/<repo-name>`.
|
|
49659
|
+
const cloneTargetFor = React.useCallback((url) => {
|
|
49660
|
+
return path$1.join(bootCwdRef.current, deriveRepoName(url));
|
|
49661
|
+
}, []);
|
|
49662
|
+
const openClone = React.useCallback(() => {
|
|
49663
|
+
setCloneUrl('');
|
|
49664
|
+
setCloneTarget('');
|
|
49665
|
+
setCloneField('url');
|
|
49666
|
+
setCloneTargetEdited(false);
|
|
49667
|
+
setCloneCompletion(completePath(`${bootCwdRef.current}/`));
|
|
49668
|
+
dispatch({ type: 'set-focus', focus: 'clone-repo' });
|
|
49669
|
+
}, [dispatch]);
|
|
49670
|
+
const commitClone = React.useCallback(async () => {
|
|
49671
|
+
const url = cloneUrlRef.current.trim();
|
|
49672
|
+
const target = expandHomePrefix(cloneTargetRef.current.trim().replace(/\/+$/, ''));
|
|
49673
|
+
if (!url) {
|
|
49674
|
+
dispatch({ type: 'set-status', status: 'Enter a remote URL.' });
|
|
49675
|
+
return;
|
|
49676
|
+
}
|
|
49677
|
+
if (!target) {
|
|
49678
|
+
dispatch({ type: 'set-status', status: 'Enter a destination path.' });
|
|
49679
|
+
return;
|
|
49680
|
+
}
|
|
49681
|
+
setCloning(true);
|
|
49682
|
+
dispatch({ type: 'set-status', status: `Cloning ${deriveRepoName(url)}…` });
|
|
49683
|
+
const result = await cloneRepo(url, target);
|
|
49684
|
+
if (unmountedRef.current)
|
|
49685
|
+
return;
|
|
49686
|
+
setCloning(false);
|
|
49687
|
+
if (!result.ok) {
|
|
49688
|
+
// Keep the modal open so the user can fix the URL / path and retry.
|
|
49689
|
+
dispatch({ type: 'set-status', status: result.message });
|
|
49690
|
+
return;
|
|
49691
|
+
}
|
|
49692
|
+
const updated = appendKnownRepo(target);
|
|
49693
|
+
dispatch({ type: 'replace-known-repos', paths: updated });
|
|
49694
|
+
dispatch({ type: 'set-focus', focus: 'list' });
|
|
49695
|
+
dispatch({ type: 'set-status', status: result.message });
|
|
49696
|
+
dispatch({ type: 'set-loading', loading: true });
|
|
49697
|
+
try {
|
|
49698
|
+
const merged = mergeKnownRepos(props.knownRepos, readKnownRepos());
|
|
49699
|
+
const overview = await props.loadOverview(props.roots, merged);
|
|
49700
|
+
writeCachedWorkspace(props.roots, overview);
|
|
49701
|
+
dispatch({ type: 'replace-overview', overview });
|
|
49702
|
+
dispatch({ type: 'anchor-cursor-by-path', path: target });
|
|
49703
|
+
}
|
|
49704
|
+
catch (err) {
|
|
49705
|
+
dispatch({ type: 'set-loading', loading: false });
|
|
49706
|
+
dispatch({
|
|
49707
|
+
type: 'set-status',
|
|
49708
|
+
status: err instanceof Error ? err.message : 'Refresh failed.',
|
|
49709
|
+
});
|
|
49710
|
+
}
|
|
49711
|
+
}, [dispatch, props]);
|
|
48283
49712
|
// Callback refs so the stable input handler can reach the latest
|
|
48284
49713
|
// closure without taking them in deps.
|
|
48285
49714
|
const commitAddRepoRef = React.useRef(commitAddRepo);
|
|
@@ -48292,6 +49721,10 @@ function WorkspaceInkApp(props) {
|
|
|
48292
49721
|
refreshRowRef.current = refreshRow;
|
|
48293
49722
|
const openAddRepoRef = React.useRef(openAddRepo);
|
|
48294
49723
|
openAddRepoRef.current = openAddRepo;
|
|
49724
|
+
const openCloneRef = React.useRef(openClone);
|
|
49725
|
+
openCloneRef.current = openClone;
|
|
49726
|
+
const commitCloneRef = React.useRef(commitClone);
|
|
49727
|
+
commitCloneRef.current = commitClone;
|
|
48295
49728
|
const requestDeleteRef = React.useRef(requestDelete);
|
|
48296
49729
|
requestDeleteRef.current = requestDelete;
|
|
48297
49730
|
const confirmDeleteRef = React.useRef(confirmDelete);
|
|
@@ -48374,6 +49807,73 @@ function WorkspaceInkApp(props) {
|
|
|
48374
49807
|
}
|
|
48375
49808
|
return;
|
|
48376
49809
|
}
|
|
49810
|
+
if (state.focus === 'clone-repo') {
|
|
49811
|
+
// While the clone is running, swallow everything except Esc
|
|
49812
|
+
// (which is a no-op here — the clone is already in flight).
|
|
49813
|
+
if (cloningRef.current)
|
|
49814
|
+
return;
|
|
49815
|
+
if (key.escape) {
|
|
49816
|
+
dispatch({ type: 'set-focus', focus: 'list' });
|
|
49817
|
+
return;
|
|
49818
|
+
}
|
|
49819
|
+
const field = cloneFieldRef.current;
|
|
49820
|
+
const url = cloneUrlRef.current;
|
|
49821
|
+
const target = cloneTargetRef.current;
|
|
49822
|
+
const targetEdited = cloneTargetEditedRef.current;
|
|
49823
|
+
if (key.return) {
|
|
49824
|
+
if (field === 'url') {
|
|
49825
|
+
if (!url.trim()) {
|
|
49826
|
+
dispatch({ type: 'set-status', status: 'Enter a remote URL.' });
|
|
49827
|
+
return;
|
|
49828
|
+
}
|
|
49829
|
+
// Advance to the (pre-filled, editable) destination field.
|
|
49830
|
+
const derived = targetEdited ? target : cloneTargetFor(url);
|
|
49831
|
+
setCloneTarget(derived);
|
|
49832
|
+
setCloneCompletion(completePath(derived));
|
|
49833
|
+
setCloneField('target');
|
|
49834
|
+
return;
|
|
49835
|
+
}
|
|
49836
|
+
void commitCloneRef.current();
|
|
49837
|
+
return;
|
|
49838
|
+
}
|
|
49839
|
+
if (key.tab && field === 'target') {
|
|
49840
|
+
const next = applyTabCompletion(target, cloneCompletionRef.current);
|
|
49841
|
+
setCloneTarget(next);
|
|
49842
|
+
setCloneTargetEdited(true);
|
|
49843
|
+
setCloneCompletion(completePath(next));
|
|
49844
|
+
return;
|
|
49845
|
+
}
|
|
49846
|
+
if (key.backspace || key.delete) {
|
|
49847
|
+
if (field === 'url') {
|
|
49848
|
+
const next = url.slice(0, -1);
|
|
49849
|
+
setCloneUrl(next);
|
|
49850
|
+
if (!targetEdited)
|
|
49851
|
+
setCloneTarget(next ? cloneTargetFor(next) : '');
|
|
49852
|
+
}
|
|
49853
|
+
else {
|
|
49854
|
+
const next = target.slice(0, -1);
|
|
49855
|
+
setCloneTarget(next);
|
|
49856
|
+
setCloneTargetEdited(true);
|
|
49857
|
+
setCloneCompletion(completePath(next || '~/'));
|
|
49858
|
+
}
|
|
49859
|
+
return;
|
|
49860
|
+
}
|
|
49861
|
+
if (rawInput && !key.ctrl && !key.meta) {
|
|
49862
|
+
if (field === 'url') {
|
|
49863
|
+
const next = url + rawInput;
|
|
49864
|
+
setCloneUrl(next);
|
|
49865
|
+
if (!targetEdited)
|
|
49866
|
+
setCloneTarget(cloneTargetFor(next));
|
|
49867
|
+
}
|
|
49868
|
+
else {
|
|
49869
|
+
const next = target + rawInput;
|
|
49870
|
+
setCloneTarget(next);
|
|
49871
|
+
setCloneTargetEdited(true);
|
|
49872
|
+
setCloneCompletion(completePath(next));
|
|
49873
|
+
}
|
|
49874
|
+
}
|
|
49875
|
+
return;
|
|
49876
|
+
}
|
|
48377
49877
|
// Ctrl+C → quit, since we disabled Ink's built-in ctrl+c exit.
|
|
48378
49878
|
// Handled here (rather than in the pure resolver) because the
|
|
48379
49879
|
// resolver doesn't have a notion of "raw key with ctrl flag" for
|
|
@@ -48414,6 +49914,9 @@ function WorkspaceInkApp(props) {
|
|
|
48414
49914
|
case 'add-repo':
|
|
48415
49915
|
openAddRepoRef.current();
|
|
48416
49916
|
break;
|
|
49917
|
+
case 'clone-repo':
|
|
49918
|
+
openCloneRef.current();
|
|
49919
|
+
break;
|
|
48417
49920
|
case 'request-delete':
|
|
48418
49921
|
requestDeleteRef.current();
|
|
48419
49922
|
break;
|
|
@@ -48463,6 +49966,11 @@ function WorkspaceInkApp(props) {
|
|
|
48463
49966
|
filterDraft,
|
|
48464
49967
|
addRepoDraft,
|
|
48465
49968
|
addRepoCompletion,
|
|
49969
|
+
cloneUrl,
|
|
49970
|
+
cloneTarget,
|
|
49971
|
+
cloneField,
|
|
49972
|
+
cloneCompletion,
|
|
49973
|
+
cloning,
|
|
48466
49974
|
columns,
|
|
48467
49975
|
rows,
|
|
48468
49976
|
spinnerTick,
|