git-coco 0.59.1 → 0.60.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.esm.mjs +1258 -271
- package/dist/index.js +1258 -271
- package/dist/tree-sitter/web-tree-sitter.wasm +0 -0
- package/package.json +1 -1
package/dist/index.esm.mjs
CHANGED
|
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
|
|
|
61
61
|
/**
|
|
62
62
|
* Current build version from package.json
|
|
63
63
|
*/
|
|
64
|
-
const BUILD_VERSION = "0.
|
|
64
|
+
const BUILD_VERSION = "0.60.0";
|
|
65
65
|
|
|
66
66
|
const isInteractive = (config) => {
|
|
67
67
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -18903,9 +18903,17 @@ async function getStashOverview(git) {
|
|
|
18903
18903
|
// %gd — stash reflog selector (stash@{N})
|
|
18904
18904
|
// %H — stash commit hash
|
|
18905
18905
|
// %P — space-separated parent hashes (first = base, see StashEntry.baseHash)
|
|
18906
|
-
// %
|
|
18906
|
+
// %cI — committer date, strict ISO 8601
|
|
18907
18907
|
// %gs — reflog subject ("WIP on main: <subject>")
|
|
18908
|
-
|
|
18908
|
+
//
|
|
18909
|
+
// NOTE: we deliberately do NOT pass `--date=iso`. That flag rewrites the
|
|
18910
|
+
// `%gd` selector from the index form (`stash@{0}`) into a timestamp
|
|
18911
|
+
// (`stash@{2026-06-03 17:29:23 -0400}`), which is noisy in the list, eats
|
|
18912
|
+
// row width, and — critically — breaks `renameStash`, which parses the
|
|
18913
|
+
// `stash@{N}` index out of the ref. `%cI` gives a strict-ISO date that's
|
|
18914
|
+
// independent of `--date`, so we get both a clean index ref and a
|
|
18915
|
+
// parseable date.
|
|
18916
|
+
const stashes = parseStashList(await git.raw(['stash', 'list', '--format=%gd%x1f%H%x1f%P%x1f%cI%x1f%gs']));
|
|
18909
18917
|
return {
|
|
18910
18918
|
stashes: await Promise.all(stashes.map(async (stash) => ({
|
|
18911
18919
|
...stash,
|
|
@@ -20574,7 +20582,7 @@ function applyCommitComposeAction(state, action) {
|
|
|
20574
20582
|
loading: false,
|
|
20575
20583
|
streamingPreview: undefined,
|
|
20576
20584
|
pendingAiDraft: action.value,
|
|
20577
|
-
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20585
|
+
message: 'AI draft ready. Press Enter (or R) to replace your text, or Esc to keep what you have.',
|
|
20578
20586
|
details: undefined,
|
|
20579
20587
|
};
|
|
20580
20588
|
}
|
|
@@ -20623,7 +20631,7 @@ function applyCommitComposeAction(state, action) {
|
|
|
20623
20631
|
loading: false,
|
|
20624
20632
|
streamingPreview: undefined,
|
|
20625
20633
|
pendingAiDraft: action.value,
|
|
20626
|
-
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20634
|
+
message: 'AI draft ready. Press Enter (or R) to replace your text, or Esc to keep what you have.',
|
|
20627
20635
|
details: undefined,
|
|
20628
20636
|
};
|
|
20629
20637
|
case 'acceptPendingAiDraft':
|
|
@@ -22179,6 +22187,25 @@ function getLogInkWorkflowActions() {
|
|
|
22179
22187
|
kind: 'destructive',
|
|
22180
22188
|
requiresConfirmation: true,
|
|
22181
22189
|
},
|
|
22190
|
+
{
|
|
22191
|
+
// Palette-only create variants (empty `key`): no global hotkey to
|
|
22192
|
+
// collide with `S` / `gZ`, reachable from `:`. Both stash a quick
|
|
22193
|
+
// WIP entry with the requested scope.
|
|
22194
|
+
id: 'stash-staged',
|
|
22195
|
+
key: '',
|
|
22196
|
+
label: 'Stash staged only',
|
|
22197
|
+
description: 'Stash just the staged (index) changes — `git stash push --staged`.',
|
|
22198
|
+
kind: 'normal',
|
|
22199
|
+
requiresConfirmation: false,
|
|
22200
|
+
},
|
|
22201
|
+
{
|
|
22202
|
+
id: 'stash-keep-index',
|
|
22203
|
+
key: '',
|
|
22204
|
+
label: 'Stash keeping index',
|
|
22205
|
+
description: 'Stash everything but leave the index intact for an immediate commit — `git stash push --keep-index`.',
|
|
22206
|
+
kind: 'normal',
|
|
22207
|
+
requiresConfirmation: false,
|
|
22208
|
+
},
|
|
22182
22209
|
{
|
|
22183
22210
|
id: 'remove-worktree',
|
|
22184
22211
|
key: 'W',
|
|
@@ -22690,6 +22717,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
22690
22717
|
description: 'Push the stash view (gz; gs is reserved for status).',
|
|
22691
22718
|
contexts: ['normal'],
|
|
22692
22719
|
},
|
|
22720
|
+
{
|
|
22721
|
+
id: 'createStash',
|
|
22722
|
+
keys: ['gZ'],
|
|
22723
|
+
label: 'stash changes',
|
|
22724
|
+
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.',
|
|
22725
|
+
contexts: ['normal'],
|
|
22726
|
+
},
|
|
22693
22727
|
{
|
|
22694
22728
|
id: 'navigateWorktrees',
|
|
22695
22729
|
keys: ['gw'],
|
|
@@ -22962,6 +22996,20 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
22962
22996
|
description: 'Add the cursored file or folder to .gitignore (pick a pattern).',
|
|
22963
22997
|
contexts: ['status'],
|
|
22964
22998
|
},
|
|
22999
|
+
{
|
|
23000
|
+
id: 'stageAll',
|
|
23001
|
+
keys: ['A'],
|
|
23002
|
+
label: 'stage all',
|
|
23003
|
+
description: 'Stage every change in the worktree (git add -A).',
|
|
23004
|
+
contexts: ['status', 'compose'],
|
|
23005
|
+
},
|
|
23006
|
+
{
|
|
23007
|
+
id: 'stagePathspec',
|
|
23008
|
+
keys: ['+'],
|
|
23009
|
+
label: 'stage paths',
|
|
23010
|
+
description: 'Stage files matching a typed pathspec (. / src/ / *.ts / a list).',
|
|
23011
|
+
contexts: ['status', 'compose'],
|
|
23012
|
+
},
|
|
22965
23013
|
{
|
|
22966
23014
|
id: 'viewChangelog',
|
|
22967
23015
|
keys: ['L'],
|
|
@@ -23035,6 +23083,19 @@ const GLOBAL_BINDING_IDS = [
|
|
|
23035
23083
|
'navigateBack',
|
|
23036
23084
|
];
|
|
23037
23085
|
const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
|
|
23086
|
+
/**
|
|
23087
|
+
* Narrow single-pane footer budget (#1135). On terminals below the
|
|
23088
|
+
* single-pane breakpoint the pane switcher (`tab: …`, ~29 cells) plus
|
|
23089
|
+
* the snap-back / peek affordance already claim most of an 80-cell row,
|
|
23090
|
+
* so the per-view hint tail and the global cluster are trimmed to what
|
|
23091
|
+
* fits without clipping — the switcher is the orientation anchor and
|
|
23092
|
+
* must stay whole. The dropped bindings remain one `?` (help) away.
|
|
23093
|
+
*
|
|
23094
|
+
* - keep only the first view hint (the most actionable for the view)
|
|
23095
|
+
* - shrink the global cluster to the two recovery essentials
|
|
23096
|
+
*/
|
|
23097
|
+
const SINGLE_PANE_GLOBAL_HINTS = ['? help', 'q quit'];
|
|
23098
|
+
const SINGLE_PANE_VIEW_HINT_LIMIT = 1;
|
|
23038
23099
|
/**
|
|
23039
23100
|
* Per-binding category mapping. Used to subdivide the help overlay's
|
|
23040
23101
|
* Global and view sections into named clusters so users don't face a
|
|
@@ -23055,6 +23116,9 @@ const BINDING_CATEGORY_BY_ID = {
|
|
|
23055
23116
|
openProjectConfig: 'view',
|
|
23056
23117
|
openGlobalConfig: 'view',
|
|
23057
23118
|
gitignoreFile: 'mutate',
|
|
23119
|
+
stageAll: 'mutate',
|
|
23120
|
+
stagePathspec: 'mutate',
|
|
23121
|
+
createStash: 'mutate',
|
|
23058
23122
|
quit: 'essentials',
|
|
23059
23123
|
refresh: 'essentials',
|
|
23060
23124
|
navigateBack: 'essentials',
|
|
@@ -23206,18 +23270,20 @@ function formatLogInkBreadcrumb(viewStack) {
|
|
|
23206
23270
|
if (viewStack.length === 1 && viewStack[0] === 'history') {
|
|
23207
23271
|
return '';
|
|
23208
23272
|
}
|
|
23209
|
-
//
|
|
23210
|
-
//
|
|
23211
|
-
|
|
23273
|
+
// Pure location breadcrumb — no trailing back-hint. The footer's
|
|
23274
|
+
// global `< back` hint already names the walk-back key, so repeating
|
|
23275
|
+
// `← <` on every nested view was redundant header chrome (TUI audit).
|
|
23276
|
+
return viewStack.join(' › ');
|
|
23212
23277
|
}
|
|
23213
23278
|
/**
|
|
23214
23279
|
* Render the nested-repo navigation stack (#931) as a breadcrumb suitable
|
|
23215
23280
|
* for the chrome header. Returns an empty string for a root-only stack
|
|
23216
23281
|
* so the header stays compact when nothing has been pushed.
|
|
23217
23282
|
*
|
|
23218
|
-
* The trailing `← esc` reminds the user that Esc
|
|
23219
|
-
*
|
|
23220
|
-
*
|
|
23283
|
+
* The trailing `← esc` reminds the user that Esc (not `<`) pops the
|
|
23284
|
+
* repo stack — a distinct key from the footer's global `< back`, so
|
|
23285
|
+
* unlike the view breadcrumb (pure location) the repo crumb keeps its
|
|
23286
|
+
* hint. The repo breadcrumb shows in addition to the view breadcrumb when
|
|
23221
23287
|
* both stacks are non-trivial; the chrome layer is responsible for
|
|
23222
23288
|
* laying them out side by side.
|
|
23223
23289
|
*
|
|
@@ -23260,7 +23326,53 @@ function combineLogInkBreadcrumbSegments(repoCrumb, viewCrumb) {
|
|
|
23260
23326
|
}
|
|
23261
23327
|
return '';
|
|
23262
23328
|
}
|
|
23329
|
+
/**
|
|
23330
|
+
* Single-pane pane switcher hint, e.g. `tab: [sidebar] main inspector`.
|
|
23331
|
+
* The active pane (derived from focus: sidebar → sidebar, detail →
|
|
23332
|
+
* inspector, otherwise main) is bracketed so the user can see which of
|
|
23333
|
+
* the three panes Tab will move them away from. Surfaced only on narrow
|
|
23334
|
+
* terminals where the other two panes aren't on screen.
|
|
23335
|
+
*/
|
|
23336
|
+
function singlePaneSwitcherHint(focus) {
|
|
23337
|
+
const active = focus === 'sidebar' ? 'sidebar' : focus === 'detail' ? 'inspector' : 'main';
|
|
23338
|
+
const label = (pane) => (pane === active ? `[${pane}]` : pane);
|
|
23339
|
+
return `tab: ${label('sidebar')} ${label('main')} ${label('inspector')}`;
|
|
23340
|
+
}
|
|
23263
23341
|
function getLogInkFooterHints(options) {
|
|
23342
|
+
const hints = computeLogInkFooterHints(options);
|
|
23343
|
+
// While peeking the sidebar (#1135 v2) the footer shows the snap-back
|
|
23344
|
+
// affordance instead of the switcher — the user is mid-glance, not
|
|
23345
|
+
// navigating, so `v`/Esc returning to main is the relevant action. The
|
|
23346
|
+
// view-hint tail + globals are trimmed to fit the narrow row (see
|
|
23347
|
+
// SINGLE_PANE_GLOBAL_HINTS).
|
|
23348
|
+
if (options.peeking) {
|
|
23349
|
+
return {
|
|
23350
|
+
contextual: ['v/esc → main', ...hints.contextual.slice(0, SINGLE_PANE_VIEW_HINT_LIMIT)],
|
|
23351
|
+
global: SINGLE_PANE_GLOBAL_HINTS,
|
|
23352
|
+
};
|
|
23353
|
+
}
|
|
23354
|
+
// On narrow terminals only one pane is on screen, so prepend a Tab
|
|
23355
|
+
// pane switcher for orientation. The caller (footer) only sets
|
|
23356
|
+
// `singlePane` in the plain per-pane states — while an overlay or
|
|
23357
|
+
// filter owns the screen the visible pane is forced (or input is
|
|
23358
|
+
// captured) and Tab does something else, so the switcher is
|
|
23359
|
+
// suppressed there to avoid showing a pane that isn't active. From the
|
|
23360
|
+
// main / inspector pane we also surface `v peek` so the momentary
|
|
23361
|
+
// sidebar glance is discoverable. The full per-view hint cluster +
|
|
23362
|
+
// global cluster don't fit alongside the switcher at the 80-col floor,
|
|
23363
|
+
// so both are trimmed (the dropped keys stay reachable via `?`).
|
|
23364
|
+
if (options.singlePane) {
|
|
23365
|
+
const lead = options.focus === 'sidebar'
|
|
23366
|
+
? [singlePaneSwitcherHint(options.focus)]
|
|
23367
|
+
: [singlePaneSwitcherHint(options.focus), 'v peek'];
|
|
23368
|
+
return {
|
|
23369
|
+
contextual: [...lead, ...hints.contextual.slice(0, SINGLE_PANE_VIEW_HINT_LIMIT)],
|
|
23370
|
+
global: SINGLE_PANE_GLOBAL_HINTS,
|
|
23371
|
+
};
|
|
23372
|
+
}
|
|
23373
|
+
return hints;
|
|
23374
|
+
}
|
|
23375
|
+
function computeLogInkFooterHints(options) {
|
|
23264
23376
|
if (options.pendingKey) {
|
|
23265
23377
|
const continuations = getLogInkChordContinuations(options.pendingKey);
|
|
23266
23378
|
if (continuations.length > 0) {
|
|
@@ -23377,7 +23489,7 @@ function getLogInkFooterHints(options) {
|
|
|
23377
23489
|
}
|
|
23378
23490
|
if (options.activeView === 'status') {
|
|
23379
23491
|
return {
|
|
23380
|
-
contextual: ['↑/↓ files', 'enter
|
|
23492
|
+
contextual: ['↑/↓ files', 'enter hunks', 'space stage', 'A stage all', 'z revert', 'e/c compose'],
|
|
23381
23493
|
global: NORMAL_GLOBAL_HINTS,
|
|
23382
23494
|
};
|
|
23383
23495
|
}
|
|
@@ -23389,16 +23501,19 @@ function getLogInkFooterHints(options) {
|
|
|
23389
23501
|
const splitToggleHint = options.diffViewMode === 'split' ? 'd unified' : 'd split';
|
|
23390
23502
|
if (options.diffSource === 'stash') {
|
|
23391
23503
|
return {
|
|
23392
|
-
contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk',
|
|
23504
|
+
contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'esc back'],
|
|
23393
23505
|
global: NORMAL_GLOBAL_HINTS,
|
|
23394
23506
|
};
|
|
23395
23507
|
}
|
|
23396
23508
|
if (options.diffSource === 'commit') {
|
|
23397
23509
|
// Commit-diff explore: read-only diff, but `c` cherry-picks the
|
|
23398
23510
|
// cursored file from the commit into the worktree, and `H`
|
|
23399
|
-
// (or `gH` for index) applies just the cursored hunk.
|
|
23511
|
+
// (or `gH` for index) applies just the cursored hunk. `j/k`
|
|
23512
|
+
// line-scroll the diff body; `[`/`]` jump between hunks — the
|
|
23513
|
+
// footer labels match the actual handlers (commit diff has no
|
|
23514
|
+
// per-file `[/]` jump; that's the stash diff).
|
|
23400
23515
|
return {
|
|
23401
|
-
contextual: ['j/k
|
|
23516
|
+
contextual: ['j/k lines', '[/] hunk', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'esc back'],
|
|
23402
23517
|
global: NORMAL_GLOBAL_HINTS,
|
|
23403
23518
|
};
|
|
23404
23519
|
}
|
|
@@ -23411,14 +23526,17 @@ function getLogInkFooterHints(options) {
|
|
|
23411
23526
|
global: NORMAL_GLOBAL_HINTS,
|
|
23412
23527
|
};
|
|
23413
23528
|
}
|
|
23529
|
+
// Worktree (staging) diff. The hunk is the unit of action: ↑/↓ walk
|
|
23530
|
+
// hunks, space stages/unstages the selected one, a stages the whole
|
|
23531
|
+
// file, z discards the hunk.
|
|
23414
23532
|
return {
|
|
23415
|
-
contextual: ['
|
|
23533
|
+
contextual: ['↑/↓ hunk', 'space stage', 'a stage file', 'z discard', 'o edit', 'esc back'],
|
|
23416
23534
|
global: NORMAL_GLOBAL_HINTS,
|
|
23417
23535
|
};
|
|
23418
23536
|
}
|
|
23419
23537
|
if (options.activeView === 'compose') {
|
|
23420
23538
|
return {
|
|
23421
|
-
contextual: ['e edit', '
|
|
23539
|
+
contextual: ['e edit', 'c commit', 'A stage all', '+ stage…', 'S split', 'I AI draft', 'esc back'],
|
|
23422
23540
|
global: NORMAL_GLOBAL_HINTS,
|
|
23423
23541
|
};
|
|
23424
23542
|
}
|
|
@@ -23448,7 +23566,7 @@ function getLogInkFooterHints(options) {
|
|
|
23448
23566
|
}
|
|
23449
23567
|
if (options.activeView === 'stash') {
|
|
23450
23568
|
return {
|
|
23451
|
-
contextual: ['↑/↓ stashes', 'enter diff', 'a apply', 'p pop', '
|
|
23569
|
+
contextual: ['↑/↓ stashes', 'enter diff', 'a/A apply', 'p pop', 'R rename', 'b branch', 'X drop · u undo'],
|
|
23452
23570
|
global: NORMAL_GLOBAL_HINTS,
|
|
23453
23571
|
};
|
|
23454
23572
|
}
|
|
@@ -24839,7 +24957,7 @@ function topOfStack(stack) {
|
|
|
24839
24957
|
}
|
|
24840
24958
|
function withPushedView(state, value) {
|
|
24841
24959
|
if (topOfStack(state.viewStack) === value) {
|
|
24842
|
-
return { ...state, pendingKey: undefined };
|
|
24960
|
+
return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
|
|
24843
24961
|
}
|
|
24844
24962
|
const viewStack = [...state.viewStack, value];
|
|
24845
24963
|
return {
|
|
@@ -24862,12 +24980,15 @@ function withPushedView(state, value) {
|
|
|
24862
24980
|
compareHead: value === 'diff' ? state.compareHead : undefined,
|
|
24863
24981
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
24864
24982
|
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
24983
|
+
// Changing the view is a deliberate destination — cancel any pending
|
|
24984
|
+
// peek return so the user isn't snapped back afterward.
|
|
24985
|
+
peekReturnFocus: undefined,
|
|
24865
24986
|
pendingKey: undefined,
|
|
24866
24987
|
};
|
|
24867
24988
|
}
|
|
24868
24989
|
function withPoppedView(state) {
|
|
24869
24990
|
if (state.viewStack.length <= 1) {
|
|
24870
|
-
return { ...state, pendingKey: undefined };
|
|
24991
|
+
return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
|
|
24871
24992
|
}
|
|
24872
24993
|
const viewStack = state.viewStack.slice(0, -1);
|
|
24873
24994
|
const next = topOfStack(viewStack);
|
|
@@ -24894,6 +25015,8 @@ function withPoppedView(state) {
|
|
|
24894
25015
|
compareHead: next === 'diff' ? state.compareHead : undefined,
|
|
24895
25016
|
pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
|
|
24896
25017
|
statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
|
|
25018
|
+
// Backing out is a deliberate navigation — cancel any peek return.
|
|
25019
|
+
peekReturnFocus: undefined,
|
|
24897
25020
|
pendingKey: undefined,
|
|
24898
25021
|
};
|
|
24899
25022
|
}
|
|
@@ -25006,7 +25129,7 @@ function withPoppedRepoFrame(state) {
|
|
|
25006
25129
|
}
|
|
25007
25130
|
function withReplacedView(state, value) {
|
|
25008
25131
|
if (topOfStack(state.viewStack) === value) {
|
|
25009
|
-
return { ...state, pendingKey: undefined };
|
|
25132
|
+
return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
|
|
25010
25133
|
}
|
|
25011
25134
|
const viewStack = [...state.viewStack.slice(0, -1), value];
|
|
25012
25135
|
return {
|
|
@@ -25020,6 +25143,9 @@ function withReplacedView(state, value) {
|
|
|
25020
25143
|
compareHead: value === 'diff' ? state.compareHead : undefined,
|
|
25021
25144
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
25022
25145
|
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
25146
|
+
// Changing the view is a deliberate destination — cancel any pending
|
|
25147
|
+
// peek return so the user isn't snapped back afterward.
|
|
25148
|
+
peekReturnFocus: undefined,
|
|
25023
25149
|
pendingKey: undefined,
|
|
25024
25150
|
};
|
|
25025
25151
|
}
|
|
@@ -25253,6 +25379,9 @@ function applyLogInkAction(state, action) {
|
|
|
25253
25379
|
// from 'commits' should always land back on a real file when
|
|
25254
25380
|
// the user returns.
|
|
25255
25381
|
statusGroupHeaderFocused: false,
|
|
25382
|
+
// Explicit focus cycle cancels a pending peek return — the
|
|
25383
|
+
// user has taken manual control of the focus.
|
|
25384
|
+
peekReturnFocus: undefined,
|
|
25256
25385
|
pendingKey: undefined,
|
|
25257
25386
|
};
|
|
25258
25387
|
case 'focusPrevious':
|
|
@@ -25261,6 +25390,7 @@ function applyLogInkAction(state, action) {
|
|
|
25261
25390
|
focus: cycleValue(FOCUS_ORDER, state.focus, -1),
|
|
25262
25391
|
sidebarHeaderFocused: false,
|
|
25263
25392
|
statusGroupHeaderFocused: false,
|
|
25393
|
+
peekReturnFocus: undefined,
|
|
25264
25394
|
pendingKey: undefined,
|
|
25265
25395
|
};
|
|
25266
25396
|
case 'move':
|
|
@@ -25758,8 +25888,35 @@ function applyLogInkAction(state, action) {
|
|
|
25758
25888
|
// the status view — clear when focus moves away so a
|
|
25759
25889
|
// re-entry starts on a real file.
|
|
25760
25890
|
statusGroupHeaderFocused: action.value === 'commits' ? state.statusGroupHeaderFocused : false,
|
|
25891
|
+
// An explicit focus set cancels a pending peek return.
|
|
25892
|
+
peekReturnFocus: undefined,
|
|
25893
|
+
pendingKey: undefined,
|
|
25894
|
+
};
|
|
25895
|
+
case 'togglePeek': {
|
|
25896
|
+
// Peek = "focus the sidebar with a return ticket." Closing returns
|
|
25897
|
+
// to the stashed focus; opening (only from a non-sidebar pane)
|
|
25898
|
+
// stashes the current focus and jumps to the sidebar. The render
|
|
25899
|
+
// layer needs no special case — `focus: 'sidebar'` already drives
|
|
25900
|
+
// the single-pane layout to show the sidebar full-width.
|
|
25901
|
+
if (state.peekReturnFocus !== undefined) {
|
|
25902
|
+
return {
|
|
25903
|
+
...state,
|
|
25904
|
+
focus: state.peekReturnFocus,
|
|
25905
|
+
peekReturnFocus: undefined,
|
|
25906
|
+
sidebarHeaderFocused: false,
|
|
25907
|
+
pendingKey: undefined,
|
|
25908
|
+
};
|
|
25909
|
+
}
|
|
25910
|
+
if (state.focus === 'sidebar') {
|
|
25911
|
+
return state;
|
|
25912
|
+
}
|
|
25913
|
+
return {
|
|
25914
|
+
...state,
|
|
25915
|
+
focus: 'sidebar',
|
|
25916
|
+
peekReturnFocus: state.focus,
|
|
25761
25917
|
pendingKey: undefined,
|
|
25762
25918
|
};
|
|
25919
|
+
}
|
|
25763
25920
|
case 'setPendingKey':
|
|
25764
25921
|
return {
|
|
25765
25922
|
...state,
|
|
@@ -26626,6 +26783,12 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
26626
26783
|
return [action({ type: 'toggleGraph' })];
|
|
26627
26784
|
case 'navigateHome':
|
|
26628
26785
|
return [action({ type: 'navigateHome' })];
|
|
26786
|
+
case 'createStash':
|
|
26787
|
+
return [action({
|
|
26788
|
+
type: 'openInputPrompt',
|
|
26789
|
+
kind: 'create-stash',
|
|
26790
|
+
label: 'Stash message (empty = WIP)',
|
|
26791
|
+
})];
|
|
26629
26792
|
case 'navigateStatus':
|
|
26630
26793
|
return [action({ type: 'pushView', value: 'status' })];
|
|
26631
26794
|
case 'navigateDiff':
|
|
@@ -26731,6 +26894,14 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
26731
26894
|
// Runtime resolves the cursored worktree file and opens the picker
|
|
26732
26895
|
// (no-ops with a warning when there's no file under the cursor).
|
|
26733
26896
|
return [{ type: 'openGitignorePicker' }];
|
|
26897
|
+
case 'stageAll':
|
|
26898
|
+
return [{ type: 'runWorkflowAction', id: 'stage-all' }];
|
|
26899
|
+
case 'stagePathspec':
|
|
26900
|
+
return [action({
|
|
26901
|
+
type: 'openInputPrompt',
|
|
26902
|
+
kind: 'stage-pathspec',
|
|
26903
|
+
label: 'Stage pathspec (e.g. `.`, `src/`, `*.ts`, or a space-separated list)',
|
|
26904
|
+
})];
|
|
26734
26905
|
case 'workflowDeleteBranch':
|
|
26735
26906
|
case 'workflowDeleteTag':
|
|
26736
26907
|
case 'workflowDropStash':
|
|
@@ -26821,6 +26992,15 @@ function submitInputPrompt(state) {
|
|
|
26821
26992
|
if (!state.inputPrompt)
|
|
26822
26993
|
return [];
|
|
26823
26994
|
const value = state.inputPrompt.value.trim();
|
|
26995
|
+
// create-stash allows an EMPTY value → quick WIP stash (git supplies its
|
|
26996
|
+
// own "WIP on <branch>" subject). Handled before the generic empty guard
|
|
26997
|
+
// so an empty stash prompt commits a WIP stash instead of bouncing.
|
|
26998
|
+
if (state.inputPrompt.kind === 'create-stash') {
|
|
26999
|
+
return [
|
|
27000
|
+
{ type: 'runWorkflowAction', id: 'create-stash', payload: value },
|
|
27001
|
+
action({ type: 'closeInputPrompt' }),
|
|
27002
|
+
];
|
|
27003
|
+
}
|
|
26824
27004
|
if (!value) {
|
|
26825
27005
|
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
|
|
26826
27006
|
}
|
|
@@ -26830,6 +27010,12 @@ function submitInputPrompt(state) {
|
|
|
26830
27010
|
action({ type: 'closeInputPrompt' }),
|
|
26831
27011
|
];
|
|
26832
27012
|
}
|
|
27013
|
+
if (state.inputPrompt.kind === 'stage-pathspec') {
|
|
27014
|
+
return [
|
|
27015
|
+
{ type: 'runWorkflowAction', id: 'stage-pathspec', payload: value },
|
|
27016
|
+
action({ type: 'closeInputPrompt' }),
|
|
27017
|
+
];
|
|
27018
|
+
}
|
|
26833
27019
|
if (state.inputPrompt.kind === 'reset-mode') {
|
|
26834
27020
|
const mode = value.toLowerCase();
|
|
26835
27021
|
if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
|
|
@@ -27056,7 +27242,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27056
27242
|
// draft was pending should see the original `R` / Esc semantics of
|
|
27057
27243
|
// wherever they are now.
|
|
27058
27244
|
if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
|
|
27059
|
-
|
|
27245
|
+
// `R` or `Enter` accept the swap (the AI draft becomes the new
|
|
27246
|
+
// content); `Enter` is the natural "yes, use it" confirmation.
|
|
27247
|
+
if ((inputValue === 'R' && !key.ctrl && !key.meta) || key.return) {
|
|
27060
27248
|
return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
|
|
27061
27249
|
}
|
|
27062
27250
|
if (key.escape) {
|
|
@@ -27442,6 +27630,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27442
27630
|
}
|
|
27443
27631
|
return events;
|
|
27444
27632
|
}
|
|
27633
|
+
// #1135 v2 — while peeking the sidebar, Esc or the peek key (`v`)
|
|
27634
|
+
// snaps back to the pane the user came from. Placed before the
|
|
27635
|
+
// generic Esc → popView so a peek glance returns to main rather than
|
|
27636
|
+
// walking the view stack. Every other key falls through to normal
|
|
27637
|
+
// handling (focus is on the sidebar during a peek), so ←/→ and ↑/↓
|
|
27638
|
+
// browse the sidebar and keep the peek open until an explicit exit.
|
|
27639
|
+
if (state.peekReturnFocus !== undefined && (key.escape || inputValue === 'v')) {
|
|
27640
|
+
return [action({ type: 'togglePeek' })];
|
|
27641
|
+
}
|
|
27445
27642
|
if (key.escape && state.viewStack.length > 1) {
|
|
27446
27643
|
return [action({ type: 'popView' })];
|
|
27447
27644
|
}
|
|
@@ -27508,6 +27705,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27508
27705
|
action({ type: 'setStatus', value: 'jumped to stash' }),
|
|
27509
27706
|
];
|
|
27510
27707
|
}
|
|
27708
|
+
// `gZ` chord: stash all changes from ANY view — including status / diff /
|
|
27709
|
+
// compose, where bare `S` is claimed by the commit-split flow. Mnemonic
|
|
27710
|
+
// pair with `gz` (jump to the stash *view*). Opens the same message
|
|
27711
|
+
// prompt; an empty message creates a quick WIP stash.
|
|
27712
|
+
if (state.pendingKey === 'g' && inputValue === 'Z') {
|
|
27713
|
+
return [action({
|
|
27714
|
+
type: 'openInputPrompt',
|
|
27715
|
+
kind: 'create-stash',
|
|
27716
|
+
label: 'Stash message (empty = WIP)',
|
|
27717
|
+
})];
|
|
27718
|
+
}
|
|
27511
27719
|
if (state.pendingKey === 'g' && inputValue === 'w') {
|
|
27512
27720
|
return [
|
|
27513
27721
|
action({ type: 'pushView', value: 'worktrees' }),
|
|
@@ -27871,6 +28079,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27871
28079
|
if (key.tab) {
|
|
27872
28080
|
return [action({ type: key.shift ? 'focusPrevious' : 'focusNext' })];
|
|
27873
28081
|
}
|
|
28082
|
+
// #1135 v2 — `v` peeks the sidebar from the main / inspector pane on
|
|
28083
|
+
// narrow (single-pane) terminals: a momentary glance that snaps back
|
|
28084
|
+
// with `v` / Esc (handled above once peeking). No-op in the three-pane
|
|
28085
|
+
// layout (every pane is already on screen) and from the sidebar itself.
|
|
28086
|
+
if (inputValue === 'v' && context.singlePane && state.focus !== 'sidebar') {
|
|
28087
|
+
return [action({ type: 'togglePeek' })];
|
|
28088
|
+
}
|
|
27874
28089
|
// ←/→ on the sidebar switch tabs (Status ↔ Branches ↔ Tags ↔
|
|
27875
28090
|
// Stashes ↔ Worktrees) — the horizontal axis is "between tabs", the
|
|
27876
28091
|
// vertical axis (↑/↓ below) is "within the active tab's items".
|
|
@@ -27956,10 +28171,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27956
28171
|
fileCount: context.worktreeFileCount,
|
|
27957
28172
|
})];
|
|
27958
28173
|
}
|
|
27959
|
-
//
|
|
27960
|
-
//
|
|
27961
|
-
//
|
|
27962
|
-
//
|
|
28174
|
+
// Worktree (staging) diff: ↑/↓ move between hunks — the hunk is the
|
|
28175
|
+
// unit you stage, so the cursor walks hunks (auto-scrolling to the
|
|
28176
|
+
// selected one). Single-hunk files fall through to line-scroll so a
|
|
28177
|
+
// long lone hunk stays readable; `[`/`]` remain hunk-jump aliases.
|
|
28178
|
+
if (state.activeView === 'diff' && (context.worktreeHunkOffsets?.length ?? 0) > 1) {
|
|
28179
|
+
return [action({
|
|
28180
|
+
type: 'jumpWorktreeHunk',
|
|
28181
|
+
delta: -1,
|
|
28182
|
+
hunkOffsets: context.worktreeHunkOffsets,
|
|
28183
|
+
})];
|
|
28184
|
+
}
|
|
27963
28185
|
if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
|
|
27964
28186
|
return [action({
|
|
27965
28187
|
type: 'pageWorktreeDiff',
|
|
@@ -28074,6 +28296,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28074
28296
|
fileCount: context.worktreeFileCount,
|
|
28075
28297
|
})];
|
|
28076
28298
|
}
|
|
28299
|
+
// Worktree (staging) diff: ↓ walks to the next hunk (see the ↑
|
|
28300
|
+
// handler). Multi-hunk only; single-hunk files line-scroll.
|
|
28301
|
+
if (state.activeView === 'diff' && (context.worktreeHunkOffsets?.length ?? 0) > 1) {
|
|
28302
|
+
return [action({
|
|
28303
|
+
type: 'jumpWorktreeHunk',
|
|
28304
|
+
delta: 1,
|
|
28305
|
+
hunkOffsets: context.worktreeHunkOffsets,
|
|
28306
|
+
})];
|
|
28307
|
+
}
|
|
28077
28308
|
if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
|
|
28078
28309
|
return [action({
|
|
28079
28310
|
type: 'pageWorktreeDiff',
|
|
@@ -28480,6 +28711,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28480
28711
|
if (inputValue === 'p' && isStashActionTarget(state) && context.stashCount) {
|
|
28481
28712
|
return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
|
|
28482
28713
|
}
|
|
28714
|
+
// `A` applies restoring the staged/unstaged split (`git stash apply
|
|
28715
|
+
// --index`) — distinct from `a` (plain apply).
|
|
28716
|
+
if (inputValue === 'A' && isStashActionTarget(state) && context.stashCount) {
|
|
28717
|
+
return [{ type: 'runWorkflowAction', id: 'apply-stash-index' }];
|
|
28718
|
+
}
|
|
28719
|
+
// `b` turns the cursored stash into a new branch (`git stash branch`).
|
|
28720
|
+
if (inputValue === 'b' && isStashActionTarget(state) && context.stashCount) {
|
|
28721
|
+
return [action({ type: 'openInputPrompt', kind: 'stash-branch', label: 'New branch from stash' })];
|
|
28722
|
+
}
|
|
28723
|
+
// `R` renames the cursored stash (store-under-new-message + drop old).
|
|
28724
|
+
if (inputValue === 'R' && isStashActionTarget(state) && context.stashCount) {
|
|
28725
|
+
return [action({ type: 'openInputPrompt', kind: 'rename-stash', label: 'Rename stash' })];
|
|
28726
|
+
}
|
|
28727
|
+
// `u` undoes the last drop. Gated on the view, NOT the count, so it
|
|
28728
|
+
// still works right after you drop your only stash (the list is empty
|
|
28729
|
+
// but the dropped commit is recoverable by hash).
|
|
28730
|
+
if (inputValue === 'u' && isStashActionTarget(state)) {
|
|
28731
|
+
return [{ type: 'runWorkflowAction', id: 'undo-drop-stash' }];
|
|
28732
|
+
}
|
|
28483
28733
|
// Per-view tag action: `P` pushes the selected tag to origin. Letter
|
|
28484
28734
|
// is scoped to the tags target so it doesn't collide with `p` for
|
|
28485
28735
|
// pop-stash. Note: this also takes precedence over the global
|
|
@@ -28708,7 +28958,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28708
28958
|
return [action({
|
|
28709
28959
|
type: 'openInputPrompt',
|
|
28710
28960
|
kind: 'create-stash',
|
|
28711
|
-
label: 'Stash message',
|
|
28961
|
+
label: 'Stash message (empty = WIP)',
|
|
28712
28962
|
})];
|
|
28713
28963
|
}
|
|
28714
28964
|
// `o` opens the file under the cursor in $EDITOR. Available on the
|
|
@@ -28977,9 +29227,35 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28977
29227
|
if (inputValue === ' ' && state.activeView === 'status' && context.worktreeFileCount) {
|
|
28978
29228
|
return [{ type: 'toggleSelectedFileStage' }];
|
|
28979
29229
|
}
|
|
29230
|
+
// `A` — stage everything (git add -A); `+` — stage by typed pathspec.
|
|
29231
|
+
// Both available from the status AND compose views so you can stage
|
|
29232
|
+
// without leaving the message editor.
|
|
29233
|
+
if (inputValue === 'A' && (state.activeView === 'status' || state.activeView === 'compose')) {
|
|
29234
|
+
return [{ type: 'runWorkflowAction', id: 'stage-all' }];
|
|
29235
|
+
}
|
|
29236
|
+
if (inputValue === '+' && (state.activeView === 'status' || state.activeView === 'compose')) {
|
|
29237
|
+
return [action({
|
|
29238
|
+
type: 'openInputPrompt',
|
|
29239
|
+
kind: 'stage-pathspec',
|
|
29240
|
+
label: 'Stage pathspec (e.g. `.`, `src/`, `*.ts`, or a space-separated list)',
|
|
29241
|
+
})];
|
|
29242
|
+
}
|
|
28980
29243
|
if (inputValue === ' ' && state.activeView === 'diff' && context.worktreeHunkOffsets?.length) {
|
|
28981
29244
|
return [{ type: 'toggleSelectedHunkStage' }];
|
|
28982
29245
|
}
|
|
29246
|
+
// Worktree diff with no hunks (a new/untracked file) — `space` stages
|
|
29247
|
+
// the whole file, since there's nothing to partial-stage.
|
|
29248
|
+
if (inputValue === ' ' &&
|
|
29249
|
+
state.activeView === 'diff' &&
|
|
29250
|
+
state.diffSource === 'worktree' &&
|
|
29251
|
+
!context.worktreeHunkOffsets?.length) {
|
|
29252
|
+
return [{ type: 'toggleSelectedFileStage' }];
|
|
29253
|
+
}
|
|
29254
|
+
// `a` stages/unstages the WHOLE current file from the staging diff —
|
|
29255
|
+
// an escape hatch out of hunk-by-hunk back to all-or-nothing.
|
|
29256
|
+
if (inputValue === 'a' && state.activeView === 'diff' && state.diffSource === 'worktree') {
|
|
29257
|
+
return [{ type: 'toggleSelectedFileStage' }];
|
|
29258
|
+
}
|
|
28983
29259
|
if (inputValue === 'z' && state.activeView === 'status' && context.worktreeFileCount) {
|
|
28984
29260
|
return [action({ type: 'setPendingMutationConfirmation', value: 'revert-file' })];
|
|
28985
29261
|
}
|
|
@@ -29754,21 +30030,22 @@ const INSPECTOR_TABBED_BELOW_ROWS = 28;
|
|
|
29754
30030
|
* wide >= 160 — plenty of room; keep absolute dates
|
|
29755
30031
|
* normal >= 120 — relative dates save 8-ish cells without hiding info
|
|
29756
30032
|
* tight >= 100 — drop date entirely; subject + refs are the priority
|
|
29757
|
-
* rail < 100 —
|
|
29758
|
-
*
|
|
30033
|
+
* rail < 100 — history rows stack to two lines; the UI also drops
|
|
30034
|
+
* to single-pane mode (see `LAYOUT_SINGLE_PANE_BELOW`)
|
|
29759
30035
|
*/
|
|
29760
30036
|
const LAYOUT_TIGHT_BELOW = 120;
|
|
29761
30037
|
const LAYOUT_NORMAL_BELOW = 160;
|
|
29762
30038
|
const LAYOUT_RAIL_BELOW = 100;
|
|
29763
30039
|
/**
|
|
29764
|
-
*
|
|
29765
|
-
*
|
|
29766
|
-
*
|
|
29767
|
-
*
|
|
30040
|
+
* Width below which the three-panel layout can't tile without starving
|
|
30041
|
+
* every pane, so the UI shows exactly one full-width pane (the focused
|
|
30042
|
+
* one) and Tab cycles which pane is visible. Coincides with the `rail`
|
|
30043
|
+
* density breakpoint — single-pane mode replaces the old 8-cell icon
|
|
30044
|
+
* rails that used to render at this width.
|
|
29768
30045
|
*/
|
|
29769
|
-
const
|
|
30046
|
+
const LAYOUT_SINGLE_PANE_BELOW = LAYOUT_RAIL_BELOW;
|
|
29770
30047
|
const SIDEBAR_AT_REST_BY_TIER = {
|
|
29771
|
-
rail: { min: 22, max: 28, fraction: 0.24 }, // unused —
|
|
30048
|
+
rail: { min: 22, max: 28, fraction: 0.24 }, // unused at rest — single-pane mode overrides the width
|
|
29772
30049
|
tight: { min: 22, max: 28, fraction: 0.24 },
|
|
29773
30050
|
normal: { min: 22, max: 30, fraction: 0.22 },
|
|
29774
30051
|
wide: { min: 28, max: 32, fraction: 0.20 },
|
|
@@ -29787,14 +30064,25 @@ function getLogInkLayout(input) {
|
|
|
29787
30064
|
: columns >= LAYOUT_RAIL_BELOW
|
|
29788
30065
|
? 'tight'
|
|
29789
30066
|
: 'rail';
|
|
29790
|
-
//
|
|
29791
|
-
//
|
|
29792
|
-
//
|
|
29793
|
-
//
|
|
29794
|
-
//
|
|
29795
|
-
|
|
29796
|
-
|
|
29797
|
-
|
|
30067
|
+
// Below the single-pane breakpoint the three-panel layout can't tile
|
|
30068
|
+
// without starving every pane, so we show exactly one full-width pane
|
|
30069
|
+
// — the focused one — and Tab cycles which pane is visible. This
|
|
30070
|
+
// replaces the retired 8-cell icon rails (an 8-cell stub showed a tab
|
|
30071
|
+
// glyph + count and nothing actionable).
|
|
30072
|
+
const singlePane = columns < LAYOUT_SINGLE_PANE_BELOW;
|
|
30073
|
+
// Which pane shows in single-pane mode. Defaults to the focused pane
|
|
30074
|
+
// (focus and visibility coalesce, so the existing Tab focus cycle
|
|
30075
|
+
// drives it). An active overlay can force a specific pane via
|
|
30076
|
+
// `forcedPane` so its surface isn't hidden behind whatever pane focus
|
|
30077
|
+
// points at.
|
|
30078
|
+
const focusPane = input.sidebarFocused
|
|
30079
|
+
? 'sidebar'
|
|
30080
|
+
: input.inspectorFocused
|
|
30081
|
+
? 'inspector'
|
|
30082
|
+
: 'main';
|
|
30083
|
+
const visiblePane = singlePane
|
|
30084
|
+
? input.forcedPane ?? focusPane
|
|
30085
|
+
: focusPane;
|
|
29798
30086
|
// Inspector width — at rest 20-32 cells (~22% of width), focused
|
|
29799
30087
|
// 36-60 cells (~40% of width). Narrow rest state keeps the commit
|
|
29800
30088
|
// graph dominant; focus expansion gives the inspector room for long
|
|
@@ -29806,42 +30094,48 @@ function getLogInkLayout(input) {
|
|
|
29806
30094
|
// "Move focus...". Capped at 100 cells so a wide terminal doesn't
|
|
29807
30095
|
// waste an absurd amount of horizontal space on the cheat sheet.
|
|
29808
30096
|
//
|
|
29809
|
-
//
|
|
29810
|
-
//
|
|
29811
|
-
// intent to read the panel.
|
|
30097
|
+
// (In single-pane mode these three-panel widths are recomputed below
|
|
30098
|
+
// so the visible pane gets the full terminal.)
|
|
29812
30099
|
const detailWidth = input.helpOverlayActive
|
|
29813
30100
|
? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
|
|
29814
30101
|
: input.inspectorFocused
|
|
29815
30102
|
? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
|
|
29816
|
-
:
|
|
29817
|
-
? LAYOUT_RAIL_PANEL_WIDTH
|
|
29818
|
-
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
30103
|
+
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
29819
30104
|
// Sidebar at rest is tier-aware (see `SIDEBAR_AT_REST_BY_TIER`):
|
|
29820
30105
|
// tight stays compact (22-28), normal shrinks slightly (22-30),
|
|
29821
30106
|
// wide grows naturally (28-48) so the side panel doesn't get pinned
|
|
29822
30107
|
// at an arbitrary cap on big terminals while the main panel hogs
|
|
29823
30108
|
// 80% of the width. Focused: 32-50 cells (~36% of width),
|
|
29824
30109
|
// regardless of tier — deliberate user intent to read the sidebar
|
|
29825
|
-
// deserves the extra width.
|
|
29826
|
-
// collapses to a fixed 8-cell strip with tab glyphs only.
|
|
30110
|
+
// deserves the extra width.
|
|
29827
30111
|
const sidebarWidth = input.sidebarFocused
|
|
29828
30112
|
? Math.max(32, Math.min(50, Math.floor(columns * 0.36)))
|
|
29829
|
-
:
|
|
29830
|
-
|
|
29831
|
-
|
|
30113
|
+
: calcSidebarAtRestWidth(columns, density);
|
|
30114
|
+
// Single-pane mode: exactly one pane renders, full-width; the other
|
|
30115
|
+
// two are hidden (width 0), not railed. Above the breakpoint the
|
|
30116
|
+
// three panels tile flush across the terminal.
|
|
30117
|
+
const paneWidths = singlePane
|
|
30118
|
+
? {
|
|
30119
|
+
sidebarWidth: visiblePane === 'sidebar' ? columns : 0,
|
|
30120
|
+
mainPanelWidth: visiblePane === 'main' ? columns : 0,
|
|
30121
|
+
detailWidth: visiblePane === 'inspector' ? columns : 0,
|
|
30122
|
+
}
|
|
30123
|
+
: {
|
|
30124
|
+
sidebarWidth,
|
|
30125
|
+
mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
|
|
30126
|
+
detailWidth,
|
|
30127
|
+
};
|
|
29832
30128
|
return {
|
|
29833
30129
|
bodyRows: Math.max(8, rows - 5),
|
|
29834
30130
|
columns,
|
|
29835
|
-
detailWidth,
|
|
29836
|
-
mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
|
|
29837
30131
|
rows,
|
|
29838
|
-
sidebarWidth,
|
|
29839
30132
|
tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
|
|
29840
30133
|
inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
|
|
29841
30134
|
density,
|
|
29842
|
-
|
|
29843
|
-
|
|
30135
|
+
singlePane,
|
|
30136
|
+
visiblePane,
|
|
29844
30137
|
historyRowMode: density === 'rail' ? 'stacked' : 'single',
|
|
30138
|
+
...paneWidths,
|
|
29845
30139
|
};
|
|
29846
30140
|
}
|
|
29847
30141
|
|
|
@@ -30890,6 +31184,45 @@ async function highlightDiffCode(filePath, lines) {
|
|
|
30890
31184
|
return result;
|
|
30891
31185
|
}
|
|
30892
31186
|
|
|
31187
|
+
/**
|
|
31188
|
+
* Humanize raw AI-provider / LangChain error strings into a short,
|
|
31189
|
+
* actionable line for the compose surface.
|
|
31190
|
+
*
|
|
31191
|
+
* The underlying errors are verbose and developer-facing — e.g.
|
|
31192
|
+
* `executeChain: Chain execution failed: 429 You exceeded your current
|
|
31193
|
+
* quota …`. We classify the common failure modes (rate limit, auth,
|
|
31194
|
+
* network, context length) into a concise message that tells the user
|
|
31195
|
+
* what happened and what to do, and fall back to the original (trimmed)
|
|
31196
|
+
* text for anything we don't recognize. Pure + tested.
|
|
31197
|
+
*/
|
|
31198
|
+
function humanizeAiError(raw) {
|
|
31199
|
+
const message = (raw || '').trim();
|
|
31200
|
+
if (!message)
|
|
31201
|
+
return 'AI request failed.';
|
|
31202
|
+
const lower = message.toLowerCase();
|
|
31203
|
+
// Rate limit / quota — the 429 in the screenshot.
|
|
31204
|
+
if (/\b429\b/.test(message) || /rate.?limit|too many requests|exceeded your current quota|quota/i.test(lower)) {
|
|
31205
|
+
return 'Rate limited by your AI provider (429) — too many requests or quota exceeded. Wait a moment, then press I to retry.';
|
|
31206
|
+
}
|
|
31207
|
+
// Auth / API key problems.
|
|
31208
|
+
if (/\b401\b|\b403\b/.test(message) || /unauthor|forbidden|invalid api key|incorrect api key|no api key|authentication/i.test(lower)) {
|
|
31209
|
+
return 'AI provider rejected the request — check your API key (run `coco init`, or press gK to edit the global config).';
|
|
31210
|
+
}
|
|
31211
|
+
// Context window overflow.
|
|
31212
|
+
if (/context length|maximum context|too many tokens|reduce the length|context_length_exceeded/i.test(lower)) {
|
|
31213
|
+
return 'The staged diff is too large for the model’s context window — stage fewer changes (or split the commit) and retry with I.';
|
|
31214
|
+
}
|
|
31215
|
+
// Network / connectivity.
|
|
31216
|
+
if (/etimedout|econnreset|enotfound|econnrefused|network error|fetch failed|socket hang up|timeout/i.test(lower)) {
|
|
31217
|
+
return 'Network error reaching the AI provider — check your connection, then press I to retry.';
|
|
31218
|
+
}
|
|
31219
|
+
// Unknown: strip the noisy `executeChain: Chain execution failed:`
|
|
31220
|
+
// prefix if present so the meaningful part leads, and keep it to one
|
|
31221
|
+
// line so it doesn't blow out the panel.
|
|
31222
|
+
const stripped = message.replace(/^.*?chain execution failed:\s*/i, '').trim() || message;
|
|
31223
|
+
return stripped.split('\n')[0];
|
|
31224
|
+
}
|
|
31225
|
+
|
|
30893
31226
|
async function runAction$4(action, successMessage) {
|
|
30894
31227
|
try {
|
|
30895
31228
|
await action();
|
|
@@ -30933,15 +31266,104 @@ async function runAction$3(action, successMessage) {
|
|
|
30933
31266
|
};
|
|
30934
31267
|
}
|
|
30935
31268
|
}
|
|
30936
|
-
function createStash(git, message) {
|
|
31269
|
+
function createStash(git, message, options = {}) {
|
|
30937
31270
|
const trimmedMessage = message.trim();
|
|
30938
|
-
|
|
30939
|
-
|
|
30940
|
-
|
|
30941
|
-
|
|
30942
|
-
|
|
31271
|
+
const args = ['stash', 'push'];
|
|
31272
|
+
// `--staged` is index-only, so untracked / `--keep-index` don't apply;
|
|
31273
|
+
// every other mode includes untracked (`-u`). `--keep-index` leaves the
|
|
31274
|
+
// index populated for an immediate follow-up commit.
|
|
31275
|
+
if (options.stagedOnly) {
|
|
31276
|
+
args.push('--staged');
|
|
31277
|
+
}
|
|
31278
|
+
else {
|
|
31279
|
+
args.push('-u');
|
|
31280
|
+
if (options.keepIndex)
|
|
31281
|
+
args.push('--keep-index');
|
|
31282
|
+
}
|
|
31283
|
+
if (trimmedMessage)
|
|
31284
|
+
args.push('-m', trimmedMessage);
|
|
31285
|
+
const paths = options.pathspec?.trim();
|
|
31286
|
+
if (paths)
|
|
31287
|
+
args.push('--', ...paths.split(/\s+/));
|
|
31288
|
+
const what = options.stagedOnly
|
|
31289
|
+
? 'staged changes'
|
|
31290
|
+
: paths
|
|
31291
|
+
? `“${paths}”`
|
|
31292
|
+
: options.keepIndex
|
|
31293
|
+
? 'changes (index kept)'
|
|
31294
|
+
: '';
|
|
31295
|
+
const success = trimmedMessage
|
|
31296
|
+
? `Created stash: ${trimmedMessage}`
|
|
31297
|
+
: what
|
|
31298
|
+
? `Stashed ${what}`
|
|
31299
|
+
: 'Created WIP stash';
|
|
31300
|
+
return runAction$3(() => git.raw(args), success);
|
|
31301
|
+
}
|
|
31302
|
+
/**
|
|
31303
|
+
* Apply a stash while restoring the original staged/unstaged split via
|
|
31304
|
+
* `--index`. Faithfully reinstates what was staged at stash time; git
|
|
31305
|
+
* errors (surfaced to the user) if the index can no longer be replayed,
|
|
31306
|
+
* in which case plain `applyStash` is the fallback.
|
|
31307
|
+
*/
|
|
31308
|
+
function applyStashKeepIndex(git, stash) {
|
|
31309
|
+
return runAction$3(() => git.raw(['stash', 'apply', '--index', stash.ref]), `Applied ${stash.ref} (index restored)`);
|
|
31310
|
+
}
|
|
31311
|
+
/**
|
|
31312
|
+
* Create a new branch from a stash's base commit, apply the stash onto
|
|
31313
|
+
* it, and drop the stash on success — `git stash branch`. The canonical
|
|
31314
|
+
* recovery when a stash no longer applies cleanly onto the current
|
|
31315
|
+
* branch (the branch starts at the exact commit the stash was made on).
|
|
31316
|
+
*/
|
|
31317
|
+
function stashBranch(git, stash, branchName) {
|
|
31318
|
+
const trimmed = branchName.trim();
|
|
31319
|
+
if (!trimmed) {
|
|
31320
|
+
return Promise.resolve({ ok: false, message: 'Cancelled: empty branch name.' });
|
|
31321
|
+
}
|
|
31322
|
+
return runAction$3(() => git.raw(['stash', 'branch', trimmed, stash.ref]), `Created branch ${trimmed} from ${stash.ref}`);
|
|
31323
|
+
}
|
|
31324
|
+
/**
|
|
31325
|
+
* Rename a stash. Git has no native rename, so: drop the original entry,
|
|
31326
|
+
* then re-store the SAME commit under the new message.
|
|
31327
|
+
*
|
|
31328
|
+
* Order matters — and it's the OPPOSITE of what you'd guess. `git stash
|
|
31329
|
+
* store` SILENTLY NO-OPS when the commit is already referenced in the
|
|
31330
|
+
* stash reflog (verified empirically), so storing first does nothing and
|
|
31331
|
+
* a follow-up drop removes the wrong entry. Dropping first removes the
|
|
31332
|
+
* reflog reference (the commit object survives), so the subsequent
|
|
31333
|
+
* `store` actually re-adds it — landing at `stash@{0}` with the new
|
|
31334
|
+
* message. The commit is captured by hash beforehand, so the drop→store
|
|
31335
|
+
* window can't lose it.
|
|
31336
|
+
*/
|
|
31337
|
+
function renameStash(git, stash, newMessage) {
|
|
31338
|
+
const trimmed = newMessage.trim();
|
|
31339
|
+
if (!trimmed) {
|
|
31340
|
+
return Promise.resolve({ ok: false, message: 'Rename cancelled: empty message.' });
|
|
31341
|
+
}
|
|
31342
|
+
if (!stash.hash) {
|
|
31343
|
+
return Promise.resolve({ ok: false, message: 'Cannot rename: stash commit hash unavailable.' });
|
|
31344
|
+
}
|
|
31345
|
+
// Preserve git's `On <branch>: <subject>` convention so the renamed
|
|
31346
|
+
// stash keeps its origin-branch context. The list + inspector parse the
|
|
31347
|
+
// branch out of that prefix (`parseStashSubject`); a bare message would
|
|
31348
|
+
// render `on <unknown>`. Falls back to the bare message when the branch
|
|
31349
|
+
// is unknown so we never store a misleading `On <unknown>:`.
|
|
31350
|
+
const branch = stash.branch && stash.branch !== '<unknown>' ? stash.branch : '';
|
|
31351
|
+
const storedMessage = branch ? `On ${branch}: ${trimmed}` : trimmed;
|
|
31352
|
+
return runAction$3(async () => {
|
|
31353
|
+
await git.raw(['stash', 'drop', stash.ref]);
|
|
31354
|
+
await git.raw(['stash', 'store', '-m', storedMessage, stash.hash]);
|
|
31355
|
+
}, `Renamed ${stash.ref} → ${trimmed}`);
|
|
31356
|
+
}
|
|
31357
|
+
/**
|
|
31358
|
+
* Re-store a previously dropped stash by its commit hash — the undo for
|
|
31359
|
+
* a `dropStash`. The dropped stash's commit stays in the object database
|
|
31360
|
+
* until git gc, so storing it back recreates the entry (at `stash@{0}`).
|
|
31361
|
+
*/
|
|
31362
|
+
function restoreStash(git, hash, message) {
|
|
31363
|
+
if (!hash) {
|
|
31364
|
+
return Promise.resolve({ ok: false, message: 'Nothing to restore.' });
|
|
30943
31365
|
}
|
|
30944
|
-
return runAction$3(() => git.raw(['stash', '
|
|
31366
|
+
return runAction$3(() => git.raw(['stash', 'store', '-m', message || 'restored stash', hash]), 'Restored dropped stash');
|
|
30945
31367
|
}
|
|
30946
31368
|
function applyStash(git, stash) {
|
|
30947
31369
|
return runAction$3(() => git.raw(['stash', 'apply', stash.ref]), `Applied ${stash.ref}`);
|
|
@@ -31814,6 +32236,28 @@ function unstageAllFiles(git, files) {
|
|
|
31814
32236
|
}
|
|
31815
32237
|
return runAction(() => git.raw(['restore', '--staged', '--', ...files.map((file) => file.path)]), `Unstaged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
|
|
31816
32238
|
}
|
|
32239
|
+
/**
|
|
32240
|
+
* Stage everything in the worktree — modifications, new files, and
|
|
32241
|
+
* deletions — in one shot (`git add -A`). The `A` hotkey + the `:`
|
|
32242
|
+
* palette's "stage all" both route here.
|
|
32243
|
+
*/
|
|
32244
|
+
function stageAll(git) {
|
|
32245
|
+
return runAction(() => git.raw(['add', '-A']), 'Staged all changes');
|
|
32246
|
+
}
|
|
32247
|
+
/**
|
|
32248
|
+
* Stage files matching one or more git pathspecs (`git add -- <spec…>`).
|
|
32249
|
+
* Powers the typed "stage…" prompt (`+`): the user types a path, a
|
|
32250
|
+
* directory, a glob like `*.ts`, or a space-separated list, and git's
|
|
32251
|
+
* own pathspec matching does the rest. Args are passed directly (no
|
|
32252
|
+
* shell), so the globs are interpreted by git, not the shell.
|
|
32253
|
+
*/
|
|
32254
|
+
function stagePathspec(git, pathspec) {
|
|
32255
|
+
const specs = pathspec.trim().split(/\s+/).filter(Boolean);
|
|
32256
|
+
if (specs.length === 0) {
|
|
32257
|
+
return Promise.resolve({ ok: false, message: 'Enter a pathspec to stage (e.g. . or src/ or *.ts).' });
|
|
32258
|
+
}
|
|
32259
|
+
return runAction(() => git.raw(['add', '--', ...specs]), `Staged ${specs.join(' ')}`);
|
|
32260
|
+
}
|
|
31817
32261
|
|
|
31818
32262
|
function hunkHeader(hunk) {
|
|
31819
32263
|
return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
|
|
@@ -32390,7 +32834,7 @@ function buildLoadedHashSet(commits) {
|
|
|
32390
32834
|
* 5a.7 of #890. Two-row layout introduced post-0.54.2; per-kind
|
|
32391
32835
|
* colors + glyphs added in the same pass.
|
|
32392
32836
|
*/
|
|
32393
|
-
function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFrame = 0) {
|
|
32837
|
+
function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFrame = 0, singlePane = false) {
|
|
32394
32838
|
const { Box, Text } = components;
|
|
32395
32839
|
// Sidebar item count drives the per-tab footer hints — when items are
|
|
32396
32840
|
// present the footer surfaces in-sidebar ops (checkout / apply / pop /
|
|
@@ -32404,6 +32848,23 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
|
|
|
32404
32848
|
default: return undefined;
|
|
32405
32849
|
}
|
|
32406
32850
|
})();
|
|
32851
|
+
// The single-pane pane switcher only makes sense in the plain
|
|
32852
|
+
// per-pane states. While an overlay or filter owns the screen the
|
|
32853
|
+
// visible pane is forced (split-plan → main; help / palette / theme /
|
|
32854
|
+
// gitignore / input prompt / confirmation / chord → inspector) or
|
|
32855
|
+
// input is captured, and Tab does something else — so the switcher
|
|
32856
|
+
// would point at a pane that isn't on screen. Suppress it then. Mirror
|
|
32857
|
+
// of the runtime's `forcedPane` derivation in `app.ts`.
|
|
32858
|
+
const overlayForcesPane = Boolean(state.splitPlan ||
|
|
32859
|
+
state.showHelp ||
|
|
32860
|
+
state.showCommandPalette ||
|
|
32861
|
+
state.showThemePicker ||
|
|
32862
|
+
state.gitignorePicker ||
|
|
32863
|
+
state.inputPrompt ||
|
|
32864
|
+
state.pendingConfirmationId ||
|
|
32865
|
+
state.pendingMutationConfirmation ||
|
|
32866
|
+
state.pendingKey ||
|
|
32867
|
+
state.filterMode);
|
|
32407
32868
|
const hints = getLogInkFooterHints({
|
|
32408
32869
|
activeView: state.activeView,
|
|
32409
32870
|
diffSource: state.diffSource,
|
|
@@ -32417,6 +32878,12 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
|
|
|
32417
32878
|
sidebarItemCount,
|
|
32418
32879
|
compareBaseSet: Boolean(state.compareBase),
|
|
32419
32880
|
splitPlanStatus: state.splitPlan?.status,
|
|
32881
|
+
singlePane: singlePane && !overlayForcesPane,
|
|
32882
|
+
// Peeking (#1135 v2) is a single-pane glance with focus on the
|
|
32883
|
+
// sidebar; the footer shows `v/esc → main` instead of the switcher.
|
|
32884
|
+
// Suppressed under an overlay (which owns the footer) just like the
|
|
32885
|
+
// switcher.
|
|
32886
|
+
peeking: Boolean(state.peekReturnFocus) && singlePane && !overlayForcesPane,
|
|
32420
32887
|
});
|
|
32421
32888
|
// Real status messages always win; idle tips only fill the slot when it
|
|
32422
32889
|
// would otherwise be empty.
|
|
@@ -32912,7 +33379,10 @@ function sidebarTabCount(tab, context) {
|
|
|
32912
33379
|
* Header chip builder. Turns the workstation's title-bar state into an
|
|
32913
33380
|
* ordered list of small visually-distinct chips:
|
|
32914
33381
|
*
|
|
32915
|
-
* coco · gfargo/coco · ⎇ main · ✓ clean ·
|
|
33382
|
+
* coco · gfargo/coco · ⎇ main · ✓ clean · [NORMAL]
|
|
33383
|
+
*
|
|
33384
|
+
* The PR chip is appended only when a pull request exists (#1133); there
|
|
33385
|
+
* is no "no PR" placeholder chip.
|
|
32916
33386
|
*
|
|
32917
33387
|
* Pre-refactor the title bar concatenated every segment into a single
|
|
32918
33388
|
* Text span, which made the eye read the whole thing as one run of
|
|
@@ -33008,10 +33478,11 @@ function buildHeaderChips(input) {
|
|
|
33008
33478
|
bold: true,
|
|
33009
33479
|
});
|
|
33010
33480
|
}
|
|
33011
|
-
// PR state.
|
|
33012
|
-
// label ("PR #1234 OPEN" / "PR #1234 DRAFT").
|
|
33013
|
-
// "no PR" chip
|
|
33014
|
-
//
|
|
33481
|
+
// PR state. Shown only when a PR actually exists — the chip uses the
|
|
33482
|
+
// PR-state glyph + a short label ("PR #1234 OPEN" / "PR #1234 DRAFT").
|
|
33483
|
+
// The old always-on "no PR" chip spent a permanent header segment to
|
|
33484
|
+
// report a negative default state on every screen; dropping it keeps
|
|
33485
|
+
// the state cluster about what *is* true (TUI audit).
|
|
33015
33486
|
if (input.pullRequest) {
|
|
33016
33487
|
const prGlyph = getPullRequestStateGlyph({ ...input.pullRequest, isDraft: Boolean(input.pullRequest.isDraft) }, theme);
|
|
33017
33488
|
const stateLabel = input.pullRequest.isDraft
|
|
@@ -33028,15 +33499,6 @@ function buildHeaderChips(input) {
|
|
|
33028
33499
|
bold: false,
|
|
33029
33500
|
});
|
|
33030
33501
|
}
|
|
33031
|
-
else {
|
|
33032
|
-
chips.push({
|
|
33033
|
-
id: 'pr',
|
|
33034
|
-
label: theme.ascii ? '- no PR' : '⊘ no PR',
|
|
33035
|
-
color: theme.colors.muted,
|
|
33036
|
-
dim: true,
|
|
33037
|
-
bold: false,
|
|
33038
|
-
});
|
|
33039
|
-
}
|
|
33040
33502
|
// View breadcrumb. Rendered only when there's content (`coco ui`
|
|
33041
33503
|
// root view → no breadcrumb chip; pushed into a sub-view → chip
|
|
33042
33504
|
// appears). Comes AFTER PR so the "state" group (app/repo/branch/
|
|
@@ -33107,7 +33569,10 @@ function measureHeaderChipsWidth(chips) {
|
|
|
33107
33569
|
* Title-bar renderer. Surfaces the workstation's identity + navigation
|
|
33108
33570
|
* state as a row of small visually-distinct chips:
|
|
33109
33571
|
*
|
|
33110
|
-
* coco · gfargo/coco · ⎇ main · ✓ clean ·
|
|
33572
|
+
* coco · gfargo/coco · ⎇ main · ✓ clean · [NORMAL]
|
|
33573
|
+
*
|
|
33574
|
+
* The PR chip is appended only when a pull request exists (e.g.
|
|
33575
|
+
* `· ⊠ PR #1234 OPEN`); there's no "no PR" placeholder chip.
|
|
33111
33576
|
*
|
|
33112
33577
|
* Per-chip color/glyph treatment lets the user scan in chunks ("what
|
|
33113
33578
|
* app, what repo, what branch, how clean, what PR state, what mode")
|
|
@@ -33547,70 +34012,10 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
33547
34012
|
return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
|
|
33548
34013
|
}, 'tab-worktrees', visibleListCount);
|
|
33549
34014
|
}
|
|
33550
|
-
|
|
33551
|
-
* Single-letter glyph for a sidebar tab in rail mode. Letters always
|
|
33552
|
-
* carry the meaning so this stays useful under ASCII; the rail is too
|
|
33553
|
-
* narrow to fit the full tab label. Pairs with `sidebarTabCount` for
|
|
33554
|
-
* the trailing count.
|
|
33555
|
-
*/
|
|
33556
|
-
function sidebarTabRailGlyph(tab) {
|
|
33557
|
-
switch (tab) {
|
|
33558
|
-
case 'status':
|
|
33559
|
-
return 'S';
|
|
33560
|
-
case 'branches':
|
|
33561
|
-
return 'B';
|
|
33562
|
-
case 'tags':
|
|
33563
|
-
return 'T';
|
|
33564
|
-
case 'stashes':
|
|
33565
|
-
return '$';
|
|
33566
|
-
case 'worktrees':
|
|
33567
|
-
return 'W';
|
|
33568
|
-
default:
|
|
33569
|
-
return '·';
|
|
33570
|
-
}
|
|
33571
|
-
}
|
|
33572
|
-
/**
|
|
33573
|
-
* Rail-mode sidebar — shown on terminals < 100 columns when the
|
|
33574
|
-
* sidebar does not hold focus. Five vertically stacked tab glyphs
|
|
33575
|
-
* with optional counts; the active tab is bracketed. Pressing Tab to
|
|
33576
|
-
* focus the sidebar pops it back to the full accordion (the layout
|
|
33577
|
-
* un-rails it on focus, this renderer is never called in that case).
|
|
33578
|
-
*/
|
|
33579
|
-
function renderSidebarRail$1(h, components, state, context, width, theme, focused, tabs) {
|
|
33580
|
-
const { Box, Text } = components;
|
|
33581
|
-
return h(Box, {
|
|
33582
|
-
borderColor: focusBorderColor(theme, focused),
|
|
33583
|
-
borderStyle: theme.borderStyle,
|
|
33584
|
-
flexDirection: 'column',
|
|
33585
|
-
width,
|
|
33586
|
-
paddingX: 1,
|
|
33587
|
-
}, h(Text, { bold: true, dimColor: !focused }, 'Repo'), h(Text, { dimColor: true }, '────'), ...tabs.map((tab) => {
|
|
33588
|
-
const isActive = tab === state.sidebarTab;
|
|
33589
|
-
const glyph = sidebarTabRailGlyph(tab);
|
|
33590
|
-
const count = sidebarTabCount(tab, context);
|
|
33591
|
-
// Count fits in 2 cells (rail content area is ~4 cells); 99+
|
|
33592
|
-
// collapses to `+` so we never overflow.
|
|
33593
|
-
const countText = count === undefined
|
|
33594
|
-
? ''
|
|
33595
|
-
: count > 99
|
|
33596
|
-
? '+'
|
|
33597
|
-
: String(count);
|
|
33598
|
-
const body = isActive ? `[${glyph}]` : ` ${glyph} `;
|
|
33599
|
-
const text = countText ? `${body}${countText}` : body;
|
|
33600
|
-
return h(Text, {
|
|
33601
|
-
key: `rail-${tab}`,
|
|
33602
|
-
bold: isActive,
|
|
33603
|
-
dimColor: !isActive,
|
|
33604
|
-
}, text);
|
|
33605
|
-
}));
|
|
33606
|
-
}
|
|
33607
|
-
function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme, railed = false) {
|
|
34015
|
+
function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme) {
|
|
33608
34016
|
const { Box, Text } = components;
|
|
33609
34017
|
const focused = state.focus === 'sidebar';
|
|
33610
34018
|
const tabs = getLogInkSidebarTabs();
|
|
33611
|
-
if (railed) {
|
|
33612
|
-
return renderSidebarRail$1(h, components, state, context, width, theme, focused, tabs);
|
|
33613
|
-
}
|
|
33614
34019
|
// Accordion layout — every tab's title is visible on its own line, but
|
|
33615
34020
|
// only the active tab expands its content underneath. Switching tabs
|
|
33616
34021
|
// (1-5 / [/]) collapses the previous and expands the next.
|
|
@@ -33669,7 +34074,8 @@ function renderSidebar$1(h, components, state, context, contextStatus, width, bo
|
|
|
33669
34074
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
33670
34075
|
* of #890. No behavior change.
|
|
33671
34076
|
*/
|
|
33672
|
-
function renderBisectSurface(
|
|
34077
|
+
function renderBisectSurface(ctx, candidateDetail, candidateLoading) {
|
|
34078
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
33673
34079
|
const { Box, Text } = components;
|
|
33674
34080
|
const focused = state.focus === 'commits';
|
|
33675
34081
|
const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
|
|
@@ -33993,7 +34399,8 @@ function formatLogInkGitHubNoRemote({ resource, }) {
|
|
|
33993
34399
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
33994
34400
|
* of #890. No behavior change.
|
|
33995
34401
|
*/
|
|
33996
|
-
function renderBranchesSurface(
|
|
34402
|
+
function renderBranchesSurface(ctx) {
|
|
34403
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
33997
34404
|
const { Box, Text } = components;
|
|
33998
34405
|
const focused = state.focus === 'commits';
|
|
33999
34406
|
const branches = context.branches;
|
|
@@ -34139,7 +34546,8 @@ function formatCacheAge(generatedAt, now) {
|
|
|
34139
34546
|
const day = Math.floor(hr / 24);
|
|
34140
34547
|
return `${day}d ago`;
|
|
34141
34548
|
}
|
|
34142
|
-
function renderChangelogSurface(
|
|
34549
|
+
function renderChangelogSurface(ctx) {
|
|
34550
|
+
const { h, components, state, bodyRows, width, theme } = ctx;
|
|
34143
34551
|
const { Box, Text } = components;
|
|
34144
34552
|
const focused = state.focus === 'commits';
|
|
34145
34553
|
const view = state.changelogView;
|
|
@@ -34331,7 +34739,8 @@ function renderStreamingPreviewLines(h, components, preview, width, theme) {
|
|
|
34331
34739
|
}, `${prefix}${line}`);
|
|
34332
34740
|
});
|
|
34333
34741
|
}
|
|
34334
|
-
function renderComposeSurface(
|
|
34742
|
+
function renderComposeSurface(ctx, spinnerFrame = 0) {
|
|
34743
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
34335
34744
|
const { Box, Text } = components;
|
|
34336
34745
|
const compose = state.commitCompose;
|
|
34337
34746
|
const focused = state.focus === 'commits';
|
|
@@ -34444,7 +34853,8 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
|
|
|
34444
34853
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.3
|
|
34445
34854
|
* of #890. No behavior change.
|
|
34446
34855
|
*/
|
|
34447
|
-
function renderConflictsSurface(
|
|
34856
|
+
function renderConflictsSurface(ctx) {
|
|
34857
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
34448
34858
|
const { Box, Text } = components;
|
|
34449
34859
|
const focused = state.focus === 'commits';
|
|
34450
34860
|
const loading = isLogInkContextKeyLoading(contextStatus, 'operation');
|
|
@@ -34913,6 +35323,50 @@ function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
|
|
|
34913
35323
|
return h(Text, { key }, h(Text, { key: `${key}-m`, color: markerColor }, marker), ...children);
|
|
34914
35324
|
}
|
|
34915
35325
|
|
|
35326
|
+
/** The hunk index owning `absLine`, or -1 for pre-hunk header/label rows. */
|
|
35327
|
+
function hunkIndexForLine(absLine, hunkOffsets) {
|
|
35328
|
+
let index = -1;
|
|
35329
|
+
for (let k = 0; k < hunkOffsets.length; k++) {
|
|
35330
|
+
if (hunkOffsets[k] <= absLine)
|
|
35331
|
+
index = k;
|
|
35332
|
+
else
|
|
35333
|
+
break;
|
|
35334
|
+
}
|
|
35335
|
+
return index;
|
|
35336
|
+
}
|
|
35337
|
+
function renderWorktreeDiffBody(h, components, params) {
|
|
35338
|
+
const { Box, Text } = components;
|
|
35339
|
+
const { lines, offset, visibleRows, width, theme, syntaxSpans, hunkOffsets, hunks, selectedIndex, keyPrefix } = params;
|
|
35340
|
+
const headerSet = new Set(hunkOffsets);
|
|
35341
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
35342
|
+
const added = theme.noColor ? undefined : theme.colors.gitAdded;
|
|
35343
|
+
const codeWidth = Math.max(8, width - 5); // 2 chrome + 1 gutter + slack
|
|
35344
|
+
const visible = lines.slice(offset, offset + visibleRows);
|
|
35345
|
+
return visible.map((line, i) => {
|
|
35346
|
+
const abs = offset + i;
|
|
35347
|
+
const key = `${keyPrefix}-${abs}`;
|
|
35348
|
+
const hunkIndex = hunkIndexForLine(abs, hunkOffsets);
|
|
35349
|
+
const hunk = hunkIndex >= 0 ? hunks[hunkIndex] : undefined;
|
|
35350
|
+
const isSelected = hunkIndex >= 0 && hunkIndex === selectedIndex;
|
|
35351
|
+
const isStaged = hunk?.state === 'staged';
|
|
35352
|
+
const bar = isSelected ? '▎' : ' ';
|
|
35353
|
+
// `@@` header row — badge + (dim) hunk position, emphasized when selected.
|
|
35354
|
+
if (headerSet.has(abs)) {
|
|
35355
|
+
const badge = theme.ascii ? (isStaged ? '[x] ' : '[ ] ') : (isStaged ? '● ' : '○ ');
|
|
35356
|
+
const badgeColor = theme.noColor ? undefined : isStaged ? added : theme.colors.muted;
|
|
35357
|
+
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)));
|
|
35358
|
+
}
|
|
35359
|
+
// Body / context / pre-hunk lines.
|
|
35360
|
+
// A staged hunk that ISN'T selected renders dim ("done", out of
|
|
35361
|
+
// focus); the selected hunk and unstaged hunks keep full diff +
|
|
35362
|
+
// syntax coloring via renderDiffLine so the focus stays vivid.
|
|
35363
|
+
const content = isStaged && !isSelected && hunkIndex >= 0
|
|
35364
|
+
? h(Text, { key: `${key}-c`, dimColor: true }, truncateCells(line, codeWidth))
|
|
35365
|
+
: renderDiffLine(h, Text, line, theme, syntaxSpans, codeWidth, `${key}-c`);
|
|
35366
|
+
return h(Box, { key, flexDirection: 'row' }, h(Text, { color: accent }, bar), content);
|
|
35367
|
+
});
|
|
35368
|
+
}
|
|
35369
|
+
|
|
34916
35370
|
/**
|
|
34917
35371
|
* Diff surface — the unified or side-by-side diff view. Four sources
|
|
34918
35372
|
* route through here, disambiguated by `state.diffSource`:
|
|
@@ -34935,7 +35389,9 @@ function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
|
|
|
34935
35389
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.4
|
|
34936
35390
|
* of #890. No behavior change.
|
|
34937
35391
|
*/
|
|
34938
|
-
function renderDiffSurface(
|
|
35392
|
+
function renderDiffSurface(ctx, diff) {
|
|
35393
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
35394
|
+
const { worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, syntaxSpans, } = diff;
|
|
34939
35395
|
const { Box, Text } = components;
|
|
34940
35396
|
const focused = state.focus === 'commits';
|
|
34941
35397
|
const worktree = context.worktree;
|
|
@@ -35135,7 +35591,22 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
35135
35591
|
}
|
|
35136
35592
|
const diffLines = worktreeDiff?.lines || [];
|
|
35137
35593
|
const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
|
|
35594
|
+
const totalHunks = worktreeHunks?.hunks.length ?? 0;
|
|
35595
|
+
const stagedHunks = worktreeHunks?.hunks.filter((hunk) => hunk.state === 'staged').length ?? 0;
|
|
35138
35596
|
const visibleDiffLines = diffLines.slice(state.worktreeDiffOffset, state.worktreeDiffOffset + visibleRows);
|
|
35597
|
+
// Hunk-position line: badge + selected hunk's state + a staged/total
|
|
35598
|
+
// progress count, so the user always sees how far through staging they
|
|
35599
|
+
// are. Untracked/new files have no hunks — point them at whole-file
|
|
35600
|
+
// staging instead of a dead-end "no hunks" message.
|
|
35601
|
+
const hunkHeaderLine = worktreeHunksLoading
|
|
35602
|
+
? 'Hunks loading…'
|
|
35603
|
+
: worktreeDiff?.untracked
|
|
35604
|
+
? (theme.ascii ? 'New file — press space to stage it whole.' : '✚ New file — press space to stage it whole.')
|
|
35605
|
+
: totalHunks
|
|
35606
|
+
? `Hunk ${state.selectedWorktreeHunkIndex + 1}/${totalHunks} · ${selectedHunk?.state === 'staged'
|
|
35607
|
+
? (theme.ascii ? '[x] staged' : '● staged')
|
|
35608
|
+
: (theme.ascii ? '[ ] unstaged' : '○ unstaged')} · ${stagedHunks}/${totalHunks} staged`
|
|
35609
|
+
: 'No stageable hunks for this file.';
|
|
35139
35610
|
const headerLines = isLogInkContextKeyLoading(contextStatus, 'worktree')
|
|
35140
35611
|
? ['Loading file context...']
|
|
35141
35612
|
: worktreeDiffLoading
|
|
@@ -35144,11 +35615,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
35144
35615
|
? [
|
|
35145
35616
|
// File path is already shown in the panel title bar (right) —
|
|
35146
35617
|
// no redundant "Selected file:" line here.
|
|
35147
|
-
|
|
35148
|
-
? 'Hunks loading...'
|
|
35149
|
-
: worktreeHunks?.hunks.length
|
|
35150
|
-
? `Hunk ${state.selectedWorktreeHunkIndex + 1}/${worktreeHunks.hunks.length} ${selectedHunk?.state || ''}`
|
|
35151
|
-
: 'No stageable hunks for this file.',
|
|
35618
|
+
hunkHeaderLine,
|
|
35152
35619
|
`Lines ${Math.min(state.worktreeDiffOffset + 1, diffLines.length || 1)}-${Math.min(state.worktreeDiffOffset + visibleDiffLines.length, diffLines.length)}/${diffLines.length}`,
|
|
35153
35620
|
'',
|
|
35154
35621
|
]
|
|
@@ -35163,11 +35630,26 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
35163
35630
|
flexShrink: 0,
|
|
35164
35631
|
paddingX: 1,
|
|
35165
35632
|
width,
|
|
35166
|
-
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)),
|
|
35633
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)),
|
|
35634
|
+
// Use the path of the file actually being diffed (the grouped/visible
|
|
35635
|
+
// selection feeds the loaded diff) — `worktreeFile` indexes the raw,
|
|
35636
|
+
// ungrouped file list and can name a different file than the diff body.
|
|
35637
|
+
h(Text, { dimColor: true }, worktreeDiff?.filePath || worktreeFile?.path || 'no file')), ...headerLines.map((line, index) => h(Text, {
|
|
35167
35638
|
key: `diff-surface-header-${index}`,
|
|
35168
35639
|
dimColor: index > 0,
|
|
35169
35640
|
}, truncateCells(line, 140))), ...(showDiffLines
|
|
35170
|
-
?
|
|
35641
|
+
? renderWorktreeDiffBody(h, components, {
|
|
35642
|
+
lines: diffLines,
|
|
35643
|
+
offset: state.worktreeDiffOffset,
|
|
35644
|
+
visibleRows,
|
|
35645
|
+
width,
|
|
35646
|
+
theme,
|
|
35647
|
+
syntaxSpans,
|
|
35648
|
+
hunkOffsets: worktreeDiff?.hunkOffsets || [],
|
|
35649
|
+
hunks: worktreeHunks?.hunks || [],
|
|
35650
|
+
selectedIndex: state.selectedWorktreeHunkIndex,
|
|
35651
|
+
keyPrefix: 'diff-surface-line',
|
|
35652
|
+
})
|
|
35171
35653
|
: []));
|
|
35172
35654
|
}
|
|
35173
35655
|
|
|
@@ -36351,7 +36833,8 @@ function renderRemoteOpLoader(h, components, state, width, bodyRows, theme, focu
|
|
|
36351
36833
|
height: innerHeight,
|
|
36352
36834
|
}, h(Text, { color: accent, bold: true }, `${spinner} ${op.label}`), h(Text, undefined, ''), h(Text, { color: accent }, track), h(Text, undefined, ''), h(Text, { dimColor: true }, 'Talking to the remote — history refreshes automatically.')));
|
|
36353
36835
|
}
|
|
36354
|
-
function renderHistoryPanel(
|
|
36836
|
+
function renderHistoryPanel(ctx, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = getRenderNow(), spinnerFrame = 0) {
|
|
36837
|
+
const { h, components, state, context, bodyRows, width, theme } = ctx;
|
|
36355
36838
|
const { Box, Text } = components;
|
|
36356
36839
|
const focused = state.focus === 'commits';
|
|
36357
36840
|
// Remote op in flight (fetch / pull / push) → swap the commit list
|
|
@@ -37053,7 +37536,8 @@ function matchesIssueFilter(issue, filter) {
|
|
|
37053
37536
|
...(issue.assignees || []),
|
|
37054
37537
|
], filter);
|
|
37055
37538
|
}
|
|
37056
|
-
function renderIssuesTriageSurface(
|
|
37539
|
+
function renderIssuesTriageSurface(ctx) {
|
|
37540
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37057
37541
|
const { Box, Text } = components;
|
|
37058
37542
|
const focused = state.focus === 'commits';
|
|
37059
37543
|
const overview = context.issueList;
|
|
@@ -37335,7 +37819,8 @@ function formatPullRequestStateLine(pr) {
|
|
|
37335
37819
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
37336
37820
|
* of #890. No behavior change.
|
|
37337
37821
|
*/
|
|
37338
|
-
function renderPullRequestSurface(
|
|
37822
|
+
function renderPullRequestSurface(ctx) {
|
|
37823
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37339
37824
|
const { Box, Text } = components;
|
|
37340
37825
|
const focused = state.focus === 'commits';
|
|
37341
37826
|
const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequest');
|
|
@@ -37508,7 +37993,8 @@ function matchesPullRequestFilter(pr, filter) {
|
|
|
37508
37993
|
...(pr.assignees || []),
|
|
37509
37994
|
], filter);
|
|
37510
37995
|
}
|
|
37511
|
-
function renderPullRequestTriageSurface(
|
|
37996
|
+
function renderPullRequestTriageSurface(ctx) {
|
|
37997
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37512
37998
|
const { Box, Text } = components;
|
|
37513
37999
|
const focused = state.focus === 'commits';
|
|
37514
38000
|
const overview = context.pullRequestList;
|
|
@@ -37624,7 +38110,8 @@ function renderPullRequestTriageSurface(h, components, state, context, contextSt
|
|
|
37624
38110
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
37625
38111
|
* of #890. No behavior change.
|
|
37626
38112
|
*/
|
|
37627
|
-
function renderReflogSurface(
|
|
38113
|
+
function renderReflogSurface(ctx) {
|
|
38114
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37628
38115
|
const { Box, Text } = components;
|
|
37629
38116
|
const focused = state.focus === 'commits';
|
|
37630
38117
|
const loading = isLogInkContextKeyLoading(contextStatus, 'reflog');
|
|
@@ -37695,7 +38182,8 @@ function renderReflogSurface(h, components, state, context, contextStatus, bodyR
|
|
|
37695
38182
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
37696
38183
|
* of #890. No behavior change.
|
|
37697
38184
|
*/
|
|
37698
|
-
function renderStashSurface(
|
|
38185
|
+
function renderStashSurface(ctx) {
|
|
38186
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37699
38187
|
const { Box, Text } = components;
|
|
37700
38188
|
const focused = state.focus === 'commits';
|
|
37701
38189
|
const loading = isLogInkContextKeyLoading(contextStatus, 'stashes');
|
|
@@ -37713,6 +38201,11 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
|
|
|
37713
38201
|
: `${stashes.length}/${allStashes.length} stashes${filterLabel}`;
|
|
37714
38202
|
const emptyLabel = formatLogInkStashEmpty({ filter: state.filter });
|
|
37715
38203
|
const loadingLabel = formatLogInkLoading({ resource: 'stashes' });
|
|
38204
|
+
const now = getRenderNow();
|
|
38205
|
+
// Available width for a row: box width minus the 2-cell horizontal
|
|
38206
|
+
// padding. Truncate to it (with a small floor) instead of a magic 140
|
|
38207
|
+
// so the richer meta degrades gracefully on narrow terminals.
|
|
38208
|
+
const rowWidth = Math.max(20, width - 2);
|
|
37716
38209
|
const lines = loading
|
|
37717
38210
|
? [h(Text, { key: 'stash-loading', dimColor: true }, loadingLabel)]
|
|
37718
38211
|
: stashes.length === 0
|
|
@@ -37721,11 +38214,25 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
|
|
|
37721
38214
|
const index = startIndex + offset;
|
|
37722
38215
|
const isSelected = index === selected;
|
|
37723
38216
|
const cursor = isSelected ? '>' : ' ';
|
|
38217
|
+
// Surface the metadata the StashEntry already carries — origin
|
|
38218
|
+
// branch, file count, and relative age — between the ref and the
|
|
38219
|
+
// message, so the list answers "which stash is this?" without an
|
|
38220
|
+
// Enter→diff round trip.
|
|
38221
|
+
const age = formatCompactRelativeDate(stash.date, now);
|
|
38222
|
+
const fileCount = stash.files.length;
|
|
38223
|
+
const meta = [
|
|
38224
|
+
stash.branch ? `on ${stash.branch}` : '',
|
|
38225
|
+
fileCount > 0 ? `${fileCount} file${fileCount === 1 ? '' : 's'}` : '',
|
|
38226
|
+
age,
|
|
38227
|
+
].filter(Boolean).join(' · ');
|
|
38228
|
+
const rowText = meta
|
|
38229
|
+
? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
|
|
38230
|
+
: `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
|
|
37724
38231
|
return h(Text, {
|
|
37725
38232
|
key: `stash-${index}`,
|
|
37726
38233
|
bold: isSelected,
|
|
37727
38234
|
dimColor: !isSelected,
|
|
37728
|
-
}, truncateCells(
|
|
38235
|
+
}, truncateCells(rowText, rowWidth));
|
|
37729
38236
|
});
|
|
37730
38237
|
const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
|
|
37731
38238
|
const stashHasMoreBelow = startIndex + listRows < stashes.length;
|
|
@@ -37785,7 +38292,8 @@ function formatStatusFilterMask(mask) {
|
|
|
37785
38292
|
active.push('untracked');
|
|
37786
38293
|
return active.join(' + ') || 'none';
|
|
37787
38294
|
}
|
|
37788
|
-
function renderStatusSurface(
|
|
38295
|
+
function renderStatusSurface(ctx) {
|
|
38296
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37789
38297
|
const { Box, Text } = components;
|
|
37790
38298
|
const focused = state.focus === 'commits';
|
|
37791
38299
|
const worktree = context.worktree;
|
|
@@ -37932,7 +38440,8 @@ function flagColor(flag, theme) {
|
|
|
37932
38440
|
return theme.colors.danger;
|
|
37933
38441
|
return undefined;
|
|
37934
38442
|
}
|
|
37935
|
-
function renderSubmodulesSurface(
|
|
38443
|
+
function renderSubmodulesSurface(ctx) {
|
|
38444
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37936
38445
|
const { Box, Text } = components;
|
|
37937
38446
|
const focused = state.focus === 'commits';
|
|
37938
38447
|
const loading = isLogInkContextKeyLoading(contextStatus, 'submodules');
|
|
@@ -38071,7 +38580,8 @@ function formatHyperlink(text, url, env = process.env) {
|
|
|
38071
38580
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
38072
38581
|
* of #890. No behavior change.
|
|
38073
38582
|
*/
|
|
38074
|
-
function renderTagsSurface(
|
|
38583
|
+
function renderTagsSurface(ctx) {
|
|
38584
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38075
38585
|
const { Box, Text } = components;
|
|
38076
38586
|
const focused = state.focus === 'commits';
|
|
38077
38587
|
const loading = isLogInkContextKeyLoading(contextStatus, 'tags');
|
|
@@ -38153,7 +38663,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
38153
38663
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
38154
38664
|
* of #890. No behavior change.
|
|
38155
38665
|
*/
|
|
38156
|
-
function renderWorktreesSurface(
|
|
38666
|
+
function renderWorktreesSurface(ctx) {
|
|
38667
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38157
38668
|
const { Box, Text } = components;
|
|
38158
38669
|
const focused = state.focus === 'commits';
|
|
38159
38670
|
const loading = isLogInkContextKeyLoading(contextStatus, 'worktreeList');
|
|
@@ -38217,7 +38728,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
38217
38728
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
|
|
38218
38729
|
* of #890. No behavior change.
|
|
38219
38730
|
*/
|
|
38220
|
-
function renderMainPanel(
|
|
38731
|
+
function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled, syntaxSpans) {
|
|
38732
|
+
// The universal render values now arrive bundled (#1136); only the
|
|
38733
|
+
// few raw values the dispatcher itself touches (split-plan overlay,
|
|
38734
|
+
// activeView switch) are destructured here. Surfaces receive `surface`
|
|
38735
|
+
// directly plus their own slices.
|
|
38736
|
+
const { h, components, state, bodyRows, width, theme } = surface;
|
|
38221
38737
|
// Split-plan overlay (#907 polish): renders in the MAIN panel (not
|
|
38222
38738
|
// detail) when active, because the content — multiple commit groups
|
|
38223
38739
|
// with file lists, rationale, hunks — needs the full center width
|
|
@@ -38229,51 +38745,66 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
38229
38745
|
return renderSplitPlanOverlay(h, components, state, width, bodyRows, theme, true, spinnerFrame);
|
|
38230
38746
|
}
|
|
38231
38747
|
if (state.activeView === 'status') {
|
|
38232
|
-
return renderStatusSurface(
|
|
38748
|
+
return renderStatusSurface(surface);
|
|
38233
38749
|
}
|
|
38234
38750
|
if (state.activeView === 'diff') {
|
|
38235
|
-
|
|
38751
|
+
const diffData = {
|
|
38752
|
+
worktreeDiff,
|
|
38753
|
+
worktreeDiffLoading,
|
|
38754
|
+
worktreeHunks,
|
|
38755
|
+
worktreeHunksLoading,
|
|
38756
|
+
filePreview,
|
|
38757
|
+
filePreviewLoading,
|
|
38758
|
+
commitDiffHunkOffsets,
|
|
38759
|
+
selectedDetailFile,
|
|
38760
|
+
stashDiffLines,
|
|
38761
|
+
stashDiffLoading,
|
|
38762
|
+
compareDiffLines,
|
|
38763
|
+
compareDiffLoading,
|
|
38764
|
+
syntaxSpans,
|
|
38765
|
+
};
|
|
38766
|
+
return renderDiffSurface(surface, diffData);
|
|
38236
38767
|
}
|
|
38237
38768
|
if (state.activeView === 'compose') {
|
|
38238
|
-
return renderComposeSurface(
|
|
38769
|
+
return renderComposeSurface(surface, spinnerFrame);
|
|
38239
38770
|
}
|
|
38240
38771
|
if (state.activeView === 'branches') {
|
|
38241
|
-
return renderBranchesSurface(
|
|
38772
|
+
return renderBranchesSurface(surface);
|
|
38242
38773
|
}
|
|
38243
38774
|
if (state.activeView === 'tags') {
|
|
38244
|
-
return renderTagsSurface(
|
|
38775
|
+
return renderTagsSurface(surface);
|
|
38245
38776
|
}
|
|
38246
38777
|
if (state.activeView === 'reflog') {
|
|
38247
|
-
return renderReflogSurface(
|
|
38778
|
+
return renderReflogSurface(surface);
|
|
38248
38779
|
}
|
|
38249
38780
|
if (state.activeView === 'bisect') {
|
|
38250
|
-
return renderBisectSurface(
|
|
38781
|
+
return renderBisectSurface(surface, bisectCandidateDetail, bisectCandidateLoading);
|
|
38251
38782
|
}
|
|
38252
38783
|
if (state.activeView === 'stash') {
|
|
38253
|
-
return renderStashSurface(
|
|
38784
|
+
return renderStashSurface(surface);
|
|
38254
38785
|
}
|
|
38255
38786
|
if (state.activeView === 'worktrees') {
|
|
38256
|
-
return renderWorktreesSurface(
|
|
38787
|
+
return renderWorktreesSurface(surface);
|
|
38257
38788
|
}
|
|
38258
38789
|
if (state.activeView === 'submodules') {
|
|
38259
|
-
return renderSubmodulesSurface(
|
|
38790
|
+
return renderSubmodulesSurface(surface);
|
|
38260
38791
|
}
|
|
38261
38792
|
if (state.activeView === 'pull-request') {
|
|
38262
|
-
return renderPullRequestSurface(
|
|
38793
|
+
return renderPullRequestSurface(surface);
|
|
38263
38794
|
}
|
|
38264
38795
|
if (state.activeView === 'pull-request-triage') {
|
|
38265
|
-
return renderPullRequestTriageSurface(
|
|
38796
|
+
return renderPullRequestTriageSurface(surface);
|
|
38266
38797
|
}
|
|
38267
38798
|
if (state.activeView === 'issues') {
|
|
38268
|
-
return renderIssuesTriageSurface(
|
|
38799
|
+
return renderIssuesTriageSurface(surface);
|
|
38269
38800
|
}
|
|
38270
38801
|
if (state.activeView === 'conflicts') {
|
|
38271
|
-
return renderConflictsSurface(
|
|
38802
|
+
return renderConflictsSurface(surface);
|
|
38272
38803
|
}
|
|
38273
38804
|
if (state.activeView === 'changelog') {
|
|
38274
|
-
return renderChangelogSurface(
|
|
38805
|
+
return renderChangelogSurface(surface);
|
|
38275
38806
|
}
|
|
38276
|
-
return renderHistoryPanel(
|
|
38807
|
+
return renderHistoryPanel(surface, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled, undefined, spinnerFrame);
|
|
38277
38808
|
}
|
|
38278
38809
|
|
|
38279
38810
|
/**
|
|
@@ -39146,21 +39677,16 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
|
|
|
39146
39677
|
const bodyVisualLines = bodyHasContent
|
|
39147
39678
|
? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, 12)
|
|
39148
39679
|
: ['<empty>'];
|
|
39149
|
-
const
|
|
39150
|
-
const
|
|
39151
|
-
const
|
|
39152
|
-
|
|
39153
|
-
|
|
39154
|
-
|
|
39155
|
-
|
|
39156
|
-
|
|
39157
|
-
|
|
39158
|
-
|
|
39159
|
-
const isLast = index === bodyVisualLines.length - 1;
|
|
39160
|
-
return ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`;
|
|
39161
|
-
}),
|
|
39162
|
-
'',
|
|
39163
|
-
];
|
|
39680
|
+
const hasSummary = Boolean(compose.summary);
|
|
39681
|
+
const summaryMarker = compose.field === 'summary' && compose.editing ? '>' : ' ';
|
|
39682
|
+
const bodyMarker = compose.field === 'body' && compose.editing ? '>' : ' ';
|
|
39683
|
+
// The generated subject is the thing the user is looking for — render
|
|
39684
|
+
// it bold + accent so it pops out of the inspector instead of blending
|
|
39685
|
+
// into the dim label/body text. The `Summary:` label stays dim.
|
|
39686
|
+
const summaryLabel = `${summaryMarker} Summary: `;
|
|
39687
|
+
const summaryColor = hasSummary && !theme.noColor ? theme.colors.accent : undefined;
|
|
39688
|
+
const summaryValueWidth = Math.max(4, width - 4 - cellWidth(summaryLabel));
|
|
39689
|
+
const summaryWrapped = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, summaryValueWidth);
|
|
39164
39690
|
const trailerLines = [
|
|
39165
39691
|
...(compose.message ? ['', compose.message] : []),
|
|
39166
39692
|
...(compose.details || []).map((line) => ` ${line}`),
|
|
@@ -39174,10 +39700,26 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
|
|
|
39174
39700
|
flexDirection: 'column',
|
|
39175
39701
|
width,
|
|
39176
39702
|
paddingX: 1,
|
|
39177
|
-
}, h(Text, { bold: true }, panelTitle('Commit', focused)),
|
|
39178
|
-
|
|
39179
|
-
|
|
39180
|
-
|
|
39703
|
+
}, h(Text, { bold: true }, panelTitle('Commit', focused)), h(Text, { key: 'commit-status', dimColor: true }, truncateCells(statusLine, width - 4)), h(Text, { key: 'commit-spacer-1' }, ''),
|
|
39704
|
+
// Summary: dim label + the subject value emphasized so it's easy to spot.
|
|
39705
|
+
h(Text, { key: 'commit-summary' }, h(Text, { dimColor: true }, summaryLabel), h(Text, {
|
|
39706
|
+
bold: hasSummary,
|
|
39707
|
+
color: summaryColor,
|
|
39708
|
+
dimColor: !hasSummary,
|
|
39709
|
+
}, summaryWrapped[0] || '<empty>')), ...summaryWrapped.slice(1).map((line, index) => h(Text, {
|
|
39710
|
+
key: `commit-summary-rest-${index}`,
|
|
39711
|
+
bold: true,
|
|
39712
|
+
color: summaryColor,
|
|
39713
|
+
}, truncateCells(`${' '.repeat(cellWidth(summaryLabel))}${line}`, width - 4))), h(Text, {
|
|
39714
|
+
key: 'commit-body-label',
|
|
39715
|
+
dimColor: !(compose.field === 'body' && compose.editing),
|
|
39716
|
+
}, truncateCells(`${bodyMarker} Body:`, width - 4)), ...bodyVisualLines.map((line, index) => {
|
|
39717
|
+
const isLast = index === bodyVisualLines.length - 1;
|
|
39718
|
+
return h(Text, {
|
|
39719
|
+
key: `commit-body-${index}`,
|
|
39720
|
+
dimColor: true,
|
|
39721
|
+
}, truncateCells(` ${line}${bodyCursor && isLast ? bodyCursor : ''}`, width - 4));
|
|
39722
|
+
}), h(Text, { key: 'commit-spacer-2' }, ''),
|
|
39181
39723
|
// Loading indicator + commit result/details stay inline with the body
|
|
39182
39724
|
// (they describe what just happened to the fields above). The action
|
|
39183
39725
|
// hint ("e edit | c commit | I AI draft") moves to the bottom of the
|
|
@@ -39268,41 +39810,11 @@ function renderPullRequestTriagePreviewPanel(h, components, state, context, cont
|
|
|
39268
39810
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
|
|
39269
39811
|
* of #890. No behavior change.
|
|
39270
39812
|
*/
|
|
39271
|
-
|
|
39272
|
-
* Rail-mode inspector — shown on terminals < 100 columns when the
|
|
39273
|
-
* detail panel does not hold focus. The full inspector (commit body,
|
|
39274
|
-
* file list, actions) does not survive truncation to ~4 content cells
|
|
39275
|
-
* so we collapse to a stack with the panel label and the selected
|
|
39276
|
-
* commit's shortHash. Focus pops the panel back to its expanded
|
|
39277
|
-
* widths via the layout, so this renderer is only reached at rest.
|
|
39278
|
-
*
|
|
39279
|
-
* Help / overlay states are still handled by their own renderers
|
|
39280
|
-
* above; this short-circuit only kicks in for the regular "view the
|
|
39281
|
-
* commit" cases.
|
|
39282
|
-
*/
|
|
39283
|
-
function renderInspectorRail(h, components, state, detail, width, theme, focused) {
|
|
39284
|
-
const { Box, Text } = components;
|
|
39285
|
-
// Prefer the loaded detail's hash (canonical) but fall back to the
|
|
39286
|
-
// selected list row's shortHash so the rail isn't blank on the
|
|
39287
|
-
// first render before getCommitDetail resolves.
|
|
39288
|
-
const selectedRow = getSelectedInkCommit(state);
|
|
39289
|
-
const hashText = detail?.hash.slice(0, 4)
|
|
39290
|
-
?? selectedRow?.shortHash.slice(0, 4)
|
|
39291
|
-
?? '····';
|
|
39292
|
-
return h(Box, {
|
|
39293
|
-
borderColor: focusBorderColor(theme, focused),
|
|
39294
|
-
borderStyle: theme.borderStyle,
|
|
39295
|
-
flexDirection: 'column',
|
|
39296
|
-
width,
|
|
39297
|
-
paddingX: 1,
|
|
39298
|
-
}, h(Text, { bold: true, dimColor: !focused }, panelTitle('Insp', focused)), h(Text, { dimColor: true }, '────'), h(Text, { color: theme.noColor ? undefined : theme.colors.accent }, hashText));
|
|
39299
|
-
}
|
|
39300
|
-
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, railed = false, bodyRows = 0) {
|
|
39813
|
+
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, bodyRows = 0) {
|
|
39301
39814
|
const focused = state.focus === 'detail';
|
|
39302
39815
|
// Overlays (help / palette / input / confirmation / chord) take
|
|
39303
|
-
// precedence over
|
|
39304
|
-
// via the help-overlay layout branch
|
|
39305
|
-
// defeat their whole purpose (the user is reading them).
|
|
39816
|
+
// precedence over every per-view surface because they claim the
|
|
39817
|
+
// panel's full width via the help-overlay layout branch.
|
|
39306
39818
|
if (state.showHelp) {
|
|
39307
39819
|
return renderHelpPanel(h, components, state, width, theme, focused, bodyRows);
|
|
39308
39820
|
}
|
|
@@ -39334,15 +39846,6 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
39334
39846
|
if (state.pendingKey && !state.splitPlan) {
|
|
39335
39847
|
return renderChordOverlay(h, components, state, width, theme, focused);
|
|
39336
39848
|
}
|
|
39337
|
-
// Rail mode applies only after every overlay above has had its say
|
|
39338
|
-
// — those would all be unreadable at 4 cells of content. The layout
|
|
39339
|
-
// also clears `railed` whenever the inspector takes focus, so we
|
|
39340
|
-
// can safely short-circuit the per-view dispatch here without
|
|
39341
|
-
// worrying about hiding the panel from a user who's actively
|
|
39342
|
-
// reading it.
|
|
39343
|
-
if (railed) {
|
|
39344
|
-
return renderInspectorRail(h, components, state, detail, width, theme, focused);
|
|
39345
|
-
}
|
|
39346
39849
|
// The synthetic "(+) new commit" row routes the inspector through the
|
|
39347
39850
|
// worktree summary so the user sees what's staged / unstaged at a glance
|
|
39348
39851
|
// — same surface as the compose view's right panel.
|
|
@@ -39392,6 +39895,43 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
39392
39895
|
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
|
|
39393
39896
|
}
|
|
39394
39897
|
|
|
39898
|
+
/**
|
|
39899
|
+
* Runtime React Context for the workstation (#1136).
|
|
39900
|
+
*
|
|
39901
|
+
* The render layer currently drills `state` / `dispatch` / `theme` /
|
|
39902
|
+
* `layout` / `context` through every `render*Surface` signature, so
|
|
39903
|
+
* adding a feature repeatedly means threading one more value through
|
|
39904
|
+
* `app → mainPanel → render<View>Surface`. This Context is the single
|
|
39905
|
+
* place those five values live; surfaces read what they need from it
|
|
39906
|
+
* instead of receiving 10–15 positional props.
|
|
39907
|
+
*
|
|
39908
|
+
* Why a factory (`getLogInkRuntimeContext(React)`) instead of a plain
|
|
39909
|
+
* module-level `React.createContext(...)`: the workstation never
|
|
39910
|
+
* statically imports React. `ink` + `react` are ESM-only and loaded via
|
|
39911
|
+
* dynamicImport at boot (see `inkRuntime.ts`), so the rest of the
|
|
39912
|
+
* codebase compiles without bundling them. The Context object must be
|
|
39913
|
+
* built from that same runtime React instance — the one that renders
|
|
39914
|
+
* the tree and the one a consumer's `useContext` reads from have to be
|
|
39915
|
+
* identical. There is exactly one React instance per process, so we
|
|
39916
|
+
* lazily create the Context on first use and cache it; `LogInkApp`'s
|
|
39917
|
+
* provider and (in later PRs) the surface consumers all share the one
|
|
39918
|
+
* identity.
|
|
39919
|
+
*/
|
|
39920
|
+
let cachedContext = null;
|
|
39921
|
+
/**
|
|
39922
|
+
* Lazily create (and thereafter return) the process-wide
|
|
39923
|
+
* `LogInkRuntimeContext`, bound to the runtime React instance. Pass the
|
|
39924
|
+
* same `React` the tree is rendered with — `LogInkApp` uses `deps.React`;
|
|
39925
|
+
* tests use the statically-imported `react`.
|
|
39926
|
+
*/
|
|
39927
|
+
function getLogInkRuntimeContext(React) {
|
|
39928
|
+
if (!cachedContext) {
|
|
39929
|
+
cachedContext = React.createContext(null);
|
|
39930
|
+
cachedContext.displayName = 'LogInkRuntimeContext';
|
|
39931
|
+
}
|
|
39932
|
+
return cachedContext;
|
|
39933
|
+
}
|
|
39934
|
+
|
|
39395
39935
|
/**
|
|
39396
39936
|
* Resolve + scaffold the coco config files the workstation can open in
|
|
39397
39937
|
* `$EDITOR` (the `gk` / `gK` chords and their command-palette entries).
|
|
@@ -39875,6 +40415,10 @@ function LogInkApp(deps) {
|
|
|
39875
40415
|
const loadingMoreCommitsRef = React.useRef(false);
|
|
39876
40416
|
const loadMoreRequestRef = React.useRef(0);
|
|
39877
40417
|
const mountedRef = React.useRef(true);
|
|
40418
|
+
// Last dropped stash {hash, message}, captured before `drop-stash` runs
|
|
40419
|
+
// so `undo-drop-stash` can re-store it. The dropped commit survives in
|
|
40420
|
+
// the object DB until gc, so the hash is enough to bring it back.
|
|
40421
|
+
const lastDroppedStashRef = React.useRef(null);
|
|
39878
40422
|
// P4.3 — idle tip rotation. tickIndex 0 ⇒ no tip; the hook bumps it after
|
|
39879
40423
|
// a grace window of empty statusMessage and then on a steady cadence, so
|
|
39880
40424
|
// the footer surfaces a different hint every interval until the user does
|
|
@@ -41118,11 +41662,15 @@ function LogInkApp(deps) {
|
|
|
41118
41662
|
dispatch({ type: 'setStatus', value: 'AI draft ready for editing', kind: 'success' });
|
|
41119
41663
|
return;
|
|
41120
41664
|
}
|
|
41665
|
+
// Humanize provider errors (rate limit / auth / context / network)
|
|
41666
|
+
// into a short actionable line; success-but-no-draft keeps its
|
|
41667
|
+
// message as-is.
|
|
41668
|
+
const composeMessage = result.ok ? result.message : humanizeAiError(result.message);
|
|
41121
41669
|
dispatch({
|
|
41122
41670
|
type: 'commitCompose',
|
|
41123
|
-
action: { type: 'setResult', message:
|
|
41671
|
+
action: { type: 'setResult', message: composeMessage, details: result.details },
|
|
41124
41672
|
});
|
|
41125
|
-
dispatch({ type: 'setStatus', value: result.
|
|
41673
|
+
dispatch({ type: 'setStatus', value: composeMessage, kind: result.ok ? undefined : 'error' });
|
|
41126
41674
|
}
|
|
41127
41675
|
catch (error) {
|
|
41128
41676
|
// Audit finding #3: defensive recovery for unexpected throws
|
|
@@ -42093,8 +42641,20 @@ function LogInkApp(deps) {
|
|
|
42093
42641
|
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
42094
42642
|
if (!stash)
|
|
42095
42643
|
return { ok: false, message: 'No stash selected' };
|
|
42644
|
+
// Remember the dropped commit so `u` can undo it.
|
|
42645
|
+
if (stash.hash)
|
|
42646
|
+
lastDroppedStashRef.current = { hash: stash.hash, message: stash.message };
|
|
42096
42647
|
return dropStash(git, stash);
|
|
42097
42648
|
},
|
|
42649
|
+
'undo-drop-stash': async () => {
|
|
42650
|
+
const dropped = lastDroppedStashRef.current;
|
|
42651
|
+
if (!dropped)
|
|
42652
|
+
return { ok: false, message: 'Nothing to undo — no stash dropped this session' };
|
|
42653
|
+
const result = await restoreStash(git, dropped.hash, dropped.message);
|
|
42654
|
+
if (result.ok)
|
|
42655
|
+
lastDroppedStashRef.current = null;
|
|
42656
|
+
return result;
|
|
42657
|
+
},
|
|
42098
42658
|
'apply-stash': async () => {
|
|
42099
42659
|
const all = context.stashes?.stashes || [];
|
|
42100
42660
|
const visible = state.filter
|
|
@@ -42105,6 +42665,16 @@ function LogInkApp(deps) {
|
|
|
42105
42665
|
return { ok: false, message: 'No stash selected' };
|
|
42106
42666
|
return applyStash(git, stash);
|
|
42107
42667
|
},
|
|
42668
|
+
'apply-stash-index': async () => {
|
|
42669
|
+
const all = context.stashes?.stashes || [];
|
|
42670
|
+
const visible = state.filter
|
|
42671
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
|
|
42672
|
+
: all;
|
|
42673
|
+
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
42674
|
+
if (!stash)
|
|
42675
|
+
return { ok: false, message: 'No stash selected' };
|
|
42676
|
+
return applyStashKeepIndex(git, stash);
|
|
42677
|
+
},
|
|
42108
42678
|
'pop-stash': async () => {
|
|
42109
42679
|
const all = context.stashes?.stashes || [];
|
|
42110
42680
|
const visible = state.filter
|
|
@@ -42115,6 +42685,26 @@ function LogInkApp(deps) {
|
|
|
42115
42685
|
return { ok: false, message: 'No stash selected' };
|
|
42116
42686
|
return popStash(git, stash);
|
|
42117
42687
|
},
|
|
42688
|
+
'rename-stash': async () => {
|
|
42689
|
+
const all = context.stashes?.stashes || [];
|
|
42690
|
+
const visible = state.filter
|
|
42691
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
|
|
42692
|
+
: all;
|
|
42693
|
+
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
42694
|
+
if (!stash)
|
|
42695
|
+
return { ok: false, message: 'No stash selected' };
|
|
42696
|
+
return renameStash(git, stash, payload ?? '');
|
|
42697
|
+
},
|
|
42698
|
+
'stash-branch': async () => {
|
|
42699
|
+
const all = context.stashes?.stashes || [];
|
|
42700
|
+
const visible = state.filter
|
|
42701
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
|
|
42702
|
+
: all;
|
|
42703
|
+
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
42704
|
+
if (!stash)
|
|
42705
|
+
return { ok: false, message: 'No stash selected' };
|
|
42706
|
+
return stashBranch(git, stash, payload ?? '');
|
|
42707
|
+
},
|
|
42118
42708
|
'bisect-good': async () => {
|
|
42119
42709
|
if (!context.bisect?.active)
|
|
42120
42710
|
return { ok: false, message: 'No bisect in progress' };
|
|
@@ -42525,11 +43115,12 @@ function LogInkApp(deps) {
|
|
|
42525
43115
|
return deleteRemoteTag(git, tag.name);
|
|
42526
43116
|
},
|
|
42527
43117
|
'create-stash': async () => {
|
|
42528
|
-
|
|
42529
|
-
|
|
42530
|
-
|
|
42531
|
-
return createStash(git, message);
|
|
43118
|
+
// Empty is allowed — createStash turns it into a quick WIP stash
|
|
43119
|
+
// (git's own `WIP on <branch>` subject). Naming is optional.
|
|
43120
|
+
return createStash(git, payload ?? '');
|
|
42532
43121
|
},
|
|
43122
|
+
'stash-staged': async () => createStash(git, payload ?? '', { stagedOnly: true }),
|
|
43123
|
+
'stash-keep-index': async () => createStash(git, payload ?? '', { keepIndex: true }),
|
|
42533
43124
|
// #783 — full PR action panel handlers. Each wraps the matching
|
|
42534
43125
|
// pullRequestActions verb. Strategy / body arrives via `payload`
|
|
42535
43126
|
// — input prompts validate before they reach here, but the
|
|
@@ -42777,6 +43368,8 @@ function LogInkApp(deps) {
|
|
|
42777
43368
|
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'untracked');
|
|
42778
43369
|
return stageAllFiles(git, files);
|
|
42779
43370
|
},
|
|
43371
|
+
'stage-all': async () => stageAll(git),
|
|
43372
|
+
'stage-pathspec': async () => stagePathspec(git, payload || ''),
|
|
42780
43373
|
};
|
|
42781
43374
|
const handler = handlers[id];
|
|
42782
43375
|
if (!handler) {
|
|
@@ -42807,6 +43400,16 @@ function LogInkApp(deps) {
|
|
|
42807
43400
|
'checkout-branch',
|
|
42808
43401
|
'continue-operation',
|
|
42809
43402
|
'pull-current-branch',
|
|
43403
|
+
// Fetch / pull / push bring in new commits and move
|
|
43404
|
+
// remote-tracking refs (origin/main, ahead/behind) — refresh the
|
|
43405
|
+
// graph so they appear instead of staying pinned to the pre-sync
|
|
43406
|
+
// state. (A successful push advances the local origin/<branch>
|
|
43407
|
+
// ref, so the chip should hop to the pushed commit.)
|
|
43408
|
+
'fetch-remotes',
|
|
43409
|
+
'fetch-selected-branch',
|
|
43410
|
+
'pull-selected-branch',
|
|
43411
|
+
'push-current-branch',
|
|
43412
|
+
'push-selected-branch',
|
|
42810
43413
|
'cherry-pick-commit',
|
|
42811
43414
|
'revert-commit',
|
|
42812
43415
|
'reset-hard-to-commit',
|
|
@@ -42861,6 +43464,11 @@ function LogInkApp(deps) {
|
|
|
42861
43464
|
if (result?.ok && id === 'add-to-gitignore') {
|
|
42862
43465
|
await refreshWorktreeContext();
|
|
42863
43466
|
}
|
|
43467
|
+
// Stage-all / stage-pathspec change staged/unstaged counts — refresh
|
|
43468
|
+
// the worktree so the status list + compose summary reflect it.
|
|
43469
|
+
if (result?.ok && (id === 'stage-all' || id === 'stage-pathspec')) {
|
|
43470
|
+
await refreshWorktreeContext();
|
|
43471
|
+
}
|
|
42864
43472
|
if (result?.ok && id === 'drop-stash') {
|
|
42865
43473
|
// Explicit worktree refresh in case the dropped stash carried
|
|
42866
43474
|
// untracked-file state that's now collected.
|
|
@@ -43444,6 +44052,11 @@ function LogInkApp(deps) {
|
|
|
43444
44052
|
? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
|
|
43445
44053
|
: undefined;
|
|
43446
44054
|
getLogInkInputEvents(state, inputValue, key, {
|
|
44055
|
+
// Narrow terminals show one pane at a time (#1135) — gates the `v`
|
|
44056
|
+
// peek key. Derived the same way the layout does, since `layout`
|
|
44057
|
+
// is computed later in the render path (not in this callback).
|
|
44058
|
+
singlePane: (windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS) <
|
|
44059
|
+
LAYOUT_SINGLE_PANE_BELOW,
|
|
43447
44060
|
detailFileCount: detail?.files.length,
|
|
43448
44061
|
previewLineCount: diffPreviewLineCount,
|
|
43449
44062
|
worktreeDiffLineCount: worktreeDiff?.lines.length,
|
|
@@ -43568,7 +44181,14 @@ function LogInkApp(deps) {
|
|
|
43568
44181
|
exit();
|
|
43569
44182
|
}
|
|
43570
44183
|
else if (event.type === 'refreshContext') {
|
|
44184
|
+
// The user-initiated refresh (`r`) refreshes BOTH the metadata
|
|
44185
|
+
// context (branches/tags/worktree) AND the commit rows. Without
|
|
44186
|
+
// the row re-fetch the history graph stays pinned to whatever
|
|
44187
|
+
// commits existed at boot — new commits (made in another
|
|
44188
|
+
// terminal, or remote commits brought in by a fetch) never
|
|
44189
|
+
// appear until relaunch, which reads as "the history is stuck."
|
|
43571
44190
|
void refreshContext();
|
|
44191
|
+
void refreshHistoryRows();
|
|
43572
44192
|
}
|
|
43573
44193
|
else if (event.type === 'toggleSelectedFileStage') {
|
|
43574
44194
|
void toggleSelectedFileStage();
|
|
@@ -43667,6 +44287,24 @@ function LogInkApp(deps) {
|
|
|
43667
44287
|
}
|
|
43668
44288
|
});
|
|
43669
44289
|
});
|
|
44290
|
+
// In single-pane mode (narrow terminals) only one pane renders, so an
|
|
44291
|
+
// active overlay must pull its own pane into view rather than stay
|
|
44292
|
+
// hidden behind whatever pane focus points at. The split-plan overlay
|
|
44293
|
+
// lives in the main panel; every other overlay (help / palette / theme
|
|
44294
|
+
// / gitignore / input prompt / confirmation / chord) renders in the
|
|
44295
|
+
// inspector. Ignored above the single-pane breakpoint (all panes show).
|
|
44296
|
+
const forcedPane = state.splitPlan
|
|
44297
|
+
? 'main'
|
|
44298
|
+
: state.showHelp ||
|
|
44299
|
+
state.showCommandPalette ||
|
|
44300
|
+
state.showThemePicker ||
|
|
44301
|
+
state.gitignorePicker ||
|
|
44302
|
+
state.inputPrompt ||
|
|
44303
|
+
state.pendingConfirmationId ||
|
|
44304
|
+
state.pendingMutationConfirmation ||
|
|
44305
|
+
state.pendingKey
|
|
44306
|
+
? 'inspector'
|
|
44307
|
+
: undefined;
|
|
43670
44308
|
// Layout depends on focus (sidebar grows when focused), so it's
|
|
43671
44309
|
// computed here — after state is in scope but before the render path.
|
|
43672
44310
|
const layout = getLogInkLayout({
|
|
@@ -43675,7 +44313,22 @@ function LogInkApp(deps) {
|
|
|
43675
44313
|
sidebarFocused: state.focus === 'sidebar',
|
|
43676
44314
|
inspectorFocused: state.focus === 'detail',
|
|
43677
44315
|
helpOverlayActive: state.showHelp,
|
|
44316
|
+
forcedPane,
|
|
43678
44317
|
});
|
|
44318
|
+
// Runtime Context provider (#1136). Bundles the five most-drilled
|
|
44319
|
+
// values so surfaces can read them from context instead of receiving
|
|
44320
|
+
// them as positional props. No consumers yet — this PR only installs
|
|
44321
|
+
// the provider at the root; the surface families migrate in later PRs.
|
|
44322
|
+
// A Context.Provider renders its children transparently (no host
|
|
44323
|
+
// output), so wrapping the tree is behavior-preserving.
|
|
44324
|
+
const RuntimeContext = getLogInkRuntimeContext(React);
|
|
44325
|
+
const runtimeContextValue = {
|
|
44326
|
+
state,
|
|
44327
|
+
dispatch,
|
|
44328
|
+
theme,
|
|
44329
|
+
layout,
|
|
44330
|
+
context,
|
|
44331
|
+
};
|
|
43679
44332
|
if (layout.tooSmall) {
|
|
43680
44333
|
return h(Box, {
|
|
43681
44334
|
flexDirection: 'column',
|
|
@@ -43689,7 +44342,35 @@ function LogInkApp(deps) {
|
|
|
43689
44342
|
if (showOnboarding) {
|
|
43690
44343
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
43691
44344
|
}
|
|
43692
|
-
|
|
44345
|
+
// Panel renderers are thunks so single-pane mode can build only the
|
|
44346
|
+
// visible pane — the main-panel render in particular is expensive, so
|
|
44347
|
+
// we don't want to invoke the two hidden ones just to drop them.
|
|
44348
|
+
const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme);
|
|
44349
|
+
const mainSurface = {
|
|
44350
|
+
h,
|
|
44351
|
+
components: { Box, Text },
|
|
44352
|
+
state,
|
|
44353
|
+
context,
|
|
44354
|
+
contextStatus,
|
|
44355
|
+
bodyRows: layout.bodyRows,
|
|
44356
|
+
width: layout.mainPanelWidth,
|
|
44357
|
+
theme,
|
|
44358
|
+
};
|
|
44359
|
+
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);
|
|
44360
|
+
const detailPanel = () => renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.bodyRows);
|
|
44361
|
+
// Single-pane mode (narrow terminals): exactly one full-width pane,
|
|
44362
|
+
// chosen by `layout.visiblePane`; Tab cycles which one. Above the
|
|
44363
|
+
// breakpoint all three tile side by side as before.
|
|
44364
|
+
const bodyPanels = layout.singlePane
|
|
44365
|
+
? [
|
|
44366
|
+
layout.visiblePane === 'sidebar'
|
|
44367
|
+
? sidebarPanel()
|
|
44368
|
+
: layout.visiblePane === 'inspector'
|
|
44369
|
+
? detailPanel()
|
|
44370
|
+
: mainPanel(),
|
|
44371
|
+
]
|
|
44372
|
+
: [sidebarPanel(), mainPanel(), detailPanel()];
|
|
44373
|
+
return h(RuntimeContext.Provider, { value: runtimeContextValue }, h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader$1(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, ...bodyPanels), renderFooter$1(h, { Box, Text }, state, context, theme, idleTip, spinnerFrame, layout.singlePane)));
|
|
43693
44374
|
}
|
|
43694
44375
|
|
|
43695
44376
|
/**
|
|
@@ -45809,6 +46490,54 @@ async function getWorkspacePullRequestCounts(repoPaths, options = {}) {
|
|
|
45809
46490
|
return { authenticated: true, counts };
|
|
45810
46491
|
}
|
|
45811
46492
|
|
|
46493
|
+
/**
|
|
46494
|
+
* Clone a remote repository into a local path — the runtime side of the
|
|
46495
|
+
* workspace surface's `c` (clone) flow.
|
|
46496
|
+
*
|
|
46497
|
+
* `deriveRepoName` is pure (and tested) so the UI can pre-fill the
|
|
46498
|
+
* destination as `<cwd>/<name>` the moment a URL is typed; `cloneRepo`
|
|
46499
|
+
* does the filesystem-touching work and reports a friendly result.
|
|
46500
|
+
*/
|
|
46501
|
+
/**
|
|
46502
|
+
* Infer the repository folder name from a clone URL or SSH spec:
|
|
46503
|
+
* git@github.com:gfargo/coco.git → coco
|
|
46504
|
+
* https://github.com/gfargo/coco → coco
|
|
46505
|
+
* https://example.com/a/b/c.git/ → c
|
|
46506
|
+
* Falls back to `repo` when nothing usable can be parsed.
|
|
46507
|
+
*/
|
|
46508
|
+
function deriveRepoName(url) {
|
|
46509
|
+
const trimmed = url.trim().replace(/\/+$/, '').replace(/\.git$/i, '');
|
|
46510
|
+
if (!trimmed)
|
|
46511
|
+
return 'repo';
|
|
46512
|
+
// Split on both `/` and `:` so `host:owner/name` SSH specs work.
|
|
46513
|
+
const segment = trimmed.split(/[/:]/).filter(Boolean).pop() || '';
|
|
46514
|
+
return segment || 'repo';
|
|
46515
|
+
}
|
|
46516
|
+
/**
|
|
46517
|
+
* Clone `url` into `targetPath`. Refuses to clobber an existing path so
|
|
46518
|
+
* a typo never overwrites a directory. Network / auth failures surface
|
|
46519
|
+
* git's own message (trimmed to one line).
|
|
46520
|
+
*/
|
|
46521
|
+
async function cloneRepo(url, targetPath) {
|
|
46522
|
+
const remote = url.trim();
|
|
46523
|
+
const dest = targetPath.trim();
|
|
46524
|
+
if (!remote)
|
|
46525
|
+
return { ok: false, message: 'Enter a remote URL to clone.' };
|
|
46526
|
+
if (!dest)
|
|
46527
|
+
return { ok: false, message: 'Enter a destination path.' };
|
|
46528
|
+
if (fs.existsSync(dest)) {
|
|
46529
|
+
return { ok: false, message: `${dest} already exists — choose another path.` };
|
|
46530
|
+
}
|
|
46531
|
+
try {
|
|
46532
|
+
await simpleGit().clone(remote, dest);
|
|
46533
|
+
return { ok: true, message: `Cloned into ${dest}` };
|
|
46534
|
+
}
|
|
46535
|
+
catch (error) {
|
|
46536
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
46537
|
+
return { ok: false, message: `Clone failed: ${raw.split('\n')[0]}` };
|
|
46538
|
+
}
|
|
46539
|
+
}
|
|
46540
|
+
|
|
45812
46541
|
function resolveStoreDir(subdir) {
|
|
45813
46542
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
45814
46543
|
const root = xdg && xdg.trim().length > 0 ? xdg : path$1.join(os$1.homedir(), '.cache');
|
|
@@ -46051,6 +46780,7 @@ function createWorkspaceState(init) {
|
|
|
46051
46780
|
showThemePicker: false,
|
|
46052
46781
|
themePickerFilter: '',
|
|
46053
46782
|
themePickerIndex: 0,
|
|
46783
|
+
helpScrollOffset: 0,
|
|
46054
46784
|
knownRepoPaths: init.knownRepoPaths ?? [],
|
|
46055
46785
|
pullRequestFetching: [],
|
|
46056
46786
|
};
|
|
@@ -46216,10 +46946,17 @@ function applyWorkspaceAction(state, action) {
|
|
|
46216
46946
|
return { ...state, status: action.status };
|
|
46217
46947
|
}
|
|
46218
46948
|
case 'toggle-help': {
|
|
46219
|
-
|
|
46949
|
+
// Always reopen at the top — picking up the last scroll position
|
|
46950
|
+
// is more surprising than predictable for a reference overlay.
|
|
46951
|
+
return { ...state, showHelp: !state.showHelp, helpScrollOffset: 0, showOnboarding: false };
|
|
46220
46952
|
}
|
|
46221
46953
|
case 'close-help': {
|
|
46222
|
-
return { ...state, showHelp: false };
|
|
46954
|
+
return { ...state, showHelp: false, helpScrollOffset: 0 };
|
|
46955
|
+
}
|
|
46956
|
+
case 'scroll-help': {
|
|
46957
|
+
// Floor-clamp at 0 only; the renderer ceiling-clamps against the
|
|
46958
|
+
// real content height so `j` past the end sticks at the last row.
|
|
46959
|
+
return { ...state, helpScrollOffset: Math.max(0, state.helpScrollOffset + action.delta) };
|
|
46223
46960
|
}
|
|
46224
46961
|
case 'toggle-theme-picker': {
|
|
46225
46962
|
return {
|
|
@@ -46549,6 +47286,7 @@ function buildWorkspaceListWindow(state, options = { rows: 20 }) {
|
|
|
46549
47286
|
const all = buildWorkspaceListRows(state, {
|
|
46550
47287
|
width: options.width,
|
|
46551
47288
|
spinnerTick: options.spinnerTick,
|
|
47289
|
+
now: options.now,
|
|
46552
47290
|
});
|
|
46553
47291
|
const visibleCount = Math.max(1, options.rows);
|
|
46554
47292
|
if (all.length <= visibleCount) {
|
|
@@ -46672,10 +47410,11 @@ function buildWorkspaceHeaderChips(state, options = { focusLabel: 'List' }) {
|
|
|
46672
47410
|
// The contextual slot drops bindings users can find via the help
|
|
46673
47411
|
// overlay (arrow keys, tab); the global slot is the safety net so
|
|
46674
47412
|
// `? help` and `q quit` never disappear.
|
|
46675
|
-
const LIST_CONTEXTUAL = ['s sort', '/ filter', 'r/R refresh', 'a add', 'd remove'];
|
|
47413
|
+
const LIST_CONTEXTUAL = ['s sort', '/ filter', 'r/R refresh', 'a add', 'c clone', 'd remove'];
|
|
46676
47414
|
const SIDEBAR_CONTEXTUAL = ['↑/↓ cycle tab', 'enter open'];
|
|
46677
47415
|
const FILTER_CONTEXTUAL = ['type to filter', 'enter apply', 'esc cancel'];
|
|
46678
47416
|
const ADD_REPO_CONTEXTUAL = ['type path', 'tab to complete', 'enter to add', 'esc to cancel'];
|
|
47417
|
+
const CLONE_REPO_CONTEXTUAL = ['enter URL', 'enter → destination', 'enter to clone', 'esc to cancel'];
|
|
46679
47418
|
const CONFIRM_DELETE_CONTEXTUAL = ['y confirm', 'any other key cancels'];
|
|
46680
47419
|
const GLOBAL_HINTS = ['? help', 'q quit'];
|
|
46681
47420
|
function contextualHintsFor(focus) {
|
|
@@ -46686,6 +47425,8 @@ function contextualHintsFor(focus) {
|
|
|
46686
47425
|
return FILTER_CONTEXTUAL;
|
|
46687
47426
|
case 'add-repo':
|
|
46688
47427
|
return ADD_REPO_CONTEXTUAL;
|
|
47428
|
+
case 'clone-repo':
|
|
47429
|
+
return CLONE_REPO_CONTEXTUAL;
|
|
46689
47430
|
case 'confirm-delete':
|
|
46690
47431
|
return CONFIRM_DELETE_CONTEXTUAL;
|
|
46691
47432
|
case 'list':
|
|
@@ -46700,6 +47441,7 @@ function buildWorkspaceFooter(state) {
|
|
|
46700
47441
|
// is open and showing them would be misleading.
|
|
46701
47442
|
const isModal = state.focus === 'filter' ||
|
|
46702
47443
|
state.focus === 'add-repo' ||
|
|
47444
|
+
state.focus === 'clone-repo' ||
|
|
46703
47445
|
state.focus === 'confirm-delete';
|
|
46704
47446
|
const global = isModal ? [] : GLOBAL_HINTS;
|
|
46705
47447
|
const allHints = [...contextual, ...global];
|
|
@@ -46761,6 +47503,7 @@ function buildWorkspaceHelpSections() {
|
|
|
46761
47503
|
{ glyph: '⟳', keys: 'r', description: 'Refresh all repos (discovery + PR counts)' },
|
|
46762
47504
|
{ glyph: '⟲', keys: 'R', description: 'Refresh just the cursored repo (faster)' },
|
|
46763
47505
|
{ glyph: '+', keys: 'a', description: 'Add a repo via path prompt (tab-completes)' },
|
|
47506
|
+
{ glyph: '⬇', keys: 'c', description: 'Clone a remote repo (defaults into the launch directory)' },
|
|
46764
47507
|
{ glyph: '✕', keys: 'd', description: 'Remove the cursored repo from the known-repos store' },
|
|
46765
47508
|
],
|
|
46766
47509
|
},
|
|
@@ -46778,7 +47521,7 @@ function buildWorkspaceOnboarding(state) {
|
|
|
46778
47521
|
: undefined,
|
|
46779
47522
|
populatedHint: empty
|
|
46780
47523
|
? undefined
|
|
46781
|
-
: 'Press `enter` to open a repo ·
|
|
47524
|
+
: 'Press `enter` to open a repo · `a` to add by path · `c` to clone · `?` for the full keymap.',
|
|
46782
47525
|
};
|
|
46783
47526
|
}
|
|
46784
47527
|
|
|
@@ -47074,6 +47817,7 @@ function renderListBody(deps, width, height) {
|
|
|
47074
47817
|
width,
|
|
47075
47818
|
rows: listRows,
|
|
47076
47819
|
spinnerTick: deps.spinnerTick,
|
|
47820
|
+
now: deps.now,
|
|
47077
47821
|
});
|
|
47078
47822
|
const visibleRepos = selectVisibleRepos(state);
|
|
47079
47823
|
const filterChip = state.filter
|
|
@@ -47109,7 +47853,7 @@ function renderListBody(deps, width, height) {
|
|
|
47109
47853
|
function renderHelpRow(deps, row, glyphWidth, keysWidth, key) {
|
|
47110
47854
|
const { React, ink, theme } = deps;
|
|
47111
47855
|
const { Box, Text } = ink;
|
|
47112
|
-
return React.createElement(Box, { key, flexDirection: 'row' }, React.createElement(Box, { width: glyphWidth, flexShrink: 0 }, React.createElement(Text, { color: theme.noColor ? undefined : theme.colors.accent, bold: true }, ` ${row.glyph ?? ' '} `)), React.createElement(Box, { width: keysWidth, flexShrink: 0 }, React.createElement(Text, { color: theme.noColor ? undefined : theme.colors.success, bold: true }, row.keys)), React.createElement(Text, null, row.description));
|
|
47856
|
+
return React.createElement(Box, { key, flexShrink: 0, flexDirection: 'row' }, React.createElement(Box, { width: glyphWidth, flexShrink: 0 }, React.createElement(Text, { color: theme.noColor ? undefined : theme.colors.accent, bold: true }, ` ${row.glyph ?? ' '} `)), React.createElement(Box, { width: keysWidth, flexShrink: 0 }, React.createElement(Text, { color: theme.noColor ? undefined : theme.colors.success, bold: true }, row.keys)), React.createElement(Text, null, row.description));
|
|
47113
47857
|
}
|
|
47114
47858
|
function renderHelpOverlay(deps) {
|
|
47115
47859
|
if (!deps.state.showHelp) {
|
|
@@ -47122,34 +47866,68 @@ function renderHelpOverlay(deps) {
|
|
|
47122
47866
|
// Columns: glyph cell (4 cells) · keys (padded to longest) · description.
|
|
47123
47867
|
const glyphWidth = 4;
|
|
47124
47868
|
const keysWidth = Math.max(14, allRows.reduce((acc, row) => Math.max(acc, row.keys.length), 0) + 4);
|
|
47125
|
-
|
|
47126
|
-
//
|
|
47127
|
-
//
|
|
47128
|
-
//
|
|
47129
|
-
|
|
47130
|
-
|
|
47131
|
-
|
|
47132
|
-
// then its rows, then a blank line.
|
|
47869
|
+
// Body lines — every scrollable row below the pinned title. Built as
|
|
47870
|
+
// a flat list (section title → optional subtitle → rows → inter-section
|
|
47871
|
+
// spacer) so we can window it against the available height. Each entry
|
|
47872
|
+
// is `flexShrink: 0` so Ink never crushes rows on top of each other
|
|
47873
|
+
// when the keymap is taller than the panel (which used to collapse the
|
|
47874
|
+
// title and the first category onto the same line).
|
|
47875
|
+
const body = [];
|
|
47133
47876
|
sections.forEach((section, sIndex) => {
|
|
47134
|
-
|
|
47877
|
+
body.push(React.createElement(Text, {
|
|
47135
47878
|
key: `section-${sIndex}-title`,
|
|
47136
47879
|
bold: true,
|
|
47137
47880
|
color: theme.noColor ? undefined : theme.colors.muted,
|
|
47138
47881
|
}, section.title.toUpperCase()));
|
|
47139
47882
|
if (section.subtitle) {
|
|
47140
|
-
|
|
47883
|
+
body.push(React.createElement(Text, { key: `section-${sIndex}-subtitle`, dimColor: true }, ` ${section.subtitle}`));
|
|
47141
47884
|
}
|
|
47142
47885
|
section.rows.forEach((row, rIndex) => {
|
|
47143
|
-
|
|
47886
|
+
body.push(renderHelpRow(deps, row, glyphWidth, keysWidth, `row-${sIndex}-${rIndex}`));
|
|
47144
47887
|
});
|
|
47145
47888
|
if (sIndex < sections.length - 1) {
|
|
47146
|
-
|
|
47889
|
+
body.push(React.createElement(Text, { key: `section-${sIndex}-spacer` }, ''));
|
|
47147
47890
|
}
|
|
47148
47891
|
});
|
|
47892
|
+
// Vertical budget: the overlay shares the column with the header
|
|
47893
|
+
// (3 rows) and footer (FOOTER_HEIGHT). Its own chrome eats the border
|
|
47894
|
+
// (2), the pinned title (1) and the title/body separator (1). Whatever
|
|
47895
|
+
// is left is the window we slide the body through.
|
|
47896
|
+
const HEADER_ROWS = 3;
|
|
47897
|
+
const overlayChromeRows = 4;
|
|
47898
|
+
const visibleRows = Math.max(4, deps.rows - HEADER_ROWS - FOOTER_HEIGHT - overlayChromeRows);
|
|
47899
|
+
// Ceiling-clamp the offset here (the reducer only floors at 0) so
|
|
47900
|
+
// scrolling past the end sticks at the last row instead of revealing
|
|
47901
|
+
// blank space.
|
|
47902
|
+
const maxOffset = Math.max(0, body.length - visibleRows);
|
|
47903
|
+
const offset = Math.min(deps.state.helpScrollOffset, maxOffset);
|
|
47904
|
+
const children = [];
|
|
47905
|
+
// Title bar — accent-tinged, matches the chip-style header on the
|
|
47906
|
+
// main surface so the help reads as the same app, just a different
|
|
47907
|
+
// panel. Pinned above the scrolling body.
|
|
47908
|
+
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 ')));
|
|
47909
|
+
children.push(React.createElement(Text, { key: 'title-sep', dimColor: true }, ''));
|
|
47910
|
+
// "more above" / "more below" hints each consume a window row so they
|
|
47911
|
+
// don't push body content off-screen. Mirrors the `coco ui` overlay.
|
|
47912
|
+
let windowSize = visibleRows;
|
|
47913
|
+
const hasMoreAbove = offset > 0;
|
|
47914
|
+
if (hasMoreAbove) {
|
|
47915
|
+
windowSize -= 1;
|
|
47916
|
+
children.push(React.createElement(Text, { key: 'more-above', dimColor: true }, ' ↑ more above (j/k or ↑/↓ to scroll)'));
|
|
47917
|
+
}
|
|
47918
|
+
const hasMoreBelow = offset + windowSize < body.length;
|
|
47919
|
+
if (hasMoreBelow) {
|
|
47920
|
+
windowSize -= 1;
|
|
47921
|
+
}
|
|
47922
|
+
children.push(...body.slice(offset, offset + windowSize));
|
|
47923
|
+
if (hasMoreBelow) {
|
|
47924
|
+
children.push(React.createElement(Text, { key: 'more-below', dimColor: true }, ' ↓ more below (j/k or ↑/↓ to scroll)'));
|
|
47925
|
+
}
|
|
47149
47926
|
return React.createElement(Box, {
|
|
47150
47927
|
borderColor: focusBorderColor(theme, true),
|
|
47151
47928
|
borderStyle: theme.borderStyle,
|
|
47152
47929
|
flexDirection: 'column',
|
|
47930
|
+
flexShrink: 0,
|
|
47153
47931
|
paddingX: 1,
|
|
47154
47932
|
}, ...children);
|
|
47155
47933
|
}
|
|
@@ -47200,6 +47978,29 @@ function renderAddRepoPrompt(deps) {
|
|
|
47200
47978
|
? React.createElement(Text, { color: toneColor('dim', theme) }, completionLine)
|
|
47201
47979
|
: null);
|
|
47202
47980
|
}
|
|
47981
|
+
function renderCloneRepoPrompt(deps) {
|
|
47982
|
+
if (deps.state.focus !== 'clone-repo') {
|
|
47983
|
+
return null;
|
|
47984
|
+
}
|
|
47985
|
+
const { React, ink, theme, cloneUrl, cloneTarget, cloneField, cloneCompletion, cloning } = deps;
|
|
47986
|
+
const { Box, Text } = ink;
|
|
47987
|
+
const urlActive = cloneField === 'url' && !cloning;
|
|
47988
|
+
const targetActive = cloneField === 'target' && !cloning;
|
|
47989
|
+
const completionLine = cloneCompletion.completions.slice(0, 8).join(' ');
|
|
47990
|
+
const hint = cloning
|
|
47991
|
+
? 'Cloning… this can take a moment for large repos.'
|
|
47992
|
+
: cloneField === 'url'
|
|
47993
|
+
? 'Paste a remote URL (https or git@…), then enter for the destination.'
|
|
47994
|
+
: 'Edit the destination · tab to complete · enter to clone · esc to cancel';
|
|
47995
|
+
return React.createElement(Box, {
|
|
47996
|
+
borderColor: focusBorderColor(theme, true),
|
|
47997
|
+
borderStyle: theme.borderStyle,
|
|
47998
|
+
flexDirection: 'column',
|
|
47999
|
+
paddingX: 1,
|
|
48000
|
+
}, 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
|
|
48001
|
+
? React.createElement(Text, { color: toneColor('dim', theme) }, completionLine)
|
|
48002
|
+
: null);
|
|
48003
|
+
}
|
|
47203
48004
|
const FOOTER_HEIGHT = 4; // 2 borders + hint row + status row
|
|
47204
48005
|
function renderFooter(deps) {
|
|
47205
48006
|
const { React, ink, state, theme } = deps;
|
|
@@ -47241,8 +48042,10 @@ function computeBodyHeight(deps) {
|
|
|
47241
48042
|
const FOOTER_ROWS = FOOTER_HEIGHT;
|
|
47242
48043
|
const onboardingRows = buildWorkspaceOnboarding(deps.state).show ? 5 : 0;
|
|
47243
48044
|
const addRepoRows = deps.state.focus === 'add-repo' ? 5 : 0;
|
|
48045
|
+
// Clone modal is one row taller (URL + Into + hint + completion).
|
|
48046
|
+
const cloneRows = deps.state.focus === 'clone-repo' ? 6 : 0;
|
|
47244
48047
|
const confirmRows = deps.state.focus === 'confirm-delete' ? 5 : 0;
|
|
47245
|
-
const reserved = HEADER_ROWS + FOOTER_ROWS + onboardingRows + addRepoRows + confirmRows;
|
|
48048
|
+
const reserved = HEADER_ROWS + FOOTER_ROWS + onboardingRows + addRepoRows + cloneRows + confirmRows;
|
|
47246
48049
|
return Math.max(8, deps.rows - reserved);
|
|
47247
48050
|
}
|
|
47248
48051
|
function renderWorkspaceApp(deps) {
|
|
@@ -47264,7 +48067,7 @@ function renderWorkspaceApp(deps) {
|
|
|
47264
48067
|
return React.createElement(Box, { flexDirection: 'column', height: rootHeight }, renderHeader(deps), renderThemePickerOverlay(React.createElement, { Box: ink.Box, Text: ink.Text }, deps.state.themePickerFilter, deps.state.themePickerIndex, bodyWidth, deps.theme, true), renderFooter(deps));
|
|
47265
48068
|
}
|
|
47266
48069
|
const bodyHeight = computeBodyHeight(deps);
|
|
47267
|
-
return React.createElement(Box, { flexDirection: 'column', height: rootHeight }, renderHeader(deps), React.createElement(Box, { flexDirection: 'row', height: bodyHeight }, renderSidebar(deps, bodyHeight), renderListBody(deps, bodyWidth - sidebarWidthFor(deps) - 2, bodyHeight)), renderOnboardingBanner(deps), renderAddRepoPrompt(deps), renderConfirmDelete(deps), renderFooter(deps));
|
|
48070
|
+
return React.createElement(Box, { flexDirection: 'column', height: rootHeight }, renderHeader(deps), React.createElement(Box, { flexDirection: 'row', height: bodyHeight }, renderSidebar(deps, bodyHeight), renderListBody(deps, bodyWidth - sidebarWidthFor(deps) - 2, bodyHeight)), renderOnboardingBanner(deps), renderAddRepoPrompt(deps), renderCloneRepoPrompt(deps), renderConfirmDelete(deps), renderFooter(deps));
|
|
47268
48071
|
}
|
|
47269
48072
|
|
|
47270
48073
|
/**
|
|
@@ -47301,6 +48104,21 @@ function resolveWorkspaceInput(input, key, state) {
|
|
|
47301
48104
|
if (key.escape || input === '?' || input === 'q') {
|
|
47302
48105
|
return { kind: 'action', action: { type: 'close-help' } };
|
|
47303
48106
|
}
|
|
48107
|
+
// The keymap is taller than the panel on short terminals — let
|
|
48108
|
+
// j/k/↑/↓ and ctrl+d/u scroll the windowed body. Mirrors the
|
|
48109
|
+
// `coco ui` help overlay.
|
|
48110
|
+
if (key.downArrow || input === 'j') {
|
|
48111
|
+
return { kind: 'action', action: { type: 'scroll-help', delta: 1 } };
|
|
48112
|
+
}
|
|
48113
|
+
if (key.upArrow || input === 'k') {
|
|
48114
|
+
return { kind: 'action', action: { type: 'scroll-help', delta: -1 } };
|
|
48115
|
+
}
|
|
48116
|
+
if (key.ctrl && input === 'd') {
|
|
48117
|
+
return { kind: 'action', action: { type: 'scroll-help', delta: 10 } };
|
|
48118
|
+
}
|
|
48119
|
+
if (key.ctrl && input === 'u') {
|
|
48120
|
+
return { kind: 'action', action: { type: 'scroll-help', delta: -10 } };
|
|
48121
|
+
}
|
|
47304
48122
|
return { kind: 'noop' };
|
|
47305
48123
|
}
|
|
47306
48124
|
// Theme picker is modal (like `coco ui`'s gC): type to filter, ↑/↓ to
|
|
@@ -47351,6 +48169,14 @@ function resolveWorkspaceInput(input, key, state) {
|
|
|
47351
48169
|
// can drive the path-completion prompt.
|
|
47352
48170
|
return { kind: 'noop' };
|
|
47353
48171
|
}
|
|
48172
|
+
if (state.focus === 'clone-repo') {
|
|
48173
|
+
if (key.escape) {
|
|
48174
|
+
return { kind: 'action', action: { type: 'set-focus', focus: 'list' } };
|
|
48175
|
+
}
|
|
48176
|
+
// Enter/Tab/printable keys drive the URL + destination prompt in the
|
|
48177
|
+
// runtime (it owns the two-field state + path completion).
|
|
48178
|
+
return { kind: 'noop' };
|
|
48179
|
+
}
|
|
47354
48180
|
// Confirm-delete is modal: only `y` confirms, anything else cancels.
|
|
47355
48181
|
if (state.focus === 'confirm-delete') {
|
|
47356
48182
|
if (input === 'y' || input === 'Y') {
|
|
@@ -47445,6 +48271,9 @@ function resolveWorkspaceInput(input, key, state) {
|
|
|
47445
48271
|
if (input === 'a') {
|
|
47446
48272
|
return { kind: 'add-repo' };
|
|
47447
48273
|
}
|
|
48274
|
+
if (input === 'c') {
|
|
48275
|
+
return { kind: 'clone-repo' };
|
|
48276
|
+
}
|
|
47448
48277
|
if (input === 'd') {
|
|
47449
48278
|
return { kind: 'request-delete' };
|
|
47450
48279
|
}
|
|
@@ -47999,6 +48828,19 @@ function WorkspaceInkApp(props) {
|
|
|
47999
48828
|
const [filterDraft, setFilterDraft] = React.useState('');
|
|
48000
48829
|
const [addRepoDraft, setAddRepoDraft] = React.useState('~/');
|
|
48001
48830
|
const [addRepoCompletion, setAddRepoCompletion] = React.useState(() => completePath('~/'));
|
|
48831
|
+
// Clone-repo modal (`c`). Two fields: the remote URL and the
|
|
48832
|
+
// destination path. `cloneField` tracks which is active; `cloneTarget`
|
|
48833
|
+
// auto-derives `<cwd>/<repo-name>` from the URL until the user edits it
|
|
48834
|
+
// (`cloneTargetEdited`). `cloning` blocks input + shows a spinner while
|
|
48835
|
+
// `git clone` runs. The boot cwd is captured once at mount so it stays
|
|
48836
|
+
// the directory the workspace launched in even after drill-in.
|
|
48837
|
+
const bootCwdRef = React.useRef(process.cwd());
|
|
48838
|
+
const [cloneUrl, setCloneUrl] = React.useState('');
|
|
48839
|
+
const [cloneTarget, setCloneTarget] = React.useState('');
|
|
48840
|
+
const [cloneField, setCloneField] = React.useState('url');
|
|
48841
|
+
const [cloneTargetEdited, setCloneTargetEdited] = React.useState(false);
|
|
48842
|
+
const [cloneCompletion, setCloneCompletion] = React.useState(() => completePath('~/'));
|
|
48843
|
+
const [cloning, setCloning] = React.useState(false);
|
|
48002
48844
|
// Tick counter for the per-row PR-fetch spinner. Bumped on a
|
|
48003
48845
|
// setInterval that only runs while at least one row is mid-fetch
|
|
48004
48846
|
// (see effect below) so idle workspaces don't burn CPU on animation
|
|
@@ -48048,6 +48890,18 @@ function WorkspaceInkApp(props) {
|
|
|
48048
48890
|
addRepoDraftRef.current = addRepoDraft;
|
|
48049
48891
|
const addRepoCompletionRef = React.useRef(addRepoCompletion);
|
|
48050
48892
|
addRepoCompletionRef.current = addRepoCompletion;
|
|
48893
|
+
const cloneUrlRef = React.useRef(cloneUrl);
|
|
48894
|
+
cloneUrlRef.current = cloneUrl;
|
|
48895
|
+
const cloneTargetRef = React.useRef(cloneTarget);
|
|
48896
|
+
cloneTargetRef.current = cloneTarget;
|
|
48897
|
+
const cloneFieldRef = React.useRef(cloneField);
|
|
48898
|
+
cloneFieldRef.current = cloneField;
|
|
48899
|
+
const cloneTargetEditedRef = React.useRef(cloneTargetEdited);
|
|
48900
|
+
cloneTargetEditedRef.current = cloneTargetEdited;
|
|
48901
|
+
const cloneCompletionRef = React.useRef(cloneCompletion);
|
|
48902
|
+
cloneCompletionRef.current = cloneCompletion;
|
|
48903
|
+
const cloningRef = React.useRef(cloning);
|
|
48904
|
+
cloningRef.current = cloning;
|
|
48051
48905
|
// Background discovery + PR-count refresh on mount.
|
|
48052
48906
|
React.useEffect(() => {
|
|
48053
48907
|
let cancelled = false;
|
|
@@ -48280,6 +49134,60 @@ function WorkspaceInkApp(props) {
|
|
|
48280
49134
|
});
|
|
48281
49135
|
}
|
|
48282
49136
|
}, [addRepoDraft, dispatch, props]);
|
|
49137
|
+
// Default destination for a clone URL: `<bootCwd>/<repo-name>`.
|
|
49138
|
+
const cloneTargetFor = React.useCallback((url) => {
|
|
49139
|
+
return path$1.join(bootCwdRef.current, deriveRepoName(url));
|
|
49140
|
+
}, []);
|
|
49141
|
+
const openClone = React.useCallback(() => {
|
|
49142
|
+
setCloneUrl('');
|
|
49143
|
+
setCloneTarget('');
|
|
49144
|
+
setCloneField('url');
|
|
49145
|
+
setCloneTargetEdited(false);
|
|
49146
|
+
setCloneCompletion(completePath(`${bootCwdRef.current}/`));
|
|
49147
|
+
dispatch({ type: 'set-focus', focus: 'clone-repo' });
|
|
49148
|
+
}, [dispatch]);
|
|
49149
|
+
const commitClone = React.useCallback(async () => {
|
|
49150
|
+
const url = cloneUrlRef.current.trim();
|
|
49151
|
+
const target = expandHomePrefix(cloneTargetRef.current.trim().replace(/\/+$/, ''));
|
|
49152
|
+
if (!url) {
|
|
49153
|
+
dispatch({ type: 'set-status', status: 'Enter a remote URL.' });
|
|
49154
|
+
return;
|
|
49155
|
+
}
|
|
49156
|
+
if (!target) {
|
|
49157
|
+
dispatch({ type: 'set-status', status: 'Enter a destination path.' });
|
|
49158
|
+
return;
|
|
49159
|
+
}
|
|
49160
|
+
setCloning(true);
|
|
49161
|
+
dispatch({ type: 'set-status', status: `Cloning ${deriveRepoName(url)}…` });
|
|
49162
|
+
const result = await cloneRepo(url, target);
|
|
49163
|
+
if (unmountedRef.current)
|
|
49164
|
+
return;
|
|
49165
|
+
setCloning(false);
|
|
49166
|
+
if (!result.ok) {
|
|
49167
|
+
// Keep the modal open so the user can fix the URL / path and retry.
|
|
49168
|
+
dispatch({ type: 'set-status', status: result.message });
|
|
49169
|
+
return;
|
|
49170
|
+
}
|
|
49171
|
+
const updated = appendKnownRepo(target);
|
|
49172
|
+
dispatch({ type: 'replace-known-repos', paths: updated });
|
|
49173
|
+
dispatch({ type: 'set-focus', focus: 'list' });
|
|
49174
|
+
dispatch({ type: 'set-status', status: result.message });
|
|
49175
|
+
dispatch({ type: 'set-loading', loading: true });
|
|
49176
|
+
try {
|
|
49177
|
+
const merged = mergeKnownRepos(props.knownRepos, readKnownRepos());
|
|
49178
|
+
const overview = await props.loadOverview(props.roots, merged);
|
|
49179
|
+
writeCachedWorkspace(props.roots, overview);
|
|
49180
|
+
dispatch({ type: 'replace-overview', overview });
|
|
49181
|
+
dispatch({ type: 'anchor-cursor-by-path', path: target });
|
|
49182
|
+
}
|
|
49183
|
+
catch (err) {
|
|
49184
|
+
dispatch({ type: 'set-loading', loading: false });
|
|
49185
|
+
dispatch({
|
|
49186
|
+
type: 'set-status',
|
|
49187
|
+
status: err instanceof Error ? err.message : 'Refresh failed.',
|
|
49188
|
+
});
|
|
49189
|
+
}
|
|
49190
|
+
}, [dispatch, props]);
|
|
48283
49191
|
// Callback refs so the stable input handler can reach the latest
|
|
48284
49192
|
// closure without taking them in deps.
|
|
48285
49193
|
const commitAddRepoRef = React.useRef(commitAddRepo);
|
|
@@ -48292,6 +49200,10 @@ function WorkspaceInkApp(props) {
|
|
|
48292
49200
|
refreshRowRef.current = refreshRow;
|
|
48293
49201
|
const openAddRepoRef = React.useRef(openAddRepo);
|
|
48294
49202
|
openAddRepoRef.current = openAddRepo;
|
|
49203
|
+
const openCloneRef = React.useRef(openClone);
|
|
49204
|
+
openCloneRef.current = openClone;
|
|
49205
|
+
const commitCloneRef = React.useRef(commitClone);
|
|
49206
|
+
commitCloneRef.current = commitClone;
|
|
48295
49207
|
const requestDeleteRef = React.useRef(requestDelete);
|
|
48296
49208
|
requestDeleteRef.current = requestDelete;
|
|
48297
49209
|
const confirmDeleteRef = React.useRef(confirmDelete);
|
|
@@ -48374,6 +49286,73 @@ function WorkspaceInkApp(props) {
|
|
|
48374
49286
|
}
|
|
48375
49287
|
return;
|
|
48376
49288
|
}
|
|
49289
|
+
if (state.focus === 'clone-repo') {
|
|
49290
|
+
// While the clone is running, swallow everything except Esc
|
|
49291
|
+
// (which is a no-op here — the clone is already in flight).
|
|
49292
|
+
if (cloningRef.current)
|
|
49293
|
+
return;
|
|
49294
|
+
if (key.escape) {
|
|
49295
|
+
dispatch({ type: 'set-focus', focus: 'list' });
|
|
49296
|
+
return;
|
|
49297
|
+
}
|
|
49298
|
+
const field = cloneFieldRef.current;
|
|
49299
|
+
const url = cloneUrlRef.current;
|
|
49300
|
+
const target = cloneTargetRef.current;
|
|
49301
|
+
const targetEdited = cloneTargetEditedRef.current;
|
|
49302
|
+
if (key.return) {
|
|
49303
|
+
if (field === 'url') {
|
|
49304
|
+
if (!url.trim()) {
|
|
49305
|
+
dispatch({ type: 'set-status', status: 'Enter a remote URL.' });
|
|
49306
|
+
return;
|
|
49307
|
+
}
|
|
49308
|
+
// Advance to the (pre-filled, editable) destination field.
|
|
49309
|
+
const derived = targetEdited ? target : cloneTargetFor(url);
|
|
49310
|
+
setCloneTarget(derived);
|
|
49311
|
+
setCloneCompletion(completePath(derived));
|
|
49312
|
+
setCloneField('target');
|
|
49313
|
+
return;
|
|
49314
|
+
}
|
|
49315
|
+
void commitCloneRef.current();
|
|
49316
|
+
return;
|
|
49317
|
+
}
|
|
49318
|
+
if (key.tab && field === 'target') {
|
|
49319
|
+
const next = applyTabCompletion(target, cloneCompletionRef.current);
|
|
49320
|
+
setCloneTarget(next);
|
|
49321
|
+
setCloneTargetEdited(true);
|
|
49322
|
+
setCloneCompletion(completePath(next));
|
|
49323
|
+
return;
|
|
49324
|
+
}
|
|
49325
|
+
if (key.backspace || key.delete) {
|
|
49326
|
+
if (field === 'url') {
|
|
49327
|
+
const next = url.slice(0, -1);
|
|
49328
|
+
setCloneUrl(next);
|
|
49329
|
+
if (!targetEdited)
|
|
49330
|
+
setCloneTarget(next ? cloneTargetFor(next) : '');
|
|
49331
|
+
}
|
|
49332
|
+
else {
|
|
49333
|
+
const next = target.slice(0, -1);
|
|
49334
|
+
setCloneTarget(next);
|
|
49335
|
+
setCloneTargetEdited(true);
|
|
49336
|
+
setCloneCompletion(completePath(next || '~/'));
|
|
49337
|
+
}
|
|
49338
|
+
return;
|
|
49339
|
+
}
|
|
49340
|
+
if (rawInput && !key.ctrl && !key.meta) {
|
|
49341
|
+
if (field === 'url') {
|
|
49342
|
+
const next = url + rawInput;
|
|
49343
|
+
setCloneUrl(next);
|
|
49344
|
+
if (!targetEdited)
|
|
49345
|
+
setCloneTarget(cloneTargetFor(next));
|
|
49346
|
+
}
|
|
49347
|
+
else {
|
|
49348
|
+
const next = target + rawInput;
|
|
49349
|
+
setCloneTarget(next);
|
|
49350
|
+
setCloneTargetEdited(true);
|
|
49351
|
+
setCloneCompletion(completePath(next));
|
|
49352
|
+
}
|
|
49353
|
+
}
|
|
49354
|
+
return;
|
|
49355
|
+
}
|
|
48377
49356
|
// Ctrl+C → quit, since we disabled Ink's built-in ctrl+c exit.
|
|
48378
49357
|
// Handled here (rather than in the pure resolver) because the
|
|
48379
49358
|
// resolver doesn't have a notion of "raw key with ctrl flag" for
|
|
@@ -48414,6 +49393,9 @@ function WorkspaceInkApp(props) {
|
|
|
48414
49393
|
case 'add-repo':
|
|
48415
49394
|
openAddRepoRef.current();
|
|
48416
49395
|
break;
|
|
49396
|
+
case 'clone-repo':
|
|
49397
|
+
openCloneRef.current();
|
|
49398
|
+
break;
|
|
48417
49399
|
case 'request-delete':
|
|
48418
49400
|
requestDeleteRef.current();
|
|
48419
49401
|
break;
|
|
@@ -48463,6 +49445,11 @@ function WorkspaceInkApp(props) {
|
|
|
48463
49445
|
filterDraft,
|
|
48464
49446
|
addRepoDraft,
|
|
48465
49447
|
addRepoCompletion,
|
|
49448
|
+
cloneUrl,
|
|
49449
|
+
cloneTarget,
|
|
49450
|
+
cloneField,
|
|
49451
|
+
cloneCompletion,
|
|
49452
|
+
cloning,
|
|
48466
49453
|
columns,
|
|
48467
49454
|
rows,
|
|
48468
49455
|
spinnerTick,
|