git-coco 0.59.0 → 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 +1262 -273
- package/dist/index.js +1262 -273
- 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;
|
|
@@ -35106,7 +35562,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
35106
35562
|
? [`Loading diff for ${selectedDetailFile?.path || 'selected file'}...`]
|
|
35107
35563
|
: previewHunks.length
|
|
35108
35564
|
? [
|
|
35109
|
-
|
|
35565
|
+
// File path is already shown in the panel title bar (right) —
|
|
35566
|
+
// no redundant "Selected file:" line here.
|
|
35110
35567
|
currentHunkLabel,
|
|
35111
35568
|
`Lines ${Math.min(state.diffPreviewOffset + 1, previewHunks.length || 1)}-${Math.min(state.diffPreviewOffset + visiblePreviewHunks.length, previewHunks.length)}/${previewHunks.length}`,
|
|
35112
35569
|
'',
|
|
@@ -35134,19 +35591,31 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
35134
35591
|
}
|
|
35135
35592
|
const diffLines = worktreeDiff?.lines || [];
|
|
35136
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;
|
|
35137
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.';
|
|
35138
35610
|
const headerLines = isLogInkContextKeyLoading(contextStatus, 'worktree')
|
|
35139
35611
|
? ['Loading file context...']
|
|
35140
35612
|
: worktreeDiffLoading
|
|
35141
35613
|
? [`Loading diff for ${worktreeFile?.path || 'selected file'}...`]
|
|
35142
35614
|
: worktreeFile
|
|
35143
35615
|
? [
|
|
35144
|
-
|
|
35145
|
-
|
|
35146
|
-
|
|
35147
|
-
: worktreeHunks?.hunks.length
|
|
35148
|
-
? `Hunk ${state.selectedWorktreeHunkIndex + 1}/${worktreeHunks.hunks.length} ${selectedHunk?.state || ''}`
|
|
35149
|
-
: 'No stageable hunks for this file.',
|
|
35616
|
+
// File path is already shown in the panel title bar (right) —
|
|
35617
|
+
// no redundant "Selected file:" line here.
|
|
35618
|
+
hunkHeaderLine,
|
|
35150
35619
|
`Lines ${Math.min(state.worktreeDiffOffset + 1, diffLines.length || 1)}-${Math.min(state.worktreeDiffOffset + visibleDiffLines.length, diffLines.length)}/${diffLines.length}`,
|
|
35151
35620
|
'',
|
|
35152
35621
|
]
|
|
@@ -35161,11 +35630,26 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
35161
35630
|
flexShrink: 0,
|
|
35162
35631
|
paddingX: 1,
|
|
35163
35632
|
width,
|
|
35164
|
-
}, 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, {
|
|
35165
35638
|
key: `diff-surface-header-${index}`,
|
|
35166
35639
|
dimColor: index > 0,
|
|
35167
35640
|
}, truncateCells(line, 140))), ...(showDiffLines
|
|
35168
|
-
?
|
|
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
|
+
})
|
|
35169
35653
|
: []));
|
|
35170
35654
|
}
|
|
35171
35655
|
|
|
@@ -36349,7 +36833,8 @@ function renderRemoteOpLoader(h, components, state, width, bodyRows, theme, focu
|
|
|
36349
36833
|
height: innerHeight,
|
|
36350
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.')));
|
|
36351
36835
|
}
|
|
36352
|
-
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;
|
|
36353
36838
|
const { Box, Text } = components;
|
|
36354
36839
|
const focused = state.focus === 'commits';
|
|
36355
36840
|
// Remote op in flight (fetch / pull / push) → swap the commit list
|
|
@@ -37051,7 +37536,8 @@ function matchesIssueFilter(issue, filter) {
|
|
|
37051
37536
|
...(issue.assignees || []),
|
|
37052
37537
|
], filter);
|
|
37053
37538
|
}
|
|
37054
|
-
function renderIssuesTriageSurface(
|
|
37539
|
+
function renderIssuesTriageSurface(ctx) {
|
|
37540
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37055
37541
|
const { Box, Text } = components;
|
|
37056
37542
|
const focused = state.focus === 'commits';
|
|
37057
37543
|
const overview = context.issueList;
|
|
@@ -37333,7 +37819,8 @@ function formatPullRequestStateLine(pr) {
|
|
|
37333
37819
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
37334
37820
|
* of #890. No behavior change.
|
|
37335
37821
|
*/
|
|
37336
|
-
function renderPullRequestSurface(
|
|
37822
|
+
function renderPullRequestSurface(ctx) {
|
|
37823
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37337
37824
|
const { Box, Text } = components;
|
|
37338
37825
|
const focused = state.focus === 'commits';
|
|
37339
37826
|
const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequest');
|
|
@@ -37506,7 +37993,8 @@ function matchesPullRequestFilter(pr, filter) {
|
|
|
37506
37993
|
...(pr.assignees || []),
|
|
37507
37994
|
], filter);
|
|
37508
37995
|
}
|
|
37509
|
-
function renderPullRequestTriageSurface(
|
|
37996
|
+
function renderPullRequestTriageSurface(ctx) {
|
|
37997
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37510
37998
|
const { Box, Text } = components;
|
|
37511
37999
|
const focused = state.focus === 'commits';
|
|
37512
38000
|
const overview = context.pullRequestList;
|
|
@@ -37622,7 +38110,8 @@ function renderPullRequestTriageSurface(h, components, state, context, contextSt
|
|
|
37622
38110
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
37623
38111
|
* of #890. No behavior change.
|
|
37624
38112
|
*/
|
|
37625
|
-
function renderReflogSurface(
|
|
38113
|
+
function renderReflogSurface(ctx) {
|
|
38114
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37626
38115
|
const { Box, Text } = components;
|
|
37627
38116
|
const focused = state.focus === 'commits';
|
|
37628
38117
|
const loading = isLogInkContextKeyLoading(contextStatus, 'reflog');
|
|
@@ -37693,7 +38182,8 @@ function renderReflogSurface(h, components, state, context, contextStatus, bodyR
|
|
|
37693
38182
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
37694
38183
|
* of #890. No behavior change.
|
|
37695
38184
|
*/
|
|
37696
|
-
function renderStashSurface(
|
|
38185
|
+
function renderStashSurface(ctx) {
|
|
38186
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37697
38187
|
const { Box, Text } = components;
|
|
37698
38188
|
const focused = state.focus === 'commits';
|
|
37699
38189
|
const loading = isLogInkContextKeyLoading(contextStatus, 'stashes');
|
|
@@ -37711,6 +38201,11 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
|
|
|
37711
38201
|
: `${stashes.length}/${allStashes.length} stashes${filterLabel}`;
|
|
37712
38202
|
const emptyLabel = formatLogInkStashEmpty({ filter: state.filter });
|
|
37713
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);
|
|
37714
38209
|
const lines = loading
|
|
37715
38210
|
? [h(Text, { key: 'stash-loading', dimColor: true }, loadingLabel)]
|
|
37716
38211
|
: stashes.length === 0
|
|
@@ -37719,11 +38214,25 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
|
|
|
37719
38214
|
const index = startIndex + offset;
|
|
37720
38215
|
const isSelected = index === selected;
|
|
37721
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}`;
|
|
37722
38231
|
return h(Text, {
|
|
37723
38232
|
key: `stash-${index}`,
|
|
37724
38233
|
bold: isSelected,
|
|
37725
38234
|
dimColor: !isSelected,
|
|
37726
|
-
}, truncateCells(
|
|
38235
|
+
}, truncateCells(rowText, rowWidth));
|
|
37727
38236
|
});
|
|
37728
38237
|
const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
|
|
37729
38238
|
const stashHasMoreBelow = startIndex + listRows < stashes.length;
|
|
@@ -37783,7 +38292,8 @@ function formatStatusFilterMask(mask) {
|
|
|
37783
38292
|
active.push('untracked');
|
|
37784
38293
|
return active.join(' + ') || 'none';
|
|
37785
38294
|
}
|
|
37786
|
-
function renderStatusSurface(
|
|
38295
|
+
function renderStatusSurface(ctx) {
|
|
38296
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37787
38297
|
const { Box, Text } = components;
|
|
37788
38298
|
const focused = state.focus === 'commits';
|
|
37789
38299
|
const worktree = context.worktree;
|
|
@@ -37930,7 +38440,8 @@ function flagColor(flag, theme) {
|
|
|
37930
38440
|
return theme.colors.danger;
|
|
37931
38441
|
return undefined;
|
|
37932
38442
|
}
|
|
37933
|
-
function renderSubmodulesSurface(
|
|
38443
|
+
function renderSubmodulesSurface(ctx) {
|
|
38444
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37934
38445
|
const { Box, Text } = components;
|
|
37935
38446
|
const focused = state.focus === 'commits';
|
|
37936
38447
|
const loading = isLogInkContextKeyLoading(contextStatus, 'submodules');
|
|
@@ -38069,7 +38580,8 @@ function formatHyperlink(text, url, env = process.env) {
|
|
|
38069
38580
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
38070
38581
|
* of #890. No behavior change.
|
|
38071
38582
|
*/
|
|
38072
|
-
function renderTagsSurface(
|
|
38583
|
+
function renderTagsSurface(ctx) {
|
|
38584
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38073
38585
|
const { Box, Text } = components;
|
|
38074
38586
|
const focused = state.focus === 'commits';
|
|
38075
38587
|
const loading = isLogInkContextKeyLoading(contextStatus, 'tags');
|
|
@@ -38151,7 +38663,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
38151
38663
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
38152
38664
|
* of #890. No behavior change.
|
|
38153
38665
|
*/
|
|
38154
|
-
function renderWorktreesSurface(
|
|
38666
|
+
function renderWorktreesSurface(ctx) {
|
|
38667
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38155
38668
|
const { Box, Text } = components;
|
|
38156
38669
|
const focused = state.focus === 'commits';
|
|
38157
38670
|
const loading = isLogInkContextKeyLoading(contextStatus, 'worktreeList');
|
|
@@ -38215,7 +38728,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
38215
38728
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
|
|
38216
38729
|
* of #890. No behavior change.
|
|
38217
38730
|
*/
|
|
38218
|
-
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;
|
|
38219
38737
|
// Split-plan overlay (#907 polish): renders in the MAIN panel (not
|
|
38220
38738
|
// detail) when active, because the content — multiple commit groups
|
|
38221
38739
|
// with file lists, rationale, hunks — needs the full center width
|
|
@@ -38227,51 +38745,66 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
38227
38745
|
return renderSplitPlanOverlay(h, components, state, width, bodyRows, theme, true, spinnerFrame);
|
|
38228
38746
|
}
|
|
38229
38747
|
if (state.activeView === 'status') {
|
|
38230
|
-
return renderStatusSurface(
|
|
38748
|
+
return renderStatusSurface(surface);
|
|
38231
38749
|
}
|
|
38232
38750
|
if (state.activeView === 'diff') {
|
|
38233
|
-
|
|
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);
|
|
38234
38767
|
}
|
|
38235
38768
|
if (state.activeView === 'compose') {
|
|
38236
|
-
return renderComposeSurface(
|
|
38769
|
+
return renderComposeSurface(surface, spinnerFrame);
|
|
38237
38770
|
}
|
|
38238
38771
|
if (state.activeView === 'branches') {
|
|
38239
|
-
return renderBranchesSurface(
|
|
38772
|
+
return renderBranchesSurface(surface);
|
|
38240
38773
|
}
|
|
38241
38774
|
if (state.activeView === 'tags') {
|
|
38242
|
-
return renderTagsSurface(
|
|
38775
|
+
return renderTagsSurface(surface);
|
|
38243
38776
|
}
|
|
38244
38777
|
if (state.activeView === 'reflog') {
|
|
38245
|
-
return renderReflogSurface(
|
|
38778
|
+
return renderReflogSurface(surface);
|
|
38246
38779
|
}
|
|
38247
38780
|
if (state.activeView === 'bisect') {
|
|
38248
|
-
return renderBisectSurface(
|
|
38781
|
+
return renderBisectSurface(surface, bisectCandidateDetail, bisectCandidateLoading);
|
|
38249
38782
|
}
|
|
38250
38783
|
if (state.activeView === 'stash') {
|
|
38251
|
-
return renderStashSurface(
|
|
38784
|
+
return renderStashSurface(surface);
|
|
38252
38785
|
}
|
|
38253
38786
|
if (state.activeView === 'worktrees') {
|
|
38254
|
-
return renderWorktreesSurface(
|
|
38787
|
+
return renderWorktreesSurface(surface);
|
|
38255
38788
|
}
|
|
38256
38789
|
if (state.activeView === 'submodules') {
|
|
38257
|
-
return renderSubmodulesSurface(
|
|
38790
|
+
return renderSubmodulesSurface(surface);
|
|
38258
38791
|
}
|
|
38259
38792
|
if (state.activeView === 'pull-request') {
|
|
38260
|
-
return renderPullRequestSurface(
|
|
38793
|
+
return renderPullRequestSurface(surface);
|
|
38261
38794
|
}
|
|
38262
38795
|
if (state.activeView === 'pull-request-triage') {
|
|
38263
|
-
return renderPullRequestTriageSurface(
|
|
38796
|
+
return renderPullRequestTriageSurface(surface);
|
|
38264
38797
|
}
|
|
38265
38798
|
if (state.activeView === 'issues') {
|
|
38266
|
-
return renderIssuesTriageSurface(
|
|
38799
|
+
return renderIssuesTriageSurface(surface);
|
|
38267
38800
|
}
|
|
38268
38801
|
if (state.activeView === 'conflicts') {
|
|
38269
|
-
return renderConflictsSurface(
|
|
38802
|
+
return renderConflictsSurface(surface);
|
|
38270
38803
|
}
|
|
38271
38804
|
if (state.activeView === 'changelog') {
|
|
38272
|
-
return renderChangelogSurface(
|
|
38805
|
+
return renderChangelogSurface(surface);
|
|
38273
38806
|
}
|
|
38274
|
-
return renderHistoryPanel(
|
|
38807
|
+
return renderHistoryPanel(surface, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled, undefined, spinnerFrame);
|
|
38275
38808
|
}
|
|
38276
38809
|
|
|
38277
38810
|
/**
|
|
@@ -39144,21 +39677,16 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
|
|
|
39144
39677
|
const bodyVisualLines = bodyHasContent
|
|
39145
39678
|
? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, 12)
|
|
39146
39679
|
: ['<empty>'];
|
|
39147
|
-
const
|
|
39148
|
-
const
|
|
39149
|
-
const
|
|
39150
|
-
|
|
39151
|
-
|
|
39152
|
-
|
|
39153
|
-
|
|
39154
|
-
|
|
39155
|
-
|
|
39156
|
-
|
|
39157
|
-
const isLast = index === bodyVisualLines.length - 1;
|
|
39158
|
-
return ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`;
|
|
39159
|
-
}),
|
|
39160
|
-
'',
|
|
39161
|
-
];
|
|
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);
|
|
39162
39690
|
const trailerLines = [
|
|
39163
39691
|
...(compose.message ? ['', compose.message] : []),
|
|
39164
39692
|
...(compose.details || []).map((line) => ` ${line}`),
|
|
@@ -39172,10 +39700,26 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
|
|
|
39172
39700
|
flexDirection: 'column',
|
|
39173
39701
|
width,
|
|
39174
39702
|
paddingX: 1,
|
|
39175
|
-
}, h(Text, { bold: true }, panelTitle('Commit', focused)),
|
|
39176
|
-
|
|
39177
|
-
|
|
39178
|
-
|
|
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' }, ''),
|
|
39179
39723
|
// Loading indicator + commit result/details stay inline with the body
|
|
39180
39724
|
// (they describe what just happened to the fields above). The action
|
|
39181
39725
|
// hint ("e edit | c commit | I AI draft") moves to the bottom of the
|
|
@@ -39266,41 +39810,11 @@ function renderPullRequestTriagePreviewPanel(h, components, state, context, cont
|
|
|
39266
39810
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
|
|
39267
39811
|
* of #890. No behavior change.
|
|
39268
39812
|
*/
|
|
39269
|
-
|
|
39270
|
-
* Rail-mode inspector — shown on terminals < 100 columns when the
|
|
39271
|
-
* detail panel does not hold focus. The full inspector (commit body,
|
|
39272
|
-
* file list, actions) does not survive truncation to ~4 content cells
|
|
39273
|
-
* so we collapse to a stack with the panel label and the selected
|
|
39274
|
-
* commit's shortHash. Focus pops the panel back to its expanded
|
|
39275
|
-
* widths via the layout, so this renderer is only reached at rest.
|
|
39276
|
-
*
|
|
39277
|
-
* Help / overlay states are still handled by their own renderers
|
|
39278
|
-
* above; this short-circuit only kicks in for the regular "view the
|
|
39279
|
-
* commit" cases.
|
|
39280
|
-
*/
|
|
39281
|
-
function renderInspectorRail(h, components, state, detail, width, theme, focused) {
|
|
39282
|
-
const { Box, Text } = components;
|
|
39283
|
-
// Prefer the loaded detail's hash (canonical) but fall back to the
|
|
39284
|
-
// selected list row's shortHash so the rail isn't blank on the
|
|
39285
|
-
// first render before getCommitDetail resolves.
|
|
39286
|
-
const selectedRow = getSelectedInkCommit(state);
|
|
39287
|
-
const hashText = detail?.hash.slice(0, 4)
|
|
39288
|
-
?? selectedRow?.shortHash.slice(0, 4)
|
|
39289
|
-
?? '····';
|
|
39290
|
-
return h(Box, {
|
|
39291
|
-
borderColor: focusBorderColor(theme, focused),
|
|
39292
|
-
borderStyle: theme.borderStyle,
|
|
39293
|
-
flexDirection: 'column',
|
|
39294
|
-
width,
|
|
39295
|
-
paddingX: 1,
|
|
39296
|
-
}, h(Text, { bold: true, dimColor: !focused }, panelTitle('Insp', focused)), h(Text, { dimColor: true }, '────'), h(Text, { color: theme.noColor ? undefined : theme.colors.accent }, hashText));
|
|
39297
|
-
}
|
|
39298
|
-
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) {
|
|
39299
39814
|
const focused = state.focus === 'detail';
|
|
39300
39815
|
// Overlays (help / palette / input / confirmation / chord) take
|
|
39301
|
-
// precedence over
|
|
39302
|
-
// via the help-overlay layout branch
|
|
39303
|
-
// 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.
|
|
39304
39818
|
if (state.showHelp) {
|
|
39305
39819
|
return renderHelpPanel(h, components, state, width, theme, focused, bodyRows);
|
|
39306
39820
|
}
|
|
@@ -39332,15 +39846,6 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
39332
39846
|
if (state.pendingKey && !state.splitPlan) {
|
|
39333
39847
|
return renderChordOverlay(h, components, state, width, theme, focused);
|
|
39334
39848
|
}
|
|
39335
|
-
// Rail mode applies only after every overlay above has had its say
|
|
39336
|
-
// — those would all be unreadable at 4 cells of content. The layout
|
|
39337
|
-
// also clears `railed` whenever the inspector takes focus, so we
|
|
39338
|
-
// can safely short-circuit the per-view dispatch here without
|
|
39339
|
-
// worrying about hiding the panel from a user who's actively
|
|
39340
|
-
// reading it.
|
|
39341
|
-
if (railed) {
|
|
39342
|
-
return renderInspectorRail(h, components, state, detail, width, theme, focused);
|
|
39343
|
-
}
|
|
39344
39849
|
// The synthetic "(+) new commit" row routes the inspector through the
|
|
39345
39850
|
// worktree summary so the user sees what's staged / unstaged at a glance
|
|
39346
39851
|
// — same surface as the compose view's right panel.
|
|
@@ -39390,6 +39895,43 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
39390
39895
|
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
|
|
39391
39896
|
}
|
|
39392
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
|
+
|
|
39393
39935
|
/**
|
|
39394
39936
|
* Resolve + scaffold the coco config files the workstation can open in
|
|
39395
39937
|
* `$EDITOR` (the `gk` / `gK` chords and their command-palette entries).
|
|
@@ -39873,6 +40415,10 @@ function LogInkApp(deps) {
|
|
|
39873
40415
|
const loadingMoreCommitsRef = React.useRef(false);
|
|
39874
40416
|
const loadMoreRequestRef = React.useRef(0);
|
|
39875
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);
|
|
39876
40422
|
// P4.3 — idle tip rotation. tickIndex 0 ⇒ no tip; the hook bumps it after
|
|
39877
40423
|
// a grace window of empty statusMessage and then on a steady cadence, so
|
|
39878
40424
|
// the footer surfaces a different hint every interval until the user does
|
|
@@ -41116,11 +41662,15 @@ function LogInkApp(deps) {
|
|
|
41116
41662
|
dispatch({ type: 'setStatus', value: 'AI draft ready for editing', kind: 'success' });
|
|
41117
41663
|
return;
|
|
41118
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);
|
|
41119
41669
|
dispatch({
|
|
41120
41670
|
type: 'commitCompose',
|
|
41121
|
-
action: { type: 'setResult', message:
|
|
41671
|
+
action: { type: 'setResult', message: composeMessage, details: result.details },
|
|
41122
41672
|
});
|
|
41123
|
-
dispatch({ type: 'setStatus', value: result.
|
|
41673
|
+
dispatch({ type: 'setStatus', value: composeMessage, kind: result.ok ? undefined : 'error' });
|
|
41124
41674
|
}
|
|
41125
41675
|
catch (error) {
|
|
41126
41676
|
// Audit finding #3: defensive recovery for unexpected throws
|
|
@@ -42091,8 +42641,20 @@ function LogInkApp(deps) {
|
|
|
42091
42641
|
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
42092
42642
|
if (!stash)
|
|
42093
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 };
|
|
42094
42647
|
return dropStash(git, stash);
|
|
42095
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
|
+
},
|
|
42096
42658
|
'apply-stash': async () => {
|
|
42097
42659
|
const all = context.stashes?.stashes || [];
|
|
42098
42660
|
const visible = state.filter
|
|
@@ -42103,6 +42665,16 @@ function LogInkApp(deps) {
|
|
|
42103
42665
|
return { ok: false, message: 'No stash selected' };
|
|
42104
42666
|
return applyStash(git, stash);
|
|
42105
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
|
+
},
|
|
42106
42678
|
'pop-stash': async () => {
|
|
42107
42679
|
const all = context.stashes?.stashes || [];
|
|
42108
42680
|
const visible = state.filter
|
|
@@ -42113,6 +42685,26 @@ function LogInkApp(deps) {
|
|
|
42113
42685
|
return { ok: false, message: 'No stash selected' };
|
|
42114
42686
|
return popStash(git, stash);
|
|
42115
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
|
+
},
|
|
42116
42708
|
'bisect-good': async () => {
|
|
42117
42709
|
if (!context.bisect?.active)
|
|
42118
42710
|
return { ok: false, message: 'No bisect in progress' };
|
|
@@ -42523,11 +43115,12 @@ function LogInkApp(deps) {
|
|
|
42523
43115
|
return deleteRemoteTag(git, tag.name);
|
|
42524
43116
|
},
|
|
42525
43117
|
'create-stash': async () => {
|
|
42526
|
-
|
|
42527
|
-
|
|
42528
|
-
|
|
42529
|
-
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 ?? '');
|
|
42530
43121
|
},
|
|
43122
|
+
'stash-staged': async () => createStash(git, payload ?? '', { stagedOnly: true }),
|
|
43123
|
+
'stash-keep-index': async () => createStash(git, payload ?? '', { keepIndex: true }),
|
|
42531
43124
|
// #783 — full PR action panel handlers. Each wraps the matching
|
|
42532
43125
|
// pullRequestActions verb. Strategy / body arrives via `payload`
|
|
42533
43126
|
// — input prompts validate before they reach here, but the
|
|
@@ -42775,6 +43368,8 @@ function LogInkApp(deps) {
|
|
|
42775
43368
|
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'untracked');
|
|
42776
43369
|
return stageAllFiles(git, files);
|
|
42777
43370
|
},
|
|
43371
|
+
'stage-all': async () => stageAll(git),
|
|
43372
|
+
'stage-pathspec': async () => stagePathspec(git, payload || ''),
|
|
42778
43373
|
};
|
|
42779
43374
|
const handler = handlers[id];
|
|
42780
43375
|
if (!handler) {
|
|
@@ -42805,6 +43400,16 @@ function LogInkApp(deps) {
|
|
|
42805
43400
|
'checkout-branch',
|
|
42806
43401
|
'continue-operation',
|
|
42807
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',
|
|
42808
43413
|
'cherry-pick-commit',
|
|
42809
43414
|
'revert-commit',
|
|
42810
43415
|
'reset-hard-to-commit',
|
|
@@ -42859,6 +43464,11 @@ function LogInkApp(deps) {
|
|
|
42859
43464
|
if (result?.ok && id === 'add-to-gitignore') {
|
|
42860
43465
|
await refreshWorktreeContext();
|
|
42861
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
|
+
}
|
|
42862
43472
|
if (result?.ok && id === 'drop-stash') {
|
|
42863
43473
|
// Explicit worktree refresh in case the dropped stash carried
|
|
42864
43474
|
// untracked-file state that's now collected.
|
|
@@ -43442,6 +44052,11 @@ function LogInkApp(deps) {
|
|
|
43442
44052
|
? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
|
|
43443
44053
|
: undefined;
|
|
43444
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,
|
|
43445
44060
|
detailFileCount: detail?.files.length,
|
|
43446
44061
|
previewLineCount: diffPreviewLineCount,
|
|
43447
44062
|
worktreeDiffLineCount: worktreeDiff?.lines.length,
|
|
@@ -43566,7 +44181,14 @@ function LogInkApp(deps) {
|
|
|
43566
44181
|
exit();
|
|
43567
44182
|
}
|
|
43568
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."
|
|
43569
44190
|
void refreshContext();
|
|
44191
|
+
void refreshHistoryRows();
|
|
43570
44192
|
}
|
|
43571
44193
|
else if (event.type === 'toggleSelectedFileStage') {
|
|
43572
44194
|
void toggleSelectedFileStage();
|
|
@@ -43665,6 +44287,24 @@ function LogInkApp(deps) {
|
|
|
43665
44287
|
}
|
|
43666
44288
|
});
|
|
43667
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;
|
|
43668
44308
|
// Layout depends on focus (sidebar grows when focused), so it's
|
|
43669
44309
|
// computed here — after state is in scope but before the render path.
|
|
43670
44310
|
const layout = getLogInkLayout({
|
|
@@ -43673,7 +44313,22 @@ function LogInkApp(deps) {
|
|
|
43673
44313
|
sidebarFocused: state.focus === 'sidebar',
|
|
43674
44314
|
inspectorFocused: state.focus === 'detail',
|
|
43675
44315
|
helpOverlayActive: state.showHelp,
|
|
44316
|
+
forcedPane,
|
|
43676
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
|
+
};
|
|
43677
44332
|
if (layout.tooSmall) {
|
|
43678
44333
|
return h(Box, {
|
|
43679
44334
|
flexDirection: 'column',
|
|
@@ -43687,7 +44342,35 @@ function LogInkApp(deps) {
|
|
|
43687
44342
|
if (showOnboarding) {
|
|
43688
44343
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
43689
44344
|
}
|
|
43690
|
-
|
|
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)));
|
|
43691
44374
|
}
|
|
43692
44375
|
|
|
43693
44376
|
/**
|
|
@@ -45807,6 +46490,54 @@ async function getWorkspacePullRequestCounts(repoPaths, options = {}) {
|
|
|
45807
46490
|
return { authenticated: true, counts };
|
|
45808
46491
|
}
|
|
45809
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
|
+
|
|
45810
46541
|
function resolveStoreDir(subdir) {
|
|
45811
46542
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
45812
46543
|
const root = xdg && xdg.trim().length > 0 ? xdg : path$1.join(os$1.homedir(), '.cache');
|
|
@@ -46049,6 +46780,7 @@ function createWorkspaceState(init) {
|
|
|
46049
46780
|
showThemePicker: false,
|
|
46050
46781
|
themePickerFilter: '',
|
|
46051
46782
|
themePickerIndex: 0,
|
|
46783
|
+
helpScrollOffset: 0,
|
|
46052
46784
|
knownRepoPaths: init.knownRepoPaths ?? [],
|
|
46053
46785
|
pullRequestFetching: [],
|
|
46054
46786
|
};
|
|
@@ -46214,10 +46946,17 @@ function applyWorkspaceAction(state, action) {
|
|
|
46214
46946
|
return { ...state, status: action.status };
|
|
46215
46947
|
}
|
|
46216
46948
|
case 'toggle-help': {
|
|
46217
|
-
|
|
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 };
|
|
46218
46952
|
}
|
|
46219
46953
|
case 'close-help': {
|
|
46220
|
-
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) };
|
|
46221
46960
|
}
|
|
46222
46961
|
case 'toggle-theme-picker': {
|
|
46223
46962
|
return {
|
|
@@ -46547,6 +47286,7 @@ function buildWorkspaceListWindow(state, options = { rows: 20 }) {
|
|
|
46547
47286
|
const all = buildWorkspaceListRows(state, {
|
|
46548
47287
|
width: options.width,
|
|
46549
47288
|
spinnerTick: options.spinnerTick,
|
|
47289
|
+
now: options.now,
|
|
46550
47290
|
});
|
|
46551
47291
|
const visibleCount = Math.max(1, options.rows);
|
|
46552
47292
|
if (all.length <= visibleCount) {
|
|
@@ -46670,10 +47410,11 @@ function buildWorkspaceHeaderChips(state, options = { focusLabel: 'List' }) {
|
|
|
46670
47410
|
// The contextual slot drops bindings users can find via the help
|
|
46671
47411
|
// overlay (arrow keys, tab); the global slot is the safety net so
|
|
46672
47412
|
// `? help` and `q quit` never disappear.
|
|
46673
|
-
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'];
|
|
46674
47414
|
const SIDEBAR_CONTEXTUAL = ['↑/↓ cycle tab', 'enter open'];
|
|
46675
47415
|
const FILTER_CONTEXTUAL = ['type to filter', 'enter apply', 'esc cancel'];
|
|
46676
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'];
|
|
46677
47418
|
const CONFIRM_DELETE_CONTEXTUAL = ['y confirm', 'any other key cancels'];
|
|
46678
47419
|
const GLOBAL_HINTS = ['? help', 'q quit'];
|
|
46679
47420
|
function contextualHintsFor(focus) {
|
|
@@ -46684,6 +47425,8 @@ function contextualHintsFor(focus) {
|
|
|
46684
47425
|
return FILTER_CONTEXTUAL;
|
|
46685
47426
|
case 'add-repo':
|
|
46686
47427
|
return ADD_REPO_CONTEXTUAL;
|
|
47428
|
+
case 'clone-repo':
|
|
47429
|
+
return CLONE_REPO_CONTEXTUAL;
|
|
46687
47430
|
case 'confirm-delete':
|
|
46688
47431
|
return CONFIRM_DELETE_CONTEXTUAL;
|
|
46689
47432
|
case 'list':
|
|
@@ -46698,6 +47441,7 @@ function buildWorkspaceFooter(state) {
|
|
|
46698
47441
|
// is open and showing them would be misleading.
|
|
46699
47442
|
const isModal = state.focus === 'filter' ||
|
|
46700
47443
|
state.focus === 'add-repo' ||
|
|
47444
|
+
state.focus === 'clone-repo' ||
|
|
46701
47445
|
state.focus === 'confirm-delete';
|
|
46702
47446
|
const global = isModal ? [] : GLOBAL_HINTS;
|
|
46703
47447
|
const allHints = [...contextual, ...global];
|
|
@@ -46759,6 +47503,7 @@ function buildWorkspaceHelpSections() {
|
|
|
46759
47503
|
{ glyph: '⟳', keys: 'r', description: 'Refresh all repos (discovery + PR counts)' },
|
|
46760
47504
|
{ glyph: '⟲', keys: 'R', description: 'Refresh just the cursored repo (faster)' },
|
|
46761
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)' },
|
|
46762
47507
|
{ glyph: '✕', keys: 'd', description: 'Remove the cursored repo from the known-repos store' },
|
|
46763
47508
|
],
|
|
46764
47509
|
},
|
|
@@ -46776,7 +47521,7 @@ function buildWorkspaceOnboarding(state) {
|
|
|
46776
47521
|
: undefined,
|
|
46777
47522
|
populatedHint: empty
|
|
46778
47523
|
? undefined
|
|
46779
|
-
: '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.',
|
|
46780
47525
|
};
|
|
46781
47526
|
}
|
|
46782
47527
|
|
|
@@ -47072,6 +47817,7 @@ function renderListBody(deps, width, height) {
|
|
|
47072
47817
|
width,
|
|
47073
47818
|
rows: listRows,
|
|
47074
47819
|
spinnerTick: deps.spinnerTick,
|
|
47820
|
+
now: deps.now,
|
|
47075
47821
|
});
|
|
47076
47822
|
const visibleRepos = selectVisibleRepos(state);
|
|
47077
47823
|
const filterChip = state.filter
|
|
@@ -47107,7 +47853,7 @@ function renderListBody(deps, width, height) {
|
|
|
47107
47853
|
function renderHelpRow(deps, row, glyphWidth, keysWidth, key) {
|
|
47108
47854
|
const { React, ink, theme } = deps;
|
|
47109
47855
|
const { Box, Text } = ink;
|
|
47110
|
-
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));
|
|
47111
47857
|
}
|
|
47112
47858
|
function renderHelpOverlay(deps) {
|
|
47113
47859
|
if (!deps.state.showHelp) {
|
|
@@ -47120,34 +47866,68 @@ function renderHelpOverlay(deps) {
|
|
|
47120
47866
|
// Columns: glyph cell (4 cells) · keys (padded to longest) · description.
|
|
47121
47867
|
const glyphWidth = 4;
|
|
47122
47868
|
const keysWidth = Math.max(14, allRows.reduce((acc, row) => Math.max(acc, row.keys.length), 0) + 4);
|
|
47123
|
-
|
|
47124
|
-
//
|
|
47125
|
-
//
|
|
47126
|
-
//
|
|
47127
|
-
|
|
47128
|
-
|
|
47129
|
-
|
|
47130
|
-
// 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 = [];
|
|
47131
47876
|
sections.forEach((section, sIndex) => {
|
|
47132
|
-
|
|
47877
|
+
body.push(React.createElement(Text, {
|
|
47133
47878
|
key: `section-${sIndex}-title`,
|
|
47134
47879
|
bold: true,
|
|
47135
47880
|
color: theme.noColor ? undefined : theme.colors.muted,
|
|
47136
47881
|
}, section.title.toUpperCase()));
|
|
47137
47882
|
if (section.subtitle) {
|
|
47138
|
-
|
|
47883
|
+
body.push(React.createElement(Text, { key: `section-${sIndex}-subtitle`, dimColor: true }, ` ${section.subtitle}`));
|
|
47139
47884
|
}
|
|
47140
47885
|
section.rows.forEach((row, rIndex) => {
|
|
47141
|
-
|
|
47886
|
+
body.push(renderHelpRow(deps, row, glyphWidth, keysWidth, `row-${sIndex}-${rIndex}`));
|
|
47142
47887
|
});
|
|
47143
47888
|
if (sIndex < sections.length - 1) {
|
|
47144
|
-
|
|
47889
|
+
body.push(React.createElement(Text, { key: `section-${sIndex}-spacer` }, ''));
|
|
47145
47890
|
}
|
|
47146
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
|
+
}
|
|
47147
47926
|
return React.createElement(Box, {
|
|
47148
47927
|
borderColor: focusBorderColor(theme, true),
|
|
47149
47928
|
borderStyle: theme.borderStyle,
|
|
47150
47929
|
flexDirection: 'column',
|
|
47930
|
+
flexShrink: 0,
|
|
47151
47931
|
paddingX: 1,
|
|
47152
47932
|
}, ...children);
|
|
47153
47933
|
}
|
|
@@ -47198,6 +47978,29 @@ function renderAddRepoPrompt(deps) {
|
|
|
47198
47978
|
? React.createElement(Text, { color: toneColor('dim', theme) }, completionLine)
|
|
47199
47979
|
: null);
|
|
47200
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
|
+
}
|
|
47201
48004
|
const FOOTER_HEIGHT = 4; // 2 borders + hint row + status row
|
|
47202
48005
|
function renderFooter(deps) {
|
|
47203
48006
|
const { React, ink, state, theme } = deps;
|
|
@@ -47239,8 +48042,10 @@ function computeBodyHeight(deps) {
|
|
|
47239
48042
|
const FOOTER_ROWS = FOOTER_HEIGHT;
|
|
47240
48043
|
const onboardingRows = buildWorkspaceOnboarding(deps.state).show ? 5 : 0;
|
|
47241
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;
|
|
47242
48047
|
const confirmRows = deps.state.focus === 'confirm-delete' ? 5 : 0;
|
|
47243
|
-
const reserved = HEADER_ROWS + FOOTER_ROWS + onboardingRows + addRepoRows + confirmRows;
|
|
48048
|
+
const reserved = HEADER_ROWS + FOOTER_ROWS + onboardingRows + addRepoRows + cloneRows + confirmRows;
|
|
47244
48049
|
return Math.max(8, deps.rows - reserved);
|
|
47245
48050
|
}
|
|
47246
48051
|
function renderWorkspaceApp(deps) {
|
|
@@ -47262,7 +48067,7 @@ function renderWorkspaceApp(deps) {
|
|
|
47262
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));
|
|
47263
48068
|
}
|
|
47264
48069
|
const bodyHeight = computeBodyHeight(deps);
|
|
47265
|
-
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));
|
|
47266
48071
|
}
|
|
47267
48072
|
|
|
47268
48073
|
/**
|
|
@@ -47299,6 +48104,21 @@ function resolveWorkspaceInput(input, key, state) {
|
|
|
47299
48104
|
if (key.escape || input === '?' || input === 'q') {
|
|
47300
48105
|
return { kind: 'action', action: { type: 'close-help' } };
|
|
47301
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
|
+
}
|
|
47302
48122
|
return { kind: 'noop' };
|
|
47303
48123
|
}
|
|
47304
48124
|
// Theme picker is modal (like `coco ui`'s gC): type to filter, ↑/↓ to
|
|
@@ -47349,6 +48169,14 @@ function resolveWorkspaceInput(input, key, state) {
|
|
|
47349
48169
|
// can drive the path-completion prompt.
|
|
47350
48170
|
return { kind: 'noop' };
|
|
47351
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
|
+
}
|
|
47352
48180
|
// Confirm-delete is modal: only `y` confirms, anything else cancels.
|
|
47353
48181
|
if (state.focus === 'confirm-delete') {
|
|
47354
48182
|
if (input === 'y' || input === 'Y') {
|
|
@@ -47443,6 +48271,9 @@ function resolveWorkspaceInput(input, key, state) {
|
|
|
47443
48271
|
if (input === 'a') {
|
|
47444
48272
|
return { kind: 'add-repo' };
|
|
47445
48273
|
}
|
|
48274
|
+
if (input === 'c') {
|
|
48275
|
+
return { kind: 'clone-repo' };
|
|
48276
|
+
}
|
|
47446
48277
|
if (input === 'd') {
|
|
47447
48278
|
return { kind: 'request-delete' };
|
|
47448
48279
|
}
|
|
@@ -47997,6 +48828,19 @@ function WorkspaceInkApp(props) {
|
|
|
47997
48828
|
const [filterDraft, setFilterDraft] = React.useState('');
|
|
47998
48829
|
const [addRepoDraft, setAddRepoDraft] = React.useState('~/');
|
|
47999
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);
|
|
48000
48844
|
// Tick counter for the per-row PR-fetch spinner. Bumped on a
|
|
48001
48845
|
// setInterval that only runs while at least one row is mid-fetch
|
|
48002
48846
|
// (see effect below) so idle workspaces don't burn CPU on animation
|
|
@@ -48046,6 +48890,18 @@ function WorkspaceInkApp(props) {
|
|
|
48046
48890
|
addRepoDraftRef.current = addRepoDraft;
|
|
48047
48891
|
const addRepoCompletionRef = React.useRef(addRepoCompletion);
|
|
48048
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;
|
|
48049
48905
|
// Background discovery + PR-count refresh on mount.
|
|
48050
48906
|
React.useEffect(() => {
|
|
48051
48907
|
let cancelled = false;
|
|
@@ -48278,6 +49134,60 @@ function WorkspaceInkApp(props) {
|
|
|
48278
49134
|
});
|
|
48279
49135
|
}
|
|
48280
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]);
|
|
48281
49191
|
// Callback refs so the stable input handler can reach the latest
|
|
48282
49192
|
// closure without taking them in deps.
|
|
48283
49193
|
const commitAddRepoRef = React.useRef(commitAddRepo);
|
|
@@ -48290,6 +49200,10 @@ function WorkspaceInkApp(props) {
|
|
|
48290
49200
|
refreshRowRef.current = refreshRow;
|
|
48291
49201
|
const openAddRepoRef = React.useRef(openAddRepo);
|
|
48292
49202
|
openAddRepoRef.current = openAddRepo;
|
|
49203
|
+
const openCloneRef = React.useRef(openClone);
|
|
49204
|
+
openCloneRef.current = openClone;
|
|
49205
|
+
const commitCloneRef = React.useRef(commitClone);
|
|
49206
|
+
commitCloneRef.current = commitClone;
|
|
48293
49207
|
const requestDeleteRef = React.useRef(requestDelete);
|
|
48294
49208
|
requestDeleteRef.current = requestDelete;
|
|
48295
49209
|
const confirmDeleteRef = React.useRef(confirmDelete);
|
|
@@ -48372,6 +49286,73 @@ function WorkspaceInkApp(props) {
|
|
|
48372
49286
|
}
|
|
48373
49287
|
return;
|
|
48374
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
|
+
}
|
|
48375
49356
|
// Ctrl+C → quit, since we disabled Ink's built-in ctrl+c exit.
|
|
48376
49357
|
// Handled here (rather than in the pure resolver) because the
|
|
48377
49358
|
// resolver doesn't have a notion of "raw key with ctrl flag" for
|
|
@@ -48412,6 +49393,9 @@ function WorkspaceInkApp(props) {
|
|
|
48412
49393
|
case 'add-repo':
|
|
48413
49394
|
openAddRepoRef.current();
|
|
48414
49395
|
break;
|
|
49396
|
+
case 'clone-repo':
|
|
49397
|
+
openCloneRef.current();
|
|
49398
|
+
break;
|
|
48415
49399
|
case 'request-delete':
|
|
48416
49400
|
requestDeleteRef.current();
|
|
48417
49401
|
break;
|
|
@@ -48461,6 +49445,11 @@ function WorkspaceInkApp(props) {
|
|
|
48461
49445
|
filterDraft,
|
|
48462
49446
|
addRepoDraft,
|
|
48463
49447
|
addRepoCompletion,
|
|
49448
|
+
cloneUrl,
|
|
49449
|
+
cloneTarget,
|
|
49450
|
+
cloneField,
|
|
49451
|
+
cloneCompletion,
|
|
49452
|
+
cloning,
|
|
48464
49453
|
columns,
|
|
48465
49454
|
rows,
|
|
48466
49455
|
spinnerTick,
|