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.js
CHANGED
|
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
|
|
|
78
78
|
/**
|
|
79
79
|
* Current build version from package.json
|
|
80
80
|
*/
|
|
81
|
-
const BUILD_VERSION = "0.
|
|
81
|
+
const BUILD_VERSION = "0.60.0";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -18920,9 +18920,17 @@ async function getStashOverview(git) {
|
|
|
18920
18920
|
// %gd — stash reflog selector (stash@{N})
|
|
18921
18921
|
// %H — stash commit hash
|
|
18922
18922
|
// %P — space-separated parent hashes (first = base, see StashEntry.baseHash)
|
|
18923
|
-
// %
|
|
18923
|
+
// %cI — committer date, strict ISO 8601
|
|
18924
18924
|
// %gs — reflog subject ("WIP on main: <subject>")
|
|
18925
|
-
|
|
18925
|
+
//
|
|
18926
|
+
// NOTE: we deliberately do NOT pass `--date=iso`. That flag rewrites the
|
|
18927
|
+
// `%gd` selector from the index form (`stash@{0}`) into a timestamp
|
|
18928
|
+
// (`stash@{2026-06-03 17:29:23 -0400}`), which is noisy in the list, eats
|
|
18929
|
+
// row width, and — critically — breaks `renameStash`, which parses the
|
|
18930
|
+
// `stash@{N}` index out of the ref. `%cI` gives a strict-ISO date that's
|
|
18931
|
+
// independent of `--date`, so we get both a clean index ref and a
|
|
18932
|
+
// parseable date.
|
|
18933
|
+
const stashes = parseStashList(await git.raw(['stash', 'list', '--format=%gd%x1f%H%x1f%P%x1f%cI%x1f%gs']));
|
|
18926
18934
|
return {
|
|
18927
18935
|
stashes: await Promise.all(stashes.map(async (stash) => ({
|
|
18928
18936
|
...stash,
|
|
@@ -20591,7 +20599,7 @@ function applyCommitComposeAction(state, action) {
|
|
|
20591
20599
|
loading: false,
|
|
20592
20600
|
streamingPreview: undefined,
|
|
20593
20601
|
pendingAiDraft: action.value,
|
|
20594
|
-
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20602
|
+
message: 'AI draft ready. Press Enter (or R) to replace your text, or Esc to keep what you have.',
|
|
20595
20603
|
details: undefined,
|
|
20596
20604
|
};
|
|
20597
20605
|
}
|
|
@@ -20640,7 +20648,7 @@ function applyCommitComposeAction(state, action) {
|
|
|
20640
20648
|
loading: false,
|
|
20641
20649
|
streamingPreview: undefined,
|
|
20642
20650
|
pendingAiDraft: action.value,
|
|
20643
|
-
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20651
|
+
message: 'AI draft ready. Press Enter (or R) to replace your text, or Esc to keep what you have.',
|
|
20644
20652
|
details: undefined,
|
|
20645
20653
|
};
|
|
20646
20654
|
case 'acceptPendingAiDraft':
|
|
@@ -22196,6 +22204,25 @@ function getLogInkWorkflowActions() {
|
|
|
22196
22204
|
kind: 'destructive',
|
|
22197
22205
|
requiresConfirmation: true,
|
|
22198
22206
|
},
|
|
22207
|
+
{
|
|
22208
|
+
// Palette-only create variants (empty `key`): no global hotkey to
|
|
22209
|
+
// collide with `S` / `gZ`, reachable from `:`. Both stash a quick
|
|
22210
|
+
// WIP entry with the requested scope.
|
|
22211
|
+
id: 'stash-staged',
|
|
22212
|
+
key: '',
|
|
22213
|
+
label: 'Stash staged only',
|
|
22214
|
+
description: 'Stash just the staged (index) changes — `git stash push --staged`.',
|
|
22215
|
+
kind: 'normal',
|
|
22216
|
+
requiresConfirmation: false,
|
|
22217
|
+
},
|
|
22218
|
+
{
|
|
22219
|
+
id: 'stash-keep-index',
|
|
22220
|
+
key: '',
|
|
22221
|
+
label: 'Stash keeping index',
|
|
22222
|
+
description: 'Stash everything but leave the index intact for an immediate commit — `git stash push --keep-index`.',
|
|
22223
|
+
kind: 'normal',
|
|
22224
|
+
requiresConfirmation: false,
|
|
22225
|
+
},
|
|
22199
22226
|
{
|
|
22200
22227
|
id: 'remove-worktree',
|
|
22201
22228
|
key: 'W',
|
|
@@ -22707,6 +22734,13 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
22707
22734
|
description: 'Push the stash view (gz; gs is reserved for status).',
|
|
22708
22735
|
contexts: ['normal'],
|
|
22709
22736
|
},
|
|
22737
|
+
{
|
|
22738
|
+
id: 'createStash',
|
|
22739
|
+
keys: ['gZ'],
|
|
22740
|
+
label: 'stash changes',
|
|
22741
|
+
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.',
|
|
22742
|
+
contexts: ['normal'],
|
|
22743
|
+
},
|
|
22710
22744
|
{
|
|
22711
22745
|
id: 'navigateWorktrees',
|
|
22712
22746
|
keys: ['gw'],
|
|
@@ -22979,6 +23013,20 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
22979
23013
|
description: 'Add the cursored file or folder to .gitignore (pick a pattern).',
|
|
22980
23014
|
contexts: ['status'],
|
|
22981
23015
|
},
|
|
23016
|
+
{
|
|
23017
|
+
id: 'stageAll',
|
|
23018
|
+
keys: ['A'],
|
|
23019
|
+
label: 'stage all',
|
|
23020
|
+
description: 'Stage every change in the worktree (git add -A).',
|
|
23021
|
+
contexts: ['status', 'compose'],
|
|
23022
|
+
},
|
|
23023
|
+
{
|
|
23024
|
+
id: 'stagePathspec',
|
|
23025
|
+
keys: ['+'],
|
|
23026
|
+
label: 'stage paths',
|
|
23027
|
+
description: 'Stage files matching a typed pathspec (. / src/ / *.ts / a list).',
|
|
23028
|
+
contexts: ['status', 'compose'],
|
|
23029
|
+
},
|
|
22982
23030
|
{
|
|
22983
23031
|
id: 'viewChangelog',
|
|
22984
23032
|
keys: ['L'],
|
|
@@ -23052,6 +23100,19 @@ const GLOBAL_BINDING_IDS = [
|
|
|
23052
23100
|
'navigateBack',
|
|
23053
23101
|
];
|
|
23054
23102
|
const NORMAL_GLOBAL_HINTS = ['g jump', '< back', '? help', ': cmds', 'q quit'];
|
|
23103
|
+
/**
|
|
23104
|
+
* Narrow single-pane footer budget (#1135). On terminals below the
|
|
23105
|
+
* single-pane breakpoint the pane switcher (`tab: …`, ~29 cells) plus
|
|
23106
|
+
* the snap-back / peek affordance already claim most of an 80-cell row,
|
|
23107
|
+
* so the per-view hint tail and the global cluster are trimmed to what
|
|
23108
|
+
* fits without clipping — the switcher is the orientation anchor and
|
|
23109
|
+
* must stay whole. The dropped bindings remain one `?` (help) away.
|
|
23110
|
+
*
|
|
23111
|
+
* - keep only the first view hint (the most actionable for the view)
|
|
23112
|
+
* - shrink the global cluster to the two recovery essentials
|
|
23113
|
+
*/
|
|
23114
|
+
const SINGLE_PANE_GLOBAL_HINTS = ['? help', 'q quit'];
|
|
23115
|
+
const SINGLE_PANE_VIEW_HINT_LIMIT = 1;
|
|
23055
23116
|
/**
|
|
23056
23117
|
* Per-binding category mapping. Used to subdivide the help overlay's
|
|
23057
23118
|
* Global and view sections into named clusters so users don't face a
|
|
@@ -23072,6 +23133,9 @@ const BINDING_CATEGORY_BY_ID = {
|
|
|
23072
23133
|
openProjectConfig: 'view',
|
|
23073
23134
|
openGlobalConfig: 'view',
|
|
23074
23135
|
gitignoreFile: 'mutate',
|
|
23136
|
+
stageAll: 'mutate',
|
|
23137
|
+
stagePathspec: 'mutate',
|
|
23138
|
+
createStash: 'mutate',
|
|
23075
23139
|
quit: 'essentials',
|
|
23076
23140
|
refresh: 'essentials',
|
|
23077
23141
|
navigateBack: 'essentials',
|
|
@@ -23223,18 +23287,20 @@ function formatLogInkBreadcrumb(viewStack) {
|
|
|
23223
23287
|
if (viewStack.length === 1 && viewStack[0] === 'history') {
|
|
23224
23288
|
return '';
|
|
23225
23289
|
}
|
|
23226
|
-
//
|
|
23227
|
-
//
|
|
23228
|
-
|
|
23290
|
+
// Pure location breadcrumb — no trailing back-hint. The footer's
|
|
23291
|
+
// global `< back` hint already names the walk-back key, so repeating
|
|
23292
|
+
// `← <` on every nested view was redundant header chrome (TUI audit).
|
|
23293
|
+
return viewStack.join(' › ');
|
|
23229
23294
|
}
|
|
23230
23295
|
/**
|
|
23231
23296
|
* Render the nested-repo navigation stack (#931) as a breadcrumb suitable
|
|
23232
23297
|
* for the chrome header. Returns an empty string for a root-only stack
|
|
23233
23298
|
* so the header stays compact when nothing has been pushed.
|
|
23234
23299
|
*
|
|
23235
|
-
* The trailing `← esc` reminds the user that Esc
|
|
23236
|
-
*
|
|
23237
|
-
*
|
|
23300
|
+
* The trailing `← esc` reminds the user that Esc (not `<`) pops the
|
|
23301
|
+
* repo stack — a distinct key from the footer's global `< back`, so
|
|
23302
|
+
* unlike the view breadcrumb (pure location) the repo crumb keeps its
|
|
23303
|
+
* hint. The repo breadcrumb shows in addition to the view breadcrumb when
|
|
23238
23304
|
* both stacks are non-trivial; the chrome layer is responsible for
|
|
23239
23305
|
* laying them out side by side.
|
|
23240
23306
|
*
|
|
@@ -23277,7 +23343,53 @@ function combineLogInkBreadcrumbSegments(repoCrumb, viewCrumb) {
|
|
|
23277
23343
|
}
|
|
23278
23344
|
return '';
|
|
23279
23345
|
}
|
|
23346
|
+
/**
|
|
23347
|
+
* Single-pane pane switcher hint, e.g. `tab: [sidebar] main inspector`.
|
|
23348
|
+
* The active pane (derived from focus: sidebar → sidebar, detail →
|
|
23349
|
+
* inspector, otherwise main) is bracketed so the user can see which of
|
|
23350
|
+
* the three panes Tab will move them away from. Surfaced only on narrow
|
|
23351
|
+
* terminals where the other two panes aren't on screen.
|
|
23352
|
+
*/
|
|
23353
|
+
function singlePaneSwitcherHint(focus) {
|
|
23354
|
+
const active = focus === 'sidebar' ? 'sidebar' : focus === 'detail' ? 'inspector' : 'main';
|
|
23355
|
+
const label = (pane) => (pane === active ? `[${pane}]` : pane);
|
|
23356
|
+
return `tab: ${label('sidebar')} ${label('main')} ${label('inspector')}`;
|
|
23357
|
+
}
|
|
23280
23358
|
function getLogInkFooterHints(options) {
|
|
23359
|
+
const hints = computeLogInkFooterHints(options);
|
|
23360
|
+
// While peeking the sidebar (#1135 v2) the footer shows the snap-back
|
|
23361
|
+
// affordance instead of the switcher — the user is mid-glance, not
|
|
23362
|
+
// navigating, so `v`/Esc returning to main is the relevant action. The
|
|
23363
|
+
// view-hint tail + globals are trimmed to fit the narrow row (see
|
|
23364
|
+
// SINGLE_PANE_GLOBAL_HINTS).
|
|
23365
|
+
if (options.peeking) {
|
|
23366
|
+
return {
|
|
23367
|
+
contextual: ['v/esc → main', ...hints.contextual.slice(0, SINGLE_PANE_VIEW_HINT_LIMIT)],
|
|
23368
|
+
global: SINGLE_PANE_GLOBAL_HINTS,
|
|
23369
|
+
};
|
|
23370
|
+
}
|
|
23371
|
+
// On narrow terminals only one pane is on screen, so prepend a Tab
|
|
23372
|
+
// pane switcher for orientation. The caller (footer) only sets
|
|
23373
|
+
// `singlePane` in the plain per-pane states — while an overlay or
|
|
23374
|
+
// filter owns the screen the visible pane is forced (or input is
|
|
23375
|
+
// captured) and Tab does something else, so the switcher is
|
|
23376
|
+
// suppressed there to avoid showing a pane that isn't active. From the
|
|
23377
|
+
// main / inspector pane we also surface `v peek` so the momentary
|
|
23378
|
+
// sidebar glance is discoverable. The full per-view hint cluster +
|
|
23379
|
+
// global cluster don't fit alongside the switcher at the 80-col floor,
|
|
23380
|
+
// so both are trimmed (the dropped keys stay reachable via `?`).
|
|
23381
|
+
if (options.singlePane) {
|
|
23382
|
+
const lead = options.focus === 'sidebar'
|
|
23383
|
+
? [singlePaneSwitcherHint(options.focus)]
|
|
23384
|
+
: [singlePaneSwitcherHint(options.focus), 'v peek'];
|
|
23385
|
+
return {
|
|
23386
|
+
contextual: [...lead, ...hints.contextual.slice(0, SINGLE_PANE_VIEW_HINT_LIMIT)],
|
|
23387
|
+
global: SINGLE_PANE_GLOBAL_HINTS,
|
|
23388
|
+
};
|
|
23389
|
+
}
|
|
23390
|
+
return hints;
|
|
23391
|
+
}
|
|
23392
|
+
function computeLogInkFooterHints(options) {
|
|
23281
23393
|
if (options.pendingKey) {
|
|
23282
23394
|
const continuations = getLogInkChordContinuations(options.pendingKey);
|
|
23283
23395
|
if (continuations.length > 0) {
|
|
@@ -23394,7 +23506,7 @@ function getLogInkFooterHints(options) {
|
|
|
23394
23506
|
}
|
|
23395
23507
|
if (options.activeView === 'status') {
|
|
23396
23508
|
return {
|
|
23397
|
-
contextual: ['↑/↓ files', 'enter
|
|
23509
|
+
contextual: ['↑/↓ files', 'enter hunks', 'space stage', 'A stage all', 'z revert', 'e/c compose'],
|
|
23398
23510
|
global: NORMAL_GLOBAL_HINTS,
|
|
23399
23511
|
};
|
|
23400
23512
|
}
|
|
@@ -23406,16 +23518,19 @@ function getLogInkFooterHints(options) {
|
|
|
23406
23518
|
const splitToggleHint = options.diffViewMode === 'split' ? 'd unified' : 'd split';
|
|
23407
23519
|
if (options.diffSource === 'stash') {
|
|
23408
23520
|
return {
|
|
23409
|
-
contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk',
|
|
23521
|
+
contextual: ['j/k lines', '[/] file', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'esc back'],
|
|
23410
23522
|
global: NORMAL_GLOBAL_HINTS,
|
|
23411
23523
|
};
|
|
23412
23524
|
}
|
|
23413
23525
|
if (options.diffSource === 'commit') {
|
|
23414
23526
|
// Commit-diff explore: read-only diff, but `c` cherry-picks the
|
|
23415
23527
|
// cursored file from the commit into the worktree, and `H`
|
|
23416
|
-
// (or `gH` for index) applies just the cursored hunk.
|
|
23528
|
+
// (or `gH` for index) applies just the cursored hunk. `j/k`
|
|
23529
|
+
// line-scroll the diff body; `[`/`]` jump between hunks — the
|
|
23530
|
+
// footer labels match the actual handlers (commit diff has no
|
|
23531
|
+
// per-file `[/]` jump; that's the stash diff).
|
|
23417
23532
|
return {
|
|
23418
|
-
contextual: ['j/k
|
|
23533
|
+
contextual: ['j/k lines', '[/] hunk', 'c cherry-pick', 'H apply hunk', splitToggleHint, 'esc back'],
|
|
23419
23534
|
global: NORMAL_GLOBAL_HINTS,
|
|
23420
23535
|
};
|
|
23421
23536
|
}
|
|
@@ -23428,14 +23543,17 @@ function getLogInkFooterHints(options) {
|
|
|
23428
23543
|
global: NORMAL_GLOBAL_HINTS,
|
|
23429
23544
|
};
|
|
23430
23545
|
}
|
|
23546
|
+
// Worktree (staging) diff. The hunk is the unit of action: ↑/↓ walk
|
|
23547
|
+
// hunks, space stages/unstages the selected one, a stages the whole
|
|
23548
|
+
// file, z discards the hunk.
|
|
23431
23549
|
return {
|
|
23432
|
-
contextual: ['
|
|
23550
|
+
contextual: ['↑/↓ hunk', 'space stage', 'a stage file', 'z discard', 'o edit', 'esc back'],
|
|
23433
23551
|
global: NORMAL_GLOBAL_HINTS,
|
|
23434
23552
|
};
|
|
23435
23553
|
}
|
|
23436
23554
|
if (options.activeView === 'compose') {
|
|
23437
23555
|
return {
|
|
23438
|
-
contextual: ['e edit', '
|
|
23556
|
+
contextual: ['e edit', 'c commit', 'A stage all', '+ stage…', 'S split', 'I AI draft', 'esc back'],
|
|
23439
23557
|
global: NORMAL_GLOBAL_HINTS,
|
|
23440
23558
|
};
|
|
23441
23559
|
}
|
|
@@ -23465,7 +23583,7 @@ function getLogInkFooterHints(options) {
|
|
|
23465
23583
|
}
|
|
23466
23584
|
if (options.activeView === 'stash') {
|
|
23467
23585
|
return {
|
|
23468
|
-
contextual: ['↑/↓ stashes', 'enter diff', 'a apply', 'p pop', '
|
|
23586
|
+
contextual: ['↑/↓ stashes', 'enter diff', 'a/A apply', 'p pop', 'R rename', 'b branch', 'X drop · u undo'],
|
|
23469
23587
|
global: NORMAL_GLOBAL_HINTS,
|
|
23470
23588
|
};
|
|
23471
23589
|
}
|
|
@@ -24856,7 +24974,7 @@ function topOfStack(stack) {
|
|
|
24856
24974
|
}
|
|
24857
24975
|
function withPushedView(state, value) {
|
|
24858
24976
|
if (topOfStack(state.viewStack) === value) {
|
|
24859
|
-
return { ...state, pendingKey: undefined };
|
|
24977
|
+
return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
|
|
24860
24978
|
}
|
|
24861
24979
|
const viewStack = [...state.viewStack, value];
|
|
24862
24980
|
return {
|
|
@@ -24879,12 +24997,15 @@ function withPushedView(state, value) {
|
|
|
24879
24997
|
compareHead: value === 'diff' ? state.compareHead : undefined,
|
|
24880
24998
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
24881
24999
|
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
25000
|
+
// Changing the view is a deliberate destination — cancel any pending
|
|
25001
|
+
// peek return so the user isn't snapped back afterward.
|
|
25002
|
+
peekReturnFocus: undefined,
|
|
24882
25003
|
pendingKey: undefined,
|
|
24883
25004
|
};
|
|
24884
25005
|
}
|
|
24885
25006
|
function withPoppedView(state) {
|
|
24886
25007
|
if (state.viewStack.length <= 1) {
|
|
24887
|
-
return { ...state, pendingKey: undefined };
|
|
25008
|
+
return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
|
|
24888
25009
|
}
|
|
24889
25010
|
const viewStack = state.viewStack.slice(0, -1);
|
|
24890
25011
|
const next = topOfStack(viewStack);
|
|
@@ -24911,6 +25032,8 @@ function withPoppedView(state) {
|
|
|
24911
25032
|
compareHead: next === 'diff' ? state.compareHead : undefined,
|
|
24912
25033
|
pendingCommitFocused: next === 'history' ? state.pendingCommitFocused : false,
|
|
24913
25034
|
statusGroupHeaderFocused: next === 'status' ? state.statusGroupHeaderFocused : false,
|
|
25035
|
+
// Backing out is a deliberate navigation — cancel any peek return.
|
|
25036
|
+
peekReturnFocus: undefined,
|
|
24914
25037
|
pendingKey: undefined,
|
|
24915
25038
|
};
|
|
24916
25039
|
}
|
|
@@ -25023,7 +25146,7 @@ function withPoppedRepoFrame(state) {
|
|
|
25023
25146
|
}
|
|
25024
25147
|
function withReplacedView(state, value) {
|
|
25025
25148
|
if (topOfStack(state.viewStack) === value) {
|
|
25026
|
-
return { ...state, pendingKey: undefined };
|
|
25149
|
+
return { ...state, peekReturnFocus: undefined, pendingKey: undefined };
|
|
25027
25150
|
}
|
|
25028
25151
|
const viewStack = [...state.viewStack.slice(0, -1), value];
|
|
25029
25152
|
return {
|
|
@@ -25037,6 +25160,9 @@ function withReplacedView(state, value) {
|
|
|
25037
25160
|
compareHead: value === 'diff' ? state.compareHead : undefined,
|
|
25038
25161
|
pendingCommitFocused: value === 'history' ? state.pendingCommitFocused : false,
|
|
25039
25162
|
statusGroupHeaderFocused: value === 'status' ? state.statusGroupHeaderFocused : false,
|
|
25163
|
+
// Changing the view is a deliberate destination — cancel any pending
|
|
25164
|
+
// peek return so the user isn't snapped back afterward.
|
|
25165
|
+
peekReturnFocus: undefined,
|
|
25040
25166
|
pendingKey: undefined,
|
|
25041
25167
|
};
|
|
25042
25168
|
}
|
|
@@ -25270,6 +25396,9 @@ function applyLogInkAction(state, action) {
|
|
|
25270
25396
|
// from 'commits' should always land back on a real file when
|
|
25271
25397
|
// the user returns.
|
|
25272
25398
|
statusGroupHeaderFocused: false,
|
|
25399
|
+
// Explicit focus cycle cancels a pending peek return — the
|
|
25400
|
+
// user has taken manual control of the focus.
|
|
25401
|
+
peekReturnFocus: undefined,
|
|
25273
25402
|
pendingKey: undefined,
|
|
25274
25403
|
};
|
|
25275
25404
|
case 'focusPrevious':
|
|
@@ -25278,6 +25407,7 @@ function applyLogInkAction(state, action) {
|
|
|
25278
25407
|
focus: cycleValue(FOCUS_ORDER, state.focus, -1),
|
|
25279
25408
|
sidebarHeaderFocused: false,
|
|
25280
25409
|
statusGroupHeaderFocused: false,
|
|
25410
|
+
peekReturnFocus: undefined,
|
|
25281
25411
|
pendingKey: undefined,
|
|
25282
25412
|
};
|
|
25283
25413
|
case 'move':
|
|
@@ -25775,8 +25905,35 @@ function applyLogInkAction(state, action) {
|
|
|
25775
25905
|
// the status view — clear when focus moves away so a
|
|
25776
25906
|
// re-entry starts on a real file.
|
|
25777
25907
|
statusGroupHeaderFocused: action.value === 'commits' ? state.statusGroupHeaderFocused : false,
|
|
25908
|
+
// An explicit focus set cancels a pending peek return.
|
|
25909
|
+
peekReturnFocus: undefined,
|
|
25910
|
+
pendingKey: undefined,
|
|
25911
|
+
};
|
|
25912
|
+
case 'togglePeek': {
|
|
25913
|
+
// Peek = "focus the sidebar with a return ticket." Closing returns
|
|
25914
|
+
// to the stashed focus; opening (only from a non-sidebar pane)
|
|
25915
|
+
// stashes the current focus and jumps to the sidebar. The render
|
|
25916
|
+
// layer needs no special case — `focus: 'sidebar'` already drives
|
|
25917
|
+
// the single-pane layout to show the sidebar full-width.
|
|
25918
|
+
if (state.peekReturnFocus !== undefined) {
|
|
25919
|
+
return {
|
|
25920
|
+
...state,
|
|
25921
|
+
focus: state.peekReturnFocus,
|
|
25922
|
+
peekReturnFocus: undefined,
|
|
25923
|
+
sidebarHeaderFocused: false,
|
|
25924
|
+
pendingKey: undefined,
|
|
25925
|
+
};
|
|
25926
|
+
}
|
|
25927
|
+
if (state.focus === 'sidebar') {
|
|
25928
|
+
return state;
|
|
25929
|
+
}
|
|
25930
|
+
return {
|
|
25931
|
+
...state,
|
|
25932
|
+
focus: 'sidebar',
|
|
25933
|
+
peekReturnFocus: state.focus,
|
|
25778
25934
|
pendingKey: undefined,
|
|
25779
25935
|
};
|
|
25936
|
+
}
|
|
25780
25937
|
case 'setPendingKey':
|
|
25781
25938
|
return {
|
|
25782
25939
|
...state,
|
|
@@ -26643,6 +26800,12 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
26643
26800
|
return [action({ type: 'toggleGraph' })];
|
|
26644
26801
|
case 'navigateHome':
|
|
26645
26802
|
return [action({ type: 'navigateHome' })];
|
|
26803
|
+
case 'createStash':
|
|
26804
|
+
return [action({
|
|
26805
|
+
type: 'openInputPrompt',
|
|
26806
|
+
kind: 'create-stash',
|
|
26807
|
+
label: 'Stash message (empty = WIP)',
|
|
26808
|
+
})];
|
|
26646
26809
|
case 'navigateStatus':
|
|
26647
26810
|
return [action({ type: 'pushView', value: 'status' })];
|
|
26648
26811
|
case 'navigateDiff':
|
|
@@ -26748,6 +26911,14 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
26748
26911
|
// Runtime resolves the cursored worktree file and opens the picker
|
|
26749
26912
|
// (no-ops with a warning when there's no file under the cursor).
|
|
26750
26913
|
return [{ type: 'openGitignorePicker' }];
|
|
26914
|
+
case 'stageAll':
|
|
26915
|
+
return [{ type: 'runWorkflowAction', id: 'stage-all' }];
|
|
26916
|
+
case 'stagePathspec':
|
|
26917
|
+
return [action({
|
|
26918
|
+
type: 'openInputPrompt',
|
|
26919
|
+
kind: 'stage-pathspec',
|
|
26920
|
+
label: 'Stage pathspec (e.g. `.`, `src/`, `*.ts`, or a space-separated list)',
|
|
26921
|
+
})];
|
|
26751
26922
|
case 'workflowDeleteBranch':
|
|
26752
26923
|
case 'workflowDeleteTag':
|
|
26753
26924
|
case 'workflowDropStash':
|
|
@@ -26838,6 +27009,15 @@ function submitInputPrompt(state) {
|
|
|
26838
27009
|
if (!state.inputPrompt)
|
|
26839
27010
|
return [];
|
|
26840
27011
|
const value = state.inputPrompt.value.trim();
|
|
27012
|
+
// create-stash allows an EMPTY value → quick WIP stash (git supplies its
|
|
27013
|
+
// own "WIP on <branch>" subject). Handled before the generic empty guard
|
|
27014
|
+
// so an empty stash prompt commits a WIP stash instead of bouncing.
|
|
27015
|
+
if (state.inputPrompt.kind === 'create-stash') {
|
|
27016
|
+
return [
|
|
27017
|
+
{ type: 'runWorkflowAction', id: 'create-stash', payload: value },
|
|
27018
|
+
action({ type: 'closeInputPrompt' }),
|
|
27019
|
+
];
|
|
27020
|
+
}
|
|
26841
27021
|
if (!value) {
|
|
26842
27022
|
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
|
|
26843
27023
|
}
|
|
@@ -26847,6 +27027,12 @@ function submitInputPrompt(state) {
|
|
|
26847
27027
|
action({ type: 'closeInputPrompt' }),
|
|
26848
27028
|
];
|
|
26849
27029
|
}
|
|
27030
|
+
if (state.inputPrompt.kind === 'stage-pathspec') {
|
|
27031
|
+
return [
|
|
27032
|
+
{ type: 'runWorkflowAction', id: 'stage-pathspec', payload: value },
|
|
27033
|
+
action({ type: 'closeInputPrompt' }),
|
|
27034
|
+
];
|
|
27035
|
+
}
|
|
26850
27036
|
if (state.inputPrompt.kind === 'reset-mode') {
|
|
26851
27037
|
const mode = value.toLowerCase();
|
|
26852
27038
|
if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
|
|
@@ -27073,7 +27259,9 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27073
27259
|
// draft was pending should see the original `R` / Esc semantics of
|
|
27074
27260
|
// wherever they are now.
|
|
27075
27261
|
if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
|
|
27076
|
-
|
|
27262
|
+
// `R` or `Enter` accept the swap (the AI draft becomes the new
|
|
27263
|
+
// content); `Enter` is the natural "yes, use it" confirmation.
|
|
27264
|
+
if ((inputValue === 'R' && !key.ctrl && !key.meta) || key.return) {
|
|
27077
27265
|
return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
|
|
27078
27266
|
}
|
|
27079
27267
|
if (key.escape) {
|
|
@@ -27459,6 +27647,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27459
27647
|
}
|
|
27460
27648
|
return events;
|
|
27461
27649
|
}
|
|
27650
|
+
// #1135 v2 — while peeking the sidebar, Esc or the peek key (`v`)
|
|
27651
|
+
// snaps back to the pane the user came from. Placed before the
|
|
27652
|
+
// generic Esc → popView so a peek glance returns to main rather than
|
|
27653
|
+
// walking the view stack. Every other key falls through to normal
|
|
27654
|
+
// handling (focus is on the sidebar during a peek), so ←/→ and ↑/↓
|
|
27655
|
+
// browse the sidebar and keep the peek open until an explicit exit.
|
|
27656
|
+
if (state.peekReturnFocus !== undefined && (key.escape || inputValue === 'v')) {
|
|
27657
|
+
return [action({ type: 'togglePeek' })];
|
|
27658
|
+
}
|
|
27462
27659
|
if (key.escape && state.viewStack.length > 1) {
|
|
27463
27660
|
return [action({ type: 'popView' })];
|
|
27464
27661
|
}
|
|
@@ -27525,6 +27722,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27525
27722
|
action({ type: 'setStatus', value: 'jumped to stash' }),
|
|
27526
27723
|
];
|
|
27527
27724
|
}
|
|
27725
|
+
// `gZ` chord: stash all changes from ANY view — including status / diff /
|
|
27726
|
+
// compose, where bare `S` is claimed by the commit-split flow. Mnemonic
|
|
27727
|
+
// pair with `gz` (jump to the stash *view*). Opens the same message
|
|
27728
|
+
// prompt; an empty message creates a quick WIP stash.
|
|
27729
|
+
if (state.pendingKey === 'g' && inputValue === 'Z') {
|
|
27730
|
+
return [action({
|
|
27731
|
+
type: 'openInputPrompt',
|
|
27732
|
+
kind: 'create-stash',
|
|
27733
|
+
label: 'Stash message (empty = WIP)',
|
|
27734
|
+
})];
|
|
27735
|
+
}
|
|
27528
27736
|
if (state.pendingKey === 'g' && inputValue === 'w') {
|
|
27529
27737
|
return [
|
|
27530
27738
|
action({ type: 'pushView', value: 'worktrees' }),
|
|
@@ -27888,6 +28096,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27888
28096
|
if (key.tab) {
|
|
27889
28097
|
return [action({ type: key.shift ? 'focusPrevious' : 'focusNext' })];
|
|
27890
28098
|
}
|
|
28099
|
+
// #1135 v2 — `v` peeks the sidebar from the main / inspector pane on
|
|
28100
|
+
// narrow (single-pane) terminals: a momentary glance that snaps back
|
|
28101
|
+
// with `v` / Esc (handled above once peeking). No-op in the three-pane
|
|
28102
|
+
// layout (every pane is already on screen) and from the sidebar itself.
|
|
28103
|
+
if (inputValue === 'v' && context.singlePane && state.focus !== 'sidebar') {
|
|
28104
|
+
return [action({ type: 'togglePeek' })];
|
|
28105
|
+
}
|
|
27891
28106
|
// ←/→ on the sidebar switch tabs (Status ↔ Branches ↔ Tags ↔
|
|
27892
28107
|
// Stashes ↔ Worktrees) — the horizontal axis is "between tabs", the
|
|
27893
28108
|
// vertical axis (↑/↓ below) is "within the active tab's items".
|
|
@@ -27973,10 +28188,17 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27973
28188
|
fileCount: context.worktreeFileCount,
|
|
27974
28189
|
})];
|
|
27975
28190
|
}
|
|
27976
|
-
//
|
|
27977
|
-
//
|
|
27978
|
-
//
|
|
27979
|
-
//
|
|
28191
|
+
// Worktree (staging) diff: ↑/↓ move between hunks — the hunk is the
|
|
28192
|
+
// unit you stage, so the cursor walks hunks (auto-scrolling to the
|
|
28193
|
+
// selected one). Single-hunk files fall through to line-scroll so a
|
|
28194
|
+
// long lone hunk stays readable; `[`/`]` remain hunk-jump aliases.
|
|
28195
|
+
if (state.activeView === 'diff' && (context.worktreeHunkOffsets?.length ?? 0) > 1) {
|
|
28196
|
+
return [action({
|
|
28197
|
+
type: 'jumpWorktreeHunk',
|
|
28198
|
+
delta: -1,
|
|
28199
|
+
hunkOffsets: context.worktreeHunkOffsets,
|
|
28200
|
+
})];
|
|
28201
|
+
}
|
|
27980
28202
|
if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
|
|
27981
28203
|
return [action({
|
|
27982
28204
|
type: 'pageWorktreeDiff',
|
|
@@ -28091,6 +28313,15 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28091
28313
|
fileCount: context.worktreeFileCount,
|
|
28092
28314
|
})];
|
|
28093
28315
|
}
|
|
28316
|
+
// Worktree (staging) diff: ↓ walks to the next hunk (see the ↑
|
|
28317
|
+
// handler). Multi-hunk only; single-hunk files line-scroll.
|
|
28318
|
+
if (state.activeView === 'diff' && (context.worktreeHunkOffsets?.length ?? 0) > 1) {
|
|
28319
|
+
return [action({
|
|
28320
|
+
type: 'jumpWorktreeHunk',
|
|
28321
|
+
delta: 1,
|
|
28322
|
+
hunkOffsets: context.worktreeHunkOffsets,
|
|
28323
|
+
})];
|
|
28324
|
+
}
|
|
28094
28325
|
if (state.activeView === 'diff' && context.worktreeDiffLineCount) {
|
|
28095
28326
|
return [action({
|
|
28096
28327
|
type: 'pageWorktreeDiff',
|
|
@@ -28497,6 +28728,25 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28497
28728
|
if (inputValue === 'p' && isStashActionTarget(state) && context.stashCount) {
|
|
28498
28729
|
return [{ type: 'runWorkflowAction', id: 'pop-stash' }];
|
|
28499
28730
|
}
|
|
28731
|
+
// `A` applies restoring the staged/unstaged split (`git stash apply
|
|
28732
|
+
// --index`) — distinct from `a` (plain apply).
|
|
28733
|
+
if (inputValue === 'A' && isStashActionTarget(state) && context.stashCount) {
|
|
28734
|
+
return [{ type: 'runWorkflowAction', id: 'apply-stash-index' }];
|
|
28735
|
+
}
|
|
28736
|
+
// `b` turns the cursored stash into a new branch (`git stash branch`).
|
|
28737
|
+
if (inputValue === 'b' && isStashActionTarget(state) && context.stashCount) {
|
|
28738
|
+
return [action({ type: 'openInputPrompt', kind: 'stash-branch', label: 'New branch from stash' })];
|
|
28739
|
+
}
|
|
28740
|
+
// `R` renames the cursored stash (store-under-new-message + drop old).
|
|
28741
|
+
if (inputValue === 'R' && isStashActionTarget(state) && context.stashCount) {
|
|
28742
|
+
return [action({ type: 'openInputPrompt', kind: 'rename-stash', label: 'Rename stash' })];
|
|
28743
|
+
}
|
|
28744
|
+
// `u` undoes the last drop. Gated on the view, NOT the count, so it
|
|
28745
|
+
// still works right after you drop your only stash (the list is empty
|
|
28746
|
+
// but the dropped commit is recoverable by hash).
|
|
28747
|
+
if (inputValue === 'u' && isStashActionTarget(state)) {
|
|
28748
|
+
return [{ type: 'runWorkflowAction', id: 'undo-drop-stash' }];
|
|
28749
|
+
}
|
|
28500
28750
|
// Per-view tag action: `P` pushes the selected tag to origin. Letter
|
|
28501
28751
|
// is scoped to the tags target so it doesn't collide with `p` for
|
|
28502
28752
|
// pop-stash. Note: this also takes precedence over the global
|
|
@@ -28725,7 +28975,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28725
28975
|
return [action({
|
|
28726
28976
|
type: 'openInputPrompt',
|
|
28727
28977
|
kind: 'create-stash',
|
|
28728
|
-
label: 'Stash message',
|
|
28978
|
+
label: 'Stash message (empty = WIP)',
|
|
28729
28979
|
})];
|
|
28730
28980
|
}
|
|
28731
28981
|
// `o` opens the file under the cursor in $EDITOR. Available on the
|
|
@@ -28994,9 +29244,35 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28994
29244
|
if (inputValue === ' ' && state.activeView === 'status' && context.worktreeFileCount) {
|
|
28995
29245
|
return [{ type: 'toggleSelectedFileStage' }];
|
|
28996
29246
|
}
|
|
29247
|
+
// `A` — stage everything (git add -A); `+` — stage by typed pathspec.
|
|
29248
|
+
// Both available from the status AND compose views so you can stage
|
|
29249
|
+
// without leaving the message editor.
|
|
29250
|
+
if (inputValue === 'A' && (state.activeView === 'status' || state.activeView === 'compose')) {
|
|
29251
|
+
return [{ type: 'runWorkflowAction', id: 'stage-all' }];
|
|
29252
|
+
}
|
|
29253
|
+
if (inputValue === '+' && (state.activeView === 'status' || state.activeView === 'compose')) {
|
|
29254
|
+
return [action({
|
|
29255
|
+
type: 'openInputPrompt',
|
|
29256
|
+
kind: 'stage-pathspec',
|
|
29257
|
+
label: 'Stage pathspec (e.g. `.`, `src/`, `*.ts`, or a space-separated list)',
|
|
29258
|
+
})];
|
|
29259
|
+
}
|
|
28997
29260
|
if (inputValue === ' ' && state.activeView === 'diff' && context.worktreeHunkOffsets?.length) {
|
|
28998
29261
|
return [{ type: 'toggleSelectedHunkStage' }];
|
|
28999
29262
|
}
|
|
29263
|
+
// Worktree diff with no hunks (a new/untracked file) — `space` stages
|
|
29264
|
+
// the whole file, since there's nothing to partial-stage.
|
|
29265
|
+
if (inputValue === ' ' &&
|
|
29266
|
+
state.activeView === 'diff' &&
|
|
29267
|
+
state.diffSource === 'worktree' &&
|
|
29268
|
+
!context.worktreeHunkOffsets?.length) {
|
|
29269
|
+
return [{ type: 'toggleSelectedFileStage' }];
|
|
29270
|
+
}
|
|
29271
|
+
// `a` stages/unstages the WHOLE current file from the staging diff —
|
|
29272
|
+
// an escape hatch out of hunk-by-hunk back to all-or-nothing.
|
|
29273
|
+
if (inputValue === 'a' && state.activeView === 'diff' && state.diffSource === 'worktree') {
|
|
29274
|
+
return [{ type: 'toggleSelectedFileStage' }];
|
|
29275
|
+
}
|
|
29000
29276
|
if (inputValue === 'z' && state.activeView === 'status' && context.worktreeFileCount) {
|
|
29001
29277
|
return [action({ type: 'setPendingMutationConfirmation', value: 'revert-file' })];
|
|
29002
29278
|
}
|
|
@@ -29771,21 +30047,22 @@ const INSPECTOR_TABBED_BELOW_ROWS = 28;
|
|
|
29771
30047
|
* wide >= 160 — plenty of room; keep absolute dates
|
|
29772
30048
|
* normal >= 120 — relative dates save 8-ish cells without hiding info
|
|
29773
30049
|
* tight >= 100 — drop date entirely; subject + refs are the priority
|
|
29774
|
-
* rail < 100 —
|
|
29775
|
-
*
|
|
30050
|
+
* rail < 100 — history rows stack to two lines; the UI also drops
|
|
30051
|
+
* to single-pane mode (see `LAYOUT_SINGLE_PANE_BELOW`)
|
|
29776
30052
|
*/
|
|
29777
30053
|
const LAYOUT_TIGHT_BELOW = 120;
|
|
29778
30054
|
const LAYOUT_NORMAL_BELOW = 160;
|
|
29779
30055
|
const LAYOUT_RAIL_BELOW = 100;
|
|
29780
30056
|
/**
|
|
29781
|
-
*
|
|
29782
|
-
*
|
|
29783
|
-
*
|
|
29784
|
-
*
|
|
30057
|
+
* Width below which the three-panel layout can't tile without starving
|
|
30058
|
+
* every pane, so the UI shows exactly one full-width pane (the focused
|
|
30059
|
+
* one) and Tab cycles which pane is visible. Coincides with the `rail`
|
|
30060
|
+
* density breakpoint — single-pane mode replaces the old 8-cell icon
|
|
30061
|
+
* rails that used to render at this width.
|
|
29785
30062
|
*/
|
|
29786
|
-
const
|
|
30063
|
+
const LAYOUT_SINGLE_PANE_BELOW = LAYOUT_RAIL_BELOW;
|
|
29787
30064
|
const SIDEBAR_AT_REST_BY_TIER = {
|
|
29788
|
-
rail: { min: 22, max: 28, fraction: 0.24 }, // unused —
|
|
30065
|
+
rail: { min: 22, max: 28, fraction: 0.24 }, // unused at rest — single-pane mode overrides the width
|
|
29789
30066
|
tight: { min: 22, max: 28, fraction: 0.24 },
|
|
29790
30067
|
normal: { min: 22, max: 30, fraction: 0.22 },
|
|
29791
30068
|
wide: { min: 28, max: 32, fraction: 0.20 },
|
|
@@ -29804,14 +30081,25 @@ function getLogInkLayout(input) {
|
|
|
29804
30081
|
: columns >= LAYOUT_RAIL_BELOW
|
|
29805
30082
|
? 'tight'
|
|
29806
30083
|
: 'rail';
|
|
29807
|
-
//
|
|
29808
|
-
//
|
|
29809
|
-
//
|
|
29810
|
-
//
|
|
29811
|
-
//
|
|
29812
|
-
|
|
29813
|
-
|
|
29814
|
-
|
|
30084
|
+
// Below the single-pane breakpoint the three-panel layout can't tile
|
|
30085
|
+
// without starving every pane, so we show exactly one full-width pane
|
|
30086
|
+
// — the focused one — and Tab cycles which pane is visible. This
|
|
30087
|
+
// replaces the retired 8-cell icon rails (an 8-cell stub showed a tab
|
|
30088
|
+
// glyph + count and nothing actionable).
|
|
30089
|
+
const singlePane = columns < LAYOUT_SINGLE_PANE_BELOW;
|
|
30090
|
+
// Which pane shows in single-pane mode. Defaults to the focused pane
|
|
30091
|
+
// (focus and visibility coalesce, so the existing Tab focus cycle
|
|
30092
|
+
// drives it). An active overlay can force a specific pane via
|
|
30093
|
+
// `forcedPane` so its surface isn't hidden behind whatever pane focus
|
|
30094
|
+
// points at.
|
|
30095
|
+
const focusPane = input.sidebarFocused
|
|
30096
|
+
? 'sidebar'
|
|
30097
|
+
: input.inspectorFocused
|
|
30098
|
+
? 'inspector'
|
|
30099
|
+
: 'main';
|
|
30100
|
+
const visiblePane = singlePane
|
|
30101
|
+
? input.forcedPane ?? focusPane
|
|
30102
|
+
: focusPane;
|
|
29815
30103
|
// Inspector width — at rest 20-32 cells (~22% of width), focused
|
|
29816
30104
|
// 36-60 cells (~40% of width). Narrow rest state keeps the commit
|
|
29817
30105
|
// graph dominant; focus expansion gives the inspector room for long
|
|
@@ -29823,42 +30111,48 @@ function getLogInkLayout(input) {
|
|
|
29823
30111
|
// "Move focus...". Capped at 100 cells so a wide terminal doesn't
|
|
29824
30112
|
// waste an absurd amount of horizontal space on the cheat sheet.
|
|
29825
30113
|
//
|
|
29826
|
-
//
|
|
29827
|
-
//
|
|
29828
|
-
// intent to read the panel.
|
|
30114
|
+
// (In single-pane mode these three-panel widths are recomputed below
|
|
30115
|
+
// so the visible pane gets the full terminal.)
|
|
29829
30116
|
const detailWidth = input.helpOverlayActive
|
|
29830
30117
|
? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
|
|
29831
30118
|
: input.inspectorFocused
|
|
29832
30119
|
? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
|
|
29833
|
-
:
|
|
29834
|
-
? LAYOUT_RAIL_PANEL_WIDTH
|
|
29835
|
-
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
30120
|
+
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
29836
30121
|
// Sidebar at rest is tier-aware (see `SIDEBAR_AT_REST_BY_TIER`):
|
|
29837
30122
|
// tight stays compact (22-28), normal shrinks slightly (22-30),
|
|
29838
30123
|
// wide grows naturally (28-48) so the side panel doesn't get pinned
|
|
29839
30124
|
// at an arbitrary cap on big terminals while the main panel hogs
|
|
29840
30125
|
// 80% of the width. Focused: 32-50 cells (~36% of width),
|
|
29841
30126
|
// regardless of tier — deliberate user intent to read the sidebar
|
|
29842
|
-
// deserves the extra width.
|
|
29843
|
-
// collapses to a fixed 8-cell strip with tab glyphs only.
|
|
30127
|
+
// deserves the extra width.
|
|
29844
30128
|
const sidebarWidth = input.sidebarFocused
|
|
29845
30129
|
? Math.max(32, Math.min(50, Math.floor(columns * 0.36)))
|
|
29846
|
-
:
|
|
29847
|
-
|
|
29848
|
-
|
|
30130
|
+
: calcSidebarAtRestWidth(columns, density);
|
|
30131
|
+
// Single-pane mode: exactly one pane renders, full-width; the other
|
|
30132
|
+
// two are hidden (width 0), not railed. Above the breakpoint the
|
|
30133
|
+
// three panels tile flush across the terminal.
|
|
30134
|
+
const paneWidths = singlePane
|
|
30135
|
+
? {
|
|
30136
|
+
sidebarWidth: visiblePane === 'sidebar' ? columns : 0,
|
|
30137
|
+
mainPanelWidth: visiblePane === 'main' ? columns : 0,
|
|
30138
|
+
detailWidth: visiblePane === 'inspector' ? columns : 0,
|
|
30139
|
+
}
|
|
30140
|
+
: {
|
|
30141
|
+
sidebarWidth,
|
|
30142
|
+
mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
|
|
30143
|
+
detailWidth,
|
|
30144
|
+
};
|
|
29849
30145
|
return {
|
|
29850
30146
|
bodyRows: Math.max(8, rows - 5),
|
|
29851
30147
|
columns,
|
|
29852
|
-
detailWidth,
|
|
29853
|
-
mainPanelWidth: Math.max(20, columns - sidebarWidth - detailWidth),
|
|
29854
30148
|
rows,
|
|
29855
|
-
sidebarWidth,
|
|
29856
30149
|
tooSmall: columns < LOG_INK_MIN_COLUMNS || rows < LOG_INK_MIN_ROWS,
|
|
29857
30150
|
inspectorTabbed: rows < INSPECTOR_TABBED_BELOW_ROWS,
|
|
29858
30151
|
density,
|
|
29859
|
-
|
|
29860
|
-
|
|
30152
|
+
singlePane,
|
|
30153
|
+
visiblePane,
|
|
29861
30154
|
historyRowMode: density === 'rail' ? 'stacked' : 'single',
|
|
30155
|
+
...paneWidths,
|
|
29862
30156
|
};
|
|
29863
30157
|
}
|
|
29864
30158
|
|
|
@@ -30907,6 +31201,45 @@ async function highlightDiffCode(filePath, lines) {
|
|
|
30907
31201
|
return result;
|
|
30908
31202
|
}
|
|
30909
31203
|
|
|
31204
|
+
/**
|
|
31205
|
+
* Humanize raw AI-provider / LangChain error strings into a short,
|
|
31206
|
+
* actionable line for the compose surface.
|
|
31207
|
+
*
|
|
31208
|
+
* The underlying errors are verbose and developer-facing — e.g.
|
|
31209
|
+
* `executeChain: Chain execution failed: 429 You exceeded your current
|
|
31210
|
+
* quota …`. We classify the common failure modes (rate limit, auth,
|
|
31211
|
+
* network, context length) into a concise message that tells the user
|
|
31212
|
+
* what happened and what to do, and fall back to the original (trimmed)
|
|
31213
|
+
* text for anything we don't recognize. Pure + tested.
|
|
31214
|
+
*/
|
|
31215
|
+
function humanizeAiError(raw) {
|
|
31216
|
+
const message = (raw || '').trim();
|
|
31217
|
+
if (!message)
|
|
31218
|
+
return 'AI request failed.';
|
|
31219
|
+
const lower = message.toLowerCase();
|
|
31220
|
+
// Rate limit / quota — the 429 in the screenshot.
|
|
31221
|
+
if (/\b429\b/.test(message) || /rate.?limit|too many requests|exceeded your current quota|quota/i.test(lower)) {
|
|
31222
|
+
return 'Rate limited by your AI provider (429) — too many requests or quota exceeded. Wait a moment, then press I to retry.';
|
|
31223
|
+
}
|
|
31224
|
+
// Auth / API key problems.
|
|
31225
|
+
if (/\b401\b|\b403\b/.test(message) || /unauthor|forbidden|invalid api key|incorrect api key|no api key|authentication/i.test(lower)) {
|
|
31226
|
+
return 'AI provider rejected the request — check your API key (run `coco init`, or press gK to edit the global config).';
|
|
31227
|
+
}
|
|
31228
|
+
// Context window overflow.
|
|
31229
|
+
if (/context length|maximum context|too many tokens|reduce the length|context_length_exceeded/i.test(lower)) {
|
|
31230
|
+
return 'The staged diff is too large for the model’s context window — stage fewer changes (or split the commit) and retry with I.';
|
|
31231
|
+
}
|
|
31232
|
+
// Network / connectivity.
|
|
31233
|
+
if (/etimedout|econnreset|enotfound|econnrefused|network error|fetch failed|socket hang up|timeout/i.test(lower)) {
|
|
31234
|
+
return 'Network error reaching the AI provider — check your connection, then press I to retry.';
|
|
31235
|
+
}
|
|
31236
|
+
// Unknown: strip the noisy `executeChain: Chain execution failed:`
|
|
31237
|
+
// prefix if present so the meaningful part leads, and keep it to one
|
|
31238
|
+
// line so it doesn't blow out the panel.
|
|
31239
|
+
const stripped = message.replace(/^.*?chain execution failed:\s*/i, '').trim() || message;
|
|
31240
|
+
return stripped.split('\n')[0];
|
|
31241
|
+
}
|
|
31242
|
+
|
|
30910
31243
|
async function runAction$4(action, successMessage) {
|
|
30911
31244
|
try {
|
|
30912
31245
|
await action();
|
|
@@ -30950,15 +31283,104 @@ async function runAction$3(action, successMessage) {
|
|
|
30950
31283
|
};
|
|
30951
31284
|
}
|
|
30952
31285
|
}
|
|
30953
|
-
function createStash(git, message) {
|
|
31286
|
+
function createStash(git, message, options = {}) {
|
|
30954
31287
|
const trimmedMessage = message.trim();
|
|
30955
|
-
|
|
30956
|
-
|
|
30957
|
-
|
|
30958
|
-
|
|
30959
|
-
|
|
31288
|
+
const args = ['stash', 'push'];
|
|
31289
|
+
// `--staged` is index-only, so untracked / `--keep-index` don't apply;
|
|
31290
|
+
// every other mode includes untracked (`-u`). `--keep-index` leaves the
|
|
31291
|
+
// index populated for an immediate follow-up commit.
|
|
31292
|
+
if (options.stagedOnly) {
|
|
31293
|
+
args.push('--staged');
|
|
31294
|
+
}
|
|
31295
|
+
else {
|
|
31296
|
+
args.push('-u');
|
|
31297
|
+
if (options.keepIndex)
|
|
31298
|
+
args.push('--keep-index');
|
|
31299
|
+
}
|
|
31300
|
+
if (trimmedMessage)
|
|
31301
|
+
args.push('-m', trimmedMessage);
|
|
31302
|
+
const paths = options.pathspec?.trim();
|
|
31303
|
+
if (paths)
|
|
31304
|
+
args.push('--', ...paths.split(/\s+/));
|
|
31305
|
+
const what = options.stagedOnly
|
|
31306
|
+
? 'staged changes'
|
|
31307
|
+
: paths
|
|
31308
|
+
? `“${paths}”`
|
|
31309
|
+
: options.keepIndex
|
|
31310
|
+
? 'changes (index kept)'
|
|
31311
|
+
: '';
|
|
31312
|
+
const success = trimmedMessage
|
|
31313
|
+
? `Created stash: ${trimmedMessage}`
|
|
31314
|
+
: what
|
|
31315
|
+
? `Stashed ${what}`
|
|
31316
|
+
: 'Created WIP stash';
|
|
31317
|
+
return runAction$3(() => git.raw(args), success);
|
|
31318
|
+
}
|
|
31319
|
+
/**
|
|
31320
|
+
* Apply a stash while restoring the original staged/unstaged split via
|
|
31321
|
+
* `--index`. Faithfully reinstates what was staged at stash time; git
|
|
31322
|
+
* errors (surfaced to the user) if the index can no longer be replayed,
|
|
31323
|
+
* in which case plain `applyStash` is the fallback.
|
|
31324
|
+
*/
|
|
31325
|
+
function applyStashKeepIndex(git, stash) {
|
|
31326
|
+
return runAction$3(() => git.raw(['stash', 'apply', '--index', stash.ref]), `Applied ${stash.ref} (index restored)`);
|
|
31327
|
+
}
|
|
31328
|
+
/**
|
|
31329
|
+
* Create a new branch from a stash's base commit, apply the stash onto
|
|
31330
|
+
* it, and drop the stash on success — `git stash branch`. The canonical
|
|
31331
|
+
* recovery when a stash no longer applies cleanly onto the current
|
|
31332
|
+
* branch (the branch starts at the exact commit the stash was made on).
|
|
31333
|
+
*/
|
|
31334
|
+
function stashBranch(git, stash, branchName) {
|
|
31335
|
+
const trimmed = branchName.trim();
|
|
31336
|
+
if (!trimmed) {
|
|
31337
|
+
return Promise.resolve({ ok: false, message: 'Cancelled: empty branch name.' });
|
|
31338
|
+
}
|
|
31339
|
+
return runAction$3(() => git.raw(['stash', 'branch', trimmed, stash.ref]), `Created branch ${trimmed} from ${stash.ref}`);
|
|
31340
|
+
}
|
|
31341
|
+
/**
|
|
31342
|
+
* Rename a stash. Git has no native rename, so: drop the original entry,
|
|
31343
|
+
* then re-store the SAME commit under the new message.
|
|
31344
|
+
*
|
|
31345
|
+
* Order matters — and it's the OPPOSITE of what you'd guess. `git stash
|
|
31346
|
+
* store` SILENTLY NO-OPS when the commit is already referenced in the
|
|
31347
|
+
* stash reflog (verified empirically), so storing first does nothing and
|
|
31348
|
+
* a follow-up drop removes the wrong entry. Dropping first removes the
|
|
31349
|
+
* reflog reference (the commit object survives), so the subsequent
|
|
31350
|
+
* `store` actually re-adds it — landing at `stash@{0}` with the new
|
|
31351
|
+
* message. The commit is captured by hash beforehand, so the drop→store
|
|
31352
|
+
* window can't lose it.
|
|
31353
|
+
*/
|
|
31354
|
+
function renameStash(git, stash, newMessage) {
|
|
31355
|
+
const trimmed = newMessage.trim();
|
|
31356
|
+
if (!trimmed) {
|
|
31357
|
+
return Promise.resolve({ ok: false, message: 'Rename cancelled: empty message.' });
|
|
31358
|
+
}
|
|
31359
|
+
if (!stash.hash) {
|
|
31360
|
+
return Promise.resolve({ ok: false, message: 'Cannot rename: stash commit hash unavailable.' });
|
|
31361
|
+
}
|
|
31362
|
+
// Preserve git's `On <branch>: <subject>` convention so the renamed
|
|
31363
|
+
// stash keeps its origin-branch context. The list + inspector parse the
|
|
31364
|
+
// branch out of that prefix (`parseStashSubject`); a bare message would
|
|
31365
|
+
// render `on <unknown>`. Falls back to the bare message when the branch
|
|
31366
|
+
// is unknown so we never store a misleading `On <unknown>:`.
|
|
31367
|
+
const branch = stash.branch && stash.branch !== '<unknown>' ? stash.branch : '';
|
|
31368
|
+
const storedMessage = branch ? `On ${branch}: ${trimmed}` : trimmed;
|
|
31369
|
+
return runAction$3(async () => {
|
|
31370
|
+
await git.raw(['stash', 'drop', stash.ref]);
|
|
31371
|
+
await git.raw(['stash', 'store', '-m', storedMessage, stash.hash]);
|
|
31372
|
+
}, `Renamed ${stash.ref} → ${trimmed}`);
|
|
31373
|
+
}
|
|
31374
|
+
/**
|
|
31375
|
+
* Re-store a previously dropped stash by its commit hash — the undo for
|
|
31376
|
+
* a `dropStash`. The dropped stash's commit stays in the object database
|
|
31377
|
+
* until git gc, so storing it back recreates the entry (at `stash@{0}`).
|
|
31378
|
+
*/
|
|
31379
|
+
function restoreStash(git, hash, message) {
|
|
31380
|
+
if (!hash) {
|
|
31381
|
+
return Promise.resolve({ ok: false, message: 'Nothing to restore.' });
|
|
30960
31382
|
}
|
|
30961
|
-
return runAction$3(() => git.raw(['stash', '
|
|
31383
|
+
return runAction$3(() => git.raw(['stash', 'store', '-m', message || 'restored stash', hash]), 'Restored dropped stash');
|
|
30962
31384
|
}
|
|
30963
31385
|
function applyStash(git, stash) {
|
|
30964
31386
|
return runAction$3(() => git.raw(['stash', 'apply', stash.ref]), `Applied ${stash.ref}`);
|
|
@@ -31831,6 +32253,28 @@ function unstageAllFiles(git, files) {
|
|
|
31831
32253
|
}
|
|
31832
32254
|
return runAction(() => git.raw(['restore', '--staged', '--', ...files.map((file) => file.path)]), `Unstaged ${files.length} ${files.length === 1 ? 'file' : 'files'}`);
|
|
31833
32255
|
}
|
|
32256
|
+
/**
|
|
32257
|
+
* Stage everything in the worktree — modifications, new files, and
|
|
32258
|
+
* deletions — in one shot (`git add -A`). The `A` hotkey + the `:`
|
|
32259
|
+
* palette's "stage all" both route here.
|
|
32260
|
+
*/
|
|
32261
|
+
function stageAll(git) {
|
|
32262
|
+
return runAction(() => git.raw(['add', '-A']), 'Staged all changes');
|
|
32263
|
+
}
|
|
32264
|
+
/**
|
|
32265
|
+
* Stage files matching one or more git pathspecs (`git add -- <spec…>`).
|
|
32266
|
+
* Powers the typed "stage…" prompt (`+`): the user types a path, a
|
|
32267
|
+
* directory, a glob like `*.ts`, or a space-separated list, and git's
|
|
32268
|
+
* own pathspec matching does the rest. Args are passed directly (no
|
|
32269
|
+
* shell), so the globs are interpreted by git, not the shell.
|
|
32270
|
+
*/
|
|
32271
|
+
function stagePathspec(git, pathspec) {
|
|
32272
|
+
const specs = pathspec.trim().split(/\s+/).filter(Boolean);
|
|
32273
|
+
if (specs.length === 0) {
|
|
32274
|
+
return Promise.resolve({ ok: false, message: 'Enter a pathspec to stage (e.g. . or src/ or *.ts).' });
|
|
32275
|
+
}
|
|
32276
|
+
return runAction(() => git.raw(['add', '--', ...specs]), `Staged ${specs.join(' ')}`);
|
|
32277
|
+
}
|
|
31834
32278
|
|
|
31835
32279
|
function hunkHeader(hunk) {
|
|
31836
32280
|
return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
|
|
@@ -32407,7 +32851,7 @@ function buildLoadedHashSet(commits) {
|
|
|
32407
32851
|
* 5a.7 of #890. Two-row layout introduced post-0.54.2; per-kind
|
|
32408
32852
|
* colors + glyphs added in the same pass.
|
|
32409
32853
|
*/
|
|
32410
|
-
function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFrame = 0) {
|
|
32854
|
+
function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFrame = 0, singlePane = false) {
|
|
32411
32855
|
const { Box, Text } = components;
|
|
32412
32856
|
// Sidebar item count drives the per-tab footer hints — when items are
|
|
32413
32857
|
// present the footer surfaces in-sidebar ops (checkout / apply / pop /
|
|
@@ -32421,6 +32865,23 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
|
|
|
32421
32865
|
default: return undefined;
|
|
32422
32866
|
}
|
|
32423
32867
|
})();
|
|
32868
|
+
// The single-pane pane switcher only makes sense in the plain
|
|
32869
|
+
// per-pane states. While an overlay or filter owns the screen the
|
|
32870
|
+
// visible pane is forced (split-plan → main; help / palette / theme /
|
|
32871
|
+
// gitignore / input prompt / confirmation / chord → inspector) or
|
|
32872
|
+
// input is captured, and Tab does something else — so the switcher
|
|
32873
|
+
// would point at a pane that isn't on screen. Suppress it then. Mirror
|
|
32874
|
+
// of the runtime's `forcedPane` derivation in `app.ts`.
|
|
32875
|
+
const overlayForcesPane = Boolean(state.splitPlan ||
|
|
32876
|
+
state.showHelp ||
|
|
32877
|
+
state.showCommandPalette ||
|
|
32878
|
+
state.showThemePicker ||
|
|
32879
|
+
state.gitignorePicker ||
|
|
32880
|
+
state.inputPrompt ||
|
|
32881
|
+
state.pendingConfirmationId ||
|
|
32882
|
+
state.pendingMutationConfirmation ||
|
|
32883
|
+
state.pendingKey ||
|
|
32884
|
+
state.filterMode);
|
|
32424
32885
|
const hints = getLogInkFooterHints({
|
|
32425
32886
|
activeView: state.activeView,
|
|
32426
32887
|
diffSource: state.diffSource,
|
|
@@ -32434,6 +32895,12 @@ function renderFooter$1(h, components, state, context, theme, idleTip, spinnerFr
|
|
|
32434
32895
|
sidebarItemCount,
|
|
32435
32896
|
compareBaseSet: Boolean(state.compareBase),
|
|
32436
32897
|
splitPlanStatus: state.splitPlan?.status,
|
|
32898
|
+
singlePane: singlePane && !overlayForcesPane,
|
|
32899
|
+
// Peeking (#1135 v2) is a single-pane glance with focus on the
|
|
32900
|
+
// sidebar; the footer shows `v/esc → main` instead of the switcher.
|
|
32901
|
+
// Suppressed under an overlay (which owns the footer) just like the
|
|
32902
|
+
// switcher.
|
|
32903
|
+
peeking: Boolean(state.peekReturnFocus) && singlePane && !overlayForcesPane,
|
|
32437
32904
|
});
|
|
32438
32905
|
// Real status messages always win; idle tips only fill the slot when it
|
|
32439
32906
|
// would otherwise be empty.
|
|
@@ -32929,7 +33396,10 @@ function sidebarTabCount(tab, context) {
|
|
|
32929
33396
|
* Header chip builder. Turns the workstation's title-bar state into an
|
|
32930
33397
|
* ordered list of small visually-distinct chips:
|
|
32931
33398
|
*
|
|
32932
|
-
* coco · gfargo/coco · ⎇ main · ✓ clean ·
|
|
33399
|
+
* coco · gfargo/coco · ⎇ main · ✓ clean · [NORMAL]
|
|
33400
|
+
*
|
|
33401
|
+
* The PR chip is appended only when a pull request exists (#1133); there
|
|
33402
|
+
* is no "no PR" placeholder chip.
|
|
32933
33403
|
*
|
|
32934
33404
|
* Pre-refactor the title bar concatenated every segment into a single
|
|
32935
33405
|
* Text span, which made the eye read the whole thing as one run of
|
|
@@ -33025,10 +33495,11 @@ function buildHeaderChips(input) {
|
|
|
33025
33495
|
bold: true,
|
|
33026
33496
|
});
|
|
33027
33497
|
}
|
|
33028
|
-
// PR state.
|
|
33029
|
-
// label ("PR #1234 OPEN" / "PR #1234 DRAFT").
|
|
33030
|
-
// "no PR" chip
|
|
33031
|
-
//
|
|
33498
|
+
// PR state. Shown only when a PR actually exists — the chip uses the
|
|
33499
|
+
// PR-state glyph + a short label ("PR #1234 OPEN" / "PR #1234 DRAFT").
|
|
33500
|
+
// The old always-on "no PR" chip spent a permanent header segment to
|
|
33501
|
+
// report a negative default state on every screen; dropping it keeps
|
|
33502
|
+
// the state cluster about what *is* true (TUI audit).
|
|
33032
33503
|
if (input.pullRequest) {
|
|
33033
33504
|
const prGlyph = getPullRequestStateGlyph({ ...input.pullRequest, isDraft: Boolean(input.pullRequest.isDraft) }, theme);
|
|
33034
33505
|
const stateLabel = input.pullRequest.isDraft
|
|
@@ -33045,15 +33516,6 @@ function buildHeaderChips(input) {
|
|
|
33045
33516
|
bold: false,
|
|
33046
33517
|
});
|
|
33047
33518
|
}
|
|
33048
|
-
else {
|
|
33049
|
-
chips.push({
|
|
33050
|
-
id: 'pr',
|
|
33051
|
-
label: theme.ascii ? '- no PR' : '⊘ no PR',
|
|
33052
|
-
color: theme.colors.muted,
|
|
33053
|
-
dim: true,
|
|
33054
|
-
bold: false,
|
|
33055
|
-
});
|
|
33056
|
-
}
|
|
33057
33519
|
// View breadcrumb. Rendered only when there's content (`coco ui`
|
|
33058
33520
|
// root view → no breadcrumb chip; pushed into a sub-view → chip
|
|
33059
33521
|
// appears). Comes AFTER PR so the "state" group (app/repo/branch/
|
|
@@ -33124,7 +33586,10 @@ function measureHeaderChipsWidth(chips) {
|
|
|
33124
33586
|
* Title-bar renderer. Surfaces the workstation's identity + navigation
|
|
33125
33587
|
* state as a row of small visually-distinct chips:
|
|
33126
33588
|
*
|
|
33127
|
-
* coco · gfargo/coco · ⎇ main · ✓ clean ·
|
|
33589
|
+
* coco · gfargo/coco · ⎇ main · ✓ clean · [NORMAL]
|
|
33590
|
+
*
|
|
33591
|
+
* The PR chip is appended only when a pull request exists (e.g.
|
|
33592
|
+
* `· ⊠ PR #1234 OPEN`); there's no "no PR" placeholder chip.
|
|
33128
33593
|
*
|
|
33129
33594
|
* Per-chip color/glyph treatment lets the user scan in chunks ("what
|
|
33130
33595
|
* app, what repo, what branch, how clean, what PR state, what mode")
|
|
@@ -33564,70 +34029,10 @@ function renderActiveSidebarContent(h, Text, tab, state, context, contextStatus,
|
|
|
33564
34029
|
return `${marker} ${worktree.branch || worktree.path} ${wstate}`;
|
|
33565
34030
|
}, 'tab-worktrees', visibleListCount);
|
|
33566
34031
|
}
|
|
33567
|
-
|
|
33568
|
-
* Single-letter glyph for a sidebar tab in rail mode. Letters always
|
|
33569
|
-
* carry the meaning so this stays useful under ASCII; the rail is too
|
|
33570
|
-
* narrow to fit the full tab label. Pairs with `sidebarTabCount` for
|
|
33571
|
-
* the trailing count.
|
|
33572
|
-
*/
|
|
33573
|
-
function sidebarTabRailGlyph(tab) {
|
|
33574
|
-
switch (tab) {
|
|
33575
|
-
case 'status':
|
|
33576
|
-
return 'S';
|
|
33577
|
-
case 'branches':
|
|
33578
|
-
return 'B';
|
|
33579
|
-
case 'tags':
|
|
33580
|
-
return 'T';
|
|
33581
|
-
case 'stashes':
|
|
33582
|
-
return '$';
|
|
33583
|
-
case 'worktrees':
|
|
33584
|
-
return 'W';
|
|
33585
|
-
default:
|
|
33586
|
-
return '·';
|
|
33587
|
-
}
|
|
33588
|
-
}
|
|
33589
|
-
/**
|
|
33590
|
-
* Rail-mode sidebar — shown on terminals < 100 columns when the
|
|
33591
|
-
* sidebar does not hold focus. Five vertically stacked tab glyphs
|
|
33592
|
-
* with optional counts; the active tab is bracketed. Pressing Tab to
|
|
33593
|
-
* focus the sidebar pops it back to the full accordion (the layout
|
|
33594
|
-
* un-rails it on focus, this renderer is never called in that case).
|
|
33595
|
-
*/
|
|
33596
|
-
function renderSidebarRail$1(h, components, state, context, width, theme, focused, tabs) {
|
|
33597
|
-
const { Box, Text } = components;
|
|
33598
|
-
return h(Box, {
|
|
33599
|
-
borderColor: focusBorderColor(theme, focused),
|
|
33600
|
-
borderStyle: theme.borderStyle,
|
|
33601
|
-
flexDirection: 'column',
|
|
33602
|
-
width,
|
|
33603
|
-
paddingX: 1,
|
|
33604
|
-
}, h(Text, { bold: true, dimColor: !focused }, 'Repo'), h(Text, { dimColor: true }, '────'), ...tabs.map((tab) => {
|
|
33605
|
-
const isActive = tab === state.sidebarTab;
|
|
33606
|
-
const glyph = sidebarTabRailGlyph(tab);
|
|
33607
|
-
const count = sidebarTabCount(tab, context);
|
|
33608
|
-
// Count fits in 2 cells (rail content area is ~4 cells); 99+
|
|
33609
|
-
// collapses to `+` so we never overflow.
|
|
33610
|
-
const countText = count === undefined
|
|
33611
|
-
? ''
|
|
33612
|
-
: count > 99
|
|
33613
|
-
? '+'
|
|
33614
|
-
: String(count);
|
|
33615
|
-
const body = isActive ? `[${glyph}]` : ` ${glyph} `;
|
|
33616
|
-
const text = countText ? `${body}${countText}` : body;
|
|
33617
|
-
return h(Text, {
|
|
33618
|
-
key: `rail-${tab}`,
|
|
33619
|
-
bold: isActive,
|
|
33620
|
-
dimColor: !isActive,
|
|
33621
|
-
}, text);
|
|
33622
|
-
}));
|
|
33623
|
-
}
|
|
33624
|
-
function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme, railed = false) {
|
|
34032
|
+
function renderSidebar$1(h, components, state, context, contextStatus, width, bodyRows, theme) {
|
|
33625
34033
|
const { Box, Text } = components;
|
|
33626
34034
|
const focused = state.focus === 'sidebar';
|
|
33627
34035
|
const tabs = getLogInkSidebarTabs();
|
|
33628
|
-
if (railed) {
|
|
33629
|
-
return renderSidebarRail$1(h, components, state, context, width, theme, focused, tabs);
|
|
33630
|
-
}
|
|
33631
34036
|
// Accordion layout — every tab's title is visible on its own line, but
|
|
33632
34037
|
// only the active tab expands its content underneath. Switching tabs
|
|
33633
34038
|
// (1-5 / [/]) collapses the previous and expands the next.
|
|
@@ -33686,7 +34091,8 @@ function renderSidebar$1(h, components, state, context, contextStatus, width, bo
|
|
|
33686
34091
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
33687
34092
|
* of #890. No behavior change.
|
|
33688
34093
|
*/
|
|
33689
|
-
function renderBisectSurface(
|
|
34094
|
+
function renderBisectSurface(ctx, candidateDetail, candidateLoading) {
|
|
34095
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
33690
34096
|
const { Box, Text } = components;
|
|
33691
34097
|
const focused = state.focus === 'commits';
|
|
33692
34098
|
const loading = isLogInkContextKeyLoading(contextStatus, 'bisect');
|
|
@@ -34010,7 +34416,8 @@ function formatLogInkGitHubNoRemote({ resource, }) {
|
|
|
34010
34416
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
34011
34417
|
* of #890. No behavior change.
|
|
34012
34418
|
*/
|
|
34013
|
-
function renderBranchesSurface(
|
|
34419
|
+
function renderBranchesSurface(ctx) {
|
|
34420
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
34014
34421
|
const { Box, Text } = components;
|
|
34015
34422
|
const focused = state.focus === 'commits';
|
|
34016
34423
|
const branches = context.branches;
|
|
@@ -34156,7 +34563,8 @@ function formatCacheAge(generatedAt, now) {
|
|
|
34156
34563
|
const day = Math.floor(hr / 24);
|
|
34157
34564
|
return `${day}d ago`;
|
|
34158
34565
|
}
|
|
34159
|
-
function renderChangelogSurface(
|
|
34566
|
+
function renderChangelogSurface(ctx) {
|
|
34567
|
+
const { h, components, state, bodyRows, width, theme } = ctx;
|
|
34160
34568
|
const { Box, Text } = components;
|
|
34161
34569
|
const focused = state.focus === 'commits';
|
|
34162
34570
|
const view = state.changelogView;
|
|
@@ -34348,7 +34756,8 @@ function renderStreamingPreviewLines(h, components, preview, width, theme) {
|
|
|
34348
34756
|
}, `${prefix}${line}`);
|
|
34349
34757
|
});
|
|
34350
34758
|
}
|
|
34351
|
-
function renderComposeSurface(
|
|
34759
|
+
function renderComposeSurface(ctx, spinnerFrame = 0) {
|
|
34760
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
34352
34761
|
const { Box, Text } = components;
|
|
34353
34762
|
const compose = state.commitCompose;
|
|
34354
34763
|
const focused = state.focus === 'commits';
|
|
@@ -34461,7 +34870,8 @@ function renderComposeSurface(h, components, state, context, contextStatus, body
|
|
|
34461
34870
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.3
|
|
34462
34871
|
* of #890. No behavior change.
|
|
34463
34872
|
*/
|
|
34464
|
-
function renderConflictsSurface(
|
|
34873
|
+
function renderConflictsSurface(ctx) {
|
|
34874
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
34465
34875
|
const { Box, Text } = components;
|
|
34466
34876
|
const focused = state.focus === 'commits';
|
|
34467
34877
|
const loading = isLogInkContextKeyLoading(contextStatus, 'operation');
|
|
@@ -34930,6 +35340,50 @@ function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
|
|
|
34930
35340
|
return h(Text, { key }, h(Text, { key: `${key}-m`, color: markerColor }, marker), ...children);
|
|
34931
35341
|
}
|
|
34932
35342
|
|
|
35343
|
+
/** The hunk index owning `absLine`, or -1 for pre-hunk header/label rows. */
|
|
35344
|
+
function hunkIndexForLine(absLine, hunkOffsets) {
|
|
35345
|
+
let index = -1;
|
|
35346
|
+
for (let k = 0; k < hunkOffsets.length; k++) {
|
|
35347
|
+
if (hunkOffsets[k] <= absLine)
|
|
35348
|
+
index = k;
|
|
35349
|
+
else
|
|
35350
|
+
break;
|
|
35351
|
+
}
|
|
35352
|
+
return index;
|
|
35353
|
+
}
|
|
35354
|
+
function renderWorktreeDiffBody(h, components, params) {
|
|
35355
|
+
const { Box, Text } = components;
|
|
35356
|
+
const { lines, offset, visibleRows, width, theme, syntaxSpans, hunkOffsets, hunks, selectedIndex, keyPrefix } = params;
|
|
35357
|
+
const headerSet = new Set(hunkOffsets);
|
|
35358
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
35359
|
+
const added = theme.noColor ? undefined : theme.colors.gitAdded;
|
|
35360
|
+
const codeWidth = Math.max(8, width - 5); // 2 chrome + 1 gutter + slack
|
|
35361
|
+
const visible = lines.slice(offset, offset + visibleRows);
|
|
35362
|
+
return visible.map((line, i) => {
|
|
35363
|
+
const abs = offset + i;
|
|
35364
|
+
const key = `${keyPrefix}-${abs}`;
|
|
35365
|
+
const hunkIndex = hunkIndexForLine(abs, hunkOffsets);
|
|
35366
|
+
const hunk = hunkIndex >= 0 ? hunks[hunkIndex] : undefined;
|
|
35367
|
+
const isSelected = hunkIndex >= 0 && hunkIndex === selectedIndex;
|
|
35368
|
+
const isStaged = hunk?.state === 'staged';
|
|
35369
|
+
const bar = isSelected ? '▎' : ' ';
|
|
35370
|
+
// `@@` header row — badge + (dim) hunk position, emphasized when selected.
|
|
35371
|
+
if (headerSet.has(abs)) {
|
|
35372
|
+
const badge = theme.ascii ? (isStaged ? '[x] ' : '[ ] ') : (isStaged ? '● ' : '○ ');
|
|
35373
|
+
const badgeColor = theme.noColor ? undefined : isStaged ? added : theme.colors.muted;
|
|
35374
|
+
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)));
|
|
35375
|
+
}
|
|
35376
|
+
// Body / context / pre-hunk lines.
|
|
35377
|
+
// A staged hunk that ISN'T selected renders dim ("done", out of
|
|
35378
|
+
// focus); the selected hunk and unstaged hunks keep full diff +
|
|
35379
|
+
// syntax coloring via renderDiffLine so the focus stays vivid.
|
|
35380
|
+
const content = isStaged && !isSelected && hunkIndex >= 0
|
|
35381
|
+
? h(Text, { key: `${key}-c`, dimColor: true }, truncateCells(line, codeWidth))
|
|
35382
|
+
: renderDiffLine(h, Text, line, theme, syntaxSpans, codeWidth, `${key}-c`);
|
|
35383
|
+
return h(Box, { key, flexDirection: 'row' }, h(Text, { color: accent }, bar), content);
|
|
35384
|
+
});
|
|
35385
|
+
}
|
|
35386
|
+
|
|
34933
35387
|
/**
|
|
34934
35388
|
* Diff surface — the unified or side-by-side diff view. Four sources
|
|
34935
35389
|
* route through here, disambiguated by `state.diffSource`:
|
|
@@ -34952,7 +35406,9 @@ function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
|
|
|
34952
35406
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.4
|
|
34953
35407
|
* of #890. No behavior change.
|
|
34954
35408
|
*/
|
|
34955
|
-
function renderDiffSurface(
|
|
35409
|
+
function renderDiffSurface(ctx, diff) {
|
|
35410
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
35411
|
+
const { worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, syntaxSpans, } = diff;
|
|
34956
35412
|
const { Box, Text } = components;
|
|
34957
35413
|
const focused = state.focus === 'commits';
|
|
34958
35414
|
const worktree = context.worktree;
|
|
@@ -35123,7 +35579,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
35123
35579
|
? [`Loading diff for ${selectedDetailFile?.path || 'selected file'}...`]
|
|
35124
35580
|
: previewHunks.length
|
|
35125
35581
|
? [
|
|
35126
|
-
|
|
35582
|
+
// File path is already shown in the panel title bar (right) —
|
|
35583
|
+
// no redundant "Selected file:" line here.
|
|
35127
35584
|
currentHunkLabel,
|
|
35128
35585
|
`Lines ${Math.min(state.diffPreviewOffset + 1, previewHunks.length || 1)}-${Math.min(state.diffPreviewOffset + visiblePreviewHunks.length, previewHunks.length)}/${previewHunks.length}`,
|
|
35129
35586
|
'',
|
|
@@ -35151,19 +35608,31 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
35151
35608
|
}
|
|
35152
35609
|
const diffLines = worktreeDiff?.lines || [];
|
|
35153
35610
|
const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
|
|
35611
|
+
const totalHunks = worktreeHunks?.hunks.length ?? 0;
|
|
35612
|
+
const stagedHunks = worktreeHunks?.hunks.filter((hunk) => hunk.state === 'staged').length ?? 0;
|
|
35154
35613
|
const visibleDiffLines = diffLines.slice(state.worktreeDiffOffset, state.worktreeDiffOffset + visibleRows);
|
|
35614
|
+
// Hunk-position line: badge + selected hunk's state + a staged/total
|
|
35615
|
+
// progress count, so the user always sees how far through staging they
|
|
35616
|
+
// are. Untracked/new files have no hunks — point them at whole-file
|
|
35617
|
+
// staging instead of a dead-end "no hunks" message.
|
|
35618
|
+
const hunkHeaderLine = worktreeHunksLoading
|
|
35619
|
+
? 'Hunks loading…'
|
|
35620
|
+
: worktreeDiff?.untracked
|
|
35621
|
+
? (theme.ascii ? 'New file — press space to stage it whole.' : '✚ New file — press space to stage it whole.')
|
|
35622
|
+
: totalHunks
|
|
35623
|
+
? `Hunk ${state.selectedWorktreeHunkIndex + 1}/${totalHunks} · ${selectedHunk?.state === 'staged'
|
|
35624
|
+
? (theme.ascii ? '[x] staged' : '● staged')
|
|
35625
|
+
: (theme.ascii ? '[ ] unstaged' : '○ unstaged')} · ${stagedHunks}/${totalHunks} staged`
|
|
35626
|
+
: 'No stageable hunks for this file.';
|
|
35155
35627
|
const headerLines = isLogInkContextKeyLoading(contextStatus, 'worktree')
|
|
35156
35628
|
? ['Loading file context...']
|
|
35157
35629
|
: worktreeDiffLoading
|
|
35158
35630
|
? [`Loading diff for ${worktreeFile?.path || 'selected file'}...`]
|
|
35159
35631
|
: worktreeFile
|
|
35160
35632
|
? [
|
|
35161
|
-
|
|
35162
|
-
|
|
35163
|
-
|
|
35164
|
-
: worktreeHunks?.hunks.length
|
|
35165
|
-
? `Hunk ${state.selectedWorktreeHunkIndex + 1}/${worktreeHunks.hunks.length} ${selectedHunk?.state || ''}`
|
|
35166
|
-
: 'No stageable hunks for this file.',
|
|
35633
|
+
// File path is already shown in the panel title bar (right) —
|
|
35634
|
+
// no redundant "Selected file:" line here.
|
|
35635
|
+
hunkHeaderLine,
|
|
35167
35636
|
`Lines ${Math.min(state.worktreeDiffOffset + 1, diffLines.length || 1)}-${Math.min(state.worktreeDiffOffset + visibleDiffLines.length, diffLines.length)}/${diffLines.length}`,
|
|
35168
35637
|
'',
|
|
35169
35638
|
]
|
|
@@ -35178,11 +35647,26 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
35178
35647
|
flexShrink: 0,
|
|
35179
35648
|
paddingX: 1,
|
|
35180
35649
|
width,
|
|
35181
|
-
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)),
|
|
35650
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Diff', focused)),
|
|
35651
|
+
// Use the path of the file actually being diffed (the grouped/visible
|
|
35652
|
+
// selection feeds the loaded diff) — `worktreeFile` indexes the raw,
|
|
35653
|
+
// ungrouped file list and can name a different file than the diff body.
|
|
35654
|
+
h(Text, { dimColor: true }, worktreeDiff?.filePath || worktreeFile?.path || 'no file')), ...headerLines.map((line, index) => h(Text, {
|
|
35182
35655
|
key: `diff-surface-header-${index}`,
|
|
35183
35656
|
dimColor: index > 0,
|
|
35184
35657
|
}, truncateCells(line, 140))), ...(showDiffLines
|
|
35185
|
-
?
|
|
35658
|
+
? renderWorktreeDiffBody(h, components, {
|
|
35659
|
+
lines: diffLines,
|
|
35660
|
+
offset: state.worktreeDiffOffset,
|
|
35661
|
+
visibleRows,
|
|
35662
|
+
width,
|
|
35663
|
+
theme,
|
|
35664
|
+
syntaxSpans,
|
|
35665
|
+
hunkOffsets: worktreeDiff?.hunkOffsets || [],
|
|
35666
|
+
hunks: worktreeHunks?.hunks || [],
|
|
35667
|
+
selectedIndex: state.selectedWorktreeHunkIndex,
|
|
35668
|
+
keyPrefix: 'diff-surface-line',
|
|
35669
|
+
})
|
|
35186
35670
|
: []));
|
|
35187
35671
|
}
|
|
35188
35672
|
|
|
@@ -36366,7 +36850,8 @@ function renderRemoteOpLoader(h, components, state, width, bodyRows, theme, focu
|
|
|
36366
36850
|
height: innerHeight,
|
|
36367
36851
|
}, 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.')));
|
|
36368
36852
|
}
|
|
36369
|
-
function renderHistoryPanel(
|
|
36853
|
+
function renderHistoryPanel(ctx, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = getRenderNow(), spinnerFrame = 0) {
|
|
36854
|
+
const { h, components, state, context, bodyRows, width, theme } = ctx;
|
|
36370
36855
|
const { Box, Text } = components;
|
|
36371
36856
|
const focused = state.focus === 'commits';
|
|
36372
36857
|
// Remote op in flight (fetch / pull / push) → swap the commit list
|
|
@@ -37068,7 +37553,8 @@ function matchesIssueFilter(issue, filter) {
|
|
|
37068
37553
|
...(issue.assignees || []),
|
|
37069
37554
|
], filter);
|
|
37070
37555
|
}
|
|
37071
|
-
function renderIssuesTriageSurface(
|
|
37556
|
+
function renderIssuesTriageSurface(ctx) {
|
|
37557
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37072
37558
|
const { Box, Text } = components;
|
|
37073
37559
|
const focused = state.focus === 'commits';
|
|
37074
37560
|
const overview = context.issueList;
|
|
@@ -37350,7 +37836,8 @@ function formatPullRequestStateLine(pr) {
|
|
|
37350
37836
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
37351
37837
|
* of #890. No behavior change.
|
|
37352
37838
|
*/
|
|
37353
|
-
function renderPullRequestSurface(
|
|
37839
|
+
function renderPullRequestSurface(ctx) {
|
|
37840
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37354
37841
|
const { Box, Text } = components;
|
|
37355
37842
|
const focused = state.focus === 'commits';
|
|
37356
37843
|
const loading = isLogInkContextKeyLoading(contextStatus, 'pullRequest');
|
|
@@ -37523,7 +38010,8 @@ function matchesPullRequestFilter(pr, filter) {
|
|
|
37523
38010
|
...(pr.assignees || []),
|
|
37524
38011
|
], filter);
|
|
37525
38012
|
}
|
|
37526
|
-
function renderPullRequestTriageSurface(
|
|
38013
|
+
function renderPullRequestTriageSurface(ctx) {
|
|
38014
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37527
38015
|
const { Box, Text } = components;
|
|
37528
38016
|
const focused = state.focus === 'commits';
|
|
37529
38017
|
const overview = context.pullRequestList;
|
|
@@ -37639,7 +38127,8 @@ function renderPullRequestTriageSurface(h, components, state, context, contextSt
|
|
|
37639
38127
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
37640
38128
|
* of #890. No behavior change.
|
|
37641
38129
|
*/
|
|
37642
|
-
function renderReflogSurface(
|
|
38130
|
+
function renderReflogSurface(ctx) {
|
|
38131
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37643
38132
|
const { Box, Text } = components;
|
|
37644
38133
|
const focused = state.focus === 'commits';
|
|
37645
38134
|
const loading = isLogInkContextKeyLoading(contextStatus, 'reflog');
|
|
@@ -37710,7 +38199,8 @@ function renderReflogSurface(h, components, state, context, contextStatus, bodyR
|
|
|
37710
38199
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
37711
38200
|
* of #890. No behavior change.
|
|
37712
38201
|
*/
|
|
37713
|
-
function renderStashSurface(
|
|
38202
|
+
function renderStashSurface(ctx) {
|
|
38203
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37714
38204
|
const { Box, Text } = components;
|
|
37715
38205
|
const focused = state.focus === 'commits';
|
|
37716
38206
|
const loading = isLogInkContextKeyLoading(contextStatus, 'stashes');
|
|
@@ -37728,6 +38218,11 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
|
|
|
37728
38218
|
: `${stashes.length}/${allStashes.length} stashes${filterLabel}`;
|
|
37729
38219
|
const emptyLabel = formatLogInkStashEmpty({ filter: state.filter });
|
|
37730
38220
|
const loadingLabel = formatLogInkLoading({ resource: 'stashes' });
|
|
38221
|
+
const now = getRenderNow();
|
|
38222
|
+
// Available width for a row: box width minus the 2-cell horizontal
|
|
38223
|
+
// padding. Truncate to it (with a small floor) instead of a magic 140
|
|
38224
|
+
// so the richer meta degrades gracefully on narrow terminals.
|
|
38225
|
+
const rowWidth = Math.max(20, width - 2);
|
|
37731
38226
|
const lines = loading
|
|
37732
38227
|
? [h(Text, { key: 'stash-loading', dimColor: true }, loadingLabel)]
|
|
37733
38228
|
: stashes.length === 0
|
|
@@ -37736,11 +38231,25 @@ function renderStashSurface(h, components, state, context, contextStatus, bodyRo
|
|
|
37736
38231
|
const index = startIndex + offset;
|
|
37737
38232
|
const isSelected = index === selected;
|
|
37738
38233
|
const cursor = isSelected ? '>' : ' ';
|
|
38234
|
+
// Surface the metadata the StashEntry already carries — origin
|
|
38235
|
+
// branch, file count, and relative age — between the ref and the
|
|
38236
|
+
// message, so the list answers "which stash is this?" without an
|
|
38237
|
+
// Enter→diff round trip.
|
|
38238
|
+
const age = formatCompactRelativeDate(stash.date, now);
|
|
38239
|
+
const fileCount = stash.files.length;
|
|
38240
|
+
const meta = [
|
|
38241
|
+
stash.branch ? `on ${stash.branch}` : '',
|
|
38242
|
+
fileCount > 0 ? `${fileCount} file${fileCount === 1 ? '' : 's'}` : '',
|
|
38243
|
+
age,
|
|
38244
|
+
].filter(Boolean).join(' · ');
|
|
38245
|
+
const rowText = meta
|
|
38246
|
+
? `${cursor} ${stash.ref.padEnd(11)} ${meta} ${stash.message}`
|
|
38247
|
+
: `${cursor} ${stash.ref.padEnd(11)} ${stash.message}`;
|
|
37739
38248
|
return h(Text, {
|
|
37740
38249
|
key: `stash-${index}`,
|
|
37741
38250
|
bold: isSelected,
|
|
37742
38251
|
dimColor: !isSelected,
|
|
37743
|
-
}, truncateCells(
|
|
38252
|
+
}, truncateCells(rowText, rowWidth));
|
|
37744
38253
|
});
|
|
37745
38254
|
const stashHasMoreAbove = startIndex > 0 && stashes.length > 0;
|
|
37746
38255
|
const stashHasMoreBelow = startIndex + listRows < stashes.length;
|
|
@@ -37800,7 +38309,8 @@ function formatStatusFilterMask(mask) {
|
|
|
37800
38309
|
active.push('untracked');
|
|
37801
38310
|
return active.join(' + ') || 'none';
|
|
37802
38311
|
}
|
|
37803
|
-
function renderStatusSurface(
|
|
38312
|
+
function renderStatusSurface(ctx) {
|
|
38313
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37804
38314
|
const { Box, Text } = components;
|
|
37805
38315
|
const focused = state.focus === 'commits';
|
|
37806
38316
|
const worktree = context.worktree;
|
|
@@ -37947,7 +38457,8 @@ function flagColor(flag, theme) {
|
|
|
37947
38457
|
return theme.colors.danger;
|
|
37948
38458
|
return undefined;
|
|
37949
38459
|
}
|
|
37950
|
-
function renderSubmodulesSurface(
|
|
38460
|
+
function renderSubmodulesSurface(ctx) {
|
|
38461
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
37951
38462
|
const { Box, Text } = components;
|
|
37952
38463
|
const focused = state.focus === 'commits';
|
|
37953
38464
|
const loading = isLogInkContextKeyLoading(contextStatus, 'submodules');
|
|
@@ -38086,7 +38597,8 @@ function formatHyperlink(text, url, env = process.env) {
|
|
|
38086
38597
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.2
|
|
38087
38598
|
* of #890. No behavior change.
|
|
38088
38599
|
*/
|
|
38089
|
-
function renderTagsSurface(
|
|
38600
|
+
function renderTagsSurface(ctx) {
|
|
38601
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38090
38602
|
const { Box, Text } = components;
|
|
38091
38603
|
const focused = state.focus === 'commits';
|
|
38092
38604
|
const loading = isLogInkContextKeyLoading(contextStatus, 'tags');
|
|
@@ -38168,7 +38680,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
38168
38680
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.1
|
|
38169
38681
|
* of #890. No behavior change.
|
|
38170
38682
|
*/
|
|
38171
|
-
function renderWorktreesSurface(
|
|
38683
|
+
function renderWorktreesSurface(ctx) {
|
|
38684
|
+
const { h, components, state, context, contextStatus, bodyRows, width, theme } = ctx;
|
|
38172
38685
|
const { Box, Text } = components;
|
|
38173
38686
|
const focused = state.focus === 'commits';
|
|
38174
38687
|
const loading = isLogInkContextKeyLoading(contextStatus, 'worktreeList');
|
|
@@ -38232,7 +38745,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
38232
38745
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
|
|
38233
38746
|
* of #890. No behavior change.
|
|
38234
38747
|
*/
|
|
38235
|
-
function renderMainPanel(
|
|
38748
|
+
function renderMainPanel(surface, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled, syntaxSpans) {
|
|
38749
|
+
// The universal render values now arrive bundled (#1136); only the
|
|
38750
|
+
// few raw values the dispatcher itself touches (split-plan overlay,
|
|
38751
|
+
// activeView switch) are destructured here. Surfaces receive `surface`
|
|
38752
|
+
// directly plus their own slices.
|
|
38753
|
+
const { h, components, state, bodyRows, width, theme } = surface;
|
|
38236
38754
|
// Split-plan overlay (#907 polish): renders in the MAIN panel (not
|
|
38237
38755
|
// detail) when active, because the content — multiple commit groups
|
|
38238
38756
|
// with file lists, rationale, hunks — needs the full center width
|
|
@@ -38244,51 +38762,66 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
38244
38762
|
return renderSplitPlanOverlay(h, components, state, width, bodyRows, theme, true, spinnerFrame);
|
|
38245
38763
|
}
|
|
38246
38764
|
if (state.activeView === 'status') {
|
|
38247
|
-
return renderStatusSurface(
|
|
38765
|
+
return renderStatusSurface(surface);
|
|
38248
38766
|
}
|
|
38249
38767
|
if (state.activeView === 'diff') {
|
|
38250
|
-
|
|
38768
|
+
const diffData = {
|
|
38769
|
+
worktreeDiff,
|
|
38770
|
+
worktreeDiffLoading,
|
|
38771
|
+
worktreeHunks,
|
|
38772
|
+
worktreeHunksLoading,
|
|
38773
|
+
filePreview,
|
|
38774
|
+
filePreviewLoading,
|
|
38775
|
+
commitDiffHunkOffsets,
|
|
38776
|
+
selectedDetailFile,
|
|
38777
|
+
stashDiffLines,
|
|
38778
|
+
stashDiffLoading,
|
|
38779
|
+
compareDiffLines,
|
|
38780
|
+
compareDiffLoading,
|
|
38781
|
+
syntaxSpans,
|
|
38782
|
+
};
|
|
38783
|
+
return renderDiffSurface(surface, diffData);
|
|
38251
38784
|
}
|
|
38252
38785
|
if (state.activeView === 'compose') {
|
|
38253
|
-
return renderComposeSurface(
|
|
38786
|
+
return renderComposeSurface(surface, spinnerFrame);
|
|
38254
38787
|
}
|
|
38255
38788
|
if (state.activeView === 'branches') {
|
|
38256
|
-
return renderBranchesSurface(
|
|
38789
|
+
return renderBranchesSurface(surface);
|
|
38257
38790
|
}
|
|
38258
38791
|
if (state.activeView === 'tags') {
|
|
38259
|
-
return renderTagsSurface(
|
|
38792
|
+
return renderTagsSurface(surface);
|
|
38260
38793
|
}
|
|
38261
38794
|
if (state.activeView === 'reflog') {
|
|
38262
|
-
return renderReflogSurface(
|
|
38795
|
+
return renderReflogSurface(surface);
|
|
38263
38796
|
}
|
|
38264
38797
|
if (state.activeView === 'bisect') {
|
|
38265
|
-
return renderBisectSurface(
|
|
38798
|
+
return renderBisectSurface(surface, bisectCandidateDetail, bisectCandidateLoading);
|
|
38266
38799
|
}
|
|
38267
38800
|
if (state.activeView === 'stash') {
|
|
38268
|
-
return renderStashSurface(
|
|
38801
|
+
return renderStashSurface(surface);
|
|
38269
38802
|
}
|
|
38270
38803
|
if (state.activeView === 'worktrees') {
|
|
38271
|
-
return renderWorktreesSurface(
|
|
38804
|
+
return renderWorktreesSurface(surface);
|
|
38272
38805
|
}
|
|
38273
38806
|
if (state.activeView === 'submodules') {
|
|
38274
|
-
return renderSubmodulesSurface(
|
|
38807
|
+
return renderSubmodulesSurface(surface);
|
|
38275
38808
|
}
|
|
38276
38809
|
if (state.activeView === 'pull-request') {
|
|
38277
|
-
return renderPullRequestSurface(
|
|
38810
|
+
return renderPullRequestSurface(surface);
|
|
38278
38811
|
}
|
|
38279
38812
|
if (state.activeView === 'pull-request-triage') {
|
|
38280
|
-
return renderPullRequestTriageSurface(
|
|
38813
|
+
return renderPullRequestTriageSurface(surface);
|
|
38281
38814
|
}
|
|
38282
38815
|
if (state.activeView === 'issues') {
|
|
38283
|
-
return renderIssuesTriageSurface(
|
|
38816
|
+
return renderIssuesTriageSurface(surface);
|
|
38284
38817
|
}
|
|
38285
38818
|
if (state.activeView === 'conflicts') {
|
|
38286
|
-
return renderConflictsSurface(
|
|
38819
|
+
return renderConflictsSurface(surface);
|
|
38287
38820
|
}
|
|
38288
38821
|
if (state.activeView === 'changelog') {
|
|
38289
|
-
return renderChangelogSurface(
|
|
38822
|
+
return renderChangelogSurface(surface);
|
|
38290
38823
|
}
|
|
38291
|
-
return renderHistoryPanel(
|
|
38824
|
+
return renderHistoryPanel(surface, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled, undefined, spinnerFrame);
|
|
38292
38825
|
}
|
|
38293
38826
|
|
|
38294
38827
|
/**
|
|
@@ -39161,21 +39694,16 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
|
|
|
39161
39694
|
const bodyVisualLines = bodyHasContent
|
|
39162
39695
|
? compose.body.split('\n').flatMap((line) => wrapCells(line, bodyTextWidth)).slice(0, 12)
|
|
39163
39696
|
: ['<empty>'];
|
|
39164
|
-
const
|
|
39165
|
-
const
|
|
39166
|
-
const
|
|
39167
|
-
|
|
39168
|
-
|
|
39169
|
-
|
|
39170
|
-
|
|
39171
|
-
|
|
39172
|
-
|
|
39173
|
-
|
|
39174
|
-
const isLast = index === bodyVisualLines.length - 1;
|
|
39175
|
-
return ` ${line}${bodyCursor && isLast ? bodyCursor : ''}`;
|
|
39176
|
-
}),
|
|
39177
|
-
'',
|
|
39178
|
-
];
|
|
39697
|
+
const hasSummary = Boolean(compose.summary);
|
|
39698
|
+
const summaryMarker = compose.field === 'summary' && compose.editing ? '>' : ' ';
|
|
39699
|
+
const bodyMarker = compose.field === 'body' && compose.editing ? '>' : ' ';
|
|
39700
|
+
// The generated subject is the thing the user is looking for — render
|
|
39701
|
+
// it bold + accent so it pops out of the inspector instead of blending
|
|
39702
|
+
// into the dim label/body text. The `Summary:` label stays dim.
|
|
39703
|
+
const summaryLabel = `${summaryMarker} Summary: `;
|
|
39704
|
+
const summaryColor = hasSummary && !theme.noColor ? theme.colors.accent : undefined;
|
|
39705
|
+
const summaryValueWidth = Math.max(4, width - 4 - cellWidth(summaryLabel));
|
|
39706
|
+
const summaryWrapped = wrapCells(`${compose.summary || '<empty>'}${summaryCursor}`, summaryValueWidth);
|
|
39179
39707
|
const trailerLines = [
|
|
39180
39708
|
...(compose.message ? ['', compose.message] : []),
|
|
39181
39709
|
...(compose.details || []).map((line) => ` ${line}`),
|
|
@@ -39189,10 +39717,26 @@ function renderCommitPanel(h, components, state, context, contextStatus, width,
|
|
|
39189
39717
|
flexDirection: 'column',
|
|
39190
39718
|
width,
|
|
39191
39719
|
paddingX: 1,
|
|
39192
|
-
}, h(Text, { bold: true }, panelTitle('Commit', focused)),
|
|
39193
|
-
|
|
39194
|
-
|
|
39195
|
-
|
|
39720
|
+
}, h(Text, { bold: true }, panelTitle('Commit', focused)), h(Text, { key: 'commit-status', dimColor: true }, truncateCells(statusLine, width - 4)), h(Text, { key: 'commit-spacer-1' }, ''),
|
|
39721
|
+
// Summary: dim label + the subject value emphasized so it's easy to spot.
|
|
39722
|
+
h(Text, { key: 'commit-summary' }, h(Text, { dimColor: true }, summaryLabel), h(Text, {
|
|
39723
|
+
bold: hasSummary,
|
|
39724
|
+
color: summaryColor,
|
|
39725
|
+
dimColor: !hasSummary,
|
|
39726
|
+
}, summaryWrapped[0] || '<empty>')), ...summaryWrapped.slice(1).map((line, index) => h(Text, {
|
|
39727
|
+
key: `commit-summary-rest-${index}`,
|
|
39728
|
+
bold: true,
|
|
39729
|
+
color: summaryColor,
|
|
39730
|
+
}, truncateCells(`${' '.repeat(cellWidth(summaryLabel))}${line}`, width - 4))), h(Text, {
|
|
39731
|
+
key: 'commit-body-label',
|
|
39732
|
+
dimColor: !(compose.field === 'body' && compose.editing),
|
|
39733
|
+
}, truncateCells(`${bodyMarker} Body:`, width - 4)), ...bodyVisualLines.map((line, index) => {
|
|
39734
|
+
const isLast = index === bodyVisualLines.length - 1;
|
|
39735
|
+
return h(Text, {
|
|
39736
|
+
key: `commit-body-${index}`,
|
|
39737
|
+
dimColor: true,
|
|
39738
|
+
}, truncateCells(` ${line}${bodyCursor && isLast ? bodyCursor : ''}`, width - 4));
|
|
39739
|
+
}), h(Text, { key: 'commit-spacer-2' }, ''),
|
|
39196
39740
|
// Loading indicator + commit result/details stay inline with the body
|
|
39197
39741
|
// (they describe what just happened to the fields above). The action
|
|
39198
39742
|
// hint ("e edit | c commit | I AI draft") moves to the bottom of the
|
|
@@ -39283,41 +39827,11 @@ function renderPullRequestTriagePreviewPanel(h, components, state, context, cont
|
|
|
39283
39827
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
|
|
39284
39828
|
* of #890. No behavior change.
|
|
39285
39829
|
*/
|
|
39286
|
-
|
|
39287
|
-
* Rail-mode inspector — shown on terminals < 100 columns when the
|
|
39288
|
-
* detail panel does not hold focus. The full inspector (commit body,
|
|
39289
|
-
* file list, actions) does not survive truncation to ~4 content cells
|
|
39290
|
-
* so we collapse to a stack with the panel label and the selected
|
|
39291
|
-
* commit's shortHash. Focus pops the panel back to its expanded
|
|
39292
|
-
* widths via the layout, so this renderer is only reached at rest.
|
|
39293
|
-
*
|
|
39294
|
-
* Help / overlay states are still handled by their own renderers
|
|
39295
|
-
* above; this short-circuit only kicks in for the regular "view the
|
|
39296
|
-
* commit" cases.
|
|
39297
|
-
*/
|
|
39298
|
-
function renderInspectorRail(h, components, state, detail, width, theme, focused) {
|
|
39299
|
-
const { Box, Text } = components;
|
|
39300
|
-
// Prefer the loaded detail's hash (canonical) but fall back to the
|
|
39301
|
-
// selected list row's shortHash so the rail isn't blank on the
|
|
39302
|
-
// first render before getCommitDetail resolves.
|
|
39303
|
-
const selectedRow = getSelectedInkCommit(state);
|
|
39304
|
-
const hashText = detail?.hash.slice(0, 4)
|
|
39305
|
-
?? selectedRow?.shortHash.slice(0, 4)
|
|
39306
|
-
?? '····';
|
|
39307
|
-
return h(Box, {
|
|
39308
|
-
borderColor: focusBorderColor(theme, focused),
|
|
39309
|
-
borderStyle: theme.borderStyle,
|
|
39310
|
-
flexDirection: 'column',
|
|
39311
|
-
width,
|
|
39312
|
-
paddingX: 1,
|
|
39313
|
-
}, h(Text, { bold: true, dimColor: !focused }, panelTitle('Insp', focused)), h(Text, { dimColor: true }, '────'), h(Text, { color: theme.noColor ? undefined : theme.colors.accent }, hashText));
|
|
39314
|
-
}
|
|
39315
|
-
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, railed = false, bodyRows = 0) {
|
|
39830
|
+
function renderDetailPanel(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, bodyRows = 0) {
|
|
39316
39831
|
const focused = state.focus === 'detail';
|
|
39317
39832
|
// Overlays (help / palette / input / confirmation / chord) take
|
|
39318
|
-
// precedence over
|
|
39319
|
-
// via the help-overlay layout branch
|
|
39320
|
-
// defeat their whole purpose (the user is reading them).
|
|
39833
|
+
// precedence over every per-view surface because they claim the
|
|
39834
|
+
// panel's full width via the help-overlay layout branch.
|
|
39321
39835
|
if (state.showHelp) {
|
|
39322
39836
|
return renderHelpPanel(h, components, state, width, theme, focused, bodyRows);
|
|
39323
39837
|
}
|
|
@@ -39349,15 +39863,6 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
39349
39863
|
if (state.pendingKey && !state.splitPlan) {
|
|
39350
39864
|
return renderChordOverlay(h, components, state, width, theme, focused);
|
|
39351
39865
|
}
|
|
39352
|
-
// Rail mode applies only after every overlay above has had its say
|
|
39353
|
-
// — those would all be unreadable at 4 cells of content. The layout
|
|
39354
|
-
// also clears `railed` whenever the inspector takes focus, so we
|
|
39355
|
-
// can safely short-circuit the per-view dispatch here without
|
|
39356
|
-
// worrying about hiding the panel from a user who's actively
|
|
39357
|
-
// reading it.
|
|
39358
|
-
if (railed) {
|
|
39359
|
-
return renderInspectorRail(h, components, state, detail, width, theme, focused);
|
|
39360
|
-
}
|
|
39361
39866
|
// The synthetic "(+) new commit" row routes the inspector through the
|
|
39362
39867
|
// worktree summary so the user sees what's staged / unstaged at a glance
|
|
39363
39868
|
// — same surface as the compose view's right panel.
|
|
@@ -39407,6 +39912,43 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
39407
39912
|
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
|
|
39408
39913
|
}
|
|
39409
39914
|
|
|
39915
|
+
/**
|
|
39916
|
+
* Runtime React Context for the workstation (#1136).
|
|
39917
|
+
*
|
|
39918
|
+
* The render layer currently drills `state` / `dispatch` / `theme` /
|
|
39919
|
+
* `layout` / `context` through every `render*Surface` signature, so
|
|
39920
|
+
* adding a feature repeatedly means threading one more value through
|
|
39921
|
+
* `app → mainPanel → render<View>Surface`. This Context is the single
|
|
39922
|
+
* place those five values live; surfaces read what they need from it
|
|
39923
|
+
* instead of receiving 10–15 positional props.
|
|
39924
|
+
*
|
|
39925
|
+
* Why a factory (`getLogInkRuntimeContext(React)`) instead of a plain
|
|
39926
|
+
* module-level `React.createContext(...)`: the workstation never
|
|
39927
|
+
* statically imports React. `ink` + `react` are ESM-only and loaded via
|
|
39928
|
+
* dynamicImport at boot (see `inkRuntime.ts`), so the rest of the
|
|
39929
|
+
* codebase compiles without bundling them. The Context object must be
|
|
39930
|
+
* built from that same runtime React instance — the one that renders
|
|
39931
|
+
* the tree and the one a consumer's `useContext` reads from have to be
|
|
39932
|
+
* identical. There is exactly one React instance per process, so we
|
|
39933
|
+
* lazily create the Context on first use and cache it; `LogInkApp`'s
|
|
39934
|
+
* provider and (in later PRs) the surface consumers all share the one
|
|
39935
|
+
* identity.
|
|
39936
|
+
*/
|
|
39937
|
+
let cachedContext = null;
|
|
39938
|
+
/**
|
|
39939
|
+
* Lazily create (and thereafter return) the process-wide
|
|
39940
|
+
* `LogInkRuntimeContext`, bound to the runtime React instance. Pass the
|
|
39941
|
+
* same `React` the tree is rendered with — `LogInkApp` uses `deps.React`;
|
|
39942
|
+
* tests use the statically-imported `react`.
|
|
39943
|
+
*/
|
|
39944
|
+
function getLogInkRuntimeContext(React) {
|
|
39945
|
+
if (!cachedContext) {
|
|
39946
|
+
cachedContext = React.createContext(null);
|
|
39947
|
+
cachedContext.displayName = 'LogInkRuntimeContext';
|
|
39948
|
+
}
|
|
39949
|
+
return cachedContext;
|
|
39950
|
+
}
|
|
39951
|
+
|
|
39410
39952
|
/**
|
|
39411
39953
|
* Resolve + scaffold the coco config files the workstation can open in
|
|
39412
39954
|
* `$EDITOR` (the `gk` / `gK` chords and their command-palette entries).
|
|
@@ -39890,6 +40432,10 @@ function LogInkApp(deps) {
|
|
|
39890
40432
|
const loadingMoreCommitsRef = React.useRef(false);
|
|
39891
40433
|
const loadMoreRequestRef = React.useRef(0);
|
|
39892
40434
|
const mountedRef = React.useRef(true);
|
|
40435
|
+
// Last dropped stash {hash, message}, captured before `drop-stash` runs
|
|
40436
|
+
// so `undo-drop-stash` can re-store it. The dropped commit survives in
|
|
40437
|
+
// the object DB until gc, so the hash is enough to bring it back.
|
|
40438
|
+
const lastDroppedStashRef = React.useRef(null);
|
|
39893
40439
|
// P4.3 — idle tip rotation. tickIndex 0 ⇒ no tip; the hook bumps it after
|
|
39894
40440
|
// a grace window of empty statusMessage and then on a steady cadence, so
|
|
39895
40441
|
// the footer surfaces a different hint every interval until the user does
|
|
@@ -41133,11 +41679,15 @@ function LogInkApp(deps) {
|
|
|
41133
41679
|
dispatch({ type: 'setStatus', value: 'AI draft ready for editing', kind: 'success' });
|
|
41134
41680
|
return;
|
|
41135
41681
|
}
|
|
41682
|
+
// Humanize provider errors (rate limit / auth / context / network)
|
|
41683
|
+
// into a short actionable line; success-but-no-draft keeps its
|
|
41684
|
+
// message as-is.
|
|
41685
|
+
const composeMessage = result.ok ? result.message : humanizeAiError(result.message);
|
|
41136
41686
|
dispatch({
|
|
41137
41687
|
type: 'commitCompose',
|
|
41138
|
-
action: { type: 'setResult', message:
|
|
41688
|
+
action: { type: 'setResult', message: composeMessage, details: result.details },
|
|
41139
41689
|
});
|
|
41140
|
-
dispatch({ type: 'setStatus', value: result.
|
|
41690
|
+
dispatch({ type: 'setStatus', value: composeMessage, kind: result.ok ? undefined : 'error' });
|
|
41141
41691
|
}
|
|
41142
41692
|
catch (error) {
|
|
41143
41693
|
// Audit finding #3: defensive recovery for unexpected throws
|
|
@@ -42108,8 +42658,20 @@ function LogInkApp(deps) {
|
|
|
42108
42658
|
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
42109
42659
|
if (!stash)
|
|
42110
42660
|
return { ok: false, message: 'No stash selected' };
|
|
42661
|
+
// Remember the dropped commit so `u` can undo it.
|
|
42662
|
+
if (stash.hash)
|
|
42663
|
+
lastDroppedStashRef.current = { hash: stash.hash, message: stash.message };
|
|
42111
42664
|
return dropStash(git, stash);
|
|
42112
42665
|
},
|
|
42666
|
+
'undo-drop-stash': async () => {
|
|
42667
|
+
const dropped = lastDroppedStashRef.current;
|
|
42668
|
+
if (!dropped)
|
|
42669
|
+
return { ok: false, message: 'Nothing to undo — no stash dropped this session' };
|
|
42670
|
+
const result = await restoreStash(git, dropped.hash, dropped.message);
|
|
42671
|
+
if (result.ok)
|
|
42672
|
+
lastDroppedStashRef.current = null;
|
|
42673
|
+
return result;
|
|
42674
|
+
},
|
|
42113
42675
|
'apply-stash': async () => {
|
|
42114
42676
|
const all = context.stashes?.stashes || [];
|
|
42115
42677
|
const visible = state.filter
|
|
@@ -42120,6 +42682,16 @@ function LogInkApp(deps) {
|
|
|
42120
42682
|
return { ok: false, message: 'No stash selected' };
|
|
42121
42683
|
return applyStash(git, stash);
|
|
42122
42684
|
},
|
|
42685
|
+
'apply-stash-index': async () => {
|
|
42686
|
+
const all = context.stashes?.stashes || [];
|
|
42687
|
+
const visible = state.filter
|
|
42688
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
|
|
42689
|
+
: all;
|
|
42690
|
+
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
42691
|
+
if (!stash)
|
|
42692
|
+
return { ok: false, message: 'No stash selected' };
|
|
42693
|
+
return applyStashKeepIndex(git, stash);
|
|
42694
|
+
},
|
|
42123
42695
|
'pop-stash': async () => {
|
|
42124
42696
|
const all = context.stashes?.stashes || [];
|
|
42125
42697
|
const visible = state.filter
|
|
@@ -42130,6 +42702,26 @@ function LogInkApp(deps) {
|
|
|
42130
42702
|
return { ok: false, message: 'No stash selected' };
|
|
42131
42703
|
return popStash(git, stash);
|
|
42132
42704
|
},
|
|
42705
|
+
'rename-stash': async () => {
|
|
42706
|
+
const all = context.stashes?.stashes || [];
|
|
42707
|
+
const visible = state.filter
|
|
42708
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
|
|
42709
|
+
: all;
|
|
42710
|
+
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
42711
|
+
if (!stash)
|
|
42712
|
+
return { ok: false, message: 'No stash selected' };
|
|
42713
|
+
return renameStash(git, stash, payload ?? '');
|
|
42714
|
+
},
|
|
42715
|
+
'stash-branch': async () => {
|
|
42716
|
+
const all = context.stashes?.stashes || [];
|
|
42717
|
+
const visible = state.filter
|
|
42718
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
|
|
42719
|
+
: all;
|
|
42720
|
+
const stash = visible[Math.min(state.selectedStashIndex, visible.length - 1)];
|
|
42721
|
+
if (!stash)
|
|
42722
|
+
return { ok: false, message: 'No stash selected' };
|
|
42723
|
+
return stashBranch(git, stash, payload ?? '');
|
|
42724
|
+
},
|
|
42133
42725
|
'bisect-good': async () => {
|
|
42134
42726
|
if (!context.bisect?.active)
|
|
42135
42727
|
return { ok: false, message: 'No bisect in progress' };
|
|
@@ -42540,11 +43132,12 @@ function LogInkApp(deps) {
|
|
|
42540
43132
|
return deleteRemoteTag(git, tag.name);
|
|
42541
43133
|
},
|
|
42542
43134
|
'create-stash': async () => {
|
|
42543
|
-
|
|
42544
|
-
|
|
42545
|
-
|
|
42546
|
-
return createStash(git, message);
|
|
43135
|
+
// Empty is allowed — createStash turns it into a quick WIP stash
|
|
43136
|
+
// (git's own `WIP on <branch>` subject). Naming is optional.
|
|
43137
|
+
return createStash(git, payload ?? '');
|
|
42547
43138
|
},
|
|
43139
|
+
'stash-staged': async () => createStash(git, payload ?? '', { stagedOnly: true }),
|
|
43140
|
+
'stash-keep-index': async () => createStash(git, payload ?? '', { keepIndex: true }),
|
|
42548
43141
|
// #783 — full PR action panel handlers. Each wraps the matching
|
|
42549
43142
|
// pullRequestActions verb. Strategy / body arrives via `payload`
|
|
42550
43143
|
// — input prompts validate before they reach here, but the
|
|
@@ -42792,6 +43385,8 @@ function LogInkApp(deps) {
|
|
|
42792
43385
|
const files = applyStatusFilterMask(context.worktree?.files || [], state.statusFilterMask).filter((file) => file.state === 'untracked');
|
|
42793
43386
|
return stageAllFiles(git, files);
|
|
42794
43387
|
},
|
|
43388
|
+
'stage-all': async () => stageAll(git),
|
|
43389
|
+
'stage-pathspec': async () => stagePathspec(git, payload || ''),
|
|
42795
43390
|
};
|
|
42796
43391
|
const handler = handlers[id];
|
|
42797
43392
|
if (!handler) {
|
|
@@ -42822,6 +43417,16 @@ function LogInkApp(deps) {
|
|
|
42822
43417
|
'checkout-branch',
|
|
42823
43418
|
'continue-operation',
|
|
42824
43419
|
'pull-current-branch',
|
|
43420
|
+
// Fetch / pull / push bring in new commits and move
|
|
43421
|
+
// remote-tracking refs (origin/main, ahead/behind) — refresh the
|
|
43422
|
+
// graph so they appear instead of staying pinned to the pre-sync
|
|
43423
|
+
// state. (A successful push advances the local origin/<branch>
|
|
43424
|
+
// ref, so the chip should hop to the pushed commit.)
|
|
43425
|
+
'fetch-remotes',
|
|
43426
|
+
'fetch-selected-branch',
|
|
43427
|
+
'pull-selected-branch',
|
|
43428
|
+
'push-current-branch',
|
|
43429
|
+
'push-selected-branch',
|
|
42825
43430
|
'cherry-pick-commit',
|
|
42826
43431
|
'revert-commit',
|
|
42827
43432
|
'reset-hard-to-commit',
|
|
@@ -42876,6 +43481,11 @@ function LogInkApp(deps) {
|
|
|
42876
43481
|
if (result?.ok && id === 'add-to-gitignore') {
|
|
42877
43482
|
await refreshWorktreeContext();
|
|
42878
43483
|
}
|
|
43484
|
+
// Stage-all / stage-pathspec change staged/unstaged counts — refresh
|
|
43485
|
+
// the worktree so the status list + compose summary reflect it.
|
|
43486
|
+
if (result?.ok && (id === 'stage-all' || id === 'stage-pathspec')) {
|
|
43487
|
+
await refreshWorktreeContext();
|
|
43488
|
+
}
|
|
42879
43489
|
if (result?.ok && id === 'drop-stash') {
|
|
42880
43490
|
// Explicit worktree refresh in case the dropped stash carried
|
|
42881
43491
|
// untracked-file state that's now collected.
|
|
@@ -43459,6 +44069,11 @@ function LogInkApp(deps) {
|
|
|
43459
44069
|
? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
|
|
43460
44070
|
: undefined;
|
|
43461
44071
|
getLogInkInputEvents(state, inputValue, key, {
|
|
44072
|
+
// Narrow terminals show one pane at a time (#1135) — gates the `v`
|
|
44073
|
+
// peek key. Derived the same way the layout does, since `layout`
|
|
44074
|
+
// is computed later in the render path (not in this callback).
|
|
44075
|
+
singlePane: (windowSize.columns || process.stdout.columns || LOG_INK_DEFAULT_COLUMNS) <
|
|
44076
|
+
LAYOUT_SINGLE_PANE_BELOW,
|
|
43462
44077
|
detailFileCount: detail?.files.length,
|
|
43463
44078
|
previewLineCount: diffPreviewLineCount,
|
|
43464
44079
|
worktreeDiffLineCount: worktreeDiff?.lines.length,
|
|
@@ -43583,7 +44198,14 @@ function LogInkApp(deps) {
|
|
|
43583
44198
|
exit();
|
|
43584
44199
|
}
|
|
43585
44200
|
else if (event.type === 'refreshContext') {
|
|
44201
|
+
// The user-initiated refresh (`r`) refreshes BOTH the metadata
|
|
44202
|
+
// context (branches/tags/worktree) AND the commit rows. Without
|
|
44203
|
+
// the row re-fetch the history graph stays pinned to whatever
|
|
44204
|
+
// commits existed at boot — new commits (made in another
|
|
44205
|
+
// terminal, or remote commits brought in by a fetch) never
|
|
44206
|
+
// appear until relaunch, which reads as "the history is stuck."
|
|
43586
44207
|
void refreshContext();
|
|
44208
|
+
void refreshHistoryRows();
|
|
43587
44209
|
}
|
|
43588
44210
|
else if (event.type === 'toggleSelectedFileStage') {
|
|
43589
44211
|
void toggleSelectedFileStage();
|
|
@@ -43682,6 +44304,24 @@ function LogInkApp(deps) {
|
|
|
43682
44304
|
}
|
|
43683
44305
|
});
|
|
43684
44306
|
});
|
|
44307
|
+
// In single-pane mode (narrow terminals) only one pane renders, so an
|
|
44308
|
+
// active overlay must pull its own pane into view rather than stay
|
|
44309
|
+
// hidden behind whatever pane focus points at. The split-plan overlay
|
|
44310
|
+
// lives in the main panel; every other overlay (help / palette / theme
|
|
44311
|
+
// / gitignore / input prompt / confirmation / chord) renders in the
|
|
44312
|
+
// inspector. Ignored above the single-pane breakpoint (all panes show).
|
|
44313
|
+
const forcedPane = state.splitPlan
|
|
44314
|
+
? 'main'
|
|
44315
|
+
: state.showHelp ||
|
|
44316
|
+
state.showCommandPalette ||
|
|
44317
|
+
state.showThemePicker ||
|
|
44318
|
+
state.gitignorePicker ||
|
|
44319
|
+
state.inputPrompt ||
|
|
44320
|
+
state.pendingConfirmationId ||
|
|
44321
|
+
state.pendingMutationConfirmation ||
|
|
44322
|
+
state.pendingKey
|
|
44323
|
+
? 'inspector'
|
|
44324
|
+
: undefined;
|
|
43685
44325
|
// Layout depends on focus (sidebar grows when focused), so it's
|
|
43686
44326
|
// computed here — after state is in scope but before the render path.
|
|
43687
44327
|
const layout = getLogInkLayout({
|
|
@@ -43690,7 +44330,22 @@ function LogInkApp(deps) {
|
|
|
43690
44330
|
sidebarFocused: state.focus === 'sidebar',
|
|
43691
44331
|
inspectorFocused: state.focus === 'detail',
|
|
43692
44332
|
helpOverlayActive: state.showHelp,
|
|
44333
|
+
forcedPane,
|
|
43693
44334
|
});
|
|
44335
|
+
// Runtime Context provider (#1136). Bundles the five most-drilled
|
|
44336
|
+
// values so surfaces can read them from context instead of receiving
|
|
44337
|
+
// them as positional props. No consumers yet — this PR only installs
|
|
44338
|
+
// the provider at the root; the surface families migrate in later PRs.
|
|
44339
|
+
// A Context.Provider renders its children transparently (no host
|
|
44340
|
+
// output), so wrapping the tree is behavior-preserving.
|
|
44341
|
+
const RuntimeContext = getLogInkRuntimeContext(React);
|
|
44342
|
+
const runtimeContextValue = {
|
|
44343
|
+
state,
|
|
44344
|
+
dispatch,
|
|
44345
|
+
theme,
|
|
44346
|
+
layout,
|
|
44347
|
+
context,
|
|
44348
|
+
};
|
|
43694
44349
|
if (layout.tooSmall) {
|
|
43695
44350
|
return h(Box, {
|
|
43696
44351
|
flexDirection: 'column',
|
|
@@ -43704,7 +44359,35 @@ function LogInkApp(deps) {
|
|
|
43704
44359
|
if (showOnboarding) {
|
|
43705
44360
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
43706
44361
|
}
|
|
43707
|
-
|
|
44362
|
+
// Panel renderers are thunks so single-pane mode can build only the
|
|
44363
|
+
// visible pane — the main-panel render in particular is expensive, so
|
|
44364
|
+
// we don't want to invoke the two hidden ones just to drop them.
|
|
44365
|
+
const sidebarPanel = () => renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme);
|
|
44366
|
+
const mainSurface = {
|
|
44367
|
+
h,
|
|
44368
|
+
components: { Box, Text },
|
|
44369
|
+
state,
|
|
44370
|
+
context,
|
|
44371
|
+
contextStatus,
|
|
44372
|
+
bodyRows: layout.bodyRows,
|
|
44373
|
+
width: layout.mainPanelWidth,
|
|
44374
|
+
theme,
|
|
44375
|
+
};
|
|
44376
|
+
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);
|
|
44377
|
+
const detailPanel = () => renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.bodyRows);
|
|
44378
|
+
// Single-pane mode (narrow terminals): exactly one full-width pane,
|
|
44379
|
+
// chosen by `layout.visiblePane`; Tab cycles which one. Above the
|
|
44380
|
+
// breakpoint all three tile side by side as before.
|
|
44381
|
+
const bodyPanels = layout.singlePane
|
|
44382
|
+
? [
|
|
44383
|
+
layout.visiblePane === 'sidebar'
|
|
44384
|
+
? sidebarPanel()
|
|
44385
|
+
: layout.visiblePane === 'inspector'
|
|
44386
|
+
? detailPanel()
|
|
44387
|
+
: mainPanel(),
|
|
44388
|
+
]
|
|
44389
|
+
: [sidebarPanel(), mainPanel(), detailPanel()];
|
|
44390
|
+
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)));
|
|
43708
44391
|
}
|
|
43709
44392
|
|
|
43710
44393
|
/**
|
|
@@ -45824,6 +46507,54 @@ async function getWorkspacePullRequestCounts(repoPaths, options = {}) {
|
|
|
45824
46507
|
return { authenticated: true, counts };
|
|
45825
46508
|
}
|
|
45826
46509
|
|
|
46510
|
+
/**
|
|
46511
|
+
* Clone a remote repository into a local path — the runtime side of the
|
|
46512
|
+
* workspace surface's `c` (clone) flow.
|
|
46513
|
+
*
|
|
46514
|
+
* `deriveRepoName` is pure (and tested) so the UI can pre-fill the
|
|
46515
|
+
* destination as `<cwd>/<name>` the moment a URL is typed; `cloneRepo`
|
|
46516
|
+
* does the filesystem-touching work and reports a friendly result.
|
|
46517
|
+
*/
|
|
46518
|
+
/**
|
|
46519
|
+
* Infer the repository folder name from a clone URL or SSH spec:
|
|
46520
|
+
* git@github.com:gfargo/coco.git → coco
|
|
46521
|
+
* https://github.com/gfargo/coco → coco
|
|
46522
|
+
* https://example.com/a/b/c.git/ → c
|
|
46523
|
+
* Falls back to `repo` when nothing usable can be parsed.
|
|
46524
|
+
*/
|
|
46525
|
+
function deriveRepoName(url) {
|
|
46526
|
+
const trimmed = url.trim().replace(/\/+$/, '').replace(/\.git$/i, '');
|
|
46527
|
+
if (!trimmed)
|
|
46528
|
+
return 'repo';
|
|
46529
|
+
// Split on both `/` and `:` so `host:owner/name` SSH specs work.
|
|
46530
|
+
const segment = trimmed.split(/[/:]/).filter(Boolean).pop() || '';
|
|
46531
|
+
return segment || 'repo';
|
|
46532
|
+
}
|
|
46533
|
+
/**
|
|
46534
|
+
* Clone `url` into `targetPath`. Refuses to clobber an existing path so
|
|
46535
|
+
* a typo never overwrites a directory. Network / auth failures surface
|
|
46536
|
+
* git's own message (trimmed to one line).
|
|
46537
|
+
*/
|
|
46538
|
+
async function cloneRepo(url, targetPath) {
|
|
46539
|
+
const remote = url.trim();
|
|
46540
|
+
const dest = targetPath.trim();
|
|
46541
|
+
if (!remote)
|
|
46542
|
+
return { ok: false, message: 'Enter a remote URL to clone.' };
|
|
46543
|
+
if (!dest)
|
|
46544
|
+
return { ok: false, message: 'Enter a destination path.' };
|
|
46545
|
+
if (fs__namespace.existsSync(dest)) {
|
|
46546
|
+
return { ok: false, message: `${dest} already exists — choose another path.` };
|
|
46547
|
+
}
|
|
46548
|
+
try {
|
|
46549
|
+
await simpleGit.simpleGit().clone(remote, dest);
|
|
46550
|
+
return { ok: true, message: `Cloned into ${dest}` };
|
|
46551
|
+
}
|
|
46552
|
+
catch (error) {
|
|
46553
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
46554
|
+
return { ok: false, message: `Clone failed: ${raw.split('\n')[0]}` };
|
|
46555
|
+
}
|
|
46556
|
+
}
|
|
46557
|
+
|
|
45827
46558
|
function resolveStoreDir(subdir) {
|
|
45828
46559
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
45829
46560
|
const root = xdg && xdg.trim().length > 0 ? xdg : path__namespace$1.join(os__namespace$1.homedir(), '.cache');
|
|
@@ -46066,6 +46797,7 @@ function createWorkspaceState(init) {
|
|
|
46066
46797
|
showThemePicker: false,
|
|
46067
46798
|
themePickerFilter: '',
|
|
46068
46799
|
themePickerIndex: 0,
|
|
46800
|
+
helpScrollOffset: 0,
|
|
46069
46801
|
knownRepoPaths: init.knownRepoPaths ?? [],
|
|
46070
46802
|
pullRequestFetching: [],
|
|
46071
46803
|
};
|
|
@@ -46231,10 +46963,17 @@ function applyWorkspaceAction(state, action) {
|
|
|
46231
46963
|
return { ...state, status: action.status };
|
|
46232
46964
|
}
|
|
46233
46965
|
case 'toggle-help': {
|
|
46234
|
-
|
|
46966
|
+
// Always reopen at the top — picking up the last scroll position
|
|
46967
|
+
// is more surprising than predictable for a reference overlay.
|
|
46968
|
+
return { ...state, showHelp: !state.showHelp, helpScrollOffset: 0, showOnboarding: false };
|
|
46235
46969
|
}
|
|
46236
46970
|
case 'close-help': {
|
|
46237
|
-
return { ...state, showHelp: false };
|
|
46971
|
+
return { ...state, showHelp: false, helpScrollOffset: 0 };
|
|
46972
|
+
}
|
|
46973
|
+
case 'scroll-help': {
|
|
46974
|
+
// Floor-clamp at 0 only; the renderer ceiling-clamps against the
|
|
46975
|
+
// real content height so `j` past the end sticks at the last row.
|
|
46976
|
+
return { ...state, helpScrollOffset: Math.max(0, state.helpScrollOffset + action.delta) };
|
|
46238
46977
|
}
|
|
46239
46978
|
case 'toggle-theme-picker': {
|
|
46240
46979
|
return {
|
|
@@ -46564,6 +47303,7 @@ function buildWorkspaceListWindow(state, options = { rows: 20 }) {
|
|
|
46564
47303
|
const all = buildWorkspaceListRows(state, {
|
|
46565
47304
|
width: options.width,
|
|
46566
47305
|
spinnerTick: options.spinnerTick,
|
|
47306
|
+
now: options.now,
|
|
46567
47307
|
});
|
|
46568
47308
|
const visibleCount = Math.max(1, options.rows);
|
|
46569
47309
|
if (all.length <= visibleCount) {
|
|
@@ -46687,10 +47427,11 @@ function buildWorkspaceHeaderChips(state, options = { focusLabel: 'List' }) {
|
|
|
46687
47427
|
// The contextual slot drops bindings users can find via the help
|
|
46688
47428
|
// overlay (arrow keys, tab); the global slot is the safety net so
|
|
46689
47429
|
// `? help` and `q quit` never disappear.
|
|
46690
|
-
const LIST_CONTEXTUAL = ['s sort', '/ filter', 'r/R refresh', 'a add', 'd remove'];
|
|
47430
|
+
const LIST_CONTEXTUAL = ['s sort', '/ filter', 'r/R refresh', 'a add', 'c clone', 'd remove'];
|
|
46691
47431
|
const SIDEBAR_CONTEXTUAL = ['↑/↓ cycle tab', 'enter open'];
|
|
46692
47432
|
const FILTER_CONTEXTUAL = ['type to filter', 'enter apply', 'esc cancel'];
|
|
46693
47433
|
const ADD_REPO_CONTEXTUAL = ['type path', 'tab to complete', 'enter to add', 'esc to cancel'];
|
|
47434
|
+
const CLONE_REPO_CONTEXTUAL = ['enter URL', 'enter → destination', 'enter to clone', 'esc to cancel'];
|
|
46694
47435
|
const CONFIRM_DELETE_CONTEXTUAL = ['y confirm', 'any other key cancels'];
|
|
46695
47436
|
const GLOBAL_HINTS = ['? help', 'q quit'];
|
|
46696
47437
|
function contextualHintsFor(focus) {
|
|
@@ -46701,6 +47442,8 @@ function contextualHintsFor(focus) {
|
|
|
46701
47442
|
return FILTER_CONTEXTUAL;
|
|
46702
47443
|
case 'add-repo':
|
|
46703
47444
|
return ADD_REPO_CONTEXTUAL;
|
|
47445
|
+
case 'clone-repo':
|
|
47446
|
+
return CLONE_REPO_CONTEXTUAL;
|
|
46704
47447
|
case 'confirm-delete':
|
|
46705
47448
|
return CONFIRM_DELETE_CONTEXTUAL;
|
|
46706
47449
|
case 'list':
|
|
@@ -46715,6 +47458,7 @@ function buildWorkspaceFooter(state) {
|
|
|
46715
47458
|
// is open and showing them would be misleading.
|
|
46716
47459
|
const isModal = state.focus === 'filter' ||
|
|
46717
47460
|
state.focus === 'add-repo' ||
|
|
47461
|
+
state.focus === 'clone-repo' ||
|
|
46718
47462
|
state.focus === 'confirm-delete';
|
|
46719
47463
|
const global = isModal ? [] : GLOBAL_HINTS;
|
|
46720
47464
|
const allHints = [...contextual, ...global];
|
|
@@ -46776,6 +47520,7 @@ function buildWorkspaceHelpSections() {
|
|
|
46776
47520
|
{ glyph: '⟳', keys: 'r', description: 'Refresh all repos (discovery + PR counts)' },
|
|
46777
47521
|
{ glyph: '⟲', keys: 'R', description: 'Refresh just the cursored repo (faster)' },
|
|
46778
47522
|
{ glyph: '+', keys: 'a', description: 'Add a repo via path prompt (tab-completes)' },
|
|
47523
|
+
{ glyph: '⬇', keys: 'c', description: 'Clone a remote repo (defaults into the launch directory)' },
|
|
46779
47524
|
{ glyph: '✕', keys: 'd', description: 'Remove the cursored repo from the known-repos store' },
|
|
46780
47525
|
],
|
|
46781
47526
|
},
|
|
@@ -46793,7 +47538,7 @@ function buildWorkspaceOnboarding(state) {
|
|
|
46793
47538
|
: undefined,
|
|
46794
47539
|
populatedHint: empty
|
|
46795
47540
|
? undefined
|
|
46796
|
-
: 'Press `enter` to open a repo ·
|
|
47541
|
+
: 'Press `enter` to open a repo · `a` to add by path · `c` to clone · `?` for the full keymap.',
|
|
46797
47542
|
};
|
|
46798
47543
|
}
|
|
46799
47544
|
|
|
@@ -47089,6 +47834,7 @@ function renderListBody(deps, width, height) {
|
|
|
47089
47834
|
width,
|
|
47090
47835
|
rows: listRows,
|
|
47091
47836
|
spinnerTick: deps.spinnerTick,
|
|
47837
|
+
now: deps.now,
|
|
47092
47838
|
});
|
|
47093
47839
|
const visibleRepos = selectVisibleRepos(state);
|
|
47094
47840
|
const filterChip = state.filter
|
|
@@ -47124,7 +47870,7 @@ function renderListBody(deps, width, height) {
|
|
|
47124
47870
|
function renderHelpRow(deps, row, glyphWidth, keysWidth, key) {
|
|
47125
47871
|
const { React, ink, theme } = deps;
|
|
47126
47872
|
const { Box, Text } = ink;
|
|
47127
|
-
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));
|
|
47873
|
+
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));
|
|
47128
47874
|
}
|
|
47129
47875
|
function renderHelpOverlay(deps) {
|
|
47130
47876
|
if (!deps.state.showHelp) {
|
|
@@ -47137,34 +47883,68 @@ function renderHelpOverlay(deps) {
|
|
|
47137
47883
|
// Columns: glyph cell (4 cells) · keys (padded to longest) · description.
|
|
47138
47884
|
const glyphWidth = 4;
|
|
47139
47885
|
const keysWidth = Math.max(14, allRows.reduce((acc, row) => Math.max(acc, row.keys.length), 0) + 4);
|
|
47140
|
-
|
|
47141
|
-
//
|
|
47142
|
-
//
|
|
47143
|
-
//
|
|
47144
|
-
|
|
47145
|
-
|
|
47146
|
-
|
|
47147
|
-
// then its rows, then a blank line.
|
|
47886
|
+
// Body lines — every scrollable row below the pinned title. Built as
|
|
47887
|
+
// a flat list (section title → optional subtitle → rows → inter-section
|
|
47888
|
+
// spacer) so we can window it against the available height. Each entry
|
|
47889
|
+
// is `flexShrink: 0` so Ink never crushes rows on top of each other
|
|
47890
|
+
// when the keymap is taller than the panel (which used to collapse the
|
|
47891
|
+
// title and the first category onto the same line).
|
|
47892
|
+
const body = [];
|
|
47148
47893
|
sections.forEach((section, sIndex) => {
|
|
47149
|
-
|
|
47894
|
+
body.push(React.createElement(Text, {
|
|
47150
47895
|
key: `section-${sIndex}-title`,
|
|
47151
47896
|
bold: true,
|
|
47152
47897
|
color: theme.noColor ? undefined : theme.colors.muted,
|
|
47153
47898
|
}, section.title.toUpperCase()));
|
|
47154
47899
|
if (section.subtitle) {
|
|
47155
|
-
|
|
47900
|
+
body.push(React.createElement(Text, { key: `section-${sIndex}-subtitle`, dimColor: true }, ` ${section.subtitle}`));
|
|
47156
47901
|
}
|
|
47157
47902
|
section.rows.forEach((row, rIndex) => {
|
|
47158
|
-
|
|
47903
|
+
body.push(renderHelpRow(deps, row, glyphWidth, keysWidth, `row-${sIndex}-${rIndex}`));
|
|
47159
47904
|
});
|
|
47160
47905
|
if (sIndex < sections.length - 1) {
|
|
47161
|
-
|
|
47906
|
+
body.push(React.createElement(Text, { key: `section-${sIndex}-spacer` }, ''));
|
|
47162
47907
|
}
|
|
47163
47908
|
});
|
|
47909
|
+
// Vertical budget: the overlay shares the column with the header
|
|
47910
|
+
// (3 rows) and footer (FOOTER_HEIGHT). Its own chrome eats the border
|
|
47911
|
+
// (2), the pinned title (1) and the title/body separator (1). Whatever
|
|
47912
|
+
// is left is the window we slide the body through.
|
|
47913
|
+
const HEADER_ROWS = 3;
|
|
47914
|
+
const overlayChromeRows = 4;
|
|
47915
|
+
const visibleRows = Math.max(4, deps.rows - HEADER_ROWS - FOOTER_HEIGHT - overlayChromeRows);
|
|
47916
|
+
// Ceiling-clamp the offset here (the reducer only floors at 0) so
|
|
47917
|
+
// scrolling past the end sticks at the last row instead of revealing
|
|
47918
|
+
// blank space.
|
|
47919
|
+
const maxOffset = Math.max(0, body.length - visibleRows);
|
|
47920
|
+
const offset = Math.min(deps.state.helpScrollOffset, maxOffset);
|
|
47921
|
+
const children = [];
|
|
47922
|
+
// Title bar — accent-tinged, matches the chip-style header on the
|
|
47923
|
+
// main surface so the help reads as the same app, just a different
|
|
47924
|
+
// panel. Pinned above the scrolling body.
|
|
47925
|
+
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 ')));
|
|
47926
|
+
children.push(React.createElement(Text, { key: 'title-sep', dimColor: true }, ''));
|
|
47927
|
+
// "more above" / "more below" hints each consume a window row so they
|
|
47928
|
+
// don't push body content off-screen. Mirrors the `coco ui` overlay.
|
|
47929
|
+
let windowSize = visibleRows;
|
|
47930
|
+
const hasMoreAbove = offset > 0;
|
|
47931
|
+
if (hasMoreAbove) {
|
|
47932
|
+
windowSize -= 1;
|
|
47933
|
+
children.push(React.createElement(Text, { key: 'more-above', dimColor: true }, ' ↑ more above (j/k or ↑/↓ to scroll)'));
|
|
47934
|
+
}
|
|
47935
|
+
const hasMoreBelow = offset + windowSize < body.length;
|
|
47936
|
+
if (hasMoreBelow) {
|
|
47937
|
+
windowSize -= 1;
|
|
47938
|
+
}
|
|
47939
|
+
children.push(...body.slice(offset, offset + windowSize));
|
|
47940
|
+
if (hasMoreBelow) {
|
|
47941
|
+
children.push(React.createElement(Text, { key: 'more-below', dimColor: true }, ' ↓ more below (j/k or ↑/↓ to scroll)'));
|
|
47942
|
+
}
|
|
47164
47943
|
return React.createElement(Box, {
|
|
47165
47944
|
borderColor: focusBorderColor(theme, true),
|
|
47166
47945
|
borderStyle: theme.borderStyle,
|
|
47167
47946
|
flexDirection: 'column',
|
|
47947
|
+
flexShrink: 0,
|
|
47168
47948
|
paddingX: 1,
|
|
47169
47949
|
}, ...children);
|
|
47170
47950
|
}
|
|
@@ -47215,6 +47995,29 @@ function renderAddRepoPrompt(deps) {
|
|
|
47215
47995
|
? React.createElement(Text, { color: toneColor('dim', theme) }, completionLine)
|
|
47216
47996
|
: null);
|
|
47217
47997
|
}
|
|
47998
|
+
function renderCloneRepoPrompt(deps) {
|
|
47999
|
+
if (deps.state.focus !== 'clone-repo') {
|
|
48000
|
+
return null;
|
|
48001
|
+
}
|
|
48002
|
+
const { React, ink, theme, cloneUrl, cloneTarget, cloneField, cloneCompletion, cloning } = deps;
|
|
48003
|
+
const { Box, Text } = ink;
|
|
48004
|
+
const urlActive = cloneField === 'url' && !cloning;
|
|
48005
|
+
const targetActive = cloneField === 'target' && !cloning;
|
|
48006
|
+
const completionLine = cloneCompletion.completions.slice(0, 8).join(' ');
|
|
48007
|
+
const hint = cloning
|
|
48008
|
+
? 'Cloning… this can take a moment for large repos.'
|
|
48009
|
+
: cloneField === 'url'
|
|
48010
|
+
? 'Paste a remote URL (https or git@…), then enter for the destination.'
|
|
48011
|
+
: 'Edit the destination · tab to complete · enter to clone · esc to cancel';
|
|
48012
|
+
return React.createElement(Box, {
|
|
48013
|
+
borderColor: focusBorderColor(theme, true),
|
|
48014
|
+
borderStyle: theme.borderStyle,
|
|
48015
|
+
flexDirection: 'column',
|
|
48016
|
+
paddingX: 1,
|
|
48017
|
+
}, 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
|
|
48018
|
+
? React.createElement(Text, { color: toneColor('dim', theme) }, completionLine)
|
|
48019
|
+
: null);
|
|
48020
|
+
}
|
|
47218
48021
|
const FOOTER_HEIGHT = 4; // 2 borders + hint row + status row
|
|
47219
48022
|
function renderFooter(deps) {
|
|
47220
48023
|
const { React, ink, state, theme } = deps;
|
|
@@ -47256,8 +48059,10 @@ function computeBodyHeight(deps) {
|
|
|
47256
48059
|
const FOOTER_ROWS = FOOTER_HEIGHT;
|
|
47257
48060
|
const onboardingRows = buildWorkspaceOnboarding(deps.state).show ? 5 : 0;
|
|
47258
48061
|
const addRepoRows = deps.state.focus === 'add-repo' ? 5 : 0;
|
|
48062
|
+
// Clone modal is one row taller (URL + Into + hint + completion).
|
|
48063
|
+
const cloneRows = deps.state.focus === 'clone-repo' ? 6 : 0;
|
|
47259
48064
|
const confirmRows = deps.state.focus === 'confirm-delete' ? 5 : 0;
|
|
47260
|
-
const reserved = HEADER_ROWS + FOOTER_ROWS + onboardingRows + addRepoRows + confirmRows;
|
|
48065
|
+
const reserved = HEADER_ROWS + FOOTER_ROWS + onboardingRows + addRepoRows + cloneRows + confirmRows;
|
|
47261
48066
|
return Math.max(8, deps.rows - reserved);
|
|
47262
48067
|
}
|
|
47263
48068
|
function renderWorkspaceApp(deps) {
|
|
@@ -47279,7 +48084,7 @@ function renderWorkspaceApp(deps) {
|
|
|
47279
48084
|
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));
|
|
47280
48085
|
}
|
|
47281
48086
|
const bodyHeight = computeBodyHeight(deps);
|
|
47282
|
-
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));
|
|
48087
|
+
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));
|
|
47283
48088
|
}
|
|
47284
48089
|
|
|
47285
48090
|
/**
|
|
@@ -47316,6 +48121,21 @@ function resolveWorkspaceInput(input, key, state) {
|
|
|
47316
48121
|
if (key.escape || input === '?' || input === 'q') {
|
|
47317
48122
|
return { kind: 'action', action: { type: 'close-help' } };
|
|
47318
48123
|
}
|
|
48124
|
+
// The keymap is taller than the panel on short terminals — let
|
|
48125
|
+
// j/k/↑/↓ and ctrl+d/u scroll the windowed body. Mirrors the
|
|
48126
|
+
// `coco ui` help overlay.
|
|
48127
|
+
if (key.downArrow || input === 'j') {
|
|
48128
|
+
return { kind: 'action', action: { type: 'scroll-help', delta: 1 } };
|
|
48129
|
+
}
|
|
48130
|
+
if (key.upArrow || input === 'k') {
|
|
48131
|
+
return { kind: 'action', action: { type: 'scroll-help', delta: -1 } };
|
|
48132
|
+
}
|
|
48133
|
+
if (key.ctrl && input === 'd') {
|
|
48134
|
+
return { kind: 'action', action: { type: 'scroll-help', delta: 10 } };
|
|
48135
|
+
}
|
|
48136
|
+
if (key.ctrl && input === 'u') {
|
|
48137
|
+
return { kind: 'action', action: { type: 'scroll-help', delta: -10 } };
|
|
48138
|
+
}
|
|
47319
48139
|
return { kind: 'noop' };
|
|
47320
48140
|
}
|
|
47321
48141
|
// Theme picker is modal (like `coco ui`'s gC): type to filter, ↑/↓ to
|
|
@@ -47366,6 +48186,14 @@ function resolveWorkspaceInput(input, key, state) {
|
|
|
47366
48186
|
// can drive the path-completion prompt.
|
|
47367
48187
|
return { kind: 'noop' };
|
|
47368
48188
|
}
|
|
48189
|
+
if (state.focus === 'clone-repo') {
|
|
48190
|
+
if (key.escape) {
|
|
48191
|
+
return { kind: 'action', action: { type: 'set-focus', focus: 'list' } };
|
|
48192
|
+
}
|
|
48193
|
+
// Enter/Tab/printable keys drive the URL + destination prompt in the
|
|
48194
|
+
// runtime (it owns the two-field state + path completion).
|
|
48195
|
+
return { kind: 'noop' };
|
|
48196
|
+
}
|
|
47369
48197
|
// Confirm-delete is modal: only `y` confirms, anything else cancels.
|
|
47370
48198
|
if (state.focus === 'confirm-delete') {
|
|
47371
48199
|
if (input === 'y' || input === 'Y') {
|
|
@@ -47460,6 +48288,9 @@ function resolveWorkspaceInput(input, key, state) {
|
|
|
47460
48288
|
if (input === 'a') {
|
|
47461
48289
|
return { kind: 'add-repo' };
|
|
47462
48290
|
}
|
|
48291
|
+
if (input === 'c') {
|
|
48292
|
+
return { kind: 'clone-repo' };
|
|
48293
|
+
}
|
|
47463
48294
|
if (input === 'd') {
|
|
47464
48295
|
return { kind: 'request-delete' };
|
|
47465
48296
|
}
|
|
@@ -48014,6 +48845,19 @@ function WorkspaceInkApp(props) {
|
|
|
48014
48845
|
const [filterDraft, setFilterDraft] = React.useState('');
|
|
48015
48846
|
const [addRepoDraft, setAddRepoDraft] = React.useState('~/');
|
|
48016
48847
|
const [addRepoCompletion, setAddRepoCompletion] = React.useState(() => completePath('~/'));
|
|
48848
|
+
// Clone-repo modal (`c`). Two fields: the remote URL and the
|
|
48849
|
+
// destination path. `cloneField` tracks which is active; `cloneTarget`
|
|
48850
|
+
// auto-derives `<cwd>/<repo-name>` from the URL until the user edits it
|
|
48851
|
+
// (`cloneTargetEdited`). `cloning` blocks input + shows a spinner while
|
|
48852
|
+
// `git clone` runs. The boot cwd is captured once at mount so it stays
|
|
48853
|
+
// the directory the workspace launched in even after drill-in.
|
|
48854
|
+
const bootCwdRef = React.useRef(process.cwd());
|
|
48855
|
+
const [cloneUrl, setCloneUrl] = React.useState('');
|
|
48856
|
+
const [cloneTarget, setCloneTarget] = React.useState('');
|
|
48857
|
+
const [cloneField, setCloneField] = React.useState('url');
|
|
48858
|
+
const [cloneTargetEdited, setCloneTargetEdited] = React.useState(false);
|
|
48859
|
+
const [cloneCompletion, setCloneCompletion] = React.useState(() => completePath('~/'));
|
|
48860
|
+
const [cloning, setCloning] = React.useState(false);
|
|
48017
48861
|
// Tick counter for the per-row PR-fetch spinner. Bumped on a
|
|
48018
48862
|
// setInterval that only runs while at least one row is mid-fetch
|
|
48019
48863
|
// (see effect below) so idle workspaces don't burn CPU on animation
|
|
@@ -48063,6 +48907,18 @@ function WorkspaceInkApp(props) {
|
|
|
48063
48907
|
addRepoDraftRef.current = addRepoDraft;
|
|
48064
48908
|
const addRepoCompletionRef = React.useRef(addRepoCompletion);
|
|
48065
48909
|
addRepoCompletionRef.current = addRepoCompletion;
|
|
48910
|
+
const cloneUrlRef = React.useRef(cloneUrl);
|
|
48911
|
+
cloneUrlRef.current = cloneUrl;
|
|
48912
|
+
const cloneTargetRef = React.useRef(cloneTarget);
|
|
48913
|
+
cloneTargetRef.current = cloneTarget;
|
|
48914
|
+
const cloneFieldRef = React.useRef(cloneField);
|
|
48915
|
+
cloneFieldRef.current = cloneField;
|
|
48916
|
+
const cloneTargetEditedRef = React.useRef(cloneTargetEdited);
|
|
48917
|
+
cloneTargetEditedRef.current = cloneTargetEdited;
|
|
48918
|
+
const cloneCompletionRef = React.useRef(cloneCompletion);
|
|
48919
|
+
cloneCompletionRef.current = cloneCompletion;
|
|
48920
|
+
const cloningRef = React.useRef(cloning);
|
|
48921
|
+
cloningRef.current = cloning;
|
|
48066
48922
|
// Background discovery + PR-count refresh on mount.
|
|
48067
48923
|
React.useEffect(() => {
|
|
48068
48924
|
let cancelled = false;
|
|
@@ -48295,6 +49151,60 @@ function WorkspaceInkApp(props) {
|
|
|
48295
49151
|
});
|
|
48296
49152
|
}
|
|
48297
49153
|
}, [addRepoDraft, dispatch, props]);
|
|
49154
|
+
// Default destination for a clone URL: `<bootCwd>/<repo-name>`.
|
|
49155
|
+
const cloneTargetFor = React.useCallback((url) => {
|
|
49156
|
+
return path__namespace$1.join(bootCwdRef.current, deriveRepoName(url));
|
|
49157
|
+
}, []);
|
|
49158
|
+
const openClone = React.useCallback(() => {
|
|
49159
|
+
setCloneUrl('');
|
|
49160
|
+
setCloneTarget('');
|
|
49161
|
+
setCloneField('url');
|
|
49162
|
+
setCloneTargetEdited(false);
|
|
49163
|
+
setCloneCompletion(completePath(`${bootCwdRef.current}/`));
|
|
49164
|
+
dispatch({ type: 'set-focus', focus: 'clone-repo' });
|
|
49165
|
+
}, [dispatch]);
|
|
49166
|
+
const commitClone = React.useCallback(async () => {
|
|
49167
|
+
const url = cloneUrlRef.current.trim();
|
|
49168
|
+
const target = expandHomePrefix(cloneTargetRef.current.trim().replace(/\/+$/, ''));
|
|
49169
|
+
if (!url) {
|
|
49170
|
+
dispatch({ type: 'set-status', status: 'Enter a remote URL.' });
|
|
49171
|
+
return;
|
|
49172
|
+
}
|
|
49173
|
+
if (!target) {
|
|
49174
|
+
dispatch({ type: 'set-status', status: 'Enter a destination path.' });
|
|
49175
|
+
return;
|
|
49176
|
+
}
|
|
49177
|
+
setCloning(true);
|
|
49178
|
+
dispatch({ type: 'set-status', status: `Cloning ${deriveRepoName(url)}…` });
|
|
49179
|
+
const result = await cloneRepo(url, target);
|
|
49180
|
+
if (unmountedRef.current)
|
|
49181
|
+
return;
|
|
49182
|
+
setCloning(false);
|
|
49183
|
+
if (!result.ok) {
|
|
49184
|
+
// Keep the modal open so the user can fix the URL / path and retry.
|
|
49185
|
+
dispatch({ type: 'set-status', status: result.message });
|
|
49186
|
+
return;
|
|
49187
|
+
}
|
|
49188
|
+
const updated = appendKnownRepo(target);
|
|
49189
|
+
dispatch({ type: 'replace-known-repos', paths: updated });
|
|
49190
|
+
dispatch({ type: 'set-focus', focus: 'list' });
|
|
49191
|
+
dispatch({ type: 'set-status', status: result.message });
|
|
49192
|
+
dispatch({ type: 'set-loading', loading: true });
|
|
49193
|
+
try {
|
|
49194
|
+
const merged = mergeKnownRepos(props.knownRepos, readKnownRepos());
|
|
49195
|
+
const overview = await props.loadOverview(props.roots, merged);
|
|
49196
|
+
writeCachedWorkspace(props.roots, overview);
|
|
49197
|
+
dispatch({ type: 'replace-overview', overview });
|
|
49198
|
+
dispatch({ type: 'anchor-cursor-by-path', path: target });
|
|
49199
|
+
}
|
|
49200
|
+
catch (err) {
|
|
49201
|
+
dispatch({ type: 'set-loading', loading: false });
|
|
49202
|
+
dispatch({
|
|
49203
|
+
type: 'set-status',
|
|
49204
|
+
status: err instanceof Error ? err.message : 'Refresh failed.',
|
|
49205
|
+
});
|
|
49206
|
+
}
|
|
49207
|
+
}, [dispatch, props]);
|
|
48298
49208
|
// Callback refs so the stable input handler can reach the latest
|
|
48299
49209
|
// closure without taking them in deps.
|
|
48300
49210
|
const commitAddRepoRef = React.useRef(commitAddRepo);
|
|
@@ -48307,6 +49217,10 @@ function WorkspaceInkApp(props) {
|
|
|
48307
49217
|
refreshRowRef.current = refreshRow;
|
|
48308
49218
|
const openAddRepoRef = React.useRef(openAddRepo);
|
|
48309
49219
|
openAddRepoRef.current = openAddRepo;
|
|
49220
|
+
const openCloneRef = React.useRef(openClone);
|
|
49221
|
+
openCloneRef.current = openClone;
|
|
49222
|
+
const commitCloneRef = React.useRef(commitClone);
|
|
49223
|
+
commitCloneRef.current = commitClone;
|
|
48310
49224
|
const requestDeleteRef = React.useRef(requestDelete);
|
|
48311
49225
|
requestDeleteRef.current = requestDelete;
|
|
48312
49226
|
const confirmDeleteRef = React.useRef(confirmDelete);
|
|
@@ -48389,6 +49303,73 @@ function WorkspaceInkApp(props) {
|
|
|
48389
49303
|
}
|
|
48390
49304
|
return;
|
|
48391
49305
|
}
|
|
49306
|
+
if (state.focus === 'clone-repo') {
|
|
49307
|
+
// While the clone is running, swallow everything except Esc
|
|
49308
|
+
// (which is a no-op here — the clone is already in flight).
|
|
49309
|
+
if (cloningRef.current)
|
|
49310
|
+
return;
|
|
49311
|
+
if (key.escape) {
|
|
49312
|
+
dispatch({ type: 'set-focus', focus: 'list' });
|
|
49313
|
+
return;
|
|
49314
|
+
}
|
|
49315
|
+
const field = cloneFieldRef.current;
|
|
49316
|
+
const url = cloneUrlRef.current;
|
|
49317
|
+
const target = cloneTargetRef.current;
|
|
49318
|
+
const targetEdited = cloneTargetEditedRef.current;
|
|
49319
|
+
if (key.return) {
|
|
49320
|
+
if (field === 'url') {
|
|
49321
|
+
if (!url.trim()) {
|
|
49322
|
+
dispatch({ type: 'set-status', status: 'Enter a remote URL.' });
|
|
49323
|
+
return;
|
|
49324
|
+
}
|
|
49325
|
+
// Advance to the (pre-filled, editable) destination field.
|
|
49326
|
+
const derived = targetEdited ? target : cloneTargetFor(url);
|
|
49327
|
+
setCloneTarget(derived);
|
|
49328
|
+
setCloneCompletion(completePath(derived));
|
|
49329
|
+
setCloneField('target');
|
|
49330
|
+
return;
|
|
49331
|
+
}
|
|
49332
|
+
void commitCloneRef.current();
|
|
49333
|
+
return;
|
|
49334
|
+
}
|
|
49335
|
+
if (key.tab && field === 'target') {
|
|
49336
|
+
const next = applyTabCompletion(target, cloneCompletionRef.current);
|
|
49337
|
+
setCloneTarget(next);
|
|
49338
|
+
setCloneTargetEdited(true);
|
|
49339
|
+
setCloneCompletion(completePath(next));
|
|
49340
|
+
return;
|
|
49341
|
+
}
|
|
49342
|
+
if (key.backspace || key.delete) {
|
|
49343
|
+
if (field === 'url') {
|
|
49344
|
+
const next = url.slice(0, -1);
|
|
49345
|
+
setCloneUrl(next);
|
|
49346
|
+
if (!targetEdited)
|
|
49347
|
+
setCloneTarget(next ? cloneTargetFor(next) : '');
|
|
49348
|
+
}
|
|
49349
|
+
else {
|
|
49350
|
+
const next = target.slice(0, -1);
|
|
49351
|
+
setCloneTarget(next);
|
|
49352
|
+
setCloneTargetEdited(true);
|
|
49353
|
+
setCloneCompletion(completePath(next || '~/'));
|
|
49354
|
+
}
|
|
49355
|
+
return;
|
|
49356
|
+
}
|
|
49357
|
+
if (rawInput && !key.ctrl && !key.meta) {
|
|
49358
|
+
if (field === 'url') {
|
|
49359
|
+
const next = url + rawInput;
|
|
49360
|
+
setCloneUrl(next);
|
|
49361
|
+
if (!targetEdited)
|
|
49362
|
+
setCloneTarget(cloneTargetFor(next));
|
|
49363
|
+
}
|
|
49364
|
+
else {
|
|
49365
|
+
const next = target + rawInput;
|
|
49366
|
+
setCloneTarget(next);
|
|
49367
|
+
setCloneTargetEdited(true);
|
|
49368
|
+
setCloneCompletion(completePath(next));
|
|
49369
|
+
}
|
|
49370
|
+
}
|
|
49371
|
+
return;
|
|
49372
|
+
}
|
|
48392
49373
|
// Ctrl+C → quit, since we disabled Ink's built-in ctrl+c exit.
|
|
48393
49374
|
// Handled here (rather than in the pure resolver) because the
|
|
48394
49375
|
// resolver doesn't have a notion of "raw key with ctrl flag" for
|
|
@@ -48429,6 +49410,9 @@ function WorkspaceInkApp(props) {
|
|
|
48429
49410
|
case 'add-repo':
|
|
48430
49411
|
openAddRepoRef.current();
|
|
48431
49412
|
break;
|
|
49413
|
+
case 'clone-repo':
|
|
49414
|
+
openCloneRef.current();
|
|
49415
|
+
break;
|
|
48432
49416
|
case 'request-delete':
|
|
48433
49417
|
requestDeleteRef.current();
|
|
48434
49418
|
break;
|
|
@@ -48478,6 +49462,11 @@ function WorkspaceInkApp(props) {
|
|
|
48478
49462
|
filterDraft,
|
|
48479
49463
|
addRepoDraft,
|
|
48480
49464
|
addRepoCompletion,
|
|
49465
|
+
cloneUrl,
|
|
49466
|
+
cloneTarget,
|
|
49467
|
+
cloneField,
|
|
49468
|
+
cloneCompletion,
|
|
49469
|
+
cloning,
|
|
48481
49470
|
columns,
|
|
48482
49471
|
rows,
|
|
48483
49472
|
spinnerTick,
|