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.js
CHANGED
|
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
|
|
|
78
78
|
/**
|
|
79
79
|
* Current build version from package.json
|
|
80
80
|
*/
|
|
81
|
-
const BUILD_VERSION = "0.
|
|
81
|
+
const BUILD_VERSION = "0.61.0";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -309,10 +309,19 @@ class LangChainExecutionError extends LangChainError {
|
|
|
309
309
|
}
|
|
310
310
|
/**
|
|
311
311
|
* Authentication-related errors (missing API keys, invalid credentials, etc.)
|
|
312
|
+
*
|
|
313
|
+
* Carries `provider` + `endpoint` context so the formatter (in
|
|
314
|
+
* `commandExecutor`) can render provider-specific recovery hints
|
|
315
|
+
* ("set OPENAI_API_KEY", "run `gh auth login`", etc.) instead of the
|
|
316
|
+
* generic "verify your API key" copy. Mirrors the shape of
|
|
317
|
+
* `LangChainNetworkError` so call sites can hand the same fields to
|
|
318
|
+
* either constructor depending on which condition fired.
|
|
312
319
|
*/
|
|
313
320
|
class LangChainAuthenticationError extends LangChainError {
|
|
314
|
-
constructor(message, context) {
|
|
315
|
-
super(message, context);
|
|
321
|
+
constructor(message, provider, endpoint, context) {
|
|
322
|
+
super(message, { ...context, provider, endpoint });
|
|
323
|
+
this.provider = provider;
|
|
324
|
+
this.endpoint = endpoint;
|
|
316
325
|
}
|
|
317
326
|
}
|
|
318
327
|
/**
|
|
@@ -450,21 +459,27 @@ function getDefaultServiceApiKey(config) {
|
|
|
450
459
|
const requiresAuth = provider === 'openai' || provider === 'anthropic';
|
|
451
460
|
if (service.authentication.type === 'APIKey') {
|
|
452
461
|
const apiKey = service.authentication.credentials?.apiKey;
|
|
462
|
+
// `endpoint` is optional on some service variants (Ollama / OpenAI-
|
|
463
|
+
// compatible) and absent on others (managed OpenAI / Anthropic).
|
|
464
|
+
// Read defensively so we still attach it when present.
|
|
465
|
+
const endpoint = service.endpoint;
|
|
453
466
|
if (requiresAuth && (!apiKey || apiKey.trim() === '')) {
|
|
454
|
-
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: API key is required for ${provider} provider but not provided`,
|
|
467
|
+
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: API key is required for ${provider} provider but not provided`, provider, endpoint, { authenticationType: service.authentication.type });
|
|
455
468
|
}
|
|
456
469
|
return apiKey || '';
|
|
457
470
|
}
|
|
458
471
|
if (service.authentication.type === 'OAuth') {
|
|
459
472
|
const token = service.authentication.credentials?.token;
|
|
473
|
+
const endpoint = service.endpoint;
|
|
460
474
|
if (requiresAuth && (!token || token.trim() === '')) {
|
|
461
|
-
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: OAuth token is required for ${provider} provider but not provided`,
|
|
475
|
+
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: OAuth token is required for ${provider} provider but not provided`, provider, endpoint, { authenticationType: service.authentication.type });
|
|
462
476
|
}
|
|
463
477
|
return token || '';
|
|
464
478
|
}
|
|
465
479
|
if (service.authentication.type === 'None') {
|
|
466
480
|
if (requiresAuth) {
|
|
467
|
-
|
|
481
|
+
const endpoint = service.endpoint;
|
|
482
|
+
throw new LangChainAuthenticationError(`getDefaultServiceApiKey: ${provider} provider requires authentication but 'None' was configured`, provider, endpoint, { authenticationType: service.authentication.type });
|
|
468
483
|
}
|
|
469
484
|
return '';
|
|
470
485
|
}
|
|
@@ -2597,18 +2612,48 @@ function formatNetworkError(error, logger) {
|
|
|
2597
2612
|
logger.log(' • Verify the service endpoint is correct', { color: 'white' });
|
|
2598
2613
|
logger.log(' • Ensure the LLM service is running and accessible', { color: 'white' });
|
|
2599
2614
|
}
|
|
2615
|
+
logger.log(' • Run `coco doctor` to verify your configured provider + endpoint', { color: 'white' });
|
|
2600
2616
|
logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
|
|
2601
2617
|
}
|
|
2602
2618
|
/**
|
|
2603
|
-
* Formats an authentication error with
|
|
2619
|
+
* Formats an authentication error with provider-aware troubleshooting.
|
|
2620
|
+
*
|
|
2621
|
+
* Pre-MEDIUM-8 the formatter was generic — "verify your API key,
|
|
2622
|
+
* check it hasn't expired" — because the error class didn't carry
|
|
2623
|
+
* any provider context. Now that `LangChainAuthenticationError`
|
|
2624
|
+
* carries `provider` + `endpoint` (mirroring `LangChainNetworkError`),
|
|
2625
|
+
* we can name the env var the user actually needs to set and route
|
|
2626
|
+
* Ollama / OpenAI-compatible / managed-provider users through the
|
|
2627
|
+
* right next step.
|
|
2604
2628
|
*/
|
|
2605
2629
|
function formatAuthenticationError(error, logger) {
|
|
2630
|
+
const provider = error.provider || 'LLM service';
|
|
2631
|
+
const endpoint = error.endpoint;
|
|
2606
2632
|
logger.log('\nFailed to execute command', { color: 'yellow' });
|
|
2607
|
-
logger.log(
|
|
2633
|
+
logger.log(`\nError: Authentication failed${error.provider ? ` for ${provider}` : ''}`, { color: 'red' });
|
|
2634
|
+
if (endpoint) {
|
|
2635
|
+
logger.log(` Endpoint: ${endpoint}`, { color: 'red' });
|
|
2636
|
+
}
|
|
2608
2637
|
logger.log('\nTroubleshooting:', { color: 'cyan' });
|
|
2609
|
-
logger.log(' • Verify your API key is correct', { color: 'white' });
|
|
2610
|
-
|
|
2611
|
-
|
|
2638
|
+
logger.log(' • Verify your API key is correct and has not expired', { color: 'white' });
|
|
2639
|
+
// Provider-specific env var hint when we know the provider.
|
|
2640
|
+
if (provider === 'openai' || provider === 'OpenAI') {
|
|
2641
|
+
logger.log(' • Set `OPENAI_API_KEY` in your shell or `service.authentication.credentials.apiKey` in config', { color: 'white' });
|
|
2642
|
+
}
|
|
2643
|
+
else if (provider === 'anthropic' || provider === 'Anthropic') {
|
|
2644
|
+
logger.log(' • Set `ANTHROPIC_API_KEY` in your shell or `service.authentication.credentials.apiKey` in config', { color: 'white' });
|
|
2645
|
+
}
|
|
2646
|
+
else if (provider === 'ollama' || provider === 'Ollama') {
|
|
2647
|
+
logger.log(' • Ollama usually does not need a key — check `service.endpoint` and that `ollama serve` is running', { color: 'white' });
|
|
2648
|
+
}
|
|
2649
|
+
else if (provider === 'openai-compatible') {
|
|
2650
|
+
logger.log(' • OpenAI-compatible endpoints need both `service.endpoint` and a valid API key', { color: 'white' });
|
|
2651
|
+
}
|
|
2652
|
+
else {
|
|
2653
|
+
logger.log(' • Ensure the API key is set in your environment or config', { color: 'white' });
|
|
2654
|
+
}
|
|
2655
|
+
logger.log(' • Run `coco init` to (re)configure your provider + key', { color: 'white' });
|
|
2656
|
+
logger.log(' • Run `coco doctor` to inspect the active config sources', { color: 'white' });
|
|
2612
2657
|
logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
|
|
2613
2658
|
}
|
|
2614
2659
|
/**
|
|
@@ -3578,8 +3623,7 @@ const handler$b = async (argv, logger) => {
|
|
|
3578
3623
|
const result = clearDiffSummaryCache(repoPath);
|
|
3579
3624
|
if (!result.ok) {
|
|
3580
3625
|
logger.log(chalk.red(`Failed to clear diff-summary cache at ${cachePath}`));
|
|
3581
|
-
|
|
3582
|
-
return;
|
|
3626
|
+
commandExit(1, 'cache clear failed');
|
|
3583
3627
|
}
|
|
3584
3628
|
if (result.removed) {
|
|
3585
3629
|
logger.log(chalk.green(`Cleared diff-summary cache at ${cachePath}`));
|
|
@@ -3619,8 +3663,7 @@ const handler$b = async (argv, logger) => {
|
|
|
3619
3663
|
if (interactive) {
|
|
3620
3664
|
const picked = await promptLanguageSelection(logger);
|
|
3621
3665
|
if (!picked) {
|
|
3622
|
-
|
|
3623
|
-
return;
|
|
3666
|
+
commandExit(1, 'cache prefetch cancelled');
|
|
3624
3667
|
}
|
|
3625
3668
|
resolved = picked;
|
|
3626
3669
|
}
|
|
@@ -3637,7 +3680,7 @@ const handler$b = async (argv, logger) => {
|
|
|
3637
3680
|
`${chalk.dim(`${result.alreadyCached.length} already cached`)} · ` +
|
|
3638
3681
|
`${chalk.red(`${result.failed.length} failed`)}`);
|
|
3639
3682
|
if (result.failed.length > 0) {
|
|
3640
|
-
|
|
3683
|
+
commandExit(1, `cache prefetch failed for ${result.failed.length} language(s)`);
|
|
3641
3684
|
}
|
|
3642
3685
|
return;
|
|
3643
3686
|
}
|
|
@@ -3670,12 +3713,12 @@ const handler$b = async (argv, logger) => {
|
|
|
3670
3713
|
}
|
|
3671
3714
|
logger.log(chalk.red(`Unknown cache subcommand: ${subcommand}`));
|
|
3672
3715
|
logger.log(chalk.dim('Use one of: clear, info, parsers, prefetch, clear-parsers, clear-github'));
|
|
3673
|
-
|
|
3716
|
+
commandExit(1, `unknown cache subcommand: ${subcommand}`);
|
|
3674
3717
|
};
|
|
3675
3718
|
|
|
3676
3719
|
var cache = {
|
|
3677
3720
|
command: command$b,
|
|
3678
|
-
desc: 'Manage
|
|
3721
|
+
desc: 'Manage coco caches (clear, info, parsers, prefetch, github)',
|
|
3679
3722
|
builder: builder$b,
|
|
3680
3723
|
handler: commandExecutor(handler$b),
|
|
3681
3724
|
};
|
|
@@ -8944,6 +8987,124 @@ async function generateAndReviewLoop({ label, factory, parser, noResult, agent,
|
|
|
8944
8987
|
return result;
|
|
8945
8988
|
}
|
|
8946
8989
|
|
|
8990
|
+
/**
|
|
8991
|
+
* Centralised glyph + label vocabulary for diagnostic / status copy.
|
|
8992
|
+
*
|
|
8993
|
+
* Before this module each surface (commandExecutor, doctor, footer,
|
|
8994
|
+
* cache, issues, prs, commit-hook flow) picked its own marks for
|
|
8995
|
+
* pass / warn / fail / info — `✓` here, `✔` there, `✖` vs `✗`. Users
|
|
8996
|
+
* couldn't lean on a consistent visual signal to scan output, and the
|
|
8997
|
+
* audit flagged it as one of the bigger inconsistencies in the
|
|
8998
|
+
* codebase.
|
|
8999
|
+
*
|
|
9000
|
+
* The vocabulary mirrors what Linux package managers + git-aware
|
|
9001
|
+
* tools converge on (`pacman`, `apt`, `nala`, `npm doctor`, etc.) —
|
|
9002
|
+
* green check / red fail / yellow warn / blue info. ASCII fallbacks
|
|
9003
|
+
* are first-class so dumb terminals (TERM=dumb / vt100) still render
|
|
9004
|
+
* a meaningful prefix.
|
|
9005
|
+
*
|
|
9006
|
+
* Conventions:
|
|
9007
|
+
* - Status glyphs (PASS / FAIL / WARN / INFO) — for diagnostic
|
|
9008
|
+
* output, command exit, doctor severity, footer message kinds.
|
|
9009
|
+
* Colour-coded variants live alongside as `*_COLORED` helpers
|
|
9010
|
+
* so callers can use either depending on context.
|
|
9011
|
+
* - Action glyphs (BULLET, ARROW) — for indented hint lines and
|
|
9012
|
+
* "next step" callouts.
|
|
9013
|
+
* - Domain glyphs (CHECK_RUN_*, DECISION_*) — keep their own
|
|
9014
|
+
* vocabularies (PR reviews, status checks) because their
|
|
9015
|
+
* semantic shape doesn't map cleanly onto pass/fail/warn/info.
|
|
9016
|
+
*
|
|
9017
|
+
* Use `pickGlyph(unicode, ascii, isAscii)` when you need to honor
|
|
9018
|
+
* `theme.ascii` mode in a single call site.
|
|
9019
|
+
*/
|
|
9020
|
+
/**
|
|
9021
|
+
* Status-severity glyph set. Same vocabulary as the workstation
|
|
9022
|
+
* footer's `kind` field (info / warning / error / success / loading)
|
|
9023
|
+
* plus `pass` for the doctor / "no problem" case.
|
|
9024
|
+
*/
|
|
9025
|
+
const GLYPHS = {
|
|
9026
|
+
pass: '✓',
|
|
9027
|
+
fail: '✖',
|
|
9028
|
+
warn: '⚠',
|
|
9029
|
+
info: 'ℹ',
|
|
9030
|
+
bullet: '•'};
|
|
9031
|
+
/**
|
|
9032
|
+
* Theme-tinted helpers for terminal output. These return chalk-wrapped
|
|
9033
|
+
* strings so callers don't repeat the `chalk.<color>(GLYPHS.<key>)`
|
|
9034
|
+
* pattern. Each maps to the canonical colour the codebase uses for
|
|
9035
|
+
* that severity:
|
|
9036
|
+
*
|
|
9037
|
+
* - PASS → green
|
|
9038
|
+
* - FAIL → red
|
|
9039
|
+
* - WARN → yellow
|
|
9040
|
+
* - INFO → blue
|
|
9041
|
+
*
|
|
9042
|
+
* Doctor's `SEVERITY_ICON` lookup is the canonical example — it now
|
|
9043
|
+
* delegates here so the colours stay in sync if the theme palette
|
|
9044
|
+
* shifts in the future.
|
|
9045
|
+
*/
|
|
9046
|
+
const PASS = () => chalk.green(GLYPHS.pass);
|
|
9047
|
+
const FAIL = () => chalk.red(GLYPHS.fail);
|
|
9048
|
+
const WARN = () => chalk.yellow(GLYPHS.warn);
|
|
9049
|
+
const INFO = () => chalk.blue(GLYPHS.info);
|
|
9050
|
+
|
|
9051
|
+
/**
|
|
9052
|
+
* Maps each provider to the env var users should set + the kebab-case
|
|
9053
|
+
* provider label used in the recovery copy. `coco init` and `coco
|
|
9054
|
+
* doctor` both reference these names; keeping the lookup in one place
|
|
9055
|
+
* makes the messages stay aligned when a new provider lands.
|
|
9056
|
+
*/
|
|
9057
|
+
const PROVIDER_ENV_VARS = {
|
|
9058
|
+
openai: { envVar: 'OPENAI_API_KEY', label: 'OpenAI' },
|
|
9059
|
+
anthropic: { envVar: 'ANTHROPIC_API_KEY', label: 'Anthropic' },
|
|
9060
|
+
ollama: { envVar: 'OLLAMA_API_KEY', label: 'Ollama' },
|
|
9061
|
+
'openai-compatible': { envVar: 'OPENAI_API_KEY', label: 'OpenAI-compatible' },
|
|
9062
|
+
};
|
|
9063
|
+
/**
|
|
9064
|
+
* Print a structured "missing API key" message + exit non-zero.
|
|
9065
|
+
*
|
|
9066
|
+
* Replaces the old `No API Key found. 🗝️🚪` one-liner that used to live
|
|
9067
|
+
* inline in commit / changelog / recap / review handlers. Centralised
|
|
9068
|
+
* because:
|
|
9069
|
+
*
|
|
9070
|
+
* 1. The message names the env var the user actually needs to set
|
|
9071
|
+
* (different per provider) — that was the single biggest gap in
|
|
9072
|
+
* the prior message.
|
|
9073
|
+
* 2. It surfaces the configured provider + model so the user can tell
|
|
9074
|
+
* which of their providers tripped the check (useful when running
|
|
9075
|
+
* with dynamic model routing).
|
|
9076
|
+
* 3. It points at `coco init` and `coco doctor` as the recovery
|
|
9077
|
+
* paths, mirroring the discoverability cue every other modern CLI
|
|
9078
|
+
* uses for first-run config errors.
|
|
9079
|
+
*
|
|
9080
|
+
* Throws `CommandExitError(1)` via `commandExit` — callers do NOT need
|
|
9081
|
+
* to handle the return value.
|
|
9082
|
+
*/
|
|
9083
|
+
function handleMissingApiKey(logger, config, options) {
|
|
9084
|
+
const provider = config.service?.provider || 'unknown';
|
|
9085
|
+
const model = config.service?.model || 'unknown';
|
|
9086
|
+
const providerInfo = PROVIDER_ENV_VARS[provider] || {
|
|
9087
|
+
envVar: 'PROVIDER_API_KEY',
|
|
9088
|
+
label: provider,
|
|
9089
|
+
};
|
|
9090
|
+
const lines = [
|
|
9091
|
+
`${FAIL()} ${chalk.bold('Missing API key')} for ${chalk.cyan(providerInfo.label)} (model: ${chalk.cyan(model)})`,
|
|
9092
|
+
'',
|
|
9093
|
+
`${chalk.bold('Next step')} — set up an API key one of these ways:`,
|
|
9094
|
+
` ${chalk.dim(GLYPHS.bullet)} Run ${chalk.cyan('coco init')} to walk through provider + key setup`,
|
|
9095
|
+
` ${chalk.dim(GLYPHS.bullet)} Export ${chalk.cyan(providerInfo.envVar)} in your shell`,
|
|
9096
|
+
` ${chalk.dim(GLYPHS.bullet)} Add the key to ${chalk.cyan('.coco.config.json')} or ${chalk.cyan('~/.gitconfig')} (under ${chalk.cyan('[coco]')})`,
|
|
9097
|
+
'',
|
|
9098
|
+
`${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('to diagnose the active config sources.')}`,
|
|
9099
|
+
];
|
|
9100
|
+
for (const line of lines) {
|
|
9101
|
+
logger.log(line);
|
|
9102
|
+
}
|
|
9103
|
+
// Tag the exit message with the failing command so process supervisors
|
|
9104
|
+
// / CI logs can grep for it without parsing the full body.
|
|
9105
|
+
commandExit(1, `${options.command}: missing API key for ${providerInfo.label}`);
|
|
9106
|
+
}
|
|
9107
|
+
|
|
8947
9108
|
const logSuccess = () => {
|
|
8948
9109
|
console.log(chalk.green(chalk.bold('\nAll set! 🦾🤖')));
|
|
8949
9110
|
};
|
|
@@ -14436,8 +14597,7 @@ const handler$a = async (argv, logger) => {
|
|
|
14436
14597
|
commandExit(1);
|
|
14437
14598
|
}
|
|
14438
14599
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
14439
|
-
logger
|
|
14440
|
-
commandExit(1);
|
|
14600
|
+
handleMissingApiKey(logger, config, { command: 'changelog' });
|
|
14441
14601
|
}
|
|
14442
14602
|
const llm = getLlm(provider, model, { ...config, service: changelogService });
|
|
14443
14603
|
const summaryLlm = getLlm(provider, summaryService.model, { ...config, service: summaryService });
|
|
@@ -16333,8 +16493,7 @@ const handler$9 = async (argv, logger) => {
|
|
|
16333
16493
|
const splitService = resolveDynamicService(config, 'commitSplit');
|
|
16334
16494
|
const model = commitService.model;
|
|
16335
16495
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
16336
|
-
logger
|
|
16337
|
-
commandExit(1);
|
|
16496
|
+
handleMissingApiKey(logger, config, { command: 'commit' });
|
|
16338
16497
|
}
|
|
16339
16498
|
const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
|
|
16340
16499
|
const llm = getLlm(provider, model, { ...config, service: commitService });
|
|
@@ -17064,9 +17223,9 @@ function checkProjectConfigFile(diagnostics) {
|
|
|
17064
17223
|
}
|
|
17065
17224
|
|
|
17066
17225
|
const SEVERITY_ICON = {
|
|
17067
|
-
error:
|
|
17068
|
-
warn:
|
|
17069
|
-
info:
|
|
17226
|
+
error: FAIL(),
|
|
17227
|
+
warn: WARN(),
|
|
17228
|
+
info: INFO(),
|
|
17070
17229
|
};
|
|
17071
17230
|
const SEVERITY_LABEL = {
|
|
17072
17231
|
error: chalk.red('error'),
|
|
@@ -17085,10 +17244,10 @@ function formatSourceInfo(sources) {
|
|
|
17085
17244
|
for (const source of sources) {
|
|
17086
17245
|
const label = SOURCE_LABELS[source.source] || source.source;
|
|
17087
17246
|
if (source.path) {
|
|
17088
|
-
lines.push(` ${
|
|
17247
|
+
lines.push(` ${PASS()} ${label} ${chalk.dim(`(${source.path})`)}`);
|
|
17089
17248
|
}
|
|
17090
17249
|
else {
|
|
17091
|
-
lines.push(` ${
|
|
17250
|
+
lines.push(` ${PASS()} ${label}`);
|
|
17092
17251
|
}
|
|
17093
17252
|
}
|
|
17094
17253
|
return lines;
|
|
@@ -17125,7 +17284,7 @@ const handler$8 = async (argv, logger) => {
|
|
|
17125
17284
|
// Run diagnostics
|
|
17126
17285
|
const diagnostics = runDiagnostics(config);
|
|
17127
17286
|
if (diagnostics.length === 0) {
|
|
17128
|
-
logger.log(chalk.green(
|
|
17287
|
+
logger.log(chalk.green(`${PASS()} No issues found. Your configuration looks good!`));
|
|
17129
17288
|
return;
|
|
17130
17289
|
}
|
|
17131
17290
|
const errors = diagnostics.filter((d) => d.severity === 'error');
|
|
@@ -17178,7 +17337,7 @@ const handler$8 = async (argv, logger) => {
|
|
|
17178
17337
|
const raw = JSON.parse(fs__namespace.readFileSync(configPath, 'utf-8'));
|
|
17179
17338
|
for (const diagnostic of fixable) {
|
|
17180
17339
|
diagnostic.autoFix(raw);
|
|
17181
|
-
logger.log(chalk.green(`
|
|
17340
|
+
logger.log(chalk.green(` ${PASS()} Fixed: ${diagnostic.message}`));
|
|
17182
17341
|
}
|
|
17183
17342
|
// Ensure $schema is present
|
|
17184
17343
|
if (!raw.$schema) {
|
|
@@ -17197,6 +17356,15 @@ const handler$8 = async (argv, logger) => {
|
|
|
17197
17356
|
logger.log(chalk.dim(`${fixable.length} issue(s) can be auto-fixed. Run \`coco doctor --fix\` to apply.`));
|
|
17198
17357
|
}
|
|
17199
17358
|
}
|
|
17359
|
+
// Exit non-zero when error-severity diagnostics were surfaced so CI
|
|
17360
|
+
// pipelines can gate on `coco doctor` without parsing its stdout.
|
|
17361
|
+
// Warnings + infos still exit clean — they're informational, not
|
|
17362
|
+
// blockers. Auto-fixed errors keep the non-zero exit so the CI run
|
|
17363
|
+
// surfaces "we patched something for you, please commit it" rather
|
|
17364
|
+
// than masquerading as a passing check.
|
|
17365
|
+
if (errors.length > 0) {
|
|
17366
|
+
commandExit(1, `${errors.length} doctor error(s)`);
|
|
17367
|
+
}
|
|
17200
17368
|
};
|
|
17201
17369
|
|
|
17202
17370
|
var doctor = {
|
|
@@ -17204,6 +17372,7 @@ var doctor = {
|
|
|
17204
17372
|
desc: 'Check your coco configuration for common issues and suggest fixes',
|
|
17205
17373
|
builder: builder$8,
|
|
17206
17374
|
handler: commandExecutor(handler$8),
|
|
17375
|
+
options: options$8,
|
|
17207
17376
|
};
|
|
17208
17377
|
|
|
17209
17378
|
const command$7 = 'init';
|
|
@@ -17583,11 +17752,7 @@ const handler$7 = async (argv, logger) => {
|
|
|
17583
17752
|
// writes the project config to X, not the launcher's cwd. The
|
|
17584
17753
|
// chdir has to happen before getProjectConfigFilePath resolves
|
|
17585
17754
|
// its target path (it reads process.cwd).
|
|
17586
|
-
|
|
17587
|
-
// `InitArgv` is `Argv<InitOptions>['argv']` which yargs types as a
|
|
17588
|
-
// union including Promise — pass just the `repo` field as a plain
|
|
17589
|
-
// object so the helper's narrow signature stays clean.
|
|
17590
|
-
applyRepoCwd({ repo: argv.repo });
|
|
17755
|
+
applyRepoCwd(argv);
|
|
17591
17756
|
const options = loadConfig(argv);
|
|
17592
17757
|
logger.log(LOGO);
|
|
17593
17758
|
let scope = options?.scope;
|
|
@@ -17722,6 +17887,44 @@ const handler$7 = async (argv, logger) => {
|
|
|
17722
17887
|
await installCommitlintPackages(scope, logger);
|
|
17723
17888
|
}
|
|
17724
17889
|
logger.log(`\ninit successful! 🦾🤖🎉`, { color: 'green' });
|
|
17890
|
+
// Post-write verification — run the same check `coco doctor` runs
|
|
17891
|
+
// so the user finds out about typos / structural issues now,
|
|
17892
|
+
// before their first `coco commit`. Re-load from disk so we
|
|
17893
|
+
// verify the persisted config (not the in-memory shape we just
|
|
17894
|
+
// built), which catches transcription bugs in the appenders.
|
|
17895
|
+
try {
|
|
17896
|
+
const persistedConfig = loadConfig({});
|
|
17897
|
+
const diagnostics = runDiagnostics(persistedConfig);
|
|
17898
|
+
const errors = diagnostics.filter((d) => d.severity === 'error');
|
|
17899
|
+
const warnings = diagnostics.filter((d) => d.severity === 'warn');
|
|
17900
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
17901
|
+
logger.log(`${PASS()} Verified: no issues found in your new config.`, { color: 'green' });
|
|
17902
|
+
}
|
|
17903
|
+
else {
|
|
17904
|
+
if (errors.length > 0) {
|
|
17905
|
+
logger.log(`${FAIL()} ${errors.length} error(s) found in the persisted config:`, { color: 'red' });
|
|
17906
|
+
for (const diagnostic of errors) {
|
|
17907
|
+
logger.log(` ${chalk.red(diagnostic.message)}`);
|
|
17908
|
+
}
|
|
17909
|
+
}
|
|
17910
|
+
if (warnings.length > 0) {
|
|
17911
|
+
logger.log(`${WARN()} ${warnings.length} warning(s) found in the persisted config:`, { color: 'yellow' });
|
|
17912
|
+
for (const diagnostic of warnings) {
|
|
17913
|
+
logger.log(` ${chalk.yellow(diagnostic.message)}`);
|
|
17914
|
+
}
|
|
17915
|
+
}
|
|
17916
|
+
logger.log(`${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('for the full diagnostic report.')}`);
|
|
17917
|
+
}
|
|
17918
|
+
}
|
|
17919
|
+
catch (verifyError) {
|
|
17920
|
+
// Verification is a polish step, not a blocker. If it crashes
|
|
17921
|
+
// (e.g. config file written to a path the loader can't reach
|
|
17922
|
+
// from the current cwd), fall through to a hint instead of
|
|
17923
|
+
// failing the whole init flow — the config is on disk and
|
|
17924
|
+
// the user can run `coco doctor` themselves.
|
|
17925
|
+
logger.log(`${chalk.dim('Skipped post-init verification:')} ${verifyError.message}`, { color: 'gray' });
|
|
17926
|
+
logger.log(`${chalk.dim('Run')} ${chalk.cyan('coco doctor')} ${chalk.dim('to verify your config manually.')}`);
|
|
17927
|
+
}
|
|
17725
17928
|
}
|
|
17726
17929
|
else {
|
|
17727
17930
|
logger.log('\ninit cancelled.', { color: 'yellow' });
|
|
@@ -17763,7 +17966,7 @@ async function installCommitlintPackages(scope, logger) {
|
|
|
17763
17966
|
|
|
17764
17967
|
var init = {
|
|
17765
17968
|
command: command$7,
|
|
17766
|
-
desc: '
|
|
17969
|
+
desc: 'Install & configure coco globally or for the current project',
|
|
17767
17970
|
builder: builder$7,
|
|
17768
17971
|
handler: commandExecutor(handler$7),
|
|
17769
17972
|
options: options$7,
|
|
@@ -17847,19 +18050,76 @@ async function getGitHubRepository(git) {
|
|
|
17847
18050
|
return url ? parseGitHubRemoteUrl$1(url) : undefined;
|
|
17848
18051
|
}
|
|
17849
18052
|
/**
|
|
17850
|
-
* Probe `gh auth status` and return
|
|
17851
|
-
*
|
|
17852
|
-
* circuit before issuing real API calls —
|
|
17853
|
-
*
|
|
17854
|
-
* of
|
|
18053
|
+
* Probe `gh auth status` and return a structured status describing
|
|
18054
|
+
* exactly which of the failure modes is in play. Used by every data
|
|
18055
|
+
* fetcher to short-circuit before issuing real API calls — and now
|
|
18056
|
+
* lets the caller surface a tailored recovery hint per failure mode
|
|
18057
|
+
* instead of one catch-all message.
|
|
18058
|
+
*
|
|
18059
|
+
* Distinguishing the modes:
|
|
18060
|
+
* - ENOENT (`gh: command not found`) → `not-installed`
|
|
18061
|
+
* - `gh auth status` exits non-zero with stderr matching the
|
|
18062
|
+
* "not logged into" / "authentication required" pattern →
|
|
18063
|
+
* `not-authenticated`
|
|
18064
|
+
* - Anything else (permission denied on the binary, timeout, etc.)
|
|
18065
|
+
* → `unknown` with the underlying error message attached for
|
|
18066
|
+
* diagnostic display.
|
|
17855
18067
|
*/
|
|
17856
|
-
async function
|
|
18068
|
+
async function getGhStatus(runner) {
|
|
17857
18069
|
try {
|
|
17858
18070
|
await runner(['auth', 'status', '--hostname', 'github.com']);
|
|
17859
|
-
return
|
|
18071
|
+
return { kind: 'ok' };
|
|
17860
18072
|
}
|
|
17861
|
-
catch {
|
|
17862
|
-
|
|
18073
|
+
catch (error) {
|
|
18074
|
+
const err = error;
|
|
18075
|
+
// ENOENT = the binary itself is missing. exec/spawn surfaces this
|
|
18076
|
+
// as either `code === 'ENOENT'` (Node's spawn error code) or a
|
|
18077
|
+
// message containing "ENOENT". Either form is unambiguous.
|
|
18078
|
+
if (err.code === 'ENOENT' || (err.message && err.message.includes('ENOENT'))) {
|
|
18079
|
+
return { kind: 'not-installed' };
|
|
18080
|
+
}
|
|
18081
|
+
// gh exits non-zero from `auth status` when the user isn't logged
|
|
18082
|
+
// in. The message body contains "not logged into" or "logged in
|
|
18083
|
+
// failed" depending on the gh version. Both patterns are stable
|
|
18084
|
+
// enough to gate on without scope-locking to a specific gh
|
|
18085
|
+
// release.
|
|
18086
|
+
const stderr = err.stderr || err.message || '';
|
|
18087
|
+
if (/not logged into|authentication.*required|you are not/i.test(stderr)) {
|
|
18088
|
+
return { kind: 'not-authenticated', detail: stderr.trim().split('\n')[0] };
|
|
18089
|
+
}
|
|
18090
|
+
// Anything else — permission denied, timeout, etc. Surface the
|
|
18091
|
+
// raw message so the user can read it; treat as unavailable.
|
|
18092
|
+
return { kind: 'unknown', detail: err.message || 'gh auth status failed' };
|
|
18093
|
+
}
|
|
18094
|
+
}
|
|
18095
|
+
/**
|
|
18096
|
+
* Backwards-compatible boolean wrapper around `getGhStatus`. Kept so
|
|
18097
|
+
* existing callers (data loaders, sidebar fetchers) don't all have to
|
|
18098
|
+
* migrate at once. New call sites should use `getGhStatus` directly
|
|
18099
|
+
* to access the discriminated failure modes.
|
|
18100
|
+
*/
|
|
18101
|
+
async function isGhAuthenticated(runner) {
|
|
18102
|
+
const status = await getGhStatus(runner);
|
|
18103
|
+
return status.kind === 'ok';
|
|
18104
|
+
}
|
|
18105
|
+
/**
|
|
18106
|
+
* Render a user-facing recovery hint for a non-`ok` gh status. Used by
|
|
18107
|
+
* `commands/issues` / `commands/prs` / pull-request workflow surfaces
|
|
18108
|
+
* so every "gh is unavailable" message tells the user the exact next
|
|
18109
|
+
* step. Keeps the wording in sync across surfaces — if a user runs
|
|
18110
|
+
* `coco prs` and `coco issues` back to back, the same broken state
|
|
18111
|
+
* surfaces the same fix.
|
|
18112
|
+
*/
|
|
18113
|
+
function describeGhStatus(status) {
|
|
18114
|
+
switch (status.kind) {
|
|
18115
|
+
case 'ok':
|
|
18116
|
+
return 'GitHub CLI is installed and authenticated.';
|
|
18117
|
+
case 'not-installed':
|
|
18118
|
+
return 'GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com/ and run `gh auth login`.';
|
|
18119
|
+
case 'not-authenticated':
|
|
18120
|
+
return `GitHub CLI is installed but not authenticated. Run \`gh auth login\` (scopes: \`repo\`, \`read:org\`).${status.detail ? ` Details: ${status.detail}` : ''}`;
|
|
18121
|
+
case 'unknown':
|
|
18122
|
+
return `GitHub CLI returned an unexpected error: ${status.detail}. Try \`gh auth status\` directly to diagnose.`;
|
|
17863
18123
|
}
|
|
17864
18124
|
}
|
|
17865
18125
|
|
|
@@ -18051,13 +18311,14 @@ async function getIssueList(git, filter = {}, runner = defaultGhRunner) {
|
|
|
18051
18311
|
message: 'No GitHub remote detected.',
|
|
18052
18312
|
};
|
|
18053
18313
|
}
|
|
18054
|
-
|
|
18314
|
+
const ghStatus = await getGhStatus(runner);
|
|
18315
|
+
if (ghStatus.kind !== 'ok') {
|
|
18055
18316
|
return {
|
|
18056
18317
|
available: true,
|
|
18057
18318
|
authenticated: false,
|
|
18058
18319
|
repository,
|
|
18059
18320
|
filter,
|
|
18060
|
-
message:
|
|
18321
|
+
message: describeGhStatus(ghStatus),
|
|
18061
18322
|
};
|
|
18062
18323
|
}
|
|
18063
18324
|
try {
|
|
@@ -18169,6 +18430,7 @@ var issues = {
|
|
|
18169
18430
|
desc: 'List GitHub issues for the current repository (read-only triage)',
|
|
18170
18431
|
builder: builder$6,
|
|
18171
18432
|
handler: commandExecutor(handler$6),
|
|
18433
|
+
options: options$6,
|
|
18172
18434
|
};
|
|
18173
18435
|
|
|
18174
18436
|
const command$5 = 'log';
|
|
@@ -18920,9 +19182,17 @@ async function getStashOverview(git) {
|
|
|
18920
19182
|
// %gd — stash reflog selector (stash@{N})
|
|
18921
19183
|
// %H — stash commit hash
|
|
18922
19184
|
// %P — space-separated parent hashes (first = base, see StashEntry.baseHash)
|
|
18923
|
-
// %
|
|
19185
|
+
// %cI — committer date, strict ISO 8601
|
|
18924
19186
|
// %gs — reflog subject ("WIP on main: <subject>")
|
|
18925
|
-
|
|
19187
|
+
//
|
|
19188
|
+
// NOTE: we deliberately do NOT pass `--date=iso`. That flag rewrites the
|
|
19189
|
+
// `%gd` selector from the index form (`stash@{0}`) into a timestamp
|
|
19190
|
+
// (`stash@{2026-06-03 17:29:23 -0400}`), which is noisy in the list, eats
|
|
19191
|
+
// row width, and — critically — breaks `renameStash`, which parses the
|
|
19192
|
+
// `stash@{N}` index out of the ref. `%cI` gives a strict-ISO date that's
|
|
19193
|
+
// independent of `--date`, so we get both a clean index ref and a
|
|
19194
|
+
// parseable date.
|
|
19195
|
+
const stashes = parseStashList(await git.raw(['stash', 'list', '--format=%gd%x1f%H%x1f%P%x1f%cI%x1f%gs']));
|
|
18926
19196
|
return {
|
|
18927
19197
|
stashes: await Promise.all(stashes.map(async (stash) => ({
|
|
18928
19198
|
...stash,
|
|
@@ -20591,7 +20861,7 @@ function applyCommitComposeAction(state, action) {
|
|
|
20591
20861
|
loading: false,
|
|
20592
20862
|
streamingPreview: undefined,
|
|
20593
20863
|
pendingAiDraft: action.value,
|
|
20594
|
-
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20864
|
+
message: 'AI draft ready. Press Enter (or R) to replace your text, or Esc to keep what you have.',
|
|
20595
20865
|
details: undefined,
|
|
20596
20866
|
};
|
|
20597
20867
|
}
|
|
@@ -20640,7 +20910,7 @@ function applyCommitComposeAction(state, action) {
|
|
|
20640
20910
|
loading: false,
|
|
20641
20911
|
streamingPreview: undefined,
|
|
20642
20912
|
pendingAiDraft: action.value,
|
|
20643
|
-
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20913
|
+
message: 'AI draft ready. Press Enter (or R) to replace your text, or Esc to keep what you have.',
|
|
20644
20914
|
details: undefined,
|
|
20645
20915
|
};
|
|
20646
20916
|
case 'acceptPendingAiDraft':
|
|
@@ -22196,6 +22466,25 @@ function getLogInkWorkflowActions() {
|
|
|
22196
22466
|
kind: 'destructive',
|
|
22197
22467
|
requiresConfirmation: true,
|
|
22198
22468
|
},
|
|
22469
|
+
{
|
|
22470
|
+
// Palette-only create variants (empty `key`): no global hotkey to
|
|
22471
|
+
// collide with `S` / `gZ`, reachable from `:`. Both stash a quick
|
|
22472
|
+
// WIP entry with the requested scope.
|
|
22473
|
+
id: 'stash-staged',
|
|
22474
|
+
key: '',
|
|
22475
|
+
label: 'Stash staged only',
|
|
22476
|
+
description: 'Stash just the staged (index) changes — `git stash push --staged`.',
|
|
22477
|
+
kind: 'normal',
|
|
22478
|
+
requiresConfirmation: false,
|
|
22479
|
+
},
|
|
22480
|
+
{
|
|
22481
|
+
id: 'stash-keep-index',
|
|
22482
|
+
key: '',
|
|
22483
|
+
label: 'Stash keeping index',
|
|
22484
|
+
description: 'Stash everything but leave the index intact for an immediate commit — `git stash push --keep-index`.',
|
|
22485
|
+
kind: 'normal',
|
|
22486
|
+
requiresConfirmation: false,
|
|
22487
|
+
},
|
|
22199
22488
|
{
|
|
22200
22489
|
id: 'remove-worktree',
|
|
22201
22490
|
key: 'W',
|
|
@@ -22707,6 +22996,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
22707
22996
|
description: 'Push the stash view (gz; gs is reserved for status).',
|
|
22708
22997
|
contexts: ['normal'],
|
|
22709
22998
|
},
|
|
22999
|
+
{
|
|
23000
|
+
id: 'createStash',
|
|
23001
|
+
keys: ['gZ'],
|
|
23002
|
+
label: 'stash changes',
|
|
23003
|
+
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.',
|
|
23004
|
+
contexts: ['normal'],
|
|
23005
|
+
},
|
|
22710
23006
|
{
|
|
22711
23007
|
id: 'navigateWorktrees',
|
|
22712
23008
|
keys: ['gw'],
|
|
@@ -22951,6 +23247,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
22951
23247
|
description: 'Create a lightweight tag at the cursored commit.',
|
|
22952
23248
|
contexts: ['history'],
|
|
22953
23249
|
},
|
|
23250
|
+
{
|
|
23251
|
+
id: 'viewKeys',
|
|
23252
|
+
keys: ['g?'],
|
|
23253
|
+
label: 'keys',
|
|
23254
|
+
description: 'Show the single-key actions available in the current view (which-key strip).',
|
|
23255
|
+
contexts: ['normal'],
|
|
23256
|
+
},
|
|
22954
23257
|
{
|
|
22955
23258
|
id: 'themePicker',
|
|
22956
23259
|
keys: ['gC'],
|
|
@@ -22979,6 +23282,20 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
22979
23282
|
description: 'Add the cursored file or folder to .gitignore (pick a pattern).',
|
|
22980
23283
|
contexts: ['status'],
|
|
22981
23284
|
},
|
|
23285
|
+
{
|
|
23286
|
+
id: 'stageAll',
|
|
23287
|
+
keys: ['A'],
|
|
23288
|
+
label: 'stage all',
|
|
23289
|
+
description: 'Stage every change in the worktree (git add -A).',
|
|
23290
|
+
contexts: ['status', 'compose'],
|
|
23291
|
+
},
|
|
23292
|
+
{
|
|
23293
|
+
id: 'stagePathspec',
|
|
23294
|
+
keys: ['+'],
|
|
23295
|
+
label: 'stage paths',
|
|
23296
|
+
description: 'Stage files matching a typed pathspec (. / src/ / *.ts / a list).',
|
|
23297
|
+
contexts: ['status', 'compose'],
|
|
23298
|
+
},
|
|
22982
23299
|
{
|
|
22983
23300
|
id: 'viewChangelog',
|
|
22984
23301
|
keys: ['L'],
|
|
@@ -23052,6 +23369,19 @@ const GLOBAL_BINDING_IDS = [
|
|
|
23052
23369
|
'navigateBack',
|
|
23053
23370
|
];
|
|
23054
23371
|
const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
|
|
23372
|
+
/**
|
|
23373
|
+
* Narrow single-pane footer budget (#1135). On terminals below the
|
|
23374
|
+
* single-pane breakpoint the pane switcher (`tab: …`, ~29 cells) plus
|
|
23375
|
+
* the snap-back / peek affordance already claim most of an 80-cell row,
|
|
23376
|
+
* so the per-view hint tail and the global cluster are trimmed to what
|
|
23377
|
+
* fits without clipping — the switcher is the orientation anchor and
|
|
23378
|
+
* must stay whole. The dropped bindings remain one `?` (help) away.
|
|
23379
|
+
*
|
|
23380
|
+
* - keep only the first view hint (the most actionable for the view)
|
|
23381
|
+
* - shrink the global cluster to the two recovery essentials
|
|
23382
|
+
*/
|
|
23383
|
+
const SINGLE_PANE_GLOBAL_HINTS = ['? help', 'q quit'];
|
|
23384
|
+
const SINGLE_PANE_VIEW_HINT_LIMIT = 1;
|
|
23055
23385
|
/**
|
|
23056
23386
|
* Per-binding category mapping. Used to subdivide the help overlay's
|
|
23057
23387
|
* Global and view sections into named clusters so users don't face a
|
|
@@ -23072,6 +23402,9 @@ const BINDING_CATEGORY_BY_ID = {
|
|
|
23072
23402
|
openProjectConfig: 'view',
|
|
23073
23403
|
openGlobalConfig: 'view',
|
|
23074
23404
|
gitignoreFile: 'mutate',
|
|
23405
|
+
stageAll: 'mutate',
|
|
23406
|
+
stagePathspec: 'mutate',
|
|
23407
|
+
createStash: 'mutate',
|
|
23075
23408
|
quit: 'essentials',
|
|
23076
23409
|
refresh: 'essentials',
|
|
23077
23410
|
navigateBack: 'essentials',
|
|
@@ -23223,18 +23556,20 @@ function formatLogInkBreadcrumb(viewStack) {
|
|
|
23223
23556
|
if (viewStack.length === 1 && viewStack[0] === 'history') {
|
|
23224
23557
|
return '';
|
|
23225
23558
|
}
|
|
23226
|
-
//
|
|
23227
|
-
//
|
|
23228
|
-
|
|
23559
|
+
// Pure location breadcrumb — no trailing back-hint. The footer's
|
|
23560
|
+
// global `< back` hint already names the walk-back key, so repeating
|
|
23561
|
+
// `← <` on every nested view was redundant header chrome (TUI audit).
|
|
23562
|
+
return viewStack.join(' › ');
|
|
23229
23563
|
}
|
|
23230
23564
|
/**
|
|
23231
23565
|
* Render the nested-repo navigation stack (#931) as a breadcrumb suitable
|
|
23232
23566
|
* for the chrome header. Returns an empty string for a root-only stack
|
|
23233
23567
|
* so the header stays compact when nothing has been pushed.
|
|
23234
23568
|
*
|
|
23235
|
-
* The trailing `← esc` reminds the user that Esc
|
|
23236
|
-
*
|
|
23237
|
-
*
|
|
23569
|
+
* The trailing `← esc` reminds the user that Esc (not `<`) pops the
|
|
23570
|
+
* repo stack — a distinct key from the footer's global `< back`, so
|
|
23571
|
+
* unlike the view breadcrumb (pure location) the repo crumb keeps its
|
|
23572
|
+
* hint. The repo breadcrumb shows in addition to the view breadcrumb when
|
|
23238
23573
|
* both stacks are non-trivial; the chrome layer is responsible for
|
|
23239
23574
|
* laying them out side by side.
|
|
23240
23575
|
*
|
|
@@ -23277,7 +23612,53 @@ function combineLogInkBreadcrumbSegments(repoCrumb, viewCrumb) {
|
|
|
23277
23612
|
}
|
|
23278
23613
|
return '';
|
|
23279
23614
|
}
|
|
23615
|
+
/**
|
|
23616
|
+
* Single-pane pane switcher hint, e.g. `tab: [sidebar] main inspector`.
|
|
23617
|
+
* The active pane (derived from focus: sidebar → sidebar, detail →
|
|
23618
|
+
* inspector, otherwise main) is bracketed so the user can see which of
|
|
23619
|
+
* the three panes Tab will move them away from. Surfaced only on narrow
|
|
23620
|
+
* terminals where the other two panes aren't on screen.
|
|
23621
|
+
*/
|
|
23622
|
+
function singlePaneSwitcherHint(focus) {
|
|
23623
|
+
const active = focus === 'sidebar' ? 'sidebar' : focus === 'detail' ? 'inspector' : 'main';
|
|
23624
|
+
const label = (pane) => (pane === active ? `[${pane}]` : pane);
|
|
23625
|
+
return `tab: ${label('sidebar')} ${label('main')} ${label('inspector')}`;
|
|
23626
|
+
}
|
|
23280
23627
|
function getLogInkFooterHints(options) {
|
|
23628
|
+
const hints = computeLogInkFooterHints(options);
|
|
23629
|
+
// While peeking the sidebar (#1135 v2) the footer shows the snap-back
|
|
23630
|
+
// affordance instead of the switcher — the user is mid-glance, not
|
|
23631
|
+
// navigating, so `v`/Esc returning to main is the relevant action. The
|
|
23632
|
+
// view-hint tail + globals are trimmed to fit the narrow row (see
|
|
23633
|
+
// SINGLE_PANE_GLOBAL_HINTS).
|
|
23634
|
+
if (options.peeking) {
|
|
23635
|
+
return {
|
|
23636
|
+
contextual: ['v/esc → main', ...hints.contextual.slice(0, SINGLE_PANE_VIEW_HINT_LIMIT)],
|
|
23637
|
+
global: SINGLE_PANE_GLOBAL_HINTS,
|
|
23638
|
+
};
|
|
23639
|
+
}
|
|
23640
|
+
// On narrow terminals only one pane is on screen, so prepend a Tab
|
|
23641
|
+
// pane switcher for orientation. The caller (footer) only sets
|
|
23642
|
+
// `singlePane` in the plain per-pane states — while an overlay or
|
|
23643
|
+
// filter owns the screen the visible pane is forced (or input is
|
|
23644
|
+
// captured) and Tab does something else, so the switcher is
|
|
23645
|
+
// suppressed there to avoid showing a pane that isn't active. From the
|
|
23646
|
+
// main / inspector pane we also surface `v peek` so the momentary
|
|
23647
|
+
// sidebar glance is discoverable. The full per-view hint cluster +
|
|
23648
|
+
// global cluster don't fit alongside the switcher at the 80-col floor,
|
|
23649
|
+
// so both are trimmed (the dropped keys stay reachable via `?`).
|
|
23650
|
+
if (options.singlePane) {
|
|
23651
|
+
const lead = options.focus === 'sidebar'
|
|
23652
|
+
? [singlePaneSwitcherHint(options.focus)]
|
|
23653
|
+
: [singlePaneSwitcherHint(options.focus), 'v peek'];
|
|
23654
|
+
return {
|
|
23655
|
+
contextual: [...lead, ...hints.contextual.slice(0, SINGLE_PANE_VIEW_HINT_LIMIT)],
|
|
23656
|
+
global: SINGLE_PANE_GLOBAL_HINTS,
|
|
23657
|
+
};
|
|
23658
|
+
}
|
|
23659
|
+
return hints;
|
|
23660
|
+
}
|
|
23661
|
+
function computeLogInkFooterHints(options) {
|
|
23281
23662
|
if (options.pendingKey) {
|
|
23282
23663
|
const continuations = getLogInkChordContinuations(options.pendingKey);
|
|
23283
23664
|
if (continuations.length > 0) {
|
|
@@ -23394,7 +23775,7 @@ function getLogInkFooterHints(options) {
|
|
|
23394
23775
|
}
|
|
23395
23776
|
if (options.activeView === 'status') {
|
|
23396
23777
|
return {
|
|
23397
|
-
contextual: ['↑/↓ files', 'enter
|
|
23778
|
+
contextual: ['↑/↓ files', 'enter hunks', 'space stage', 'A stage all', 'z revert', 'e/c compose'],
|
|
23398
23779
|
global: NORMAL_GLOBAL_HINTS,
|
|
23399
23780
|
};
|
|
23400
23781
|
}
|
|
@@ -23406,16 +23787,19 @@ function getLogInkFooterHints(options) {
|
|
|
23406
23787
|
const splitToggleHint = options.diffViewMode === 'split' ? 'd unified' : 'd split';
|
|
23407
23788
|
if (options.diffSource === 'stash') {
|
|
23408
23789
|
return {
|
|
23409
|
-
contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk',
|
|
23790
|
+
contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'esc back'],
|
|
23410
23791
|
global: NORMAL_GLOBAL_HINTS,
|
|
23411
23792
|
};
|
|
23412
23793
|
}
|
|
23413
23794
|
if (options.diffSource === 'commit') {
|
|
23414
23795
|
// Commit-diff explore: read-only diff, but `c` cherry-picks the
|
|
23415
23796
|
// cursored file from the commit into the worktree, and `H`
|
|
23416
|
-
// (or `gH` for index) applies just the cursored hunk.
|
|
23797
|
+
// (or `gH` for index) applies just the cursored hunk. `j/k`
|
|
23798
|
+
// line-scroll the diff body; `[`/`]` jump between hunks — the
|
|
23799
|
+
// footer labels match the actual handlers (commit diff has no
|
|
23800
|
+
// per-file `[/]` jump; that's the stash diff).
|
|
23417
23801
|
return {
|
|
23418
|
-
contextual: ['j/k
|
|
23802
|
+
contextual: ['j/k lines', '[/] hunk', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'esc back'],
|
|
23419
23803
|
global: NORMAL_GLOBAL_HINTS,
|
|
23420
23804
|
};
|
|
23421
23805
|
}
|
|
@@ -23428,14 +23812,17 @@ function getLogInkFooterHints(options) {
|
|
|
23428
23812
|
global: NORMAL_GLOBAL_HINTS,
|
|
23429
23813
|
};
|
|
23430
23814
|
}
|
|
23815
|
+
// Worktree (staging) diff. The hunk is the unit of action: ↑/↓ walk
|
|
23816
|
+
// hunks, space stages/unstages the selected one, a stages the whole
|
|
23817
|
+
// file, z discards the hunk.
|
|
23431
23818
|
return {
|
|
23432
|
-
contextual: ['
|
|
23819
|
+
contextual: ['↑/↓ hunk', 'space stage', 'a stage file', 'z discard', 'o edit', 'esc back'],
|
|
23433
23820
|
global: NORMAL_GLOBAL_HINTS,
|
|
23434
23821
|
};
|
|
23435
23822
|
}
|
|
23436
23823
|
if (options.activeView === 'compose') {
|
|
23437
23824
|
return {
|
|
23438
|
-
contextual: ['e edit', '
|
|
23825
|
+
contextual: ['e edit', 'c commit', 'A stage all', '+ stage…', 'S split', 'I AI draft', 'esc back'],
|
|
23439
23826
|
global: NORMAL_GLOBAL_HINTS,
|
|
23440
23827
|
};
|
|
23441
23828
|
}
|
|
@@ -23465,7 +23852,7 @@ function getLogInkFooterHints(options) {
|
|
|
23465
23852
|
}
|
|
23466
23853
|
if (options.activeView === 'stash') {
|
|
23467
23854
|
return {
|
|
23468
|
-
contextual: ['↑/↓ stashes', 'enter diff', 'a apply', 'p pop', '
|
|
23855
|
+
contextual: ['↑/↓ stashes', 'enter diff', 'a/A apply', 'p pop', 'R rename', 'b branch', 'X drop · u undo'],
|
|
23469
23856
|
global: NORMAL_GLOBAL_HINTS,
|
|
23470
23857
|
};
|
|
23471
23858
|
}
|
|
@@ -23594,6 +23981,48 @@ function getLogInkHelpSections(options) {
|
|
|
23594
23981
|
},
|
|
23595
23982
|
];
|
|
23596
23983
|
}
|
|
23984
|
+
/**
|
|
23985
|
+
* True when a key string is a single, bare printable key (e.g. `c`, `R`,
|
|
23986
|
+
* `[`) rather than a chord (`gh`, `gg`) or a named special key (`up`,
|
|
23987
|
+
* `page down`). Used by the which-key view-keys strip, which surfaces only
|
|
23988
|
+
* the single-key overloads — the chord set already has its own overlay.
|
|
23989
|
+
*/
|
|
23990
|
+
function isBareSingleKey(key) {
|
|
23991
|
+
return key.length === 1 && key !== ' ';
|
|
23992
|
+
}
|
|
23993
|
+
/**
|
|
23994
|
+
* Single-key bindings available in the current view (#1137). Powers the
|
|
23995
|
+
* `g?` which-key strip: the per-view counterpart to the `g`-chord overlay.
|
|
23996
|
+
*
|
|
23997
|
+
* Sourced entirely from `LOG_INK_KEY_BINDINGS` (no duplicated key data) and
|
|
23998
|
+
* filtered the same way the help overlay's "This view" section is — by
|
|
23999
|
+
* `contexts` against the active view + focus — then narrowed to bindings
|
|
24000
|
+
* that expose at least one bare single key. Globals (`q`, `?`, `/`, `:`, …)
|
|
24001
|
+
* are excluded: they're always available and already live in the footer and
|
|
24002
|
+
* onboarding tour, so the strip stays focused on the deliberate per-view
|
|
24003
|
+
* overloads (`c`, `R`, `a`, `m`, `S`, `[`/`]`, …) the keymap guard protects.
|
|
24004
|
+
*
|
|
24005
|
+
* Sorted by the first bare key for stable, scannable output.
|
|
24006
|
+
*/
|
|
24007
|
+
function getLogInkViewKeyBindings(options) {
|
|
24008
|
+
return LOG_INK_KEY_BINDINGS
|
|
24009
|
+
.filter((binding) => !GLOBAL_BINDING_IDS.includes(binding.id) &&
|
|
24010
|
+
bindingMatchesViewContext(binding, options) &&
|
|
24011
|
+
binding.keys.some(isBareSingleKey))
|
|
24012
|
+
.sort((a, b) => {
|
|
24013
|
+
const aKey = a.keys.find(isBareSingleKey) ?? '';
|
|
24014
|
+
const bKey = b.keys.find(isBareSingleKey) ?? '';
|
|
24015
|
+
return aKey.localeCompare(bKey);
|
|
24016
|
+
});
|
|
24017
|
+
}
|
|
24018
|
+
/**
|
|
24019
|
+
* Format only the bare single keys of a binding for the view-keys strip
|
|
24020
|
+
* (e.g. `['up', 'k']` → `k`). Named/chord keys are dropped — the strip is
|
|
24021
|
+
* about the single-key affordance, and the full key list lives in `?` help.
|
|
24022
|
+
*/
|
|
24023
|
+
function formatBindingBareKeys(binding) {
|
|
24024
|
+
return binding.keys.filter(isBareSingleKey).join(' / ');
|
|
24025
|
+
}
|
|
23597
24026
|
function bindingToPaletteCommand(binding) {
|
|
23598
24027
|
return {
|
|
23599
24028
|
id: binding.id,
|
|
@@ -24856,7 +25285,7 @@ function topOfStack(stack) {
|
|
|
24856
25285
|
}
|
|
24857
25286
|
function withPushedView(state, value) {
|
|
24858
25287
|
if (topOfStack(state.viewStack) === value) {
|
|
24859
|
-
return { ...state, pendingKey: undefined };
|
|
25288
|
+
return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
|
|
24860
25289
|
}
|
|
24861
25290
|
const viewStack = [...state.viewStack, value];
|
|
24862
25291
|
return {
|
|
@@ -24879,12 +25308,15 @@ function withPushedView(state, value) {
|
|
|
24879
25308
|
compareHead: value === 'diff' ? state.compareHead : undefined,
|
|
24880
25309
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
24881
25310
|
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
25311
|
+
// Changing the view is a deliberate destination — cancel any pending
|
|
25312
|
+
// peek return so the user isn't snapped back afterward.
|
|
25313
|
+
peekReturnFocus: undefined,
|
|
24882
25314
|
pendingKey: undefined,
|
|
24883
25315
|
};
|
|
24884
25316
|
}
|
|
24885
25317
|
function withPoppedView(state) {
|
|
24886
25318
|
if (state.viewStack.length <= 1) {
|
|
24887
|
-
return { ...state, pendingKey: undefined };
|
|
25319
|
+
return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
|
|
24888
25320
|
}
|
|
24889
25321
|
const viewStack = state.viewStack.slice(0, -1);
|
|
24890
25322
|
const next = topOfStack(viewStack);
|
|
@@ -24911,6 +25343,8 @@ function withPoppedView(state) {
|
|
|
24911
25343
|
compareHead: next === 'diff' ? state.compareHead : undefined,
|
|
24912
25344
|
pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
|
|
24913
25345
|
statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
|
|
25346
|
+
// Backing out is a deliberate navigation — cancel any peek return.
|
|
25347
|
+
peekReturnFocus: undefined,
|
|
24914
25348
|
pendingKey: undefined,
|
|
24915
25349
|
};
|
|
24916
25350
|
}
|
|
@@ -25023,7 +25457,7 @@ function withPoppedRepoFrame(state) {
|
|
|
25023
25457
|
}
|
|
25024
25458
|
function withReplacedView(state, value) {
|
|
25025
25459
|
if (topOfStack(state.viewStack) === value) {
|
|
25026
|
-
return { ...state, pendingKey: undefined };
|
|
25460
|
+
return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
|
|
25027
25461
|
}
|
|
25028
25462
|
const viewStack = [...state.viewStack.slice(0, -1), value];
|
|
25029
25463
|
return {
|
|
@@ -25037,6 +25471,9 @@ function withReplacedView(state, value) {
|
|
|
25037
25471
|
compareHead: value === 'diff' ? state.compareHead : undefined,
|
|
25038
25472
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
25039
25473
|
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
25474
|
+
// Changing the view is a deliberate destination — cancel any pending
|
|
25475
|
+
// peek return so the user isn't snapped back afterward.
|
|
25476
|
+
peekReturnFocus: undefined,
|
|
25040
25477
|
pendingKey: undefined,
|
|
25041
25478
|
};
|
|
25042
25479
|
}
|
|
@@ -25099,7 +25536,39 @@ function replaceRows(state, rows) {
|
|
|
25099
25536
|
}
|
|
25100
25537
|
function appendRows(state, rows) {
|
|
25101
25538
|
const selected = getSelectedInkCommit(state);
|
|
25102
|
-
|
|
25539
|
+
// Dedup the merged row list by commit hash so the graph renderer —
|
|
25540
|
+
// which windows directly over `state.rows` (toFullGraphItems →
|
|
25541
|
+
// expandRowsWithSpacers) — and the selection list (deduped commits)
|
|
25542
|
+
// agree on one canonical, duplicate-free row order. Overlapping
|
|
25543
|
+
// appends, notably the anchored `loadCommitContext` page that
|
|
25544
|
+
// re-walks history from the tip, otherwise stack the newest commits
|
|
25545
|
+
// below the oldest ones already loaded. The renderer then shows the
|
|
25546
|
+
// initial commit directly above HEAD and the cursor can scroll
|
|
25547
|
+
// forever through the duplicated tail — the history graph "looping
|
|
25548
|
+
// back on itself". Drop graph-only topology rows that trail a dropped
|
|
25549
|
+
// duplicate commit too, since they describe that duplicate's lanes
|
|
25550
|
+
// and would otherwise dangle.
|
|
25551
|
+
const seenHashes = new Set();
|
|
25552
|
+
const nextRows = [];
|
|
25553
|
+
let droppingTrailingGraph = false;
|
|
25554
|
+
for (const row of [...state.rows, ...rows]) {
|
|
25555
|
+
if (row.type === 'commit') {
|
|
25556
|
+
if (seenHashes.has(row.hash)) {
|
|
25557
|
+
droppingTrailingGraph = true;
|
|
25558
|
+
continue;
|
|
25559
|
+
}
|
|
25560
|
+
seenHashes.add(row.hash);
|
|
25561
|
+
droppingTrailingGraph = false;
|
|
25562
|
+
nextRows.push(row);
|
|
25563
|
+
continue;
|
|
25564
|
+
}
|
|
25565
|
+
// Graph-only topology row: keep it unless it trails a just-dropped
|
|
25566
|
+
// duplicate commit (then it belongs to the duplicate page's lanes).
|
|
25567
|
+
if (droppingTrailingGraph) {
|
|
25568
|
+
continue;
|
|
25569
|
+
}
|
|
25570
|
+
nextRows.push(row);
|
|
25571
|
+
}
|
|
25103
25572
|
const seen = new Set();
|
|
25104
25573
|
const commits = getCommitRows(nextRows).filter((commit) => {
|
|
25105
25574
|
if (seen.has(commit.hash)) {
|
|
@@ -25191,6 +25660,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
25191
25660
|
fullGraph: options.fullGraph ?? true,
|
|
25192
25661
|
showHelp: false,
|
|
25193
25662
|
helpScrollOffset: 0,
|
|
25663
|
+
showViewKeys: false,
|
|
25194
25664
|
showCommandPalette: false,
|
|
25195
25665
|
workflowActionId: undefined,
|
|
25196
25666
|
pendingConfirmationId: undefined,
|
|
@@ -25270,6 +25740,9 @@ function applyLogInkAction(state, action) {
|
|
|
25270
25740
|
// from 'commits' should always land back on a real file when
|
|
25271
25741
|
// the user returns.
|
|
25272
25742
|
statusGroupHeaderFocused: false,
|
|
25743
|
+
// Explicit focus cycle cancels a pending peek return — the
|
|
25744
|
+
// user has taken manual control of the focus.
|
|
25745
|
+
peekReturnFocus: undefined,
|
|
25273
25746
|
pendingKey: undefined,
|
|
25274
25747
|
};
|
|
25275
25748
|
case 'focusPrevious':
|
|
@@ -25278,6 +25751,7 @@ function applyLogInkAction(state, action) {
|
|
|
25278
25751
|
focus: cycleValue(FOCUS_ORDER, state.focus, -1),
|
|
25279
25752
|
sidebarHeaderFocused: false,
|
|
25280
25753
|
statusGroupHeaderFocused: false,
|
|
25754
|
+
peekReturnFocus: undefined,
|
|
25281
25755
|
pendingKey: undefined,
|
|
25282
25756
|
};
|
|
25283
25757
|
case 'move':
|
|
@@ -25692,6 +26166,22 @@ function applyLogInkAction(state, action) {
|
|
|
25692
26166
|
pendingKey: undefined,
|
|
25693
26167
|
};
|
|
25694
26168
|
}
|
|
26169
|
+
case 'returnFromCommit': {
|
|
26170
|
+
// After a successful commit we leave the compose view automatically.
|
|
26171
|
+
// Where to: a still-dirty tree the user was staging from returns to
|
|
26172
|
+
// the Status view so they can finish the rest; an otherwise-complete
|
|
26173
|
+
// commit returns to the History view, where the new commit now shows.
|
|
26174
|
+
// We pop frames one at a time (reusing withPoppedView) so sidebar-tab
|
|
26175
|
+
// and diff-state restoration stays identical to manual Esc/back —
|
|
26176
|
+
// this also unwinds an intermediate `diff` frame (status → diff →
|
|
26177
|
+
// compose) back to the status frame it sits under.
|
|
26178
|
+
const target = action.stillDirty && state.viewStack.includes('status') ? 'status' : HOME_VIEW;
|
|
26179
|
+
let next = state;
|
|
26180
|
+
while (next.viewStack.length > 1 && topOfStack(next.viewStack) !== target) {
|
|
26181
|
+
next = withPoppedView(next);
|
|
26182
|
+
}
|
|
26183
|
+
return { ...next, pendingKey: undefined };
|
|
26184
|
+
}
|
|
25695
26185
|
case 'navigateOpenDiffForCommit': {
|
|
25696
26186
|
const next = withPushedView(state, 'diff');
|
|
25697
26187
|
const filteredCommits = state.filteredCommits;
|
|
@@ -25775,8 +26265,35 @@ function applyLogInkAction(state, action) {
|
|
|
25775
26265
|
// the status view — clear when focus moves away so a
|
|
25776
26266
|
// re-entry starts on a real file.
|
|
25777
26267
|
statusGroupHeaderFocused: action.value === 'commits' ? state.statusGroupHeaderFocused : false,
|
|
26268
|
+
// An explicit focus set cancels a pending peek return.
|
|
26269
|
+
peekReturnFocus: undefined,
|
|
25778
26270
|
pendingKey: undefined,
|
|
25779
26271
|
};
|
|
26272
|
+
case 'togglePeek': {
|
|
26273
|
+
// Peek = "focus the sidebar with a return ticket." Closing returns
|
|
26274
|
+
// to the stashed focus; opening (only from a non-sidebar pane)
|
|
26275
|
+
// stashes the current focus and jumps to the sidebar. The render
|
|
26276
|
+
// layer needs no special case — `focus: 'sidebar'` already drives
|
|
26277
|
+
// the single-pane layout to show the sidebar full-width.
|
|
26278
|
+
if (state.peekReturnFocus !== undefined) {
|
|
26279
|
+
return {
|
|
26280
|
+
...state,
|
|
26281
|
+
focus: state.peekReturnFocus,
|
|
26282
|
+
peekReturnFocus: undefined,
|
|
26283
|
+
sidebarHeaderFocused: false,
|
|
26284
|
+
pendingKey: undefined,
|
|
26285
|
+
};
|
|
26286
|
+
}
|
|
26287
|
+
if (state.focus === 'sidebar') {
|
|
26288
|
+
return state;
|
|
26289
|
+
}
|
|
26290
|
+
return {
|
|
26291
|
+
...state,
|
|
26292
|
+
focus: 'sidebar',
|
|
26293
|
+
peekReturnFocus: state.focus,
|
|
26294
|
+
pendingKey: undefined,
|
|
26295
|
+
};
|
|
26296
|
+
}
|
|
25780
26297
|
case 'setPendingKey':
|
|
25781
26298
|
return {
|
|
25782
26299
|
...state,
|
|
@@ -25861,6 +26378,7 @@ function applyLogInkAction(state, action) {
|
|
|
25861
26378
|
showCommandPalette: false,
|
|
25862
26379
|
showHelp: false,
|
|
25863
26380
|
helpScrollOffset: 0,
|
|
26381
|
+
showViewKeys: false,
|
|
25864
26382
|
pendingKey: undefined,
|
|
25865
26383
|
};
|
|
25866
26384
|
case 'toggleGraph':
|
|
@@ -25879,9 +26397,24 @@ function applyLogInkAction(state, action) {
|
|
|
25879
26397
|
// than picking up where the user last scrolled.
|
|
25880
26398
|
helpScrollOffset: 0,
|
|
25881
26399
|
showCommandPalette: false,
|
|
26400
|
+
// Opening full help supersedes the compact view-keys strip — this
|
|
26401
|
+
// is the progressive-disclosure step (`?` from the strip expands
|
|
26402
|
+
// to the full categorized help, #1137).
|
|
26403
|
+
showViewKeys: false,
|
|
25882
26404
|
pendingKey: undefined,
|
|
25883
26405
|
};
|
|
25884
26406
|
}
|
|
26407
|
+
case 'toggleViewKeys':
|
|
26408
|
+
return {
|
|
26409
|
+
...state,
|
|
26410
|
+
showViewKeys: !state.showViewKeys,
|
|
26411
|
+
// The view-keys strip is mutually exclusive with the other
|
|
26412
|
+
// overlays; opening it closes anything else that was showing.
|
|
26413
|
+
showHelp: false,
|
|
26414
|
+
helpScrollOffset: 0,
|
|
26415
|
+
showCommandPalette: false,
|
|
26416
|
+
pendingKey: undefined,
|
|
26417
|
+
};
|
|
25885
26418
|
case 'scrollHelp':
|
|
25886
26419
|
// No upper-bound clamp here — the renderer caps the offset
|
|
25887
26420
|
// against the actual content height at render time. The
|
|
@@ -25898,6 +26431,7 @@ function applyLogInkAction(state, action) {
|
|
|
25898
26431
|
showCommandPalette: opening,
|
|
25899
26432
|
showHelp: false,
|
|
25900
26433
|
helpScrollOffset: 0,
|
|
26434
|
+
showViewKeys: false,
|
|
25901
26435
|
// Reset palette interaction state on every open/close so the next
|
|
25902
26436
|
// session starts from a clean slate.
|
|
25903
26437
|
paletteFilter: '',
|
|
@@ -25945,8 +26479,9 @@ function applyLogInkAction(state, action) {
|
|
|
25945
26479
|
return {
|
|
25946
26480
|
...state,
|
|
25947
26481
|
showThemePicker: opening,
|
|
25948
|
-
// Only one overlay at a time — close help / palette on open.
|
|
26482
|
+
// Only one overlay at a time — close help / palette / view-keys on open.
|
|
25949
26483
|
showHelp: false,
|
|
26484
|
+
showViewKeys: false,
|
|
25950
26485
|
showCommandPalette: false,
|
|
25951
26486
|
themePickerFilter: '',
|
|
25952
26487
|
themePickerIndex: 0,
|
|
@@ -26643,6 +27178,12 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
26643
27178
|
return [action({ type: 'toggleGraph' })];
|
|
26644
27179
|
case 'navigateHome':
|
|
26645
27180
|
return [action({ type: 'navigateHome' })];
|
|
27181
|
+
case 'createStash':
|
|
27182
|
+
return [action({
|
|
27183
|
+
type: 'openInputPrompt',
|
|
27184
|
+
kind: 'create-stash',
|
|
27185
|
+
label: 'Stash message (empty = WIP)',
|
|
27186
|
+
})];
|
|
26646
27187
|
case 'navigateStatus':
|
|
26647
27188
|
return [action({ type: 'pushView', value: 'status' })];
|
|
26648
27189
|
case 'navigateDiff':
|
|
@@ -26740,6 +27281,10 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
26740
27281
|
// Palette closes on execute (toggleCommandPalette runs first), then
|
|
26741
27282
|
// this opens the theme picker.
|
|
26742
27283
|
return [action({ type: 'toggleThemePicker' })];
|
|
27284
|
+
case 'viewKeys':
|
|
27285
|
+
// Palette closes on execute (toggleCommandPalette runs first), then
|
|
27286
|
+
// this opens the per-view which-key strip (#1137).
|
|
27287
|
+
return [action({ type: 'toggleViewKeys' })];
|
|
26743
27288
|
case 'openProjectConfig':
|
|
26744
27289
|
return [{ type: 'openConfigInEditor', scope: 'project' }];
|
|
26745
27290
|
case 'openGlobalConfig':
|
|
@@ -26748,6 +27293,14 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
26748
27293
|
// Runtime resolves the cursored worktree file and opens the picker
|
|
26749
27294
|
// (no-ops with a warning when there's no file under the cursor).
|
|
26750
27295
|
return [{ type: 'openGitignorePicker' }];
|
|
27296
|
+
case 'stageAll':
|
|
27297
|
+
return [{ type: 'runWorkflowAction', id: 'stage-all' }];
|
|
27298
|
+
case 'stagePathspec':
|
|
27299
|
+
return [action({
|
|
27300
|
+
type: 'openInputPrompt',
|
|
27301
|
+
kind: 'stage-pathspec',
|
|
27302
|
+
label: 'Stage pathspec (e.g. `.`, `src/`, `*.ts`, or a space-separated list)',
|
|
27303
|
+
})];
|
|
26751
27304
|
case 'workflowDeleteBranch':
|
|
26752
27305
|
case 'workflowDeleteTag':
|
|
26753
27306
|
case 'workflowDropStash':
|
|
@@ -26838,6 +27391,15 @@ function submitInputPrompt(state) {
|
|
|
26838
27391
|
if (!state.inputPrompt)
|
|
26839
27392
|
return [];
|
|
26840
27393
|
const value = state.inputPrompt.value.trim();
|
|
27394
|
+
// create-stash allows an EMPTY value → quick WIP stash (git supplies its
|
|
27395
|
+
// own "WIP on <branch>" subject). Handled before the generic empty guard
|
|
27396
|
+
// so an empty stash prompt commits a WIP stash instead of bouncing.
|
|
27397
|
+
if (state.inputPrompt.kind === 'create-stash') {
|
|
27398
|
+
return [
|
|
27399
|
+
{ type: 'runWorkflowAction', id: 'create-stash', payload: value },
|
|
27400
|
+
action({ type: 'closeInputPrompt' }),
|
|
27401
|
+
];
|
|
27402
|
+
}
|
|
26841
27403
|
if (!value) {
|
|
26842
27404
|
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
|
|
26843
27405
|
}
|
|
@@ -26847,6 +27409,12 @@ function submitInputPrompt(state) {
|
|
|
26847
27409
|
action({ type: 'closeInputPrompt' }),
|
|
26848
27410
|
];
|
|
26849
27411
|
}
|
|
27412
|
+
if (state.inputPrompt.kind === 'stage-pathspec') {
|
|
27413
|
+
return [
|
|
27414
|
+
{ type: 'runWorkflowAction', id: 'stage-pathspec', payload: value },
|
|
27415
|
+
action({ type: 'closeInputPrompt' }),
|
|
27416
|
+
];
|
|
27417
|
+
}
|
|
26850
27418
|
if (state.inputPrompt.kind === 'reset-mode') {
|
|
26851
27419
|
const mode = value.toLowerCase();
|
|
26852
27420
|
if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
|
|
@@ -27073,7 +27641,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27073
27641
|
// draft was pending should see the original `R` / Esc semantics of
|
|
27074
27642
|
// wherever they are now.
|
|
27075
27643
|
if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
|
|
27076
|
-
|
|
27644
|
+
// `R` or `Enter` accept the swap (the AI draft becomes the new
|
|
27645
|
+
// content); `Enter` is the natural "yes, use it" confirmation.
|
|
27646
|
+
if ((inputValue === 'R' && !key.ctrl && !key.meta) || key.return) {
|
|
27077
27647
|
return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
|
|
27078
27648
|
}
|
|
27079
27649
|
if (key.escape) {
|
|
@@ -27443,6 +28013,26 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27443
28013
|
}
|
|
27444
28014
|
return [];
|
|
27445
28015
|
}
|
|
28016
|
+
// #1137 — the `g?` which-key strip. While it's open the keyboard is
|
|
28017
|
+
// claimed (mirrors the help overlay) so a stray keystroke can't drop
|
|
28018
|
+
// the user into a per-view action they didn't mean to trigger. Esc
|
|
28019
|
+
// closes; `?` is the progressive-disclosure step up to the full
|
|
28020
|
+
// categorized help; `q` still quits. Everything else is swallowed —
|
|
28021
|
+
// the user peeks, dismisses, then presses the key they came for.
|
|
28022
|
+
if (state.showViewKeys) {
|
|
28023
|
+
if (key.escape) {
|
|
28024
|
+
return [action({ type: 'toggleViewKeys' })];
|
|
28025
|
+
}
|
|
28026
|
+
if (inputValue === '?') {
|
|
28027
|
+
// Expand the compact strip into the full help overlay. `toggleHelp`
|
|
28028
|
+
// clears `showViewKeys` so the two never render at once.
|
|
28029
|
+
return [action({ type: 'toggleHelp' })];
|
|
28030
|
+
}
|
|
28031
|
+
if (inputValue === 'q') {
|
|
28032
|
+
return [{ type: 'exit' }];
|
|
28033
|
+
}
|
|
28034
|
+
return [];
|
|
28035
|
+
}
|
|
27446
28036
|
// #879 item 4 — Esc cancels an in-flight bisect-start wizard. Runs
|
|
27447
28037
|
// BEFORE the generic `popView` so we both clear the wizard state
|
|
27448
28038
|
// and walk back to the bisect view in one keystroke. Without this
|
|
@@ -27459,6 +28049,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27459
28049
|
}
|
|
27460
28050
|
return events;
|
|
27461
28051
|
}
|
|
28052
|
+
// #1135 v2 — while peeking the sidebar, Esc or the peek key (`v`)
|
|
28053
|
+
// snaps back to the pane the user came from. Placed before the
|
|
28054
|
+
// generic Esc → popView so a peek glance returns to main rather than
|
|
28055
|
+
// walking the view stack. Every other key falls through to normal
|
|
28056
|
+
// handling (focus is on the sidebar during a peek), so ←/→ and ↑/↓
|
|
28057
|
+
// browse the sidebar and keep the peek open until an explicit exit.
|
|
28058
|
+
if (state.peekReturnFocus !== undefined && (key.escape || inputValue === 'v')) {
|
|
28059
|
+
return [action({ type: 'togglePeek' })];
|
|
28060
|
+
}
|
|
27462
28061
|
if (key.escape && state.viewStack.length > 1) {
|
|
27463
28062
|
return [action({ type: 'popView' })];
|
|
27464
28063
|
}
|
|
@@ -27477,6 +28076,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27477
28076
|
}
|
|
27478
28077
|
return [{ type: 'exit' }];
|
|
27479
28078
|
}
|
|
28079
|
+
// `g?` chord (#1137) — open the per-view which-key strip. Placed
|
|
28080
|
+
// BEFORE the bare `?` (full help) check below so the chord is read as
|
|
28081
|
+
// a unit: with `g` pending, `?` opens the view-keys strip rather than
|
|
28082
|
+
// toggling full help. Surfaces automatically in the `g` which-key menu
|
|
28083
|
+
// because its key is a two-char `g`-prefixed binding.
|
|
28084
|
+
if (state.pendingKey === 'g' && inputValue === '?') {
|
|
28085
|
+
return [
|
|
28086
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
28087
|
+
action({ type: 'toggleViewKeys' }),
|
|
28088
|
+
];
|
|
28089
|
+
}
|
|
27480
28090
|
if (inputValue === '?') {
|
|
27481
28091
|
return [action({ type: 'toggleHelp' })];
|
|
27482
28092
|
}
|
|
@@ -27525,6 +28135,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27525
28135
|
action({ type: 'setStatus', value: 'jumped to stash' }),
|
|
27526
28136
|
];
|
|
27527
28137
|
}
|
|
28138
|
+
// `gZ` chord: stash all changes from ANY view — including status / diff /
|
|
28139
|
+
// compose, where bare `S` is claimed by the commit-split flow. Mnemonic
|
|
28140
|
+
// pair with `gz` (jump to the stash *view*). Opens the same message
|
|
28141
|
+
// prompt; an empty message creates a quick WIP stash.
|
|
28142
|
+
if (state.pendingKey === 'g' && inputValue === 'Z') {
|
|
28143
|
+
return [action({
|
|
28144
|
+
type: 'openInputPrompt',
|
|
28145
|
+
kind: 'create-stash',
|
|
28146
|
+
label: 'Stash message (empty = WIP)',
|
|
28147
|
+
})];
|
|
28148
|
+
}
|
|
27528
28149
|
if (state.pendingKey === 'g' && inputValue === 'w') {
|
|
27529
28150
|
return [
|
|
27530
28151
|
action({ type: 'pushView', value: 'worktrees' }),
|
|
@@ -27888,6 +28509,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27888
28509
|
if (key.tab) {
|
|
27889
28510
|
return [action({ type: key.shift ? 'focusPrevious' : 'focusNext' })];
|
|
27890
28511
|
}
|
|
28512
|
+
// #1135 v2 — `v` peeks the sidebar from the main / inspector pane on
|
|
28513
|
+
// narrow (single-pane) terminals: a momentary glance that snaps back
|
|
28514
|
+
// with `v` / Esc (handled above once peeking). No-op in the three-pane
|
|
28515
|
+
// layout (every pane is already on screen) and from the sidebar itself.
|
|
28516
|
+
if (inputValue === 'v' && context.singlePane && state.focus !== 'sidebar') {
|
|
28517
|
+
return [action({ type: 'togglePeek' })];
|
|
28518
|
+
}
|
|
27891
28519
|
// ←/→ on the sidebar switch tabs (Status ↔ Branches ↔ Tags ↔
|
|
27892
28520
|
// Stashes ↔ Worktrees) — the horizontal axis is "between tabs", the
|
|
27893
28521
|
// vertical axis (↑/↓ below) is "within the active tab's items".
|
|
@@ -27973,10 +28601,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27973
28601
|
fileCount: context.worktreeFileCount,
|
|
27974
28602
|
})];
|
|
27975
28603
|
}
|
|
27976
|
-
//
|
|
27977
|
-
//
|
|
27978
|
-
//
|
|
27979
|
-
//
|
|
28604
|
+
// Worktree (staging) diff: ↑/↓ move between hunks — the hunk is the
|
|
28605
|
+
// unit you stage, so the cursor walks hunks (auto-scrolling to the
|
|
28606
|
+
// selected one). Single-hunk files fall through to line-scroll so a
|
|
28607
|
+
// long lone hunk stays readable; `[`/`]` remain hunk-jump aliases.
|
|
28608
|
+
if (state.activeView === 'diff' && (context.worktreeHunkOffsets?.length ?? 0) > 1) {
|
|
28609
|
+
return [action({
|
|
28610
|
+
type: 'jumpWorktreeHunk',
|
|
28611
|
+
delta: -1,
|
|
28612
|
+
hunkOffsets: context.worktreeHunkOffsets,
|
|
28613
|
+
})];
|
|
28614
|
+
}
|
|
27980
28615
|
if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
|
|
27981
28616
|
return [action({
|
|
27982
28617
|
type: 'pageWorktreeDiff',
|
|
@@ -28091,6 +28726,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28091
28726
|
fileCount: context.worktreeFileCount,
|
|
28092
28727
|
})];
|
|
28093
28728
|
}
|
|
28729
|
+
// Worktree (staging) diff: ↓ walks to the next hunk (see the ↑
|
|
28730
|
+
// handler). Multi-hunk only; single-hunk files line-scroll.
|
|
28731
|
+
if (state.activeView === 'diff' && (context.worktreeHunkOffsets?.length ?? 0) > 1) {
|
|
28732
|
+
return [action({
|
|
28733
|
+
type: 'jumpWorktreeHunk',
|
|
28734
|
+
delta: 1,
|
|
28735
|
+
hunkOffsets: context.worktreeHunkOffsets,
|
|
28736
|
+
})];
|
|
28737
|
+
}
|
|
28094
28738
|
if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
|
|
28095
28739
|
return [action({
|
|
28096
28740
|
type: 'pageWorktreeDiff',
|
|
@@ -28497,6 +29141,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28497
29141
|
if (inputValue === 'p' && isStashActionTarget(state) && context.stashCount) {
|
|
28498
29142
|
return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
|
|
28499
29143
|
}
|
|
29144
|
+
// `A` applies restoring the staged/unstaged split (`git stash apply
|
|
29145
|
+
// --index`) — distinct from `a` (plain apply).
|
|
29146
|
+
if (inputValue === 'A' && isStashActionTarget(state) && context.stashCount) {
|
|
29147
|
+
return [{ type: 'runWorkflowAction', id: 'apply-stash-index' }];
|
|
29148
|
+
}
|
|
29149
|
+
// `b` turns the cursored stash into a new branch (`git stash branch`).
|
|
29150
|
+
if (inputValue === 'b' && isStashActionTarget(state) && context.stashCount) {
|
|
29151
|
+
return [action({ type: 'openInputPrompt', kind: 'stash-branch', label: 'New branch from stash' })];
|
|
29152
|
+
}
|
|
29153
|
+
// `R` renames the cursored stash (store-under-new-message + drop old).
|
|
29154
|
+
if (inputValue === 'R' && isStashActionTarget(state) && context.stashCount) {
|
|
29155
|
+
return [action({ type: 'openInputPrompt', kind: 'rename-stash', label: 'Rename stash' })];
|
|
29156
|
+
}
|
|
29157
|
+
// `u` undoes the last drop. Gated on the view, NOT the count, so it
|
|
29158
|
+
// still works right after you drop your only stash (the list is empty
|
|
29159
|
+
// but the dropped commit is recoverable by hash).
|
|
29160
|
+
if (inputValue === 'u' && isStashActionTarget(state)) {
|
|
29161
|
+
return [{ type: 'runWorkflowAction', id: 'undo-drop-stash' }];
|
|
29162
|
+
}
|
|
28500
29163
|
// Per-view tag action: `P` pushes the selected tag to origin. Letter
|
|
28501
29164
|
// is scoped to the tags target so it doesn't collide with `p` for
|
|
28502
29165
|
// pop-stash. Note: this also takes precedence over the global
|
|
@@ -28725,7 +29388,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28725
29388
|
return [action({
|
|
28726
29389
|
type: 'openInputPrompt',
|
|
28727
29390
|
kind: 'create-stash',
|
|
28728
|
-
label: 'Stash message',
|
|
29391
|
+
label: 'Stash message (empty = WIP)',
|
|
28729
29392
|
})];
|
|
28730
29393
|
}
|
|
28731
29394
|
// `o` opens the file under the cursor in $EDITOR. Available on the
|
|
@@ -28994,9 +29657,35 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28994
29657
|
if (inputValue === ' ' && state.activeView === 'status' && context.worktreeFileCount) {
|
|
28995
29658
|
return [{ type: 'toggleSelectedFileStage' }];
|
|
28996
29659
|
}
|
|
29660
|
+
// `A` — stage everything (git add -A); `+` — stage by typed pathspec.
|
|
29661
|
+
// Both available from the status AND compose views so you can stage
|
|
29662
|
+
// without leaving the message editor.
|
|
29663
|
+
if (inputValue === 'A' && (state.activeView === 'status' || state.activeView === 'compose')) {
|
|
29664
|
+
return [{ type: 'runWorkflowAction', id: 'stage-all' }];
|
|
29665
|
+
}
|
|
29666
|
+
if (inputValue === '+' && (state.activeView === 'status' || state.activeView === 'compose')) {
|
|
29667
|
+
return [action({
|
|
29668
|
+
type: 'openInputPrompt',
|
|
29669
|
+
kind: 'stage-pathspec',
|
|
29670
|
+
label: 'Stage pathspec (e.g. `.`, `src/`, `*.ts`, or a space-separated list)',
|
|
29671
|
+
})];
|
|
29672
|
+
}
|
|
28997
29673
|
if (inputValue === ' ' && state.activeView === 'diff' && context.worktreeHunkOffsets?.length) {
|
|
28998
29674
|
return [{ type: 'toggleSelectedHunkStage' }];
|
|
28999
29675
|
}
|
|
29676
|
+
// Worktree diff with no hunks (a new/untracked file) — `space` stages
|
|
29677
|
+
// the whole file, since there's nothing to partial-stage.
|
|
29678
|
+
if (inputValue === ' ' &&
|
|
29679
|
+
state.activeView === 'diff' &&
|
|
29680
|
+
state.diffSource === 'worktree' &&
|
|
29681
|
+
!context.worktreeHunkOffsets?.length) {
|
|
29682
|
+
return [{ type: 'toggleSelectedFileStage' }];
|
|
29683
|
+
}
|
|
29684
|
+
// `a` stages/unstages the WHOLE current file from the staging diff —
|
|
29685
|
+
// an escape hatch out of hunk-by-hunk back to all-or-nothing.
|
|
29686
|
+
if (inputValue === 'a' && state.activeView === 'diff' && state.diffSource === 'worktree') {
|
|
29687
|
+
return [{ type: 'toggleSelectedFileStage' }];
|
|
29688
|
+
}
|
|
29000
29689
|
if (inputValue === 'z' && state.activeView === 'status' && context.worktreeFileCount) {
|
|
29001
29690
|
return [action({ type: 'setPendingMutationConfirmation', value: 'revert-file' })];
|
|
29002
29691
|
}
|
|
@@ -29771,21 +30460,22 @@ const INSPECTOR_TABBED_BELOW_ROWS = 28;
|
|
|
29771
30460
|
* wide >= 160 — plenty of room; keep absolute dates
|
|
29772
30461
|
* normal >= 120 — relative dates save 8-ish cells without hiding info
|
|
29773
30462
|
* tight >= 100 — drop date entirely; subject + refs are the priority
|
|
29774
|
-
* rail < 100 —
|
|
29775
|
-
*
|
|
30463
|
+
* rail < 100 — history rows stack to two lines; the UI also drops
|
|
30464
|
+
* to single-pane mode (see `LAYOUT_SINGLE_PANE_BELOW`)
|
|
29776
30465
|
*/
|
|
29777
30466
|
const LAYOUT_TIGHT_BELOW = 120;
|
|
29778
30467
|
const LAYOUT_NORMAL_BELOW = 160;
|
|
29779
30468
|
const LAYOUT_RAIL_BELOW = 100;
|
|
29780
30469
|
/**
|
|
29781
|
-
*
|
|
29782
|
-
*
|
|
29783
|
-
*
|
|
29784
|
-
*
|
|
30470
|
+
* Width below which the three-panel layout can't tile without starving
|
|
30471
|
+
* every pane, so the UI shows exactly one full-width pane (the focused
|
|
30472
|
+
* one) and Tab cycles which pane is visible. Coincides with the `rail`
|
|
30473
|
+
* density breakpoint — single-pane mode replaces the old 8-cell icon
|
|
30474
|
+
* rails that used to render at this width.
|
|
29785
30475
|
*/
|
|
29786
|
-
const
|
|
30476
|
+
const LAYOUT_SINGLE_PANE_BELOW = LAYOUT_RAIL_BELOW;
|
|
29787
30477
|
const SIDEBAR_AT_REST_BY_TIER = {
|
|
29788
|
-
rail: { min: 22, max: 28, fraction: 0.24 }, // unused —
|
|
30478
|
+
rail: { min: 22, max: 28, fraction: 0.24 }, // unused at rest — single-pane mode overrides the width
|
|
29789
30479
|
tight: { min: 22, max: 28, fraction: 0.24 },
|
|
29790
30480
|
normal: { min: 22, max: 30, fraction: 0.22 },
|
|
29791
30481
|
wide: { min: 28, max: 32, fraction: 0.20 },
|
|
@@ -29804,14 +30494,25 @@ function getLogInkLayout(input) {
|
|
|
29804
30494
|
: columns >= LAYOUT_RAIL_BELOW
|
|
29805
30495
|
? 'tight'
|
|
29806
30496
|
: 'rail';
|
|
29807
|
-
//
|
|
29808
|
-
//
|
|
29809
|
-
//
|
|
29810
|
-
//
|
|
29811
|
-
//
|
|
29812
|
-
|
|
29813
|
-
|
|
29814
|
-
|
|
30497
|
+
// Below the single-pane breakpoint the three-panel layout can't tile
|
|
30498
|
+
// without starving every pane, so we show exactly one full-width pane
|
|
30499
|
+
// — the focused one — and Tab cycles which pane is visible. This
|
|
30500
|
+
// replaces the retired 8-cell icon rails (an 8-cell stub showed a tab
|
|
30501
|
+
// glyph + count and nothing actionable).
|
|
30502
|
+
const singlePane = columns < LAYOUT_SINGLE_PANE_BELOW;
|
|
30503
|
+
// Which pane shows in single-pane mode. Defaults to the focused pane
|
|
30504
|
+
// (focus and visibility coalesce, so the existing Tab focus cycle
|
|
30505
|
+
// drives it). An active overlay can force a specific pane via
|
|
30506
|
+
// `forcedPane` so its surface isn't hidden behind whatever pane focus
|
|
30507
|
+
// points at.
|
|
30508
|
+
const focusPane = input.sidebarFocused
|
|
30509
|
+
? 'sidebar'
|
|
30510
|
+
: input.inspectorFocused
|
|
30511
|
+
? 'inspector'
|
|
30512
|
+
: 'main';
|
|
30513
|
+
const visiblePane = singlePane
|
|
30514
|
+
? input.forcedPane ?? focusPane
|
|
30515
|
+
: focusPane;
|
|
29815
30516
|
// Inspector width — at rest 20-32 cells (~22% of width), focused
|
|
29816
30517
|
// 36-60 cells (~40% of width). Narrow rest state keeps the commit
|
|
29817
30518
|
// graph dominant; focus expansion gives the inspector room for long
|
|
@@ -29823,42 +30524,48 @@ function getLogInkLayout(input) {
|
|
|
29823
30524
|
// "Move focus...". Capped at 100 cells so a wide terminal doesn't
|
|
29824
30525
|
// waste an absurd amount of horizontal space on the cheat sheet.
|
|
29825
30526
|
//
|
|
29826
|
-
//
|
|
29827
|
-
//
|
|
29828
|
-
// intent to read the panel.
|
|
30527
|
+
// (In single-pane mode these three-panel widths are recomputed below
|
|
30528
|
+
// so the visible pane gets the full terminal.)
|
|
29829
30529
|
const detailWidth = input.helpOverlayActive
|
|
29830
30530
|
? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
|
|
29831
30531
|
: input.inspectorFocused
|
|
29832
30532
|
? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
|
|
29833
|
-
:
|
|
29834
|
-
? LAYOUT_RAIL_PANEL_WIDTH
|
|
29835
|
-
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
30533
|
+
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
29836
30534
|
// Sidebar at rest is tier-aware (see `SIDEBAR_AT_REST_BY_TIER`):
|
|
29837
30535
|
// tight stays compact (22-28), normal shrinks slightly (22-30),
|
|
29838
30536
|
// wide grows naturally (28-48) so the side panel doesn't get pinned
|
|
29839
30537
|
// at an arbitrary cap on big terminals while the main panel hogs
|
|
29840
30538
|
// 80% of the width. Focused: 32-50 cells (~36% of width),
|
|
29841
30539
|
// regardless of tier — deliberate user intent to read the sidebar
|
|
29842
|
-
// deserves the extra width.
|
|
29843
|
-
// collapses to a fixed 8-cell strip with tab glyphs only.
|
|
30540
|
+
// deserves the extra width.
|
|
29844
30541
|
const sidebarWidth = input.sidebarFocused
|
|
29845
30542
|
? Math.max(32, Math.min(50, Math.floor(columns * 0.36)))
|
|
29846
|
-
:
|
|
29847
|
-
|
|
29848
|
-
|
|
30543
|
+
: calcSidebarAtRestWidth(columns, density);
|
|
30544
|
+
// Single-pane mode: exactly one pane renders, full-width; the other
|
|
30545
|
+
// two are hidden (width 0), not railed. Above the breakpoint the
|
|
30546
|
+
// three panels tile flush across the terminal.
|
|
30547
|
+
const paneWidths = singlePane
|
|
30548
|
+
? {
|
|
30549
|
+
sidebarWidth: visiblePane === 'sidebar' ? columns : 0,
|
|
30550
|
+
mainPanelWidth: visiblePane === 'main' ? columns : 0,
|
|
30551
|
+
detailWidth: visiblePane === 'inspector' ? columns : 0,
|
|
30552
|
+
}
|
|
30553
|
+
: {
|
|
30554
|
+
sidebarWidth,
|
|
30555
|
+
mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
|
|
30556
|
+
detailWidth,
|
|
30557
|
+
};
|
|
29849
30558
|
return {
|
|
29850
30559
|
bodyRows: Math.max(8, rows - 5),
|
|
29851
30560
|
columns,
|
|
29852
|
-
detailWidth,
|
|
29853
|
-
mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
|
|
29854
30561
|
rows,
|
|
29855
|
-
sidebarWidth,
|
|
29856
30562
|
tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
|
|
29857
30563
|
inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
|
|
29858
30564
|
density,
|
|
29859
|
-
|
|
29860
|
-
|
|
30565
|
+
singlePane,
|
|
30566
|
+
visiblePane,
|
|
29861
30567
|
historyRowMode: density === 'rail' ? 'stacked' : 'single',
|
|
30568
|
+
...paneWidths,
|
|
29862
30569
|
};
|
|
29863
30570
|
}
|
|
29864
30571
|
|
|
@@ -30362,7 +31069,7 @@ function fetchBranch(git, branch) {
|
|
|
30362
31069
|
if (!branch.upstream || !branch.remote) {
|
|
30363
31070
|
return Promise.resolve({
|
|
30364
31071
|
ok: false,
|
|
30365
|
-
message: `${branch.shortName} has no upstream —
|
|
31072
|
+
message: `${branch.shortName} has no upstream — set one with \`git push -u <remote> ${branch.shortName}\` to enable fetch.`,
|
|
30366
31073
|
});
|
|
30367
31074
|
}
|
|
30368
31075
|
// `branch.upstream` is the short form (e.g. `origin/main`); the
|
|
@@ -30400,7 +31107,7 @@ function pullBranch(git, branch, currentBranchName) {
|
|
|
30400
31107
|
if (!branch.upstream || !branch.remote) {
|
|
30401
31108
|
return Promise.resolve({
|
|
30402
31109
|
ok: false,
|
|
30403
|
-
message: `${branch.shortName} has no upstream —
|
|
31110
|
+
message: `${branch.shortName} has no upstream — set one with \`git push -u <remote> ${branch.shortName}\` to enable pull.`,
|
|
30404
31111
|
});
|
|
30405
31112
|
}
|
|
30406
31113
|
// Current branch — defer to the in-place workflow.
|
|
@@ -30907,6 +31614,45 @@ async function highlightDiffCode(filePath, lines) {
|
|
|
30907
31614
|
return result;
|
|
30908
31615
|
}
|
|
30909
31616
|
|
|
31617
|
+
/**
|
|
31618
|
+
* Humanize raw AI-provider / LangChain error strings into a short,
|
|
31619
|
+
* actionable line for the compose surface.
|
|
31620
|
+
*
|
|
31621
|
+
* The underlying errors are verbose and developer-facing — e.g.
|
|
31622
|
+
* `executeChain: Chain execution failed: 429 You exceeded your current
|
|
31623
|
+
* quota …`. We classify the common failure modes (rate limit, auth,
|
|
31624
|
+
* network, context length) into a concise message that tells the user
|
|
31625
|
+
* what happened and what to do, and fall back to the original (trimmed)
|
|
31626
|
+
* text for anything we don't recognize. Pure + tested.
|
|
31627
|
+
*/
|
|
31628
|
+
function humanizeAiError(raw) {
|
|
31629
|
+
const message = (raw || '').trim();
|
|
31630
|
+
if (!message)
|
|
31631
|
+
return 'AI request failed.';
|
|
31632
|
+
const lower = message.toLowerCase();
|
|
31633
|
+
// Rate limit / quota — the 429 in the screenshot.
|
|
31634
|
+
if (/\b429\b/.test(message) || /rate.?limit|too many requests|exceeded your current quota|quota/i.test(lower)) {
|
|
31635
|
+
return 'Rate limited by your AI provider (429) — too many requests or quota exceeded. Wait a moment, then press I to retry.';
|
|
31636
|
+
}
|
|
31637
|
+
// Auth / API key problems.
|
|
31638
|
+
if (/\b401\b|\b403\b/.test(message) || /unauthor|forbidden|invalid api key|incorrect api key|no api key|authentication/i.test(lower)) {
|
|
31639
|
+
return 'AI provider rejected the request — check your API key (run `coco init`, or press gK to edit the global config).';
|
|
31640
|
+
}
|
|
31641
|
+
// Context window overflow.
|
|
31642
|
+
if (/context length|maximum context|too many tokens|reduce the length|context_length_exceeded/i.test(lower)) {
|
|
31643
|
+
return 'The staged diff is too large for the model’s context window — stage fewer changes (or split the commit) and retry with I.';
|
|
31644
|
+
}
|
|
31645
|
+
// Network / connectivity.
|
|
31646
|
+
if (/etimedout|econnreset|enotfound|econnrefused|network error|fetch failed|socket hang up|timeout/i.test(lower)) {
|
|
31647
|
+
return 'Network error reaching the AI provider — check your connection, then press I to retry.';
|
|
31648
|
+
}
|
|
31649
|
+
// Unknown: strip the noisy `executeChain: Chain execution failed:`
|
|
31650
|
+
// prefix if present so the meaningful part leads, and keep it to one
|
|
31651
|
+
// line so it doesn't blow out the panel.
|
|
31652
|
+
const stripped = message.replace(/^.*?chain execution failed:\s*/i, '').trim() || message;
|
|
31653
|
+
return stripped.split('\n')[0];
|
|
31654
|
+
}
|
|
31655
|
+
|
|
30910
31656
|
async function runAction$4(action, successMessage) {
|
|
30911
31657
|
try {
|
|
30912
31658
|
await action();
|
|
@@ -30950,15 +31696,104 @@ async function runAction$3(action, successMessage) {
|
|
|
30950
31696
|
};
|
|
30951
31697
|
}
|
|
30952
31698
|
}
|
|
30953
|
-
function createStash(git, message) {
|
|
31699
|
+
function createStash(git, message, options = {}) {
|
|
30954
31700
|
const trimmedMessage = message.trim();
|
|
30955
|
-
|
|
30956
|
-
|
|
30957
|
-
|
|
30958
|
-
|
|
30959
|
-
|
|
31701
|
+
const args = ['stash', 'push'];
|
|
31702
|
+
// `--staged` is index-only, so untracked / `--keep-index` don't apply;
|
|
31703
|
+
// every other mode includes untracked (`-u`). `--keep-index` leaves the
|
|
31704
|
+
// index populated for an immediate follow-up commit.
|
|
31705
|
+
if (options.stagedOnly) {
|
|
31706
|
+
args.push('--staged');
|
|
30960
31707
|
}
|
|
30961
|
-
|
|
31708
|
+
else {
|
|
31709
|
+
args.push('-u');
|
|
31710
|
+
if (options.keepIndex)
|
|
31711
|
+
args.push('--keep-index');
|
|
31712
|
+
}
|
|
31713
|
+
if (trimmedMessage)
|
|
31714
|
+
args.push('-m', trimmedMessage);
|
|
31715
|
+
const paths = options.pathspec?.trim();
|
|
31716
|
+
if (paths)
|
|
31717
|
+
args.push('--', ...paths.split(/\s+/));
|
|
31718
|
+
const what = options.stagedOnly
|
|
31719
|
+
? 'staged changes'
|
|
31720
|
+
: paths
|
|
31721
|
+
? `“${paths}”`
|
|
31722
|
+
: options.keepIndex
|
|
31723
|
+
? 'changes (index kept)'
|
|
31724
|
+
: '';
|
|
31725
|
+
const success = trimmedMessage
|
|
31726
|
+
? `Created stash: ${trimmedMessage}`
|
|
31727
|
+
: what
|
|
31728
|
+
? `Stashed ${what}`
|
|
31729
|
+
: 'Created WIP stash';
|
|
31730
|
+
return runAction$3(() => git.raw(args), success);
|
|
31731
|
+
}
|
|
31732
|
+
/**
|
|
31733
|
+
* Apply a stash while restoring the original staged/unstaged split via
|
|
31734
|
+
* `--index`. Faithfully reinstates what was staged at stash time; git
|
|
31735
|
+
* errors (surfaced to the user) if the index can no longer be replayed,
|
|
31736
|
+
* in which case plain `applyStash` is the fallback.
|
|
31737
|
+
*/
|
|
31738
|
+
function applyStashKeepIndex(git, stash) {
|
|
31739
|
+
return runAction$3(() => git.raw(['stash', 'apply', '--index', stash.ref]), `Applied ${stash.ref} (index restored)`);
|
|
31740
|
+
}
|
|
31741
|
+
/**
|
|
31742
|
+
* Create a new branch from a stash's base commit, apply the stash onto
|
|
31743
|
+
* it, and drop the stash on success — `git stash branch`. The canonical
|
|
31744
|
+
* recovery when a stash no longer applies cleanly onto the current
|
|
31745
|
+
* branch (the branch starts at the exact commit the stash was made on).
|
|
31746
|
+
*/
|
|
31747
|
+
function stashBranch(git, stash, branchName) {
|
|
31748
|
+
const trimmed = branchName.trim();
|
|
31749
|
+
if (!trimmed) {
|
|
31750
|
+
return Promise.resolve({ ok: false, message: 'Cancelled: empty branch name.' });
|
|
31751
|
+
}
|
|
31752
|
+
return runAction$3(() => git.raw(['stash', 'branch', trimmed, stash.ref]), `Created branch ${trimmed} from ${stash.ref}`);
|
|
31753
|
+
}
|
|
31754
|
+
/**
|
|
31755
|
+
* Rename a stash. Git has no native rename, so: drop the original entry,
|
|
31756
|
+
* then re-store the SAME commit under the new message.
|
|
31757
|
+
*
|
|
31758
|
+
* Order matters — and it's the OPPOSITE of what you'd guess. `git stash
|
|
31759
|
+
* store` SILENTLY NO-OPS when the commit is already referenced in the
|
|
31760
|
+
* stash reflog (verified empirically), so storing first does nothing and
|
|
31761
|
+
* a follow-up drop removes the wrong entry. Dropping first removes the
|
|
31762
|
+
* reflog reference (the commit object survives), so the subsequent
|
|
31763
|
+
* `store` actually re-adds it — landing at `stash@{0}` with the new
|
|
31764
|
+
* message. The commit is captured by hash beforehand, so the drop→store
|
|
31765
|
+
* window can't lose it.
|
|
31766
|
+
*/
|
|
31767
|
+
function renameStash(git, stash, newMessage) {
|
|
31768
|
+
const trimmed = newMessage.trim();
|
|
31769
|
+
if (!trimmed) {
|
|
31770
|
+
return Promise.resolve({ ok: false, message: 'Rename cancelled: empty message.' });
|
|
31771
|
+
}
|
|
31772
|
+
if (!stash.hash) {
|
|
31773
|
+
return Promise.resolve({ ok: false, message: 'Cannot rename: stash commit hash unavailable.' });
|
|
31774
|
+
}
|
|
31775
|
+
// Preserve git's `On <branch>: <subject>` convention so the renamed
|
|
31776
|
+
// stash keeps its origin-branch context. The list + inspector parse the
|
|
31777
|
+
// branch out of that prefix (`parseStashSubject`); a bare message would
|
|
31778
|
+
// render `on <unknown>`. Falls back to the bare message when the branch
|
|
31779
|
+
// is unknown so we never store a misleading `On <unknown>:`.
|
|
31780
|
+
const branch = stash.branch && stash.branch !== '<unknown>' ? stash.branch : '';
|
|
31781
|
+
const storedMessage = branch ? `On ${branch}: ${trimmed}` : trimmed;
|
|
31782
|
+
return runAction$3(async () => {
|
|
31783
|
+
await git.raw(['stash', 'drop', stash.ref]);
|
|
31784
|
+
await git.raw(['stash', 'store', '-m', storedMessage, stash.hash]);
|
|
31785
|
+
}, `Renamed ${stash.ref} → ${trimmed}`);
|
|
31786
|
+
}
|
|
31787
|
+
/**
|
|
31788
|
+
* Re-store a previously dropped stash by its commit hash — the undo for
|
|
31789
|
+
* a `dropStash`. The dropped stash's commit stays in the object database
|
|
31790
|
+
* until git gc, so storing it back recreates the entry (at `stash@{0}`).
|
|
31791
|
+
*/
|
|
31792
|
+
function restoreStash(git, hash, message) {
|
|
31793
|
+
if (!hash) {
|
|
31794
|
+
return Promise.resolve({ ok: false, message: 'Nothing to restore.' });
|
|
31795
|
+
}
|
|
31796
|
+
return runAction$3(() => git.raw(['stash', 'store', '-m', message || 'restored stash', hash]), 'Restored dropped stash');
|
|
30962
31797
|
}
|
|
30963
31798
|
function applyStash(git, stash) {
|
|
30964
31799
|
return runAction$3(() => git.raw(['stash', 'apply', stash.ref]), `Applied ${stash.ref}`);
|
|
@@ -31580,13 +32415,14 @@ async function getPullRequestList(git, filter = {}, runner = defaultGhRunner) {
|
|
|
31580
32415
|
message: 'No GitHub remote detected.',
|
|
31581
32416
|
};
|
|
31582
32417
|
}
|
|
31583
|
-
|
|
32418
|
+
const ghStatus = await getGhStatus(runner);
|
|
32419
|
+
if (ghStatus.kind !== 'ok') {
|
|
31584
32420
|
return {
|
|
31585
32421
|
available: true,
|
|
31586
32422
|
authenticated: false,
|
|
31587
32423
|
repository,
|
|
31588
32424
|
filter,
|
|
31589
|
-
message:
|
|
32425
|
+
message: describeGhStatus(ghStatus),
|
|
31590
32426
|
};
|
|
31591
32427
|
}
|
|
31592
32428
|
try {
|
|
@@ -31831,6 +32667,28 @@ function unstageAllFiles(git, files) {
|
|
|
31831
32667
|
}
|
|
31832
32668
|
return runAction(() => git.raw(['restore', '--staged', '--', ...files.map((file) => file.path)]), `Unstaged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
|
|
31833
32669
|
}
|
|
32670
|
+
/**
|
|
32671
|
+
* Stage everything in the worktree — modifications, new files, and
|
|
32672
|
+
* deletions — in one shot (`git add -A`). The `A` hotkey + the `:`
|
|
32673
|
+
* palette's "stage all" both route here.
|
|
32674
|
+
*/
|
|
32675
|
+
function stageAll(git) {
|
|
32676
|
+
return runAction(() => git.raw(['add', '-A']), 'Staged all changes');
|
|
32677
|
+
}
|
|
32678
|
+
/**
|
|
32679
|
+
* Stage files matching one or more git pathspecs (`git add -- <spec…>`).
|
|
32680
|
+
* Powers the typed "stage…" prompt (`+`): the user types a path, a
|
|
32681
|
+
* directory, a glob like `*.ts`, or a space-separated list, and git's
|
|
32682
|
+
* own pathspec matching does the rest. Args are passed directly (no
|
|
32683
|
+
* shell), so the globs are interpreted by git, not the shell.
|
|
32684
|
+
*/
|
|
32685
|
+
function stagePathspec(git, pathspec) {
|
|
32686
|
+
const specs = pathspec.trim().split(/\s+/).filter(Boolean);
|
|
32687
|
+
if (specs.length === 0) {
|
|
32688
|
+
return Promise.resolve({ ok: false, message: 'Enter a pathspec to stage (e.g. . or src/ or *.ts).' });
|
|
32689
|
+
}
|
|
32690
|
+
return runAction(() => git.raw(['add', '--', ...specs]), `Staged ${specs.join(' ')}`);
|
|
32691
|
+
}
|
|
31834
32692
|
|
|
31835
32693
|
function hunkHeader(hunk) {
|
|
31836
32694
|
return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
|
|
@@ -32407,7 +33265,7 @@ function buildLoadedHashSet(commits) {
|
|
|
32407
33265
|
* 5a.7 of #890. Two-row layout introduced post-0.54.2; per-kind
|
|
32408
33266
|
* colors + glyphs added in the same pass.
|
|
32409
33267
|
*/
|
|
32410
|
-
function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFrame = 0) {
|
|
33268
|
+
function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFrame = 0, singlePane = false) {
|
|
32411
33269
|
const { Box, Text } = components;
|
|
32412
33270
|
// Sidebar item count drives the per-tab footer hints — when items are
|
|
32413
33271
|
// present the footer surfaces in-sidebar ops (checkout / apply / pop /
|
|
@@ -32421,6 +33279,24 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
|
|
|
32421
33279
|
default: return undefined;
|
|
32422
33280
|
}
|
|
32423
33281
|
})();
|
|
33282
|
+
// The single-pane pane switcher only makes sense in the plain
|
|
33283
|
+
// per-pane states. While an overlay or filter owns the screen the
|
|
33284
|
+
// visible pane is forced (split-plan → main; help / palette / theme /
|
|
33285
|
+
// gitignore / input prompt / confirmation / chord → inspector) or
|
|
33286
|
+
// input is captured, and Tab does something else — so the switcher
|
|
33287
|
+
// would point at a pane that isn't on screen. Suppress it then. Mirror
|
|
33288
|
+
// of the runtime's `forcedPane` derivation in `app.ts`.
|
|
33289
|
+
const overlayForcesPane = Boolean(state.splitPlan ||
|
|
33290
|
+
state.showHelp ||
|
|
33291
|
+
state.showViewKeys ||
|
|
33292
|
+
state.showCommandPalette ||
|
|
33293
|
+
state.showThemePicker ||
|
|
33294
|
+
state.gitignorePicker ||
|
|
33295
|
+
state.inputPrompt ||
|
|
33296
|
+
state.pendingConfirmationId ||
|
|
33297
|
+
state.pendingMutationConfirmation ||
|
|
33298
|
+
state.pendingKey ||
|
|
33299
|
+
state.filterMode);
|
|
32424
33300
|
const hints = getLogInkFooterHints({
|
|
32425
33301
|
activeView: state.activeView,
|
|
32426
33302
|
diffSource: state.diffSource,
|
|
@@ -32434,6 +33310,12 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
|
|
|
32434
33310
|
sidebarItemCount,
|
|
32435
33311
|
compareBaseSet: Boolean(state.compareBase),
|
|
32436
33312
|
splitPlanStatus: state.splitPlan?.status,
|
|
33313
|
+
singlePane: singlePane && !overlayForcesPane,
|
|
33314
|
+
// Peeking (#1135 v2) is a single-pane glance with focus on the
|
|
33315
|
+
// sidebar; the footer shows `v/esc → main` instead of the switcher.
|
|
33316
|
+
// Suppressed under an overlay (which owns the footer) just like the
|
|
33317
|
+
// switcher.
|
|
33318
|
+
peeking: Boolean(state.peekReturnFocus) && singlePane && !overlayForcesPane,
|
|
32437
33319
|
});
|
|
32438
33320
|
// Real status messages always win; idle tips only fill the slot when it
|
|
32439
33321
|
// would otherwise be empty.
|
|
@@ -32929,7 +33811,10 @@ function sidebarTabCount(tab, context) {
|
|
|
32929
33811
|
* Header chip builder. Turns the workstation's title-bar state into an
|
|
32930
33812
|
* ordered list of small visually-distinct chips:
|
|
32931
33813
|
*
|
|
32932
|
-
* coco · gfargo/coco · ⎇ main · ✓ clean ·
|
|
33814
|
+
* coco · gfargo/coco · ⎇ main · ✓ clean · [NORMAL]
|
|
33815
|
+
*
|
|
33816
|
+
* The PR chip is appended only when a pull request exists (#1133); there
|
|
33817
|
+
* is no "no PR" placeholder chip.
|
|
32933
33818
|
*
|
|
32934
33819
|
* Pre-refactor the title bar concatenated every segment into a single
|
|
32935
33820
|
* Text span, which made the eye read the whole thing as one run of
|
|
@@ -33025,10 +33910,11 @@ function buildHeaderChips(input) {
|
|
|
33025
33910
|
bold: true,
|
|
33026
33911
|
});
|
|
33027
33912
|
}
|
|
33028
|
-
// PR state.
|
|
33029
|
-
// label ("PR #1234 OPEN" / "PR #1234 DRAFT").
|
|
33030
|
-
// "no PR" chip
|
|
33031
|
-
//
|
|
33913
|
+
// PR state. Shown only when a PR actually exists — the chip uses the
|
|
33914
|
+
// PR-state glyph + a short label ("PR #1234 OPEN" / "PR #1234 DRAFT").
|
|
33915
|
+
// The old always-on "no PR" chip spent a permanent header segment to
|
|
33916
|
+
// report a negative default state on every screen; dropping it keeps
|
|
33917
|
+
// the state cluster about what *is* true (TUI audit).
|
|
33032
33918
|
if (input.pullRequest) {
|
|
33033
33919
|
const prGlyph = getPullRequestStateGlyph({ ...input.pullRequest, isDraft: Boolean(input.pullRequest.isDraft) }, theme);
|
|
33034
33920
|
const stateLabel = input.pullRequest.isDraft
|
|
@@ -33045,15 +33931,6 @@ function buildHeaderChips(input) {
|
|
|
33045
33931
|
bold: false,
|
|
33046
33932
|
});
|
|
33047
33933
|
}
|
|
33048
|
-
else {
|
|
33049
|
-
chips.push({
|
|
33050
|
-
id: 'pr',
|
|
33051
|
-
label: theme.ascii ? '- no PR' : '⊘ no PR',
|
|
33052
|
-
color: theme.colors.muted,
|
|
33053
|
-
dim: true,
|
|
33054
|
-
bold: false,
|
|
33055
|
-
});
|
|
33056
|
-
}
|
|
33057
33934
|
// View breadcrumb. Rendered only when there's content (`coco ui`
|
|
33058
33935
|
// root view → no breadcrumb chip; pushed into a sub-view → chip
|
|
33059
33936
|
// appears). Comes AFTER PR so the "state" group (app/repo/branch/
|
|
@@ -33124,7 +34001,10 @@ function measureHeaderChipsWidth(chips) {
|
|
|
33124
34001
|
* Title-bar renderer. Surfaces the workstation's identity + navigation
|
|
33125
34002
|
* state as a row of small visually-distinct chips:
|
|
33126
34003
|
*
|
|
33127
|
-
* coco · gfargo/coco · ⎇ main · ✓ clean ·
|
|
34004
|
+
* coco · gfargo/coco · ⎇ main · ✓ clean · [NORMAL]
|
|
34005
|
+
*
|
|
34006
|
+
* The PR chip is appended only when a pull request exists (e.g.
|
|
34007
|
+
* `· ⊠ PR #1234 OPEN`); there's no "no PR" placeholder chip.
|
|
33128
34008
|
*
|
|
33129
34009
|
* Per-chip color/glyph treatment lets the user scan in chunks ("what
|
|
33130
34010
|
* app, what repo, what branch, how clean, what PR state, what mode")
|
|
@@ -33564,70 +34444,10 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
33564
34444
|
return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
|
|
33565
34445
|
}, 'tab-worktrees', visibleListCount);
|
|
33566
34446
|
}
|
|
33567
|
-
|
|
33568
|
-
* Single-letter glyph for a sidebar tab in rail mode. Letters always
|
|
33569
|
-
* carry the meaning so this stays useful under ASCII; the rail is too
|
|
33570
|
-
* narrow to fit the full tab label. Pairs with `sidebarTabCount` for
|
|
33571
|
-
* the trailing count.
|
|
33572
|
-
*/
|
|
33573
|
-
function sidebarTabRailGlyph(tab) {
|
|
33574
|
-
switch (tab) {
|
|
33575
|
-
case 'status':
|
|
33576
|
-
return 'S';
|
|
33577
|
-
case 'branches':
|
|
33578
|
-
return 'B';
|
|
33579
|
-
case 'tags':
|
|
33580
|
-
return 'T';
|
|
33581
|
-
case 'stashes':
|
|
33582
|
-
return '$';
|
|
33583
|
-
case 'worktrees':
|
|
33584
|
-
return 'W';
|
|
33585
|
-
default:
|
|
33586
|
-
return '·';
|
|
33587
|
-
}
|
|
33588
|
-
}
|
|
33589
|
-
/**
|
|
33590
|
-
* Rail-mode sidebar — shown on terminals < 100 columns when the
|
|
33591
|
-
* sidebar does not hold focus. Five vertically stacked tab glyphs
|
|
33592
|
-
* with optional counts; the active tab is bracketed. Pressing Tab to
|
|
33593
|
-
* focus the sidebar pops it back to the full accordion (the layout
|
|
33594
|
-
* un-rails it on focus, this renderer is never called in that case).
|
|
33595
|
-
*/
|
|
33596
|
-
function renderSidebarRail$1(h, components, state, context, width, theme, focused, tabs) {
|
|
33597
|
-
const { Box, Text } = components;
|
|
33598
|
-
return h(Box, {
|
|
33599
|
-
borderColor: focusBorderColor(theme, focused),
|
|
33600
|
-
borderStyle: theme.borderStyle,
|
|
33601
|
-
flexDirection: 'column',
|
|
33602
|
-
width,
|
|
33603
|
-
paddingX: 1,
|
|
33604
|
-
}, h(Text, { bold: true, dimColor: !focused }, 'Repo'), h(Text, { dimColor: true }, '────'), ...tabs.map((tab) => {
|
|
33605
|
-
const isActive = tab === state.sidebarTab;
|
|
33606
|
-
const glyph = sidebarTabRailGlyph(tab);
|
|
33607
|
-
const count = sidebarTabCount(tab, context);
|
|
33608
|
-
// Count fits in 2 cells (rail content area is ~4 cells); 99+
|
|
33609
|
-
// collapses to `+` so we never overflow.
|
|
33610
|
-
const countText = count === undefined
|
|
33611
|
-
? ''
|
|
33612
|
-
: count > 99
|
|
33613
|
-
? '+'
|
|
33614
|
-
: String(count);
|
|
33615
|
-
const body = isActive ? `[${glyph}]` : ` ${glyph} `;
|
|
33616
|
-
const text = countText ? `${body}${countText}` : body;
|
|
33617
|
-
return h(Text, {
|
|
33618
|
-
key: `rail-${tab}`,
|
|
33619
|
-
bold: isActive,
|
|
33620
|
-
dimColor: !isActive,
|
|
33621
|
-
}, text);
|
|
33622
|
-
}));
|
|
33623
|
-
}
|
|
33624
|
-
function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme, railed = false) {
|
|
34447
|
+
function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme) {
|
|
33625
34448
|
const { Box, Text } = components;
|
|
33626
34449
|
const focused = state.focus === 'sidebar';
|
|
33627
34450
|
const tabs = getLogInkSidebarTabs();
|
|
33628
|
-
if (railed) {
|
|
33629
|
-
return renderSidebarRail$1(h, components, state, context, width, theme, focused, tabs);
|
|
33630
|
-
}
|
|
33631
34451
|
// Accordion layout — every tab's title is visible on its own line, but
|
|
33632
34452
|
// only the active tab expands its content underneath. Switching tabs
|
|
33633
34453
|
// (1-5 / [/]) collapses the previous and expands the next.
|
|
@@ -33686,7 +34506,8 @@ function renderSidebar$1(h, components, state, context, contextStatus, width, bo
|
|
|
33686
34506
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
33687
34507
|
* of #890. No behavior change.
|
|
33688
34508
|
*/
|
|
33689
|
-
function renderBisectSurface(
|
|
34509
|
+
function renderBisectSurface(ctx, candidateDetail, candidateLoading) {
|
|
34510
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
33690
34511
|
const { Box, Text } = components;
|
|
33691
34512
|
const focused = state.focus === 'commits';
|
|
33692
34513
|
const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
|
|
@@ -34010,7 +34831,8 @@ function formatLogInkGitHubNoRemote({ resource, }) {
|
|
|
34010
34831
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
34011
34832
|
* of #890. No behavior change.
|
|
34012
34833
|
*/
|
|
34013
|
-
function renderBranchesSurface(
|
|
34834
|
+
function renderBranchesSurface(ctx) {
|
|
34835
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
34014
34836
|
const { Box, Text } = components;
|
|
34015
34837
|
const focused = state.focus === 'commits';
|
|
34016
34838
|
const branches = context.branches;
|
|
@@ -34156,7 +34978,8 @@ function formatCacheAge(generatedAt, now) {
|
|
|
34156
34978
|
const day = Math.floor(hr / 24);
|
|
34157
34979
|
return `${day}d ago`;
|
|
34158
34980
|
}
|
|
34159
|
-
function renderChangelogSurface(
|
|
34981
|
+
function renderChangelogSurface(ctx) {
|
|
34982
|
+
const { h, components, state, bodyRows, width, theme } = ctx;
|
|
34160
34983
|
const { Box, Text } = components;
|
|
34161
34984
|
const focused = state.focus === 'commits';
|
|
34162
34985
|
const view = state.changelogView;
|
|
@@ -34348,7 +35171,8 @@ function renderStreamingPreviewLines(h, components, preview, width, theme) {
|
|
|
34348
35171
|
}, `${prefix}${line}`);
|
|
34349
35172
|
});
|
|
34350
35173
|
}
|
|
34351
|
-
function renderComposeSurface(
|
|
35174
|
+
function renderComposeSurface(ctx, spinnerFrame = 0) {
|
|
35175
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
34352
35176
|
const { Box, Text } = components;
|
|
34353
35177
|
const compose = state.commitCompose;
|
|
34354
35178
|
const focused = state.focus === 'commits';
|
|
@@ -34369,8 +35193,16 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
|
|
|
34369
35193
|
const bodyVisualLines = compose.body
|
|
34370
35194
|
? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, bodyRowsAvailable)
|
|
34371
35195
|
: ['<empty>'];
|
|
34372
|
-
|
|
34373
|
-
)
|
|
35196
|
+
// Summary now renders on its own indented line under the label (like the
|
|
35197
|
+
// body), so it wraps at the full content width instead of the cramped
|
|
35198
|
+
// "Summary " (9) + chrome budget it had when label and value shared a row.
|
|
35199
|
+
const summaryVisualLines = compose.summary
|
|
35200
|
+
? compose.summary.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth))
|
|
35201
|
+
: ['<empty>'];
|
|
35202
|
+
// Subject length drives a subtle counter on the Summary label: dim under
|
|
35203
|
+
// 50, warning past the conventional 50-char soft limit, danger past 72.
|
|
35204
|
+
// Counted in code points so multibyte subjects aren't over-counted.
|
|
35205
|
+
const summaryLength = [...compose.summary].length;
|
|
34374
35206
|
// State-line cycles through three modes (#881 phase 3 added the
|
|
34375
35207
|
// loading variant): editing copy when the user is typing, cancel
|
|
34376
35208
|
// hint when an AI draft is generating, default guidance otherwise.
|
|
@@ -34390,6 +35222,52 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
|
|
|
34390
35222
|
const noStagedHint = !isLogInkContextKeyLoading(contextStatus, 'worktree')
|
|
34391
35223
|
? formatLogInkComposeEmpty({ hasStaged: hasStagedFiles })
|
|
34392
35224
|
: undefined;
|
|
35225
|
+
// Section header for a field (Summary / Body). The active field's label
|
|
35226
|
+
// carries an arrow marker + the repo's selection highlight (matching the
|
|
35227
|
+
// status surface, see status/index.ts) so the user can see which field
|
|
35228
|
+
// their keystrokes target — even before entering edit mode, and even
|
|
35229
|
+
// under NO_COLOR where the marker + bold/dim carry the signal alone. An
|
|
35230
|
+
// optional length counter (Summary only) trails the label outside the
|
|
35231
|
+
// highlight so its own warning/danger color stays legible.
|
|
35232
|
+
const renderSectionHeader = (name, field, count) => {
|
|
35233
|
+
const active = compose.field === field;
|
|
35234
|
+
const highlight = active && focused && !theme.noColor;
|
|
35235
|
+
const marker = active ? (theme.ascii ? '> ' : '▸ ') : ' ';
|
|
35236
|
+
const badge = active && compose.editing ? ' EDITING' : '';
|
|
35237
|
+
const children = [
|
|
35238
|
+
h(Text, {
|
|
35239
|
+
key: `compose-${field}-label`,
|
|
35240
|
+
bold: active,
|
|
35241
|
+
dimColor: !active,
|
|
35242
|
+
backgroundColor: highlight ? theme.colors.selection : undefined,
|
|
35243
|
+
color: highlight ? theme.colors.selectionForeground : undefined,
|
|
35244
|
+
}, `${marker}${name}${badge}`),
|
|
35245
|
+
];
|
|
35246
|
+
if (count !== undefined) {
|
|
35247
|
+
const countColor = theme.noColor
|
|
35248
|
+
? undefined
|
|
35249
|
+
: count > 72
|
|
35250
|
+
? theme.colors.danger
|
|
35251
|
+
: count > 50
|
|
35252
|
+
? theme.colors.warning
|
|
35253
|
+
: undefined;
|
|
35254
|
+
children.push(h(Text, {
|
|
35255
|
+
key: `compose-${field}-count`,
|
|
35256
|
+
color: countColor,
|
|
35257
|
+
dimColor: countColor === undefined,
|
|
35258
|
+
}, ` ${count}`));
|
|
35259
|
+
}
|
|
35260
|
+
return h(Box, { key: `compose-${field}-header` }, ...children);
|
|
35261
|
+
};
|
|
35262
|
+
// Content lines for a field — indented two cells under the header, with
|
|
35263
|
+
// the edit cursor parked on the final line when this field is active.
|
|
35264
|
+
const renderSectionContent = (lines, field, cursor) => lines.map((line, index) => {
|
|
35265
|
+
const isLast = index === lines.length - 1;
|
|
35266
|
+
return h(Text, {
|
|
35267
|
+
key: `compose-${field}-${index}`,
|
|
35268
|
+
dimColor: line === '<empty>',
|
|
35269
|
+
}, ` ${line}${cursor && isLast ? cursor : ''}`);
|
|
35270
|
+
});
|
|
34393
35271
|
return h(Box, {
|
|
34394
35272
|
borderColor: focusBorderColor(theme, focused),
|
|
34395
35273
|
borderStyle: theme.borderStyle,
|
|
@@ -34397,20 +35275,7 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
|
|
|
34397
35275
|
flexShrink: 0,
|
|
34398
35276
|
paddingX: 1,
|
|
34399
35277
|
width,
|
|
34400
|
-
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Compose commit', focused)), h(Text, { dimColor: true }, statusLine)), h(Text, undefined, ''), h(Text,
|
|
34401
|
-
bold: compose.field === 'summary' && compose.editing,
|
|
34402
|
-
}, `Summary ${summaryVisualLines[0] || ''}`), ...summaryVisualLines.slice(1).map((line, index) => h(Text, {
|
|
34403
|
-
key: `compose-summary-${index}`,
|
|
34404
|
-
bold: compose.field === 'summary' && compose.editing,
|
|
34405
|
-
}, ` ${line}`)), h(Text, undefined, ''), h(Text, {
|
|
34406
|
-
bold: compose.field === 'body' && compose.editing,
|
|
34407
|
-
}, 'Body'), ...bodyVisualLines.map((line, index) => {
|
|
34408
|
-
const isLast = index === bodyVisualLines.length - 1;
|
|
34409
|
-
return h(Text, {
|
|
34410
|
-
key: `compose-body-${index}`,
|
|
34411
|
-
dimColor: line === '<empty>',
|
|
34412
|
-
}, ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`);
|
|
34413
|
-
}),
|
|
35278
|
+
}, 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),
|
|
34414
35279
|
// Loading indicator + post-action message belong inline with the draft
|
|
34415
35280
|
// (they describe what just happened to the fields above). The state-
|
|
34416
35281
|
// line ("Editing — Enter switches summary↔body…" / "Press e to edit
|
|
@@ -34461,7 +35326,8 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
|
|
|
34461
35326
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.3
|
|
34462
35327
|
* of #890. No behavior change.
|
|
34463
35328
|
*/
|
|
34464
|
-
function renderConflictsSurface(
|
|
35329
|
+
function renderConflictsSurface(ctx) {
|
|
35330
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
34465
35331
|
const { Box, Text } = components;
|
|
34466
35332
|
const focused = state.focus === 'commits';
|
|
34467
35333
|
const loading = isLogInkContextKeyLoading(contextStatus, 'operation');
|
|
@@ -34930,6 +35796,50 @@ function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
|
|
|
34930
35796
|
return h(Text, { key }, h(Text, { key: `${key}-m`, color: markerColor }, marker), ...children);
|
|
34931
35797
|
}
|
|
34932
35798
|
|
|
35799
|
+
/** The hunk index owning `absLine`, or -1 for pre-hunk header/label rows. */
|
|
35800
|
+
function hunkIndexForLine(absLine, hunkOffsets) {
|
|
35801
|
+
let index = -1;
|
|
35802
|
+
for (let k = 0; k < hunkOffsets.length; k++) {
|
|
35803
|
+
if (hunkOffsets[k] <= absLine)
|
|
35804
|
+
index = k;
|
|
35805
|
+
else
|
|
35806
|
+
break;
|
|
35807
|
+
}
|
|
35808
|
+
return index;
|
|
35809
|
+
}
|
|
35810
|
+
function renderWorktreeDiffBody(h, components, params) {
|
|
35811
|
+
const { Box, Text } = components;
|
|
35812
|
+
const { lines, offset, visibleRows, width, theme, syntaxSpans, hunkOffsets, hunks, selectedIndex, keyPrefix } = params;
|
|
35813
|
+
const headerSet = new Set(hunkOffsets);
|
|
35814
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
35815
|
+
const added = theme.noColor ? undefined : theme.colors.gitAdded;
|
|
35816
|
+
const codeWidth = Math.max(8, width - 5); // 2 chrome + 1 gutter + slack
|
|
35817
|
+
const visible = lines.slice(offset, offset + visibleRows);
|
|
35818
|
+
return visible.map((line, i) => {
|
|
35819
|
+
const abs = offset + i;
|
|
35820
|
+
const key = `${keyPrefix}-${abs}`;
|
|
35821
|
+
const hunkIndex = hunkIndexForLine(abs, hunkOffsets);
|
|
35822
|
+
const hunk = hunkIndex >= 0 ? hunks[hunkIndex] : undefined;
|
|
35823
|
+
const isSelected = hunkIndex >= 0 && hunkIndex === selectedIndex;
|
|
35824
|
+
const isStaged = hunk?.state === 'staged';
|
|
35825
|
+
const bar = isSelected ? '▎' : ' ';
|
|
35826
|
+
// `@@` header row — badge + (dim) hunk position, emphasized when selected.
|
|
35827
|
+
if (headerSet.has(abs)) {
|
|
35828
|
+
const badge = theme.ascii ? (isStaged ? '[x] ' : '[ ] ') : (isStaged ? '● ' : '○ ');
|
|
35829
|
+
const badgeColor = theme.noColor ? undefined : isStaged ? added : theme.colors.muted;
|
|
35830
|
+
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)));
|
|
35831
|
+
}
|
|
35832
|
+
// Body / context / pre-hunk lines.
|
|
35833
|
+
// A staged hunk that ISN'T selected renders dim ("done", out of
|
|
35834
|
+
// focus); the selected hunk and unstaged hunks keep full diff +
|
|
35835
|
+
// syntax coloring via renderDiffLine so the focus stays vivid.
|
|
35836
|
+
const content = isStaged && !isSelected && hunkIndex >= 0
|
|
35837
|
+
? h(Text, { key: `${key}-c`, dimColor: true }, truncateCells(line, codeWidth))
|
|
35838
|
+
: renderDiffLine(h, Text, line, theme, syntaxSpans, codeWidth, `${key}-c`);
|
|
35839
|
+
return h(Box, { key, flexDirection: 'row' }, h(Text, { color: accent }, bar), content);
|
|
35840
|
+
});
|
|
35841
|
+
}
|
|
35842
|
+
|
|
34933
35843
|
/**
|
|
34934
35844
|
* Diff surface — the unified or side-by-side diff view. Four sources
|
|
34935
35845
|
* route through here, disambiguated by `state.diffSource`:
|
|
@@ -34952,7 +35862,9 @@ function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
|
|
|
34952
35862
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.4
|
|
34953
35863
|
* of #890. No behavior change.
|
|
34954
35864
|
*/
|
|
34955
|
-
function renderDiffSurface(
|
|
35865
|
+
function renderDiffSurface(ctx, diff) {
|
|
35866
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
35867
|
+
const { worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, syntaxSpans, } = diff;
|
|
34956
35868
|
const { Box, Text } = components;
|
|
34957
35869
|
const focused = state.focus === 'commits';
|
|
34958
35870
|
const worktree = context.worktree;
|
|
@@ -35152,7 +36064,22 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
35152
36064
|
}
|
|
35153
36065
|
const diffLines = worktreeDiff?.lines || [];
|
|
35154
36066
|
const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
|
|
36067
|
+
const totalHunks = worktreeHunks?.hunks.length ?? 0;
|
|
36068
|
+
const stagedHunks = worktreeHunks?.hunks.filter((hunk) => hunk.state === 'staged').length ?? 0;
|
|
35155
36069
|
const visibleDiffLines = diffLines.slice(state.worktreeDiffOffset, state.worktreeDiffOffset + visibleRows);
|
|
36070
|
+
// Hunk-position line: badge + selected hunk's state + a staged/total
|
|
36071
|
+
// progress count, so the user always sees how far through staging they
|
|
36072
|
+
// are. Untracked/new files have no hunks — point them at whole-file
|
|
36073
|
+
// staging instead of a dead-end "no hunks" message.
|
|
36074
|
+
const hunkHeaderLine = worktreeHunksLoading
|
|
36075
|
+
? 'Hunks loading…'
|
|
36076
|
+
: worktreeDiff?.untracked
|
|
36077
|
+
? (theme.ascii ? 'New file — press space to stage it whole.' : '✚ New file — press space to stage it whole.')
|
|
36078
|
+
: totalHunks
|
|
36079
|
+
? `Hunk ${state.selectedWorktreeHunkIndex + 1}/${totalHunks} · ${selectedHunk?.state === 'staged'
|
|
36080
|
+
? (theme.ascii ? '[x] staged' : '● staged')
|
|
36081
|
+
: (theme.ascii ? '[ ] unstaged' : '○ unstaged')} · ${stagedHunks}/${totalHunks} staged`
|
|
36082
|
+
: 'No stageable hunks for this file.';
|
|
35156
36083
|
const headerLines = isLogInkContextKeyLoading(contextStatus, 'worktree')
|
|
35157
36084
|
? ['Loading file context...']
|
|
35158
36085
|
: worktreeDiffLoading
|
|
@@ -35161,11 +36088,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
35161
36088
|
? [
|
|
35162
36089
|
// File path is already shown in the panel title bar (right) —
|
|
35163
36090
|
// no redundant "Selected file:" line here.
|
|
35164
|
-
|
|
35165
|
-
? 'Hunks loading...'
|
|
35166
|
-
: worktreeHunks?.hunks.length
|
|
35167
|
-
? `Hunk ${state.selectedWorktreeHunkIndex + 1}/${worktreeHunks.hunks.length} ${selectedHunk?.state || ''}`
|
|
35168
|
-
: 'No stageable hunks for this file.',
|
|
36091
|
+
hunkHeaderLine,
|
|
35169
36092
|
`Lines ${Math.min(state.worktreeDiffOffset + 1, diffLines.length || 1)}-${Math.min(state.worktreeDiffOffset + visibleDiffLines.length, diffLines.length)}/${diffLines.length}`,
|
|
35170
36093
|
'',
|
|
35171
36094
|
]
|
|
@@ -35180,11 +36103,26 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
35180
36103
|
flexShrink: 0,
|
|
35181
36104
|
paddingX: 1,
|
|
35182
36105
|
width,
|
|
35183
|
-
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)),
|
|
36106
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)),
|
|
36107
|
+
// Use the path of the file actually being diffed (the grouped/visible
|
|
36108
|
+
// selection feeds the loaded diff) — `worktreeFile` indexes the raw,
|
|
36109
|
+
// ungrouped file list and can name a different file than the diff body.
|
|
36110
|
+
h(Text, { dimColor: true }, worktreeDiff?.filePath || worktreeFile?.path || 'no file')), ...headerLines.map((line, index) => h(Text, {
|
|
35184
36111
|
key: `diff-surface-header-${index}`,
|
|
35185
36112
|
dimColor: index > 0,
|
|
35186
36113
|
}, truncateCells(line, 140))), ...(showDiffLines
|
|
35187
|
-
?
|
|
36114
|
+
? renderWorktreeDiffBody(h, components, {
|
|
36115
|
+
lines: diffLines,
|
|
36116
|
+
offset: state.worktreeDiffOffset,
|
|
36117
|
+
visibleRows,
|
|
36118
|
+
width,
|
|
36119
|
+
theme,
|
|
36120
|
+
syntaxSpans,
|
|
36121
|
+
hunkOffsets: worktreeDiff?.hunkOffsets || [],
|
|
36122
|
+
hunks: worktreeHunks?.hunks || [],
|
|
36123
|
+
selectedIndex: state.selectedWorktreeHunkIndex,
|
|
36124
|
+
keyPrefix: 'diff-surface-line',
|
|
36125
|
+
})
|
|
35188
36126
|
: []));
|
|
35189
36127
|
}
|
|
35190
36128
|
|
|
@@ -36368,7 +37306,8 @@ function renderRemoteOpLoader(h, components, state, width, bodyRows, theme, focu
|
|
|
36368
37306
|
height: innerHeight,
|
|
36369
37307
|
}, 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.')));
|
|
36370
37308
|
}
|
|
36371
|
-
function renderHistoryPanel(
|
|
37309
|
+
function renderHistoryPanel(ctx, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = getRenderNow(), spinnerFrame = 0) {
|
|
37310
|
+
const { h, components, state, context, bodyRows, width, theme } = ctx;
|
|
36372
37311
|
const { Box, Text } = components;
|
|
36373
37312
|
const focused = state.focus === 'commits';
|
|
36374
37313
|
// Remote op in flight (fetch / pull / push) → swap the commit list
|
|
@@ -36674,6 +37613,54 @@ function renderChordOverlay(h, components, state, width, theme, focused) {
|
|
|
36674
37613
|
paddingX: 1,
|
|
36675
37614
|
}, ...lines);
|
|
36676
37615
|
}
|
|
37616
|
+
/**
|
|
37617
|
+
* Which-key view-keys strip (#1137). The per-view counterpart to the
|
|
37618
|
+
* `g`-chord overlay: opened by `g?`, it lists the single-key actions
|
|
37619
|
+
* available in the current view (the deliberate overloads — `c`, `R`,
|
|
37620
|
+
* `a`, `m`, `S`, `[`/`]`, …) with their labels, sourced from
|
|
37621
|
+
* `LOG_INK_KEY_BINDINGS` filtered by the active view + focus.
|
|
37622
|
+
*
|
|
37623
|
+
* Renders in the detail panel slot like the chord overlay. `?` steps up
|
|
37624
|
+
* to the full categorized help; Esc closes.
|
|
37625
|
+
*/
|
|
37626
|
+
function renderViewKeysOverlay(h, components, state, width, theme, focused) {
|
|
37627
|
+
const { Box, Text } = components;
|
|
37628
|
+
const bindings = getLogInkViewKeyBindings({
|
|
37629
|
+
activeView: state.activeView,
|
|
37630
|
+
focus: state.focus,
|
|
37631
|
+
});
|
|
37632
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
37633
|
+
const lines = [
|
|
37634
|
+
h(Text, { key: 'view-keys-title', bold: true }, panelTitle(`keys · ${state.activeView}`, focused)),
|
|
37635
|
+
h(Text, { key: 'view-keys-spacer' }, ''),
|
|
37636
|
+
];
|
|
37637
|
+
if (bindings.length === 0) {
|
|
37638
|
+
lines.push(h(Text, {
|
|
37639
|
+
key: 'view-keys-empty',
|
|
37640
|
+
dimColor: true,
|
|
37641
|
+
}, truncateCells('No single-key actions in this view — use ? for the full help.', width - 4)));
|
|
37642
|
+
}
|
|
37643
|
+
else {
|
|
37644
|
+
// Pad keys to the widest entry so labels align into a scannable column.
|
|
37645
|
+
const keyColumn = bindings.reduce((max, binding) => Math.max(max, formatBindingBareKeys(binding).length), 0);
|
|
37646
|
+
for (const binding of bindings) {
|
|
37647
|
+
const keys = formatBindingBareKeys(binding);
|
|
37648
|
+
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))));
|
|
37649
|
+
}
|
|
37650
|
+
}
|
|
37651
|
+
lines.push(h(Text, { key: 'view-keys-foot-spacer' }, ''));
|
|
37652
|
+
lines.push(h(Text, {
|
|
37653
|
+
key: 'view-keys-hint',
|
|
37654
|
+
dimColor: true,
|
|
37655
|
+
}, truncateCells('? full help · esc closes', width - 4)));
|
|
37656
|
+
return h(Box, {
|
|
37657
|
+
borderColor: focusBorderColor(theme, focused),
|
|
37658
|
+
borderStyle: theme.borderStyle,
|
|
37659
|
+
flexDirection: 'column',
|
|
37660
|
+
width,
|
|
37661
|
+
paddingX: 1,
|
|
37662
|
+
}, ...lines);
|
|
37663
|
+
}
|
|
36677
37664
|
function renderHelpPanel(h, components, state, width, theme, focused, bodyRows = 0) {
|
|
36678
37665
|
const { Box, Text } = components;
|
|
36679
37666
|
// Build the full list of body rows (everything below the title).
|
|
@@ -37070,7 +38057,8 @@ function matchesIssueFilter(issue, filter) {
|
|
|
37070
38057
|
...(issue.assignees || []),
|
|
37071
38058
|
], filter);
|
|
37072
38059
|
}
|
|
37073
|
-
function renderIssuesTriageSurface(
|
|
38060
|
+
function renderIssuesTriageSurface(ctx) {
|
|
38061
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37074
38062
|
const { Box, Text } = components;
|
|
37075
38063
|
const focused = state.focus === 'commits';
|
|
37076
38064
|
const overview = context.issueList;
|
|
@@ -37352,7 +38340,8 @@ function formatPullRequestStateLine(pr) {
|
|
|
37352
38340
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
37353
38341
|
* of #890. No behavior change.
|
|
37354
38342
|
*/
|
|
37355
|
-
function renderPullRequestSurface(
|
|
38343
|
+
function renderPullRequestSurface(ctx) {
|
|
38344
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37356
38345
|
const { Box, Text } = components;
|
|
37357
38346
|
const focused = state.focus === 'commits';
|
|
37358
38347
|
const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequest');
|
|
@@ -37525,7 +38514,8 @@ function matchesPullRequestFilter(pr, filter) {
|
|
|
37525
38514
|
...(pr.assignees || []),
|
|
37526
38515
|
], filter);
|
|
37527
38516
|
}
|
|
37528
|
-
function renderPullRequestTriageSurface(
|
|
38517
|
+
function renderPullRequestTriageSurface(ctx) {
|
|
38518
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37529
38519
|
const { Box, Text } = components;
|
|
37530
38520
|
const focused = state.focus === 'commits';
|
|
37531
38521
|
const overview = context.pullRequestList;
|
|
@@ -37641,7 +38631,8 @@ function renderPullRequestTriageSurface(h, components, state, context, contextSt
|
|
|
37641
38631
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
37642
38632
|
* of #890. No behavior change.
|
|
37643
38633
|
*/
|
|
37644
|
-
function renderReflogSurface(
|
|
38634
|
+
function renderReflogSurface(ctx) {
|
|
38635
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37645
38636
|
const { Box, Text } = components;
|
|
37646
38637
|
const focused = state.focus === 'commits';
|
|
37647
38638
|
const loading = isLogInkContextKeyLoading(contextStatus, 'reflog');
|
|
@@ -37712,7 +38703,8 @@ function renderReflogSurface(h, components, state, context, contextStatus, bodyR
|
|
|
37712
38703
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
37713
38704
|
* of #890. No behavior change.
|
|
37714
38705
|
*/
|
|
37715
|
-
function renderStashSurface(
|
|
38706
|
+
function renderStashSurface(ctx) {
|
|
38707
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37716
38708
|
const { Box, Text } = components;
|
|
37717
38709
|
const focused = state.focus === 'commits';
|
|
37718
38710
|
const loading = isLogInkContextKeyLoading(contextStatus, 'stashes');
|
|
@@ -37730,6 +38722,11 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
|
|
|
37730
38722
|
: `${stashes.length}/${allStashes.length} stashes${filterLabel}`;
|
|
37731
38723
|
const emptyLabel = formatLogInkStashEmpty({ filter: state.filter });
|
|
37732
38724
|
const loadingLabel = formatLogInkLoading({ resource: 'stashes' });
|
|
38725
|
+
const now = getRenderNow();
|
|
38726
|
+
// Available width for a row: box width minus the 2-cell horizontal
|
|
38727
|
+
// padding. Truncate to it (with a small floor) instead of a magic 140
|
|
38728
|
+
// so the richer meta degrades gracefully on narrow terminals.
|
|
38729
|
+
const rowWidth = Math.max(20, width - 2);
|
|
37733
38730
|
const lines = loading
|
|
37734
38731
|
? [h(Text, { key: 'stash-loading', dimColor: true }, loadingLabel)]
|
|
37735
38732
|
: stashes.length === 0
|
|
@@ -37738,11 +38735,25 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
|
|
|
37738
38735
|
const index = startIndex + offset;
|
|
37739
38736
|
const isSelected = index === selected;
|
|
37740
38737
|
const cursor = isSelected ? '>' : ' ';
|
|
38738
|
+
// Surface the metadata the StashEntry already carries — origin
|
|
38739
|
+
// branch, file count, and relative age — between the ref and the
|
|
38740
|
+
// message, so the list answers "which stash is this?" without an
|
|
38741
|
+
// Enter→diff round trip.
|
|
38742
|
+
const age = formatCompactRelativeDate(stash.date, now);
|
|
38743
|
+
const fileCount = stash.files.length;
|
|
38744
|
+
const meta = [
|
|
38745
|
+
stash.branch ? `on ${stash.branch}` : '',
|
|
38746
|
+
fileCount > 0 ? `${fileCount} file${fileCount === 1 ? '' : 's'}` : '',
|
|
38747
|
+
age,
|
|
38748
|
+
].filter(Boolean).join(' · ');
|
|
38749
|
+
const rowText = meta
|
|
38750
|
+
? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
|
|
38751
|
+
: `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
|
|
37741
38752
|
return h(Text, {
|
|
37742
38753
|
key: `stash-${index}`,
|
|
37743
38754
|
bold: isSelected,
|
|
37744
38755
|
dimColor: !isSelected,
|
|
37745
|
-
}, truncateCells(
|
|
38756
|
+
}, truncateCells(rowText, rowWidth));
|
|
37746
38757
|
});
|
|
37747
38758
|
const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
|
|
37748
38759
|
const stashHasMoreBelow = startIndex + listRows < stashes.length;
|
|
@@ -37802,7 +38813,8 @@ function formatStatusFilterMask(mask) {
|
|
|
37802
38813
|
active.push('untracked');
|
|
37803
38814
|
return active.join(' + ') || 'none';
|
|
37804
38815
|
}
|
|
37805
|
-
function renderStatusSurface(
|
|
38816
|
+
function renderStatusSurface(ctx) {
|
|
38817
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37806
38818
|
const { Box, Text } = components;
|
|
37807
38819
|
const focused = state.focus === 'commits';
|
|
37808
38820
|
const worktree = context.worktree;
|
|
@@ -37949,7 +38961,8 @@ function flagColor(flag, theme) {
|
|
|
37949
38961
|
return theme.colors.danger;
|
|
37950
38962
|
return undefined;
|
|
37951
38963
|
}
|
|
37952
|
-
function renderSubmodulesSurface(
|
|
38964
|
+
function renderSubmodulesSurface(ctx) {
|
|
38965
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37953
38966
|
const { Box, Text } = components;
|
|
37954
38967
|
const focused = state.focus === 'commits';
|
|
37955
38968
|
const loading = isLogInkContextKeyLoading(contextStatus, 'submodules');
|
|
@@ -38088,7 +39101,8 @@ function formatHyperlink(text, url, env = process.env) {
|
|
|
38088
39101
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
38089
39102
|
* of #890. No behavior change.
|
|
38090
39103
|
*/
|
|
38091
|
-
function renderTagsSurface(
|
|
39104
|
+
function renderTagsSurface(ctx) {
|
|
39105
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38092
39106
|
const { Box, Text } = components;
|
|
38093
39107
|
const focused = state.focus === 'commits';
|
|
38094
39108
|
const loading = isLogInkContextKeyLoading(contextStatus, 'tags');
|
|
@@ -38170,7 +39184,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
38170
39184
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
38171
39185
|
* of #890. No behavior change.
|
|
38172
39186
|
*/
|
|
38173
|
-
function renderWorktreesSurface(
|
|
39187
|
+
function renderWorktreesSurface(ctx) {
|
|
39188
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38174
39189
|
const { Box, Text } = components;
|
|
38175
39190
|
const focused = state.focus === 'commits';
|
|
38176
39191
|
const loading = isLogInkContextKeyLoading(contextStatus, 'worktreeList');
|
|
@@ -38234,7 +39249,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
38234
39249
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
|
|
38235
39250
|
* of #890. No behavior change.
|
|
38236
39251
|
*/
|
|
38237
|
-
function renderMainPanel(
|
|
39252
|
+
function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled, syntaxSpans) {
|
|
39253
|
+
// The universal render values now arrive bundled (#1136); only the
|
|
39254
|
+
// few raw values the dispatcher itself touches (split-plan overlay,
|
|
39255
|
+
// activeView switch) are destructured here. Surfaces receive `surface`
|
|
39256
|
+
// directly plus their own slices.
|
|
39257
|
+
const { h, components, state, bodyRows, width, theme } = surface;
|
|
38238
39258
|
// Split-plan overlay (#907 polish): renders in the MAIN panel (not
|
|
38239
39259
|
// detail) when active, because the content — multiple commit groups
|
|
38240
39260
|
// with file lists, rationale, hunks — needs the full center width
|
|
@@ -38246,51 +39266,66 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
38246
39266
|
return renderSplitPlanOverlay(h, components, state, width, bodyRows, theme, true, spinnerFrame);
|
|
38247
39267
|
}
|
|
38248
39268
|
if (state.activeView === 'status') {
|
|
38249
|
-
return renderStatusSurface(
|
|
39269
|
+
return renderStatusSurface(surface);
|
|
38250
39270
|
}
|
|
38251
39271
|
if (state.activeView === 'diff') {
|
|
38252
|
-
|
|
39272
|
+
const diffData = {
|
|
39273
|
+
worktreeDiff,
|
|
39274
|
+
worktreeDiffLoading,
|
|
39275
|
+
worktreeHunks,
|
|
39276
|
+
worktreeHunksLoading,
|
|
39277
|
+
filePreview,
|
|
39278
|
+
filePreviewLoading,
|
|
39279
|
+
commitDiffHunkOffsets,
|
|
39280
|
+
selectedDetailFile,
|
|
39281
|
+
stashDiffLines,
|
|
39282
|
+
stashDiffLoading,
|
|
39283
|
+
compareDiffLines,
|
|
39284
|
+
compareDiffLoading,
|
|
39285
|
+
syntaxSpans,
|
|
39286
|
+
};
|
|
39287
|
+
return renderDiffSurface(surface, diffData);
|
|
38253
39288
|
}
|
|
38254
39289
|
if (state.activeView === 'compose') {
|
|
38255
|
-
return renderComposeSurface(
|
|
39290
|
+
return renderComposeSurface(surface, spinnerFrame);
|
|
38256
39291
|
}
|
|
38257
39292
|
if (state.activeView === 'branches') {
|
|
38258
|
-
return renderBranchesSurface(
|
|
39293
|
+
return renderBranchesSurface(surface);
|
|
38259
39294
|
}
|
|
38260
39295
|
if (state.activeView === 'tags') {
|
|
38261
|
-
return renderTagsSurface(
|
|
39296
|
+
return renderTagsSurface(surface);
|
|
38262
39297
|
}
|
|
38263
39298
|
if (state.activeView === 'reflog') {
|
|
38264
|
-
return renderReflogSurface(
|
|
39299
|
+
return renderReflogSurface(surface);
|
|
38265
39300
|
}
|
|
38266
39301
|
if (state.activeView === 'bisect') {
|
|
38267
|
-
return renderBisectSurface(
|
|
39302
|
+
return renderBisectSurface(surface, bisectCandidateDetail, bisectCandidateLoading);
|
|
38268
39303
|
}
|
|
38269
39304
|
if (state.activeView === 'stash') {
|
|
38270
|
-
return renderStashSurface(
|
|
39305
|
+
return renderStashSurface(surface);
|
|
38271
39306
|
}
|
|
38272
39307
|
if (state.activeView === 'worktrees') {
|
|
38273
|
-
return renderWorktreesSurface(
|
|
39308
|
+
return renderWorktreesSurface(surface);
|
|
38274
39309
|
}
|
|
38275
39310
|
if (state.activeView === 'submodules') {
|
|
38276
|
-
return renderSubmodulesSurface(
|
|
39311
|
+
return renderSubmodulesSurface(surface);
|
|
38277
39312
|
}
|
|
38278
39313
|
if (state.activeView === 'pull-request') {
|
|
38279
|
-
return renderPullRequestSurface(
|
|
39314
|
+
return renderPullRequestSurface(surface);
|
|
38280
39315
|
}
|
|
38281
39316
|
if (state.activeView === 'pull-request-triage') {
|
|
38282
|
-
return renderPullRequestTriageSurface(
|
|
39317
|
+
return renderPullRequestTriageSurface(surface);
|
|
38283
39318
|
}
|
|
38284
39319
|
if (state.activeView === 'issues') {
|
|
38285
|
-
return renderIssuesTriageSurface(
|
|
39320
|
+
return renderIssuesTriageSurface(surface);
|
|
38286
39321
|
}
|
|
38287
39322
|
if (state.activeView === 'conflicts') {
|
|
38288
|
-
return renderConflictsSurface(
|
|
39323
|
+
return renderConflictsSurface(surface);
|
|
38289
39324
|
}
|
|
38290
39325
|
if (state.activeView === 'changelog') {
|
|
38291
|
-
return renderChangelogSurface(
|
|
39326
|
+
return renderChangelogSurface(surface);
|
|
38292
39327
|
}
|
|
38293
|
-
return renderHistoryPanel(
|
|
39328
|
+
return renderHistoryPanel(surface, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled, undefined, spinnerFrame);
|
|
38294
39329
|
}
|
|
38295
39330
|
|
|
38296
39331
|
/**
|
|
@@ -39163,21 +40198,16 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
|
|
|
39163
40198
|
const bodyVisualLines = bodyHasContent
|
|
39164
40199
|
? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, 12)
|
|
39165
40200
|
: ['<empty>'];
|
|
39166
|
-
const
|
|
39167
|
-
const
|
|
39168
|
-
const
|
|
39169
|
-
|
|
39170
|
-
|
|
39171
|
-
|
|
39172
|
-
|
|
39173
|
-
|
|
39174
|
-
|
|
39175
|
-
|
|
39176
|
-
const isLast = index === bodyVisualLines.length - 1;
|
|
39177
|
-
return ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`;
|
|
39178
|
-
}),
|
|
39179
|
-
'',
|
|
39180
|
-
];
|
|
40201
|
+
const hasSummary = Boolean(compose.summary);
|
|
40202
|
+
const summaryMarker = compose.field === 'summary' && compose.editing ? '>' : ' ';
|
|
40203
|
+
const bodyMarker = compose.field === 'body' && compose.editing ? '>' : ' ';
|
|
40204
|
+
// The generated subject is the thing the user is looking for — render
|
|
40205
|
+
// it bold + accent so it pops out of the inspector instead of blending
|
|
40206
|
+
// into the dim label/body text. The `Summary:` label stays dim.
|
|
40207
|
+
const summaryLabel = `${summaryMarker} Summary: `;
|
|
40208
|
+
const summaryColor = hasSummary && !theme.noColor ? theme.colors.accent : undefined;
|
|
40209
|
+
const summaryValueWidth = Math.max(4, width - 4 - cellWidth(summaryLabel));
|
|
40210
|
+
const summaryWrapped = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, summaryValueWidth);
|
|
39181
40211
|
const trailerLines = [
|
|
39182
40212
|
...(compose.message ? ['', compose.message] : []),
|
|
39183
40213
|
...(compose.details || []).map((line) => ` ${line}`),
|
|
@@ -39191,10 +40221,26 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
|
|
|
39191
40221
|
flexDirection: 'column',
|
|
39192
40222
|
width,
|
|
39193
40223
|
paddingX: 1,
|
|
39194
|
-
}, h(Text, { bold: true }, panelTitle('Commit', focused)),
|
|
39195
|
-
|
|
39196
|
-
|
|
39197
|
-
|
|
40224
|
+
}, h(Text, { bold: true }, panelTitle('Commit', focused)), h(Text, { key: 'commit-status', dimColor: true }, truncateCells(statusLine, width - 4)), h(Text, { key: 'commit-spacer-1' }, ''),
|
|
40225
|
+
// Summary: dim label + the subject value emphasized so it's easy to spot.
|
|
40226
|
+
h(Text, { key: 'commit-summary' }, h(Text, { dimColor: true }, summaryLabel), h(Text, {
|
|
40227
|
+
bold: hasSummary,
|
|
40228
|
+
color: summaryColor,
|
|
40229
|
+
dimColor: !hasSummary,
|
|
40230
|
+
}, summaryWrapped[0] || '<empty>')), ...summaryWrapped.slice(1).map((line, index) => h(Text, {
|
|
40231
|
+
key: `commit-summary-rest-${index}`,
|
|
40232
|
+
bold: true,
|
|
40233
|
+
color: summaryColor,
|
|
40234
|
+
}, truncateCells(`${' '.repeat(cellWidth(summaryLabel))}${line}`, width - 4))), h(Text, {
|
|
40235
|
+
key: 'commit-body-label',
|
|
40236
|
+
dimColor: !(compose.field === 'body' && compose.editing),
|
|
40237
|
+
}, truncateCells(`${bodyMarker} Body:`, width - 4)), ...bodyVisualLines.map((line, index) => {
|
|
40238
|
+
const isLast = index === bodyVisualLines.length - 1;
|
|
40239
|
+
return h(Text, {
|
|
40240
|
+
key: `commit-body-${index}`,
|
|
40241
|
+
dimColor: true,
|
|
40242
|
+
}, truncateCells(` ${line}${bodyCursor && isLast ? bodyCursor : ''}`, width - 4));
|
|
40243
|
+
}), h(Text, { key: 'commit-spacer-2' }, ''),
|
|
39198
40244
|
// Loading indicator + commit result/details stay inline with the body
|
|
39199
40245
|
// (they describe what just happened to the fields above). The action
|
|
39200
40246
|
// hint ("e edit | c commit | I AI draft") moves to the bottom of the
|
|
@@ -39285,44 +40331,20 @@ function renderPullRequestTriagePreviewPanel(h, components, state, context, cont
|
|
|
39285
40331
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
|
|
39286
40332
|
* of #890. No behavior change.
|
|
39287
40333
|
*/
|
|
39288
|
-
|
|
39289
|
-
* Rail-mode inspector — shown on terminals < 100 columns when the
|
|
39290
|
-
* detail panel does not hold focus. The full inspector (commit body,
|
|
39291
|
-
* file list, actions) does not survive truncation to ~4 content cells
|
|
39292
|
-
* so we collapse to a stack with the panel label and the selected
|
|
39293
|
-
* commit's shortHash. Focus pops the panel back to its expanded
|
|
39294
|
-
* widths via the layout, so this renderer is only reached at rest.
|
|
39295
|
-
*
|
|
39296
|
-
* Help / overlay states are still handled by their own renderers
|
|
39297
|
-
* above; this short-circuit only kicks in for the regular "view the
|
|
39298
|
-
* commit" cases.
|
|
39299
|
-
*/
|
|
39300
|
-
function renderInspectorRail(h, components, state, detail, width, theme, focused) {
|
|
39301
|
-
const { Box, Text } = components;
|
|
39302
|
-
// Prefer the loaded detail's hash (canonical) but fall back to the
|
|
39303
|
-
// selected list row's shortHash so the rail isn't blank on the
|
|
39304
|
-
// first render before getCommitDetail resolves.
|
|
39305
|
-
const selectedRow = getSelectedInkCommit(state);
|
|
39306
|
-
const hashText = detail?.hash.slice(0, 4)
|
|
39307
|
-
?? selectedRow?.shortHash.slice(0, 4)
|
|
39308
|
-
?? '····';
|
|
39309
|
-
return h(Box, {
|
|
39310
|
-
borderColor: focusBorderColor(theme, focused),
|
|
39311
|
-
borderStyle: theme.borderStyle,
|
|
39312
|
-
flexDirection: 'column',
|
|
39313
|
-
width,
|
|
39314
|
-
paddingX: 1,
|
|
39315
|
-
}, h(Text, { bold: true, dimColor: !focused }, panelTitle('Insp', focused)), h(Text, { dimColor: true }, '────'), h(Text, { color: theme.noColor ? undefined : theme.colors.accent }, hashText));
|
|
39316
|
-
}
|
|
39317
|
-
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, railed = false, bodyRows = 0) {
|
|
40334
|
+
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, bodyRows = 0) {
|
|
39318
40335
|
const focused = state.focus === 'detail';
|
|
39319
40336
|
// Overlays (help / palette / input / confirmation / chord) take
|
|
39320
|
-
// precedence over
|
|
39321
|
-
// via the help-overlay layout branch
|
|
39322
|
-
// defeat their whole purpose (the user is reading them).
|
|
40337
|
+
// precedence over every per-view surface because they claim the
|
|
40338
|
+
// panel's full width via the help-overlay layout branch.
|
|
39323
40339
|
if (state.showHelp) {
|
|
39324
40340
|
return renderHelpPanel(h, components, state, width, theme, focused, bodyRows);
|
|
39325
40341
|
}
|
|
40342
|
+
// #1137 — the `g?` which-key strip lists the current view's single-key
|
|
40343
|
+
// actions. Checked alongside the other overlays; the reducer keeps it
|
|
40344
|
+
// mutually exclusive with help / palette / pickers.
|
|
40345
|
+
if (state.showViewKeys) {
|
|
40346
|
+
return renderViewKeysOverlay(h, components, state, width, theme, focused);
|
|
40347
|
+
}
|
|
39326
40348
|
if (state.showCommandPalette) {
|
|
39327
40349
|
return renderCommandPalette(h, components, state, width, theme, focused);
|
|
39328
40350
|
}
|
|
@@ -39351,15 +40373,6 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
39351
40373
|
if (state.pendingKey && !state.splitPlan) {
|
|
39352
40374
|
return renderChordOverlay(h, components, state, width, theme, focused);
|
|
39353
40375
|
}
|
|
39354
|
-
// Rail mode applies only after every overlay above has had its say
|
|
39355
|
-
// — those would all be unreadable at 4 cells of content. The layout
|
|
39356
|
-
// also clears `railed` whenever the inspector takes focus, so we
|
|
39357
|
-
// can safely short-circuit the per-view dispatch here without
|
|
39358
|
-
// worrying about hiding the panel from a user who's actively
|
|
39359
|
-
// reading it.
|
|
39360
|
-
if (railed) {
|
|
39361
|
-
return renderInspectorRail(h, components, state, detail, width, theme, focused);
|
|
39362
|
-
}
|
|
39363
40376
|
// The synthetic "(+) new commit" row routes the inspector through the
|
|
39364
40377
|
// worktree summary so the user sees what's staged / unstaged at a glance
|
|
39365
40378
|
// — same surface as the compose view's right panel.
|
|
@@ -39409,6 +40422,43 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
39409
40422
|
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
|
|
39410
40423
|
}
|
|
39411
40424
|
|
|
40425
|
+
/**
|
|
40426
|
+
* Runtime React Context for the workstation (#1136).
|
|
40427
|
+
*
|
|
40428
|
+
* The render layer currently drills `state` / `dispatch` / `theme` /
|
|
40429
|
+
* `layout` / `context` through every `render*Surface` signature, so
|
|
40430
|
+
* adding a feature repeatedly means threading one more value through
|
|
40431
|
+
* `app → mainPanel → render<View>Surface`. This Context is the single
|
|
40432
|
+
* place those five values live; surfaces read what they need from it
|
|
40433
|
+
* instead of receiving 10–15 positional props.
|
|
40434
|
+
*
|
|
40435
|
+
* Why a factory (`getLogInkRuntimeContext(React)`) instead of a plain
|
|
40436
|
+
* module-level `React.createContext(...)`: the workstation never
|
|
40437
|
+
* statically imports React. `ink` + `react` are ESM-only and loaded via
|
|
40438
|
+
* dynamicImport at boot (see `inkRuntime.ts`), so the rest of the
|
|
40439
|
+
* codebase compiles without bundling them. The Context object must be
|
|
40440
|
+
* built from that same runtime React instance — the one that renders
|
|
40441
|
+
* the tree and the one a consumer's `useContext` reads from have to be
|
|
40442
|
+
* identical. There is exactly one React instance per process, so we
|
|
40443
|
+
* lazily create the Context on first use and cache it; `LogInkApp`'s
|
|
40444
|
+
* provider and (in later PRs) the surface consumers all share the one
|
|
40445
|
+
* identity.
|
|
40446
|
+
*/
|
|
40447
|
+
let cachedContext = null;
|
|
40448
|
+
/**
|
|
40449
|
+
* Lazily create (and thereafter return) the process-wide
|
|
40450
|
+
* `LogInkRuntimeContext`, bound to the runtime React instance. Pass the
|
|
40451
|
+
* same `React` the tree is rendered with — `LogInkApp` uses `deps.React`;
|
|
40452
|
+
* tests use the statically-imported `react`.
|
|
40453
|
+
*/
|
|
40454
|
+
function getLogInkRuntimeContext(React) {
|
|
40455
|
+
if (!cachedContext) {
|
|
40456
|
+
cachedContext = React.createContext(null);
|
|
40457
|
+
cachedContext.displayName = 'LogInkRuntimeContext';
|
|
40458
|
+
}
|
|
40459
|
+
return cachedContext;
|
|
40460
|
+
}
|
|
40461
|
+
|
|
39412
40462
|
/**
|
|
39413
40463
|
* Resolve + scaffold the coco config files the workstation can open in
|
|
39414
40464
|
* `$EDITOR` (the `gk` / `gK` chords and their command-palette entries).
|
|
@@ -39892,6 +40942,10 @@ function LogInkApp(deps) {
|
|
|
39892
40942
|
const loadingMoreCommitsRef = React.useRef(false);
|
|
39893
40943
|
const loadMoreRequestRef = React.useRef(0);
|
|
39894
40944
|
const mountedRef = React.useRef(true);
|
|
40945
|
+
// Last dropped stash {hash, message}, captured before `drop-stash` runs
|
|
40946
|
+
// so `undo-drop-stash` can re-store it. The dropped commit survives in
|
|
40947
|
+
// the object DB until gc, so the hash is enough to bring it back.
|
|
40948
|
+
const lastDroppedStashRef = React.useRef(null);
|
|
39895
40949
|
// P4.3 — idle tip rotation. tickIndex 0 ⇒ no tip; the hook bumps it after
|
|
39896
40950
|
// a grace window of empty statusMessage and then on a steady cadence, so
|
|
39897
40951
|
// the footer surfaces a different hint every interval until the user does
|
|
@@ -40196,6 +41250,10 @@ function LogInkApp(deps) {
|
|
|
40196
41250
|
worktree,
|
|
40197
41251
|
}), issuedAtDepth);
|
|
40198
41252
|
setContextStatus((current) => updateLogInkContextStatus(current, 'worktree', 'ready'), issuedAtDepth);
|
|
41253
|
+
// Returned so callers needing the *fresh* overview (e.g. post-commit
|
|
41254
|
+
// navigation) can read it directly instead of racing the async
|
|
41255
|
+
// `setContext` update, which won't be visible in their closure.
|
|
41256
|
+
return worktree;
|
|
40199
41257
|
}, [git, runtimes.length, setContext, setContextStatus]);
|
|
40200
41258
|
// Live refresh: watch .git metadata + the working tree root and reload
|
|
40201
41259
|
// context when something changes outside the TUI (editor save, external
|
|
@@ -41058,7 +42116,14 @@ function LogInkApp(deps) {
|
|
|
41058
42116
|
// and see the pre-commit log (same silent-failure shape as
|
|
41059
42117
|
// the split-apply case caught in this PR).
|
|
41060
42118
|
await refreshHistoryRows();
|
|
41061
|
-
await refreshWorktreeContext();
|
|
42119
|
+
const worktree = await refreshWorktreeContext();
|
|
42120
|
+
// Leave the compose view automatically: a still-dirty tree returns
|
|
42121
|
+
// to Status (so the user can keep staging), an otherwise-complete
|
|
42122
|
+
// commit returns to History (where the new commit now shows). The
|
|
42123
|
+
// reducer inspects the live viewStack to pick the destination.
|
|
42124
|
+
const stillDirty = Boolean(worktree &&
|
|
42125
|
+
worktree.stagedCount + worktree.unstagedCount + worktree.untrackedCount > 0);
|
|
42126
|
+
dispatch({ type: 'returnFromCommit', stillDirty });
|
|
41062
42127
|
}
|
|
41063
42128
|
}, [
|
|
41064
42129
|
context.worktree?.stagedCount,
|
|
@@ -41135,11 +42200,15 @@ function LogInkApp(deps) {
|
|
|
41135
42200
|
dispatch({ type: 'setStatus', value: 'AI draft ready for editing', kind: 'success' });
|
|
41136
42201
|
return;
|
|
41137
42202
|
}
|
|
42203
|
+
// Humanize provider errors (rate limit / auth / context / network)
|
|
42204
|
+
// into a short actionable line; success-but-no-draft keeps its
|
|
42205
|
+
// message as-is.
|
|
42206
|
+
const composeMessage = result.ok ? result.message : humanizeAiError(result.message);
|
|
41138
42207
|
dispatch({
|
|
41139
42208
|
type: 'commitCompose',
|
|
41140
|
-
action: { type: 'setResult', message:
|
|
42209
|
+
action: { type: 'setResult', message: composeMessage, details: result.details },
|
|
41141
42210
|
});
|
|
41142
|
-
dispatch({ type: 'setStatus', value: result.
|
|
42211
|
+
dispatch({ type: 'setStatus', value: composeMessage, kind: result.ok ? undefined : 'error' });
|
|
41143
42212
|
}
|
|
41144
42213
|
catch (error) {
|
|
41145
42214
|
// Audit finding #3: defensive recovery for unexpected throws
|
|
@@ -42110,8 +43179,20 @@ function LogInkApp(deps) {
|
|
|
42110
43179
|
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
42111
43180
|
if (!stash)
|
|
42112
43181
|
return { ok: false, message: 'No stash selected' };
|
|
43182
|
+
// Remember the dropped commit so `u` can undo it.
|
|
43183
|
+
if (stash.hash)
|
|
43184
|
+
lastDroppedStashRef.current = { hash: stash.hash, message: stash.message };
|
|
42113
43185
|
return dropStash(git, stash);
|
|
42114
43186
|
},
|
|
43187
|
+
'undo-drop-stash': async () => {
|
|
43188
|
+
const dropped = lastDroppedStashRef.current;
|
|
43189
|
+
if (!dropped)
|
|
43190
|
+
return { ok: false, message: 'Nothing to undo — no stash dropped this session' };
|
|
43191
|
+
const result = await restoreStash(git, dropped.hash, dropped.message);
|
|
43192
|
+
if (result.ok)
|
|
43193
|
+
lastDroppedStashRef.current = null;
|
|
43194
|
+
return result;
|
|
43195
|
+
},
|
|
42115
43196
|
'apply-stash': async () => {
|
|
42116
43197
|
const all = context.stashes?.stashes || [];
|
|
42117
43198
|
const visible = state.filter
|
|
@@ -42122,6 +43203,16 @@ function LogInkApp(deps) {
|
|
|
42122
43203
|
return { ok: false, message: 'No stash selected' };
|
|
42123
43204
|
return applyStash(git, stash);
|
|
42124
43205
|
},
|
|
43206
|
+
'apply-stash-index': async () => {
|
|
43207
|
+
const all = context.stashes?.stashes || [];
|
|
43208
|
+
const visible = state.filter
|
|
43209
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
|
|
43210
|
+
: all;
|
|
43211
|
+
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
43212
|
+
if (!stash)
|
|
43213
|
+
return { ok: false, message: 'No stash selected' };
|
|
43214
|
+
return applyStashKeepIndex(git, stash);
|
|
43215
|
+
},
|
|
42125
43216
|
'pop-stash': async () => {
|
|
42126
43217
|
const all = context.stashes?.stashes || [];
|
|
42127
43218
|
const visible = state.filter
|
|
@@ -42132,6 +43223,26 @@ function LogInkApp(deps) {
|
|
|
42132
43223
|
return { ok: false, message: 'No stash selected' };
|
|
42133
43224
|
return popStash(git, stash);
|
|
42134
43225
|
},
|
|
43226
|
+
'rename-stash': async () => {
|
|
43227
|
+
const all = context.stashes?.stashes || [];
|
|
43228
|
+
const visible = state.filter
|
|
43229
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
|
|
43230
|
+
: all;
|
|
43231
|
+
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
43232
|
+
if (!stash)
|
|
43233
|
+
return { ok: false, message: 'No stash selected' };
|
|
43234
|
+
return renameStash(git, stash, payload ?? '');
|
|
43235
|
+
},
|
|
43236
|
+
'stash-branch': async () => {
|
|
43237
|
+
const all = context.stashes?.stashes || [];
|
|
43238
|
+
const visible = state.filter
|
|
43239
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
|
|
43240
|
+
: all;
|
|
43241
|
+
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
43242
|
+
if (!stash)
|
|
43243
|
+
return { ok: false, message: 'No stash selected' };
|
|
43244
|
+
return stashBranch(git, stash, payload ?? '');
|
|
43245
|
+
},
|
|
42135
43246
|
'bisect-good': async () => {
|
|
42136
43247
|
if (!context.bisect?.active)
|
|
42137
43248
|
return { ok: false, message: 'No bisect in progress' };
|
|
@@ -42542,11 +43653,12 @@ function LogInkApp(deps) {
|
|
|
42542
43653
|
return deleteRemoteTag(git, tag.name);
|
|
42543
43654
|
},
|
|
42544
43655
|
'create-stash': async () => {
|
|
42545
|
-
|
|
42546
|
-
|
|
42547
|
-
|
|
42548
|
-
return createStash(git, message);
|
|
43656
|
+
// Empty is allowed — createStash turns it into a quick WIP stash
|
|
43657
|
+
// (git's own `WIP on <branch>` subject). Naming is optional.
|
|
43658
|
+
return createStash(git, payload ?? '');
|
|
42549
43659
|
},
|
|
43660
|
+
'stash-staged': async () => createStash(git, payload ?? '', { stagedOnly: true }),
|
|
43661
|
+
'stash-keep-index': async () => createStash(git, payload ?? '', { keepIndex: true }),
|
|
42550
43662
|
// #783 — full PR action panel handlers. Each wraps the matching
|
|
42551
43663
|
// pullRequestActions verb. Strategy / body arrives via `payload`
|
|
42552
43664
|
// — input prompts validate before they reach here, but the
|
|
@@ -42794,6 +43906,8 @@ function LogInkApp(deps) {
|
|
|
42794
43906
|
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'untracked');
|
|
42795
43907
|
return stageAllFiles(git, files);
|
|
42796
43908
|
},
|
|
43909
|
+
'stage-all': async () => stageAll(git),
|
|
43910
|
+
'stage-pathspec': async () => stagePathspec(git, payload || ''),
|
|
42797
43911
|
};
|
|
42798
43912
|
const handler = handlers[id];
|
|
42799
43913
|
if (!handler) {
|
|
@@ -42824,6 +43938,16 @@ function LogInkApp(deps) {
|
|
|
42824
43938
|
'checkout-branch',
|
|
42825
43939
|
'continue-operation',
|
|
42826
43940
|
'pull-current-branch',
|
|
43941
|
+
// Fetch / pull / push bring in new commits and move
|
|
43942
|
+
// remote-tracking refs (origin/main, ahead/behind) — refresh the
|
|
43943
|
+
// graph so they appear instead of staying pinned to the pre-sync
|
|
43944
|
+
// state. (A successful push advances the local origin/<branch>
|
|
43945
|
+
// ref, so the chip should hop to the pushed commit.)
|
|
43946
|
+
'fetch-remotes',
|
|
43947
|
+
'fetch-selected-branch',
|
|
43948
|
+
'pull-selected-branch',
|
|
43949
|
+
'push-current-branch',
|
|
43950
|
+
'push-selected-branch',
|
|
42827
43951
|
'cherry-pick-commit',
|
|
42828
43952
|
'revert-commit',
|
|
42829
43953
|
'reset-hard-to-commit',
|
|
@@ -42878,6 +44002,11 @@ function LogInkApp(deps) {
|
|
|
42878
44002
|
if (result?.ok && id === 'add-to-gitignore') {
|
|
42879
44003
|
await refreshWorktreeContext();
|
|
42880
44004
|
}
|
|
44005
|
+
// Stage-all / stage-pathspec change staged/unstaged counts — refresh
|
|
44006
|
+
// the worktree so the status list + compose summary reflect it.
|
|
44007
|
+
if (result?.ok && (id === 'stage-all' || id === 'stage-pathspec')) {
|
|
44008
|
+
await refreshWorktreeContext();
|
|
44009
|
+
}
|
|
42881
44010
|
if (result?.ok && id === 'drop-stash') {
|
|
42882
44011
|
// Explicit worktree refresh in case the dropped stash carried
|
|
42883
44012
|
// untracked-file state that's now collected.
|
|
@@ -43461,6 +44590,11 @@ function LogInkApp(deps) {
|
|
|
43461
44590
|
? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
|
|
43462
44591
|
: undefined;
|
|
43463
44592
|
getLogInkInputEvents(state, inputValue, key, {
|
|
44593
|
+
// Narrow terminals show one pane at a time (#1135) — gates the `v`
|
|
44594
|
+
// peek key. Derived the same way the layout does, since `layout`
|
|
44595
|
+
// is computed later in the render path (not in this callback).
|
|
44596
|
+
singlePane: (windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS) <
|
|
44597
|
+
LAYOUT_SINGLE_PANE_BELOW,
|
|
43464
44598
|
detailFileCount: detail?.files.length,
|
|
43465
44599
|
previewLineCount: diffPreviewLineCount,
|
|
43466
44600
|
worktreeDiffLineCount: worktreeDiff?.lines.length,
|
|
@@ -43585,7 +44719,14 @@ function LogInkApp(deps) {
|
|
|
43585
44719
|
exit();
|
|
43586
44720
|
}
|
|
43587
44721
|
else if (event.type === 'refreshContext') {
|
|
44722
|
+
// The user-initiated refresh (`r`) refreshes BOTH the metadata
|
|
44723
|
+
// context (branches/tags/worktree) AND the commit rows. Without
|
|
44724
|
+
// the row re-fetch the history graph stays pinned to whatever
|
|
44725
|
+
// commits existed at boot — new commits (made in another
|
|
44726
|
+
// terminal, or remote commits brought in by a fetch) never
|
|
44727
|
+
// appear until relaunch, which reads as "the history is stuck."
|
|
43588
44728
|
void refreshContext();
|
|
44729
|
+
void refreshHistoryRows();
|
|
43589
44730
|
}
|
|
43590
44731
|
else if (event.type === 'toggleSelectedFileStage') {
|
|
43591
44732
|
void toggleSelectedFileStage();
|
|
@@ -43684,6 +44825,25 @@ function LogInkApp(deps) {
|
|
|
43684
44825
|
}
|
|
43685
44826
|
});
|
|
43686
44827
|
});
|
|
44828
|
+
// In single-pane mode (narrow terminals) only one pane renders, so an
|
|
44829
|
+
// active overlay must pull its own pane into view rather than stay
|
|
44830
|
+
// hidden behind whatever pane focus points at. The split-plan overlay
|
|
44831
|
+
// lives in the main panel; every other overlay (help / palette / theme
|
|
44832
|
+
// / gitignore / input prompt / confirmation / chord) renders in the
|
|
44833
|
+
// inspector. Ignored above the single-pane breakpoint (all panes show).
|
|
44834
|
+
const forcedPane = state.splitPlan
|
|
44835
|
+
? 'main'
|
|
44836
|
+
: state.showHelp ||
|
|
44837
|
+
state.showViewKeys ||
|
|
44838
|
+
state.showCommandPalette ||
|
|
44839
|
+
state.showThemePicker ||
|
|
44840
|
+
state.gitignorePicker ||
|
|
44841
|
+
state.inputPrompt ||
|
|
44842
|
+
state.pendingConfirmationId ||
|
|
44843
|
+
state.pendingMutationConfirmation ||
|
|
44844
|
+
state.pendingKey
|
|
44845
|
+
? 'inspector'
|
|
44846
|
+
: undefined;
|
|
43687
44847
|
// Layout depends on focus (sidebar grows when focused), so it's
|
|
43688
44848
|
// computed here — after state is in scope but before the render path.
|
|
43689
44849
|
const layout = getLogInkLayout({
|
|
@@ -43692,7 +44852,22 @@ function LogInkApp(deps) {
|
|
|
43692
44852
|
sidebarFocused: state.focus === 'sidebar',
|
|
43693
44853
|
inspectorFocused: state.focus === 'detail',
|
|
43694
44854
|
helpOverlayActive: state.showHelp,
|
|
44855
|
+
forcedPane,
|
|
43695
44856
|
});
|
|
44857
|
+
// Runtime Context provider (#1136). Bundles the five most-drilled
|
|
44858
|
+
// values so surfaces can read them from context instead of receiving
|
|
44859
|
+
// them as positional props. No consumers yet — this PR only installs
|
|
44860
|
+
// the provider at the root; the surface families migrate in later PRs.
|
|
44861
|
+
// A Context.Provider renders its children transparently (no host
|
|
44862
|
+
// output), so wrapping the tree is behavior-preserving.
|
|
44863
|
+
const RuntimeContext = getLogInkRuntimeContext(React);
|
|
44864
|
+
const runtimeContextValue = {
|
|
44865
|
+
state,
|
|
44866
|
+
dispatch,
|
|
44867
|
+
theme,
|
|
44868
|
+
layout,
|
|
44869
|
+
context,
|
|
44870
|
+
};
|
|
43696
44871
|
if (layout.tooSmall) {
|
|
43697
44872
|
return h(Box, {
|
|
43698
44873
|
flexDirection: 'column',
|
|
@@ -43706,7 +44881,35 @@ function LogInkApp(deps) {
|
|
|
43706
44881
|
if (showOnboarding) {
|
|
43707
44882
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
43708
44883
|
}
|
|
43709
|
-
|
|
44884
|
+
// Panel renderers are thunks so single-pane mode can build only the
|
|
44885
|
+
// visible pane — the main-panel render in particular is expensive, so
|
|
44886
|
+
// we don't want to invoke the two hidden ones just to drop them.
|
|
44887
|
+
const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme);
|
|
44888
|
+
const mainSurface = {
|
|
44889
|
+
h,
|
|
44890
|
+
components: { Box, Text },
|
|
44891
|
+
state,
|
|
44892
|
+
context,
|
|
44893
|
+
contextStatus,
|
|
44894
|
+
bodyRows: layout.bodyRows,
|
|
44895
|
+
width: layout.mainPanelWidth,
|
|
44896
|
+
theme,
|
|
44897
|
+
};
|
|
44898
|
+
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);
|
|
44899
|
+
const detailPanel = () => renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.bodyRows);
|
|
44900
|
+
// Single-pane mode (narrow terminals): exactly one full-width pane,
|
|
44901
|
+
// chosen by `layout.visiblePane`; Tab cycles which one. Above the
|
|
44902
|
+
// breakpoint all three tile side by side as before.
|
|
44903
|
+
const bodyPanels = layout.singlePane
|
|
44904
|
+
? [
|
|
44905
|
+
layout.visiblePane === 'sidebar'
|
|
44906
|
+
? sidebarPanel()
|
|
44907
|
+
: layout.visiblePane === 'inspector'
|
|
44908
|
+
? detailPanel()
|
|
44909
|
+
: mainPanel(),
|
|
44910
|
+
]
|
|
44911
|
+
: [sidebarPanel(), mainPanel(), detailPanel()];
|
|
44912
|
+
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)));
|
|
43710
44913
|
}
|
|
43711
44914
|
|
|
43712
44915
|
/**
|
|
@@ -44444,6 +45647,7 @@ var prs = {
|
|
|
44444
45647
|
desc: 'List GitHub pull requests for the current repository (read-only triage)',
|
|
44445
45648
|
builder: builder$4,
|
|
44446
45649
|
handler: commandExecutor(handler$3),
|
|
45650
|
+
options: options$4,
|
|
44447
45651
|
};
|
|
44448
45652
|
|
|
44449
45653
|
const RecapLlmResponseSchema = objectType({
|
|
@@ -44523,8 +45727,7 @@ const handler$2 = async (argv, logger) => {
|
|
|
44523
45727
|
const summaryService = resolveDynamicService(config, 'summarize');
|
|
44524
45728
|
const model = recapService.model;
|
|
44525
45729
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
44526
|
-
logger
|
|
44527
|
-
commandExit(1);
|
|
45730
|
+
handleMissingApiKey(logger, config, { command: 'recap' });
|
|
44528
45731
|
}
|
|
44529
45732
|
const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
|
|
44530
45733
|
const llm = getLlm(provider, model, { ...config, service: recapService });
|
|
@@ -45112,8 +46315,7 @@ const handler$1 = async (argv, logger) => {
|
|
|
45112
46315
|
const summaryService = resolveDynamicService(config, argv.branch ? 'largeDiff' : 'summarize');
|
|
45113
46316
|
const model = reviewService.model;
|
|
45114
46317
|
if (config.service.authentication.type !== 'None' && !key) {
|
|
45115
|
-
logger
|
|
45116
|
-
commandExit(1);
|
|
46318
|
+
handleMissingApiKey(logger, config, { command: 'review' });
|
|
45117
46319
|
}
|
|
45118
46320
|
const tokenizer = await getTokenCounter(provider === 'openai' ? model : 'gpt-4o');
|
|
45119
46321
|
const llm = getLlm(provider, model, { ...config, service: reviewService });
|
|
@@ -45826,6 +47028,54 @@ async function getWorkspacePullRequestCounts(repoPaths, options = {}) {
|
|
|
45826
47028
|
return { authenticated: true, counts };
|
|
45827
47029
|
}
|
|
45828
47030
|
|
|
47031
|
+
/**
|
|
47032
|
+
* Clone a remote repository into a local path — the runtime side of the
|
|
47033
|
+
* workspace surface's `c` (clone) flow.
|
|
47034
|
+
*
|
|
47035
|
+
* `deriveRepoName` is pure (and tested) so the UI can pre-fill the
|
|
47036
|
+
* destination as `<cwd>/<name>` the moment a URL is typed; `cloneRepo`
|
|
47037
|
+
* does the filesystem-touching work and reports a friendly result.
|
|
47038
|
+
*/
|
|
47039
|
+
/**
|
|
47040
|
+
* Infer the repository folder name from a clone URL or SSH spec:
|
|
47041
|
+
* git@github.com:gfargo/coco.git → coco
|
|
47042
|
+
* https://github.com/gfargo/coco → coco
|
|
47043
|
+
* https://example.com/a/b/c.git/ → c
|
|
47044
|
+
* Falls back to `repo` when nothing usable can be parsed.
|
|
47045
|
+
*/
|
|
47046
|
+
function deriveRepoName(url) {
|
|
47047
|
+
const trimmed = url.trim().replace(/\/+$/, '').replace(/\.git$/i, '');
|
|
47048
|
+
if (!trimmed)
|
|
47049
|
+
return 'repo';
|
|
47050
|
+
// Split on both `/` and `:` so `host:owner/name` SSH specs work.
|
|
47051
|
+
const segment = trimmed.split(/[/:]/).filter(Boolean).pop() || '';
|
|
47052
|
+
return segment || 'repo';
|
|
47053
|
+
}
|
|
47054
|
+
/**
|
|
47055
|
+
* Clone `url` into `targetPath`. Refuses to clobber an existing path so
|
|
47056
|
+
* a typo never overwrites a directory. Network / auth failures surface
|
|
47057
|
+
* git's own message (trimmed to one line).
|
|
47058
|
+
*/
|
|
47059
|
+
async function cloneRepo(url, targetPath) {
|
|
47060
|
+
const remote = url.trim();
|
|
47061
|
+
const dest = targetPath.trim();
|
|
47062
|
+
if (!remote)
|
|
47063
|
+
return { ok: false, message: 'Enter a remote URL to clone.' };
|
|
47064
|
+
if (!dest)
|
|
47065
|
+
return { ok: false, message: 'Enter a destination path.' };
|
|
47066
|
+
if (fs__namespace.existsSync(dest)) {
|
|
47067
|
+
return { ok: false, message: `${dest} already exists — choose another path.` };
|
|
47068
|
+
}
|
|
47069
|
+
try {
|
|
47070
|
+
await simpleGit.simpleGit().clone(remote, dest);
|
|
47071
|
+
return { ok: true, message: `Cloned into ${dest}` };
|
|
47072
|
+
}
|
|
47073
|
+
catch (error) {
|
|
47074
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
47075
|
+
return { ok: false, message: `Clone failed: ${raw.split('\n')[0]}` };
|
|
47076
|
+
}
|
|
47077
|
+
}
|
|
47078
|
+
|
|
45829
47079
|
function resolveStoreDir(subdir) {
|
|
45830
47080
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
45831
47081
|
const root = xdg && xdg.trim().length > 0 ? xdg : path__namespace$1.join(os__namespace$1.homedir(), '.cache');
|
|
@@ -46068,6 +47318,7 @@ function createWorkspaceState(init) {
|
|
|
46068
47318
|
showThemePicker: false,
|
|
46069
47319
|
themePickerFilter: '',
|
|
46070
47320
|
themePickerIndex: 0,
|
|
47321
|
+
helpScrollOffset: 0,
|
|
46071
47322
|
knownRepoPaths: init.knownRepoPaths ?? [],
|
|
46072
47323
|
pullRequestFetching: [],
|
|
46073
47324
|
};
|
|
@@ -46233,10 +47484,17 @@ function applyWorkspaceAction(state, action) {
|
|
|
46233
47484
|
return { ...state, status: action.status };
|
|
46234
47485
|
}
|
|
46235
47486
|
case 'toggle-help': {
|
|
46236
|
-
|
|
47487
|
+
// Always reopen at the top — picking up the last scroll position
|
|
47488
|
+
// is more surprising than predictable for a reference overlay.
|
|
47489
|
+
return { ...state, showHelp: !state.showHelp, helpScrollOffset: 0, showOnboarding: false };
|
|
46237
47490
|
}
|
|
46238
47491
|
case 'close-help': {
|
|
46239
|
-
return { ...state, showHelp: false };
|
|
47492
|
+
return { ...state, showHelp: false, helpScrollOffset: 0 };
|
|
47493
|
+
}
|
|
47494
|
+
case 'scroll-help': {
|
|
47495
|
+
// Floor-clamp at 0 only; the renderer ceiling-clamps against the
|
|
47496
|
+
// real content height so `j` past the end sticks at the last row.
|
|
47497
|
+
return { ...state, helpScrollOffset: Math.max(0, state.helpScrollOffset + action.delta) };
|
|
46240
47498
|
}
|
|
46241
47499
|
case 'toggle-theme-picker': {
|
|
46242
47500
|
return {
|
|
@@ -46566,6 +47824,7 @@ function buildWorkspaceListWindow(state, options = { rows: 20 }) {
|
|
|
46566
47824
|
const all = buildWorkspaceListRows(state, {
|
|
46567
47825
|
width: options.width,
|
|
46568
47826
|
spinnerTick: options.spinnerTick,
|
|
47827
|
+
now: options.now,
|
|
46569
47828
|
});
|
|
46570
47829
|
const visibleCount = Math.max(1, options.rows);
|
|
46571
47830
|
if (all.length <= visibleCount) {
|
|
@@ -46689,10 +47948,11 @@ function buildWorkspaceHeaderChips(state, options = { focusLabel: 'List' }) {
|
|
|
46689
47948
|
// The contextual slot drops bindings users can find via the help
|
|
46690
47949
|
// overlay (arrow keys, tab); the global slot is the safety net so
|
|
46691
47950
|
// `? help` and `q quit` never disappear.
|
|
46692
|
-
const LIST_CONTEXTUAL = ['s sort', '/ filter', 'r/R refresh', 'a add', 'd remove'];
|
|
47951
|
+
const LIST_CONTEXTUAL = ['s sort', '/ filter', 'r/R refresh', 'a add', 'c clone', 'd remove'];
|
|
46693
47952
|
const SIDEBAR_CONTEXTUAL = ['↑/↓ cycle tab', 'enter open'];
|
|
46694
47953
|
const FILTER_CONTEXTUAL = ['type to filter', 'enter apply', 'esc cancel'];
|
|
46695
47954
|
const ADD_REPO_CONTEXTUAL = ['type path', 'tab to complete', 'enter to add', 'esc to cancel'];
|
|
47955
|
+
const CLONE_REPO_CONTEXTUAL = ['enter URL', 'enter → destination', 'enter to clone', 'esc to cancel'];
|
|
46696
47956
|
const CONFIRM_DELETE_CONTEXTUAL = ['y confirm', 'any other key cancels'];
|
|
46697
47957
|
const GLOBAL_HINTS = ['? help', 'q quit'];
|
|
46698
47958
|
function contextualHintsFor(focus) {
|
|
@@ -46703,6 +47963,8 @@ function contextualHintsFor(focus) {
|
|
|
46703
47963
|
return FILTER_CONTEXTUAL;
|
|
46704
47964
|
case 'add-repo':
|
|
46705
47965
|
return ADD_REPO_CONTEXTUAL;
|
|
47966
|
+
case 'clone-repo':
|
|
47967
|
+
return CLONE_REPO_CONTEXTUAL;
|
|
46706
47968
|
case 'confirm-delete':
|
|
46707
47969
|
return CONFIRM_DELETE_CONTEXTUAL;
|
|
46708
47970
|
case 'list':
|
|
@@ -46717,6 +47979,7 @@ function buildWorkspaceFooter(state) {
|
|
|
46717
47979
|
// is open and showing them would be misleading.
|
|
46718
47980
|
const isModal = state.focus === 'filter' ||
|
|
46719
47981
|
state.focus === 'add-repo' ||
|
|
47982
|
+
state.focus === 'clone-repo' ||
|
|
46720
47983
|
state.focus === 'confirm-delete';
|
|
46721
47984
|
const global = isModal ? [] : GLOBAL_HINTS;
|
|
46722
47985
|
const allHints = [...contextual, ...global];
|
|
@@ -46778,6 +48041,7 @@ function buildWorkspaceHelpSections() {
|
|
|
46778
48041
|
{ glyph: '⟳', keys: 'r', description: 'Refresh all repos (discovery + PR counts)' },
|
|
46779
48042
|
{ glyph: '⟲', keys: 'R', description: 'Refresh just the cursored repo (faster)' },
|
|
46780
48043
|
{ glyph: '+', keys: 'a', description: 'Add a repo via path prompt (tab-completes)' },
|
|
48044
|
+
{ glyph: '⬇', keys: 'c', description: 'Clone a remote repo (defaults into the launch directory)' },
|
|
46781
48045
|
{ glyph: '✕', keys: 'd', description: 'Remove the cursored repo from the known-repos store' },
|
|
46782
48046
|
],
|
|
46783
48047
|
},
|
|
@@ -46795,7 +48059,7 @@ function buildWorkspaceOnboarding(state) {
|
|
|
46795
48059
|
: undefined,
|
|
46796
48060
|
populatedHint: empty
|
|
46797
48061
|
? undefined
|
|
46798
|
-
: 'Press `enter` to open a repo ·
|
|
48062
|
+
: 'Press `enter` to open a repo · `a` to add by path · `c` to clone · `?` for the full keymap.',
|
|
46799
48063
|
};
|
|
46800
48064
|
}
|
|
46801
48065
|
|
|
@@ -47091,6 +48355,7 @@ function renderListBody(deps, width, height) {
|
|
|
47091
48355
|
width,
|
|
47092
48356
|
rows: listRows,
|
|
47093
48357
|
spinnerTick: deps.spinnerTick,
|
|
48358
|
+
now: deps.now,
|
|
47094
48359
|
});
|
|
47095
48360
|
const visibleRepos = selectVisibleRepos(state);
|
|
47096
48361
|
const filterChip = state.filter
|
|
@@ -47126,7 +48391,7 @@ function renderListBody(deps, width, height) {
|
|
|
47126
48391
|
function renderHelpRow(deps, row, glyphWidth, keysWidth, key) {
|
|
47127
48392
|
const { React, ink, theme } = deps;
|
|
47128
48393
|
const { Box, Text } = ink;
|
|
47129
|
-
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));
|
|
48394
|
+
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));
|
|
47130
48395
|
}
|
|
47131
48396
|
function renderHelpOverlay(deps) {
|
|
47132
48397
|
if (!deps.state.showHelp) {
|
|
@@ -47139,34 +48404,68 @@ function renderHelpOverlay(deps) {
|
|
|
47139
48404
|
// Columns: glyph cell (4 cells) · keys (padded to longest) · description.
|
|
47140
48405
|
const glyphWidth = 4;
|
|
47141
48406
|
const keysWidth = Math.max(14, allRows.reduce((acc, row) => Math.max(acc, row.keys.length), 0) + 4);
|
|
47142
|
-
|
|
47143
|
-
//
|
|
47144
|
-
//
|
|
47145
|
-
//
|
|
47146
|
-
|
|
47147
|
-
|
|
47148
|
-
|
|
47149
|
-
// then its rows, then a blank line.
|
|
48407
|
+
// Body lines — every scrollable row below the pinned title. Built as
|
|
48408
|
+
// a flat list (section title → optional subtitle → rows → inter-section
|
|
48409
|
+
// spacer) so we can window it against the available height. Each entry
|
|
48410
|
+
// is `flexShrink: 0` so Ink never crushes rows on top of each other
|
|
48411
|
+
// when the keymap is taller than the panel (which used to collapse the
|
|
48412
|
+
// title and the first category onto the same line).
|
|
48413
|
+
const body = [];
|
|
47150
48414
|
sections.forEach((section, sIndex) => {
|
|
47151
|
-
|
|
48415
|
+
body.push(React.createElement(Text, {
|
|
47152
48416
|
key: `section-${sIndex}-title`,
|
|
47153
48417
|
bold: true,
|
|
47154
48418
|
color: theme.noColor ? undefined : theme.colors.muted,
|
|
47155
48419
|
}, section.title.toUpperCase()));
|
|
47156
48420
|
if (section.subtitle) {
|
|
47157
|
-
|
|
48421
|
+
body.push(React.createElement(Text, { key: `section-${sIndex}-subtitle`, dimColor: true }, ` ${section.subtitle}`));
|
|
47158
48422
|
}
|
|
47159
48423
|
section.rows.forEach((row, rIndex) => {
|
|
47160
|
-
|
|
48424
|
+
body.push(renderHelpRow(deps, row, glyphWidth, keysWidth, `row-${sIndex}-${rIndex}`));
|
|
47161
48425
|
});
|
|
47162
48426
|
if (sIndex < sections.length - 1) {
|
|
47163
|
-
|
|
48427
|
+
body.push(React.createElement(Text, { key: `section-${sIndex}-spacer` }, ''));
|
|
47164
48428
|
}
|
|
47165
48429
|
});
|
|
48430
|
+
// Vertical budget: the overlay shares the column with the header
|
|
48431
|
+
// (3 rows) and footer (FOOTER_HEIGHT). Its own chrome eats the border
|
|
48432
|
+
// (2), the pinned title (1) and the title/body separator (1). Whatever
|
|
48433
|
+
// is left is the window we slide the body through.
|
|
48434
|
+
const HEADER_ROWS = 3;
|
|
48435
|
+
const overlayChromeRows = 4;
|
|
48436
|
+
const visibleRows = Math.max(4, deps.rows - HEADER_ROWS - FOOTER_HEIGHT - overlayChromeRows);
|
|
48437
|
+
// Ceiling-clamp the offset here (the reducer only floors at 0) so
|
|
48438
|
+
// scrolling past the end sticks at the last row instead of revealing
|
|
48439
|
+
// blank space.
|
|
48440
|
+
const maxOffset = Math.max(0, body.length - visibleRows);
|
|
48441
|
+
const offset = Math.min(deps.state.helpScrollOffset, maxOffset);
|
|
48442
|
+
const children = [];
|
|
48443
|
+
// Title bar — accent-tinged, matches the chip-style header on the
|
|
48444
|
+
// main surface so the help reads as the same app, just a different
|
|
48445
|
+
// panel. Pinned above the scrolling body.
|
|
48446
|
+
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 ')));
|
|
48447
|
+
children.push(React.createElement(Text, { key: 'title-sep', dimColor: true }, ''));
|
|
48448
|
+
// "more above" / "more below" hints each consume a window row so they
|
|
48449
|
+
// don't push body content off-screen. Mirrors the `coco ui` overlay.
|
|
48450
|
+
let windowSize = visibleRows;
|
|
48451
|
+
const hasMoreAbove = offset > 0;
|
|
48452
|
+
if (hasMoreAbove) {
|
|
48453
|
+
windowSize -= 1;
|
|
48454
|
+
children.push(React.createElement(Text, { key: 'more-above', dimColor: true }, ' ↑ more above (j/k or ↑/↓ to scroll)'));
|
|
48455
|
+
}
|
|
48456
|
+
const hasMoreBelow = offset + windowSize < body.length;
|
|
48457
|
+
if (hasMoreBelow) {
|
|
48458
|
+
windowSize -= 1;
|
|
48459
|
+
}
|
|
48460
|
+
children.push(...body.slice(offset, offset + windowSize));
|
|
48461
|
+
if (hasMoreBelow) {
|
|
48462
|
+
children.push(React.createElement(Text, { key: 'more-below', dimColor: true }, ' ↓ more below (j/k or ↑/↓ to scroll)'));
|
|
48463
|
+
}
|
|
47166
48464
|
return React.createElement(Box, {
|
|
47167
48465
|
borderColor: focusBorderColor(theme, true),
|
|
47168
48466
|
borderStyle: theme.borderStyle,
|
|
47169
48467
|
flexDirection: 'column',
|
|
48468
|
+
flexShrink: 0,
|
|
47170
48469
|
paddingX: 1,
|
|
47171
48470
|
}, ...children);
|
|
47172
48471
|
}
|
|
@@ -47217,6 +48516,29 @@ function renderAddRepoPrompt(deps) {
|
|
|
47217
48516
|
? React.createElement(Text, { color: toneColor('dim', theme) }, completionLine)
|
|
47218
48517
|
: null);
|
|
47219
48518
|
}
|
|
48519
|
+
function renderCloneRepoPrompt(deps) {
|
|
48520
|
+
if (deps.state.focus !== 'clone-repo') {
|
|
48521
|
+
return null;
|
|
48522
|
+
}
|
|
48523
|
+
const { React, ink, theme, cloneUrl, cloneTarget, cloneField, cloneCompletion, cloning } = deps;
|
|
48524
|
+
const { Box, Text } = ink;
|
|
48525
|
+
const urlActive = cloneField === 'url' && !cloning;
|
|
48526
|
+
const targetActive = cloneField === 'target' && !cloning;
|
|
48527
|
+
const completionLine = cloneCompletion.completions.slice(0, 8).join(' ');
|
|
48528
|
+
const hint = cloning
|
|
48529
|
+
? 'Cloning… this can take a moment for large repos.'
|
|
48530
|
+
: cloneField === 'url'
|
|
48531
|
+
? 'Paste a remote URL (https or git@…), then enter for the destination.'
|
|
48532
|
+
: 'Edit the destination · tab to complete · enter to clone · esc to cancel';
|
|
48533
|
+
return React.createElement(Box, {
|
|
48534
|
+
borderColor: focusBorderColor(theme, true),
|
|
48535
|
+
borderStyle: theme.borderStyle,
|
|
48536
|
+
flexDirection: 'column',
|
|
48537
|
+
paddingX: 1,
|
|
48538
|
+
}, 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
|
|
48539
|
+
? React.createElement(Text, { color: toneColor('dim', theme) }, completionLine)
|
|
48540
|
+
: null);
|
|
48541
|
+
}
|
|
47220
48542
|
const FOOTER_HEIGHT = 4; // 2 borders + hint row + status row
|
|
47221
48543
|
function renderFooter(deps) {
|
|
47222
48544
|
const { React, ink, state, theme } = deps;
|
|
@@ -47258,8 +48580,10 @@ function computeBodyHeight(deps) {
|
|
|
47258
48580
|
const FOOTER_ROWS = FOOTER_HEIGHT;
|
|
47259
48581
|
const onboardingRows = buildWorkspaceOnboarding(deps.state).show ? 5 : 0;
|
|
47260
48582
|
const addRepoRows = deps.state.focus === 'add-repo' ? 5 : 0;
|
|
48583
|
+
// Clone modal is one row taller (URL + Into + hint + completion).
|
|
48584
|
+
const cloneRows = deps.state.focus === 'clone-repo' ? 6 : 0;
|
|
47261
48585
|
const confirmRows = deps.state.focus === 'confirm-delete' ? 5 : 0;
|
|
47262
|
-
const reserved = HEADER_ROWS + FOOTER_ROWS + onboardingRows + addRepoRows + confirmRows;
|
|
48586
|
+
const reserved = HEADER_ROWS + FOOTER_ROWS + onboardingRows + addRepoRows + cloneRows + confirmRows;
|
|
47263
48587
|
return Math.max(8, deps.rows - reserved);
|
|
47264
48588
|
}
|
|
47265
48589
|
function renderWorkspaceApp(deps) {
|
|
@@ -47281,7 +48605,7 @@ function renderWorkspaceApp(deps) {
|
|
|
47281
48605
|
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));
|
|
47282
48606
|
}
|
|
47283
48607
|
const bodyHeight = computeBodyHeight(deps);
|
|
47284
|
-
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));
|
|
48608
|
+
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));
|
|
47285
48609
|
}
|
|
47286
48610
|
|
|
47287
48611
|
/**
|
|
@@ -47318,6 +48642,21 @@ function resolveWorkspaceInput(input, key, state) {
|
|
|
47318
48642
|
if (key.escape || input === '?' || input === 'q') {
|
|
47319
48643
|
return { kind: 'action', action: { type: 'close-help' } };
|
|
47320
48644
|
}
|
|
48645
|
+
// The keymap is taller than the panel on short terminals — let
|
|
48646
|
+
// j/k/↑/↓ and ctrl+d/u scroll the windowed body. Mirrors the
|
|
48647
|
+
// `coco ui` help overlay.
|
|
48648
|
+
if (key.downArrow || input === 'j') {
|
|
48649
|
+
return { kind: 'action', action: { type: 'scroll-help', delta: 1 } };
|
|
48650
|
+
}
|
|
48651
|
+
if (key.upArrow || input === 'k') {
|
|
48652
|
+
return { kind: 'action', action: { type: 'scroll-help', delta: -1 } };
|
|
48653
|
+
}
|
|
48654
|
+
if (key.ctrl && input === 'd') {
|
|
48655
|
+
return { kind: 'action', action: { type: 'scroll-help', delta: 10 } };
|
|
48656
|
+
}
|
|
48657
|
+
if (key.ctrl && input === 'u') {
|
|
48658
|
+
return { kind: 'action', action: { type: 'scroll-help', delta: -10 } };
|
|
48659
|
+
}
|
|
47321
48660
|
return { kind: 'noop' };
|
|
47322
48661
|
}
|
|
47323
48662
|
// Theme picker is modal (like `coco ui`'s gC): type to filter, ↑/↓ to
|
|
@@ -47368,6 +48707,14 @@ function resolveWorkspaceInput(input, key, state) {
|
|
|
47368
48707
|
// can drive the path-completion prompt.
|
|
47369
48708
|
return { kind: 'noop' };
|
|
47370
48709
|
}
|
|
48710
|
+
if (state.focus === 'clone-repo') {
|
|
48711
|
+
if (key.escape) {
|
|
48712
|
+
return { kind: 'action', action: { type: 'set-focus', focus: 'list' } };
|
|
48713
|
+
}
|
|
48714
|
+
// Enter/Tab/printable keys drive the URL + destination prompt in the
|
|
48715
|
+
// runtime (it owns the two-field state + path completion).
|
|
48716
|
+
return { kind: 'noop' };
|
|
48717
|
+
}
|
|
47371
48718
|
// Confirm-delete is modal: only `y` confirms, anything else cancels.
|
|
47372
48719
|
if (state.focus === 'confirm-delete') {
|
|
47373
48720
|
if (input === 'y' || input === 'Y') {
|
|
@@ -47462,6 +48809,9 @@ function resolveWorkspaceInput(input, key, state) {
|
|
|
47462
48809
|
if (input === 'a') {
|
|
47463
48810
|
return { kind: 'add-repo' };
|
|
47464
48811
|
}
|
|
48812
|
+
if (input === 'c') {
|
|
48813
|
+
return { kind: 'clone-repo' };
|
|
48814
|
+
}
|
|
47465
48815
|
if (input === 'd') {
|
|
47466
48816
|
return { kind: 'request-delete' };
|
|
47467
48817
|
}
|
|
@@ -48016,6 +49366,19 @@ function WorkspaceInkApp(props) {
|
|
|
48016
49366
|
const [filterDraft, setFilterDraft] = React.useState('');
|
|
48017
49367
|
const [addRepoDraft, setAddRepoDraft] = React.useState('~/');
|
|
48018
49368
|
const [addRepoCompletion, setAddRepoCompletion] = React.useState(() => completePath('~/'));
|
|
49369
|
+
// Clone-repo modal (`c`). Two fields: the remote URL and the
|
|
49370
|
+
// destination path. `cloneField` tracks which is active; `cloneTarget`
|
|
49371
|
+
// auto-derives `<cwd>/<repo-name>` from the URL until the user edits it
|
|
49372
|
+
// (`cloneTargetEdited`). `cloning` blocks input + shows a spinner while
|
|
49373
|
+
// `git clone` runs. The boot cwd is captured once at mount so it stays
|
|
49374
|
+
// the directory the workspace launched in even after drill-in.
|
|
49375
|
+
const bootCwdRef = React.useRef(process.cwd());
|
|
49376
|
+
const [cloneUrl, setCloneUrl] = React.useState('');
|
|
49377
|
+
const [cloneTarget, setCloneTarget] = React.useState('');
|
|
49378
|
+
const [cloneField, setCloneField] = React.useState('url');
|
|
49379
|
+
const [cloneTargetEdited, setCloneTargetEdited] = React.useState(false);
|
|
49380
|
+
const [cloneCompletion, setCloneCompletion] = React.useState(() => completePath('~/'));
|
|
49381
|
+
const [cloning, setCloning] = React.useState(false);
|
|
48019
49382
|
// Tick counter for the per-row PR-fetch spinner. Bumped on a
|
|
48020
49383
|
// setInterval that only runs while at least one row is mid-fetch
|
|
48021
49384
|
// (see effect below) so idle workspaces don't burn CPU on animation
|
|
@@ -48065,6 +49428,18 @@ function WorkspaceInkApp(props) {
|
|
|
48065
49428
|
addRepoDraftRef.current = addRepoDraft;
|
|
48066
49429
|
const addRepoCompletionRef = React.useRef(addRepoCompletion);
|
|
48067
49430
|
addRepoCompletionRef.current = addRepoCompletion;
|
|
49431
|
+
const cloneUrlRef = React.useRef(cloneUrl);
|
|
49432
|
+
cloneUrlRef.current = cloneUrl;
|
|
49433
|
+
const cloneTargetRef = React.useRef(cloneTarget);
|
|
49434
|
+
cloneTargetRef.current = cloneTarget;
|
|
49435
|
+
const cloneFieldRef = React.useRef(cloneField);
|
|
49436
|
+
cloneFieldRef.current = cloneField;
|
|
49437
|
+
const cloneTargetEditedRef = React.useRef(cloneTargetEdited);
|
|
49438
|
+
cloneTargetEditedRef.current = cloneTargetEdited;
|
|
49439
|
+
const cloneCompletionRef = React.useRef(cloneCompletion);
|
|
49440
|
+
cloneCompletionRef.current = cloneCompletion;
|
|
49441
|
+
const cloningRef = React.useRef(cloning);
|
|
49442
|
+
cloningRef.current = cloning;
|
|
48068
49443
|
// Background discovery + PR-count refresh on mount.
|
|
48069
49444
|
React.useEffect(() => {
|
|
48070
49445
|
let cancelled = false;
|
|
@@ -48297,6 +49672,60 @@ function WorkspaceInkApp(props) {
|
|
|
48297
49672
|
});
|
|
48298
49673
|
}
|
|
48299
49674
|
}, [addRepoDraft, dispatch, props]);
|
|
49675
|
+
// Default destination for a clone URL: `<bootCwd>/<repo-name>`.
|
|
49676
|
+
const cloneTargetFor = React.useCallback((url) => {
|
|
49677
|
+
return path__namespace$1.join(bootCwdRef.current, deriveRepoName(url));
|
|
49678
|
+
}, []);
|
|
49679
|
+
const openClone = React.useCallback(() => {
|
|
49680
|
+
setCloneUrl('');
|
|
49681
|
+
setCloneTarget('');
|
|
49682
|
+
setCloneField('url');
|
|
49683
|
+
setCloneTargetEdited(false);
|
|
49684
|
+
setCloneCompletion(completePath(`${bootCwdRef.current}/`));
|
|
49685
|
+
dispatch({ type: 'set-focus', focus: 'clone-repo' });
|
|
49686
|
+
}, [dispatch]);
|
|
49687
|
+
const commitClone = React.useCallback(async () => {
|
|
49688
|
+
const url = cloneUrlRef.current.trim();
|
|
49689
|
+
const target = expandHomePrefix(cloneTargetRef.current.trim().replace(/\/+$/, ''));
|
|
49690
|
+
if (!url) {
|
|
49691
|
+
dispatch({ type: 'set-status', status: 'Enter a remote URL.' });
|
|
49692
|
+
return;
|
|
49693
|
+
}
|
|
49694
|
+
if (!target) {
|
|
49695
|
+
dispatch({ type: 'set-status', status: 'Enter a destination path.' });
|
|
49696
|
+
return;
|
|
49697
|
+
}
|
|
49698
|
+
setCloning(true);
|
|
49699
|
+
dispatch({ type: 'set-status', status: `Cloning ${deriveRepoName(url)}…` });
|
|
49700
|
+
const result = await cloneRepo(url, target);
|
|
49701
|
+
if (unmountedRef.current)
|
|
49702
|
+
return;
|
|
49703
|
+
setCloning(false);
|
|
49704
|
+
if (!result.ok) {
|
|
49705
|
+
// Keep the modal open so the user can fix the URL / path and retry.
|
|
49706
|
+
dispatch({ type: 'set-status', status: result.message });
|
|
49707
|
+
return;
|
|
49708
|
+
}
|
|
49709
|
+
const updated = appendKnownRepo(target);
|
|
49710
|
+
dispatch({ type: 'replace-known-repos', paths: updated });
|
|
49711
|
+
dispatch({ type: 'set-focus', focus: 'list' });
|
|
49712
|
+
dispatch({ type: 'set-status', status: result.message });
|
|
49713
|
+
dispatch({ type: 'set-loading', loading: true });
|
|
49714
|
+
try {
|
|
49715
|
+
const merged = mergeKnownRepos(props.knownRepos, readKnownRepos());
|
|
49716
|
+
const overview = await props.loadOverview(props.roots, merged);
|
|
49717
|
+
writeCachedWorkspace(props.roots, overview);
|
|
49718
|
+
dispatch({ type: 'replace-overview', overview });
|
|
49719
|
+
dispatch({ type: 'anchor-cursor-by-path', path: target });
|
|
49720
|
+
}
|
|
49721
|
+
catch (err) {
|
|
49722
|
+
dispatch({ type: 'set-loading', loading: false });
|
|
49723
|
+
dispatch({
|
|
49724
|
+
type: 'set-status',
|
|
49725
|
+
status: err instanceof Error ? err.message : 'Refresh failed.',
|
|
49726
|
+
});
|
|
49727
|
+
}
|
|
49728
|
+
}, [dispatch, props]);
|
|
48300
49729
|
// Callback refs so the stable input handler can reach the latest
|
|
48301
49730
|
// closure without taking them in deps.
|
|
48302
49731
|
const commitAddRepoRef = React.useRef(commitAddRepo);
|
|
@@ -48309,6 +49738,10 @@ function WorkspaceInkApp(props) {
|
|
|
48309
49738
|
refreshRowRef.current = refreshRow;
|
|
48310
49739
|
const openAddRepoRef = React.useRef(openAddRepo);
|
|
48311
49740
|
openAddRepoRef.current = openAddRepo;
|
|
49741
|
+
const openCloneRef = React.useRef(openClone);
|
|
49742
|
+
openCloneRef.current = openClone;
|
|
49743
|
+
const commitCloneRef = React.useRef(commitClone);
|
|
49744
|
+
commitCloneRef.current = commitClone;
|
|
48312
49745
|
const requestDeleteRef = React.useRef(requestDelete);
|
|
48313
49746
|
requestDeleteRef.current = requestDelete;
|
|
48314
49747
|
const confirmDeleteRef = React.useRef(confirmDelete);
|
|
@@ -48391,6 +49824,73 @@ function WorkspaceInkApp(props) {
|
|
|
48391
49824
|
}
|
|
48392
49825
|
return;
|
|
48393
49826
|
}
|
|
49827
|
+
if (state.focus === 'clone-repo') {
|
|
49828
|
+
// While the clone is running, swallow everything except Esc
|
|
49829
|
+
// (which is a no-op here — the clone is already in flight).
|
|
49830
|
+
if (cloningRef.current)
|
|
49831
|
+
return;
|
|
49832
|
+
if (key.escape) {
|
|
49833
|
+
dispatch({ type: 'set-focus', focus: 'list' });
|
|
49834
|
+
return;
|
|
49835
|
+
}
|
|
49836
|
+
const field = cloneFieldRef.current;
|
|
49837
|
+
const url = cloneUrlRef.current;
|
|
49838
|
+
const target = cloneTargetRef.current;
|
|
49839
|
+
const targetEdited = cloneTargetEditedRef.current;
|
|
49840
|
+
if (key.return) {
|
|
49841
|
+
if (field === 'url') {
|
|
49842
|
+
if (!url.trim()) {
|
|
49843
|
+
dispatch({ type: 'set-status', status: 'Enter a remote URL.' });
|
|
49844
|
+
return;
|
|
49845
|
+
}
|
|
49846
|
+
// Advance to the (pre-filled, editable) destination field.
|
|
49847
|
+
const derived = targetEdited ? target : cloneTargetFor(url);
|
|
49848
|
+
setCloneTarget(derived);
|
|
49849
|
+
setCloneCompletion(completePath(derived));
|
|
49850
|
+
setCloneField('target');
|
|
49851
|
+
return;
|
|
49852
|
+
}
|
|
49853
|
+
void commitCloneRef.current();
|
|
49854
|
+
return;
|
|
49855
|
+
}
|
|
49856
|
+
if (key.tab && field === 'target') {
|
|
49857
|
+
const next = applyTabCompletion(target, cloneCompletionRef.current);
|
|
49858
|
+
setCloneTarget(next);
|
|
49859
|
+
setCloneTargetEdited(true);
|
|
49860
|
+
setCloneCompletion(completePath(next));
|
|
49861
|
+
return;
|
|
49862
|
+
}
|
|
49863
|
+
if (key.backspace || key.delete) {
|
|
49864
|
+
if (field === 'url') {
|
|
49865
|
+
const next = url.slice(0, -1);
|
|
49866
|
+
setCloneUrl(next);
|
|
49867
|
+
if (!targetEdited)
|
|
49868
|
+
setCloneTarget(next ? cloneTargetFor(next) : '');
|
|
49869
|
+
}
|
|
49870
|
+
else {
|
|
49871
|
+
const next = target.slice(0, -1);
|
|
49872
|
+
setCloneTarget(next);
|
|
49873
|
+
setCloneTargetEdited(true);
|
|
49874
|
+
setCloneCompletion(completePath(next || '~/'));
|
|
49875
|
+
}
|
|
49876
|
+
return;
|
|
49877
|
+
}
|
|
49878
|
+
if (rawInput && !key.ctrl && !key.meta) {
|
|
49879
|
+
if (field === 'url') {
|
|
49880
|
+
const next = url + rawInput;
|
|
49881
|
+
setCloneUrl(next);
|
|
49882
|
+
if (!targetEdited)
|
|
49883
|
+
setCloneTarget(cloneTargetFor(next));
|
|
49884
|
+
}
|
|
49885
|
+
else {
|
|
49886
|
+
const next = target + rawInput;
|
|
49887
|
+
setCloneTarget(next);
|
|
49888
|
+
setCloneTargetEdited(true);
|
|
49889
|
+
setCloneCompletion(completePath(next));
|
|
49890
|
+
}
|
|
49891
|
+
}
|
|
49892
|
+
return;
|
|
49893
|
+
}
|
|
48394
49894
|
// Ctrl+C → quit, since we disabled Ink's built-in ctrl+c exit.
|
|
48395
49895
|
// Handled here (rather than in the pure resolver) because the
|
|
48396
49896
|
// resolver doesn't have a notion of "raw key with ctrl flag" for
|
|
@@ -48431,6 +49931,9 @@ function WorkspaceInkApp(props) {
|
|
|
48431
49931
|
case 'add-repo':
|
|
48432
49932
|
openAddRepoRef.current();
|
|
48433
49933
|
break;
|
|
49934
|
+
case 'clone-repo':
|
|
49935
|
+
openCloneRef.current();
|
|
49936
|
+
break;
|
|
48434
49937
|
case 'request-delete':
|
|
48435
49938
|
requestDeleteRef.current();
|
|
48436
49939
|
break;
|
|
@@ -48480,6 +49983,11 @@ function WorkspaceInkApp(props) {
|
|
|
48480
49983
|
filterDraft,
|
|
48481
49984
|
addRepoDraft,
|
|
48482
49985
|
addRepoCompletion,
|
|
49986
|
+
cloneUrl,
|
|
49987
|
+
cloneTarget,
|
|
49988
|
+
cloneField,
|
|
49989
|
+
cloneCompletion,
|
|
49990
|
+
cloning,
|
|
48483
49991
|
columns,
|
|
48484
49992
|
rows,
|
|
48485
49993
|
spinnerTick,
|