git-coco 0.40.1 → 0.41.1
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 +569 -138
- package/dist/index.js +569 -138
- 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.41.1";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -14976,6 +14976,19 @@ function getLogInkWorkflowActions() {
|
|
|
14976
14976
|
kind: 'destructive',
|
|
14977
14977
|
requiresConfirmation: true,
|
|
14978
14978
|
},
|
|
14979
|
+
{
|
|
14980
|
+
// Per-view-only — the inkInput handler scopes this to the
|
|
14981
|
+
// worktrees surface so the global `D` keystroke (delete-branch)
|
|
14982
|
+
// keeps working from elsewhere. The empty `key` keeps the
|
|
14983
|
+
// workflow palette-discoverable but does not register a global
|
|
14984
|
+
// hotkey that would collide with delete-branch.
|
|
14985
|
+
id: 'remove-worktree-and-branch',
|
|
14986
|
+
key: '',
|
|
14987
|
+
label: 'Remove worktree + delete branch',
|
|
14988
|
+
description: 'Remove the selected worktree and delete the branch it was tracking after confirmation.',
|
|
14989
|
+
kind: 'destructive',
|
|
14990
|
+
requiresConfirmation: true,
|
|
14991
|
+
},
|
|
14979
14992
|
{
|
|
14980
14993
|
id: 'abort-operation',
|
|
14981
14994
|
key: 'A',
|
|
@@ -16338,6 +16351,11 @@ function replaceRows(state, rows) {
|
|
|
16338
16351
|
selectedFileIndex: 0,
|
|
16339
16352
|
pendingCommitFocused: false,
|
|
16340
16353
|
pendingKey: undefined,
|
|
16354
|
+
// Rows just landed — clear the boot-loading flag so the history
|
|
16355
|
+
// surface drops the "Loading commits…" placeholder. Safe to clear
|
|
16356
|
+
// unconditionally because `replaceRows` only fires after a real
|
|
16357
|
+
// git log returns.
|
|
16358
|
+
bootLoading: false,
|
|
16341
16359
|
};
|
|
16342
16360
|
}
|
|
16343
16361
|
function appendRows(state, rows) {
|
|
@@ -16428,6 +16446,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
16428
16446
|
diffViewMode: 'unified',
|
|
16429
16447
|
inspectorTab: 'inspector',
|
|
16430
16448
|
inspectorActionIndex: 0,
|
|
16449
|
+
bootLoading: options.bootLoading ?? false,
|
|
16431
16450
|
};
|
|
16432
16451
|
}
|
|
16433
16452
|
function getSelectedInkCommit(state) {
|
|
@@ -16629,6 +16648,12 @@ function applyLogInkAction(state, action) {
|
|
|
16629
16648
|
inspectorActionIndex: 0,
|
|
16630
16649
|
pendingKey: undefined,
|
|
16631
16650
|
};
|
|
16651
|
+
case 'setBootLoading':
|
|
16652
|
+
return {
|
|
16653
|
+
...state,
|
|
16654
|
+
bootLoading: action.value,
|
|
16655
|
+
pendingKey: undefined,
|
|
16656
|
+
};
|
|
16632
16657
|
case 'moveTag':
|
|
16633
16658
|
return {
|
|
16634
16659
|
...state,
|
|
@@ -18428,6 +18453,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18428
18453
|
if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
|
|
18429
18454
|
return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
|
|
18430
18455
|
}
|
|
18456
|
+
// Per-view worktree action: `D` removes the worktree AND deletes
|
|
18457
|
+
// the branch it was tracking (#838). Scoped to the worktrees
|
|
18458
|
+
// surface so it intercepts BEFORE the global workflow-by-key
|
|
18459
|
+
// dispatcher would otherwise route `D` to delete-branch (which
|
|
18460
|
+
// would silently target whatever was last cursored on the branches
|
|
18461
|
+
// surface instead of acting on the worktree under the cursor here).
|
|
18462
|
+
// `W` keeps its existing "remove worktree only" semantics.
|
|
18463
|
+
if (inputValue === 'D' && isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
18464
|
+
return [action({ type: 'setPendingConfirmation', value: 'remove-worktree-and-branch' })];
|
|
18465
|
+
}
|
|
18431
18466
|
// #783 — full PR action panel keys, scoped to the pull-request view.
|
|
18432
18467
|
// All five wrap a `gh pr <verb>` invocation; merge / request-changes /
|
|
18433
18468
|
// comment open prompts first, the rest route through the y-confirm
|
|
@@ -18705,7 +18740,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18705
18740
|
* fall back to "already seen" so we never block startup.
|
|
18706
18741
|
*/
|
|
18707
18742
|
const MARKER_BASENAME = 'onboarding.seen';
|
|
18708
|
-
function resolveCacheDir$
|
|
18743
|
+
function resolveCacheDir$3() {
|
|
18709
18744
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
18710
18745
|
if (xdg && xdg.trim().length > 0) {
|
|
18711
18746
|
return path__namespace$1.join(xdg, 'coco');
|
|
@@ -18713,7 +18748,7 @@ function resolveCacheDir$2() {
|
|
|
18713
18748
|
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
|
|
18714
18749
|
}
|
|
18715
18750
|
function getOnboardingMarkerPath() {
|
|
18716
|
-
return path__namespace$1.join(resolveCacheDir$
|
|
18751
|
+
return path__namespace$1.join(resolveCacheDir$3(), MARKER_BASENAME);
|
|
18717
18752
|
}
|
|
18718
18753
|
function hasSeenOnboarding() {
|
|
18719
18754
|
try {
|
|
@@ -18744,14 +18779,14 @@ function markOnboardingSeen() {
|
|
|
18744
18779
|
* settings: best-effort, XDG-friendly, no PII in the cache filename.
|
|
18745
18780
|
*/
|
|
18746
18781
|
const VALID_MODES = ['unified', 'split'];
|
|
18747
|
-
function resolveCacheDir$
|
|
18782
|
+
function resolveCacheDir$2() {
|
|
18748
18783
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
18749
18784
|
if (xdg && xdg.trim().length > 0) {
|
|
18750
18785
|
return path__namespace$1.join(xdg, 'coco');
|
|
18751
18786
|
}
|
|
18752
18787
|
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
|
|
18753
18788
|
}
|
|
18754
|
-
function repoKey$
|
|
18789
|
+
function repoKey$2(repoPath) {
|
|
18755
18790
|
// sha1 is used here as a non-security cache-key derivation — we just
|
|
18756
18791
|
// need a deterministic short identifier for the marker filename. No
|
|
18757
18792
|
// PII or auth context is hashed.
|
|
@@ -18759,7 +18794,7 @@ function repoKey$1(repoPath) {
|
|
|
18759
18794
|
return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
18760
18795
|
}
|
|
18761
18796
|
function getDiffViewModeMarkerPath(repoPath) {
|
|
18762
|
-
return path__namespace$1.join(resolveCacheDir$
|
|
18797
|
+
return path__namespace$1.join(resolveCacheDir$2(), `diff-view-mode.${repoKey$2(repoPath)}`);
|
|
18763
18798
|
}
|
|
18764
18799
|
function getSavedDiffViewMode(repoPath) {
|
|
18765
18800
|
try {
|
|
@@ -18801,14 +18836,14 @@ const VALID_TABS = [
|
|
|
18801
18836
|
'stashes',
|
|
18802
18837
|
'worktrees',
|
|
18803
18838
|
];
|
|
18804
|
-
function resolveCacheDir() {
|
|
18839
|
+
function resolveCacheDir$1() {
|
|
18805
18840
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
18806
18841
|
if (xdg && xdg.trim().length > 0) {
|
|
18807
18842
|
return path__namespace$1.join(xdg, 'coco');
|
|
18808
18843
|
}
|
|
18809
18844
|
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
|
|
18810
18845
|
}
|
|
18811
|
-
function repoKey(repoPath) {
|
|
18846
|
+
function repoKey$1(repoPath) {
|
|
18812
18847
|
// sha1 is used here as a non-security cache-key derivation — we just
|
|
18813
18848
|
// need a deterministic short identifier for the marker filename so
|
|
18814
18849
|
// re-creating a repo at the same path keeps the same preference.
|
|
@@ -18818,7 +18853,7 @@ function repoKey(repoPath) {
|
|
|
18818
18853
|
return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
18819
18854
|
}
|
|
18820
18855
|
function getSidebarTabMarkerPath(repoPath) {
|
|
18821
|
-
return path__namespace$1.join(resolveCacheDir(), `sidebar-tab.${repoKey(repoPath)}`);
|
|
18856
|
+
return path__namespace$1.join(resolveCacheDir$1(), `sidebar-tab.${repoKey$1(repoPath)}`);
|
|
18822
18857
|
}
|
|
18823
18858
|
function getSavedSidebarTab(repoPath) {
|
|
18824
18859
|
try {
|
|
@@ -19245,9 +19280,16 @@ function getLogInkLayout(input) {
|
|
|
19245
19280
|
// graph dominant; focus expansion gives the inspector room for long
|
|
19246
19281
|
// commit bodies / file lists / action labels. Mirrors the sidebar
|
|
19247
19282
|
// pattern (sidebarFocused above): instant transition per render.
|
|
19248
|
-
|
|
19249
|
-
|
|
19250
|
-
|
|
19283
|
+
//
|
|
19284
|
+
// Help overlay overrides both — it borrows ~50% of the terminal so
|
|
19285
|
+
// hotkey descriptions render in full instead of truncating to
|
|
19286
|
+
// "Move focus...". Capped at 100 cells so a wide terminal doesn't
|
|
19287
|
+
// waste an absurd amount of horizontal space on the cheat sheet.
|
|
19288
|
+
const detailWidth = input.helpOverlayActive
|
|
19289
|
+
? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
|
|
19290
|
+
: input.inspectorFocused
|
|
19291
|
+
? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
|
|
19292
|
+
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
19251
19293
|
// Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
|
|
19252
19294
|
// (~36% of width). The transition is instant per render — focus tab to
|
|
19253
19295
|
// expand, focus away to collapse.
|
|
@@ -21491,6 +21533,56 @@ function worktreePathAction(worktree) {
|
|
|
21491
21533
|
message: `Worktree path: ${worktree.path}`,
|
|
21492
21534
|
};
|
|
21493
21535
|
}
|
|
21536
|
+
/**
|
|
21537
|
+
* Remove a worktree AND delete the branch it was tracking (#838). The
|
|
21538
|
+
* canonical "I'm done with this side branch" wind-down: removes the
|
|
21539
|
+
* worktree directory, then runs `git branch -d` on the previously
|
|
21540
|
+
* checked-out branch.
|
|
21541
|
+
*
|
|
21542
|
+
* Both pre-flight guards inherit from the underlying helpers:
|
|
21543
|
+
* - removeWorktree refuses the current worktree and dirty worktrees
|
|
21544
|
+
* - deleteBranch refuses the current branch and uses `-d` (safe
|
|
21545
|
+
* delete, refuses unmerged commits)
|
|
21546
|
+
*
|
|
21547
|
+
* Aborts cleanly at any failure point and surfaces a message that
|
|
21548
|
+
* names which step broke. When the worktree had no branch (detached
|
|
21549
|
+
* HEAD) the branch step is silently skipped — there's nothing to
|
|
21550
|
+
* delete and the worktree removal alone counts as success.
|
|
21551
|
+
*/
|
|
21552
|
+
async function removeWorktreeAndBranch(git, worktree, branchRefs) {
|
|
21553
|
+
const removeResult = await removeWorktree(git, worktree);
|
|
21554
|
+
if (!removeResult.ok) {
|
|
21555
|
+
return removeResult;
|
|
21556
|
+
}
|
|
21557
|
+
const branchName = worktree.branch;
|
|
21558
|
+
if (!branchName) {
|
|
21559
|
+
return {
|
|
21560
|
+
ok: true,
|
|
21561
|
+
message: `Removed worktree ${worktree.path} (no branch to delete)`,
|
|
21562
|
+
};
|
|
21563
|
+
}
|
|
21564
|
+
// Look up the local BranchRef for the branch this worktree was on.
|
|
21565
|
+
// deleteBranch needs the full ref (not just the name) so its
|
|
21566
|
+
// current-branch and local-only guards apply correctly.
|
|
21567
|
+
const branch = branchRefs.find((entry) => entry.type === 'local' && entry.shortName === branchName);
|
|
21568
|
+
if (!branch) {
|
|
21569
|
+
return {
|
|
21570
|
+
ok: true,
|
|
21571
|
+
message: `Removed worktree ${worktree.path} (branch ${branchName} not found in local branches)`,
|
|
21572
|
+
};
|
|
21573
|
+
}
|
|
21574
|
+
const deleteResult = await deleteBranch(git, branch);
|
|
21575
|
+
if (!deleteResult.ok) {
|
|
21576
|
+
return {
|
|
21577
|
+
ok: false,
|
|
21578
|
+
message: `Removed worktree ${worktree.path}, but branch delete failed: ${deleteResult.message}`,
|
|
21579
|
+
};
|
|
21580
|
+
}
|
|
21581
|
+
return {
|
|
21582
|
+
ok: true,
|
|
21583
|
+
message: `Removed worktree ${worktree.path} and deleted branch ${branchName}`,
|
|
21584
|
+
};
|
|
21585
|
+
}
|
|
21494
21586
|
|
|
21495
21587
|
function shortBranch(branch) {
|
|
21496
21588
|
return branch?.replace(/^refs\/heads\//, '');
|
|
@@ -23739,15 +23831,20 @@ async function loadLogInkContext(git) {
|
|
|
23739
23831
|
};
|
|
23740
23832
|
}
|
|
23741
23833
|
function loadLogInkContextEntries(git) {
|
|
23834
|
+
// Boot-time per-key fetches. Each load() runs in parallel from
|
|
23835
|
+
// `LogInkApp`'s mount effect. `pullRequest` is intentionally
|
|
23836
|
+
// omitted (#808) — its `gh pr view --json` call duplicates the
|
|
23837
|
+
// slim PR fetch already happening inside `getProviderOverview`,
|
|
23838
|
+
// and the only consumer that needs the *full* enriched response is
|
|
23839
|
+
// the dedicated PR view (`g p`). Lazy-loaded by a separate effect
|
|
23840
|
+
// when the user actually navigates there. Header / yank / workflow
|
|
23841
|
+
// paths read the slim version off `provider.currentPullRequest` so
|
|
23842
|
+
// the chrome stays populated immediately on boot.
|
|
23742
23843
|
return [
|
|
23743
23844
|
{
|
|
23744
23845
|
key: 'branches',
|
|
23745
23846
|
load: () => safe(getBranchOverview(git)),
|
|
23746
23847
|
},
|
|
23747
|
-
{
|
|
23748
|
-
key: 'pullRequest',
|
|
23749
|
-
load: () => safe(getPullRequestOverview(git)),
|
|
23750
|
-
},
|
|
23751
23848
|
{
|
|
23752
23849
|
key: 'tags',
|
|
23753
23850
|
load: () => safe(getTagOverview(git)),
|
|
@@ -23960,8 +24057,15 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
|
23960
24057
|
const input = streams.input || process.stdin;
|
|
23961
24058
|
const output = streams.output || process.stdout;
|
|
23962
24059
|
const error = streams.error || process.stderr;
|
|
24060
|
+
// Non-TTY fallback (CI logs, piped output) needs the rows up-front
|
|
24061
|
+
// because the renderer just dumps a static snapshot. Run the
|
|
24062
|
+
// deferred loader synchronously here when present so callers get
|
|
24063
|
+
// the same shape regardless of the entry path.
|
|
23963
24064
|
if (!canStartLogInkTui(input, output)) {
|
|
23964
|
-
|
|
24065
|
+
const fallbackRows = options.loadRows && rows.length === 0
|
|
24066
|
+
? await options.loadRows()
|
|
24067
|
+
: rows;
|
|
24068
|
+
await startInteractiveLog(git, fallbackRows, {
|
|
23965
24069
|
appLabel: options.appLabel,
|
|
23966
24070
|
input,
|
|
23967
24071
|
output,
|
|
@@ -23980,6 +24084,7 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
|
23980
24084
|
ink,
|
|
23981
24085
|
initialView: options.initialView || 'history',
|
|
23982
24086
|
logArgv: options.logArgv,
|
|
24087
|
+
loadRows: options.loadRows,
|
|
23983
24088
|
React,
|
|
23984
24089
|
rows,
|
|
23985
24090
|
theme: createLogInkTheme(options.theme),
|
|
@@ -24076,7 +24181,7 @@ function enrichFilterActionWithRectification(action, state, context) {
|
|
|
24076
24181
|
}
|
|
24077
24182
|
}
|
|
24078
24183
|
function LogInkApp(deps) {
|
|
24079
|
-
const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, logArgv, React, resumeRef, rows, theme } = deps;
|
|
24184
|
+
const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
|
|
24080
24185
|
const { Box, Text, useApp, useInput, useWindowSize } = ink;
|
|
24081
24186
|
const h = React.createElement;
|
|
24082
24187
|
const { exit } = useApp();
|
|
@@ -24097,9 +24202,26 @@ function LogInkApp(deps) {
|
|
|
24097
24202
|
// user's cache dir so the tip never reappears once dismissed. Lazy
|
|
24098
24203
|
// initializer so the fs check only runs on mount, not every render.
|
|
24099
24204
|
const [showOnboarding, setShowOnboarding] = React.useState(() => !hasSeenOnboarding());
|
|
24100
|
-
const [state, setState] = React.useState(() => createLogInkState(rows, {
|
|
24205
|
+
const [state, setState] = React.useState(() => createLogInkState(rows, {
|
|
24206
|
+
activeView: initialView,
|
|
24207
|
+
// Boot loader is in flight whenever the caller passed
|
|
24208
|
+
// `loadRows`, regardless of whether `rows` was empty or
|
|
24209
|
+
// pre-populated from the disk cache (#808). The history
|
|
24210
|
+
// surface only shows the "Loading commits…" placeholder when
|
|
24211
|
+
// there are zero visible commits, so cached data renders
|
|
24212
|
+
// immediately while the chrome still flags the refresh.
|
|
24213
|
+
bootLoading: Boolean(loadRows),
|
|
24214
|
+
}));
|
|
24101
24215
|
const [context, setContext] = React.useState({});
|
|
24102
|
-
const [contextStatus, setContextStatus] = React.useState(() =>
|
|
24216
|
+
const [contextStatus, setContextStatus] = React.useState(() => {
|
|
24217
|
+
// Boot starts every fetched key in 'loading' so the surfaces show
|
|
24218
|
+
// their loading hints immediately. `pullRequest` is the exception
|
|
24219
|
+
// (#808) — it isn't part of the boot fetch entries; it lazy-loads
|
|
24220
|
+
// when the user enters the PR view. Marking it 'idle' avoids a
|
|
24221
|
+
// permanent "loading" flag in the chrome and lets the dedicated
|
|
24222
|
+
// PR view's own load effect drive its loading state.
|
|
24223
|
+
return updateLogInkContextStatus(createLogInkContextStatus('loading'), 'pullRequest', 'idle');
|
|
24224
|
+
});
|
|
24103
24225
|
const [detail, setDetail] = React.useState(undefined);
|
|
24104
24226
|
const [detailLoading, setDetailLoading] = React.useState(false);
|
|
24105
24227
|
const [filePreview, setFilePreview] = React.useState(undefined);
|
|
@@ -24170,9 +24292,78 @@ function LogInkApp(deps) {
|
|
|
24170
24292
|
const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
|
|
24171
24293
|
const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
|
|
24172
24294
|
const selectedWorktreeFile = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex];
|
|
24295
|
+
// Stash patch per-file segmentation (#808). Hoisted out of the
|
|
24296
|
+
// useInput callback (was running on every keystroke), the yank
|
|
24297
|
+
// handler (was running per `y` press), and renderDiffSurface (was
|
|
24298
|
+
// running per paint) into a single LogInkApp-scoped memo. When the
|
|
24299
|
+
// active stash diff has hundreds of files, the prior fan-out was
|
|
24300
|
+
// re-walking the entire patch text 2-3x per keystroke for no
|
|
24301
|
+
// observable reason — the parsed list is purely a function of the
|
|
24302
|
+
// line array, which only changes when the user opens a different
|
|
24303
|
+
// stash.
|
|
24304
|
+
const stashDiffParsedFiles = React.useMemo(() => stashDiffLines ? parseStashDiffFiles(stashDiffLines) : [], [stashDiffLines]);
|
|
24305
|
+
// Filtered promoted-view lists (#808). These were recomputed inline
|
|
24306
|
+
// inside useInput on every keystroke — for a repo with hundreds of
|
|
24307
|
+
// branches / tags and an active filter, that's hundreds of regex
|
|
24308
|
+
// matches per arrow-key press. Memoizing on (raw list, filter)
|
|
24309
|
+
// collapses the work to one pass per filter / data change.
|
|
24310
|
+
const filteredBranchList = React.useMemo(() => {
|
|
24311
|
+
const all = context.branches?.localBranches || [];
|
|
24312
|
+
if (!state.filter)
|
|
24313
|
+
return all;
|
|
24314
|
+
return all.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter));
|
|
24315
|
+
}, [context.branches?.localBranches, state.filter]);
|
|
24316
|
+
const filteredTagList = React.useMemo(() => {
|
|
24317
|
+
const all = context.tags?.tags || [];
|
|
24318
|
+
if (!state.filter)
|
|
24319
|
+
return all;
|
|
24320
|
+
return all.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter));
|
|
24321
|
+
}, [context.tags?.tags, state.filter]);
|
|
24322
|
+
const filteredStashList = React.useMemo(() => {
|
|
24323
|
+
const all = context.stashes?.stashes || [];
|
|
24324
|
+
if (!state.filter)
|
|
24325
|
+
return all;
|
|
24326
|
+
return all.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter));
|
|
24327
|
+
}, [context.stashes?.stashes, state.filter]);
|
|
24328
|
+
const filteredWorktreeList = React.useMemo(() => {
|
|
24329
|
+
const all = context.worktreeList?.worktrees || [];
|
|
24330
|
+
if (!state.filter)
|
|
24331
|
+
return all;
|
|
24332
|
+
return all.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || ''], state.filter));
|
|
24333
|
+
}, [context.worktreeList?.worktrees, state.filter]);
|
|
24173
24334
|
const dispatch = React.useCallback((action) => {
|
|
24174
24335
|
setState((current) => applyLogInkAction(current, action));
|
|
24175
24336
|
}, []);
|
|
24337
|
+
// Deferred commit-log loader (#808). Runs once on mount when the
|
|
24338
|
+
// caller opted into the lazy boot path. The Ink tree is already on
|
|
24339
|
+
// screen at this point — without this the user stares at a black
|
|
24340
|
+
// terminal during the synchronous git log pre-mount fetch. The
|
|
24341
|
+
// mounted-ref guard prevents a late-resolving promise from
|
|
24342
|
+
// dispatching after the user `q` quits before rows arrive.
|
|
24343
|
+
React.useEffect(() => {
|
|
24344
|
+
if (!loadRows)
|
|
24345
|
+
return;
|
|
24346
|
+
let cancelled = false;
|
|
24347
|
+
void loadRows()
|
|
24348
|
+
.then((nextRows) => {
|
|
24349
|
+
if (cancelled || !mountedRef.current)
|
|
24350
|
+
return;
|
|
24351
|
+
dispatch({ type: 'replaceRows', rows: nextRows });
|
|
24352
|
+
})
|
|
24353
|
+
.catch((error) => {
|
|
24354
|
+
if (cancelled || !mountedRef.current)
|
|
24355
|
+
return;
|
|
24356
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
24357
|
+
dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}` });
|
|
24358
|
+
dispatch({ type: 'setBootLoading', value: false });
|
|
24359
|
+
});
|
|
24360
|
+
return () => {
|
|
24361
|
+
cancelled = true;
|
|
24362
|
+
};
|
|
24363
|
+
// Intentionally one-shot — re-running the boot load on hot
|
|
24364
|
+
// dispatch / loader changes would refetch the entire log on every
|
|
24365
|
+
// re-render. The loader fires once per app mount and that's it.
|
|
24366
|
+
}, []);
|
|
24176
24367
|
// Auto-dismiss status messages after a short window so transient
|
|
24177
24368
|
// confirmations ("Pulled current branch", "Edited foo.ts") don't
|
|
24178
24369
|
// linger forever. Each new message resets the timer; clearing the
|
|
@@ -24394,6 +24585,34 @@ function LogInkApp(deps) {
|
|
|
24394
24585
|
active = false;
|
|
24395
24586
|
};
|
|
24396
24587
|
}, [git]);
|
|
24588
|
+
// Lazy-load the full pullRequest overview (#808). Only fires when
|
|
24589
|
+
// the user actually navigates to the PR view, and only when we
|
|
24590
|
+
// don't already have data (so a workflow-triggered refresh that
|
|
24591
|
+
// hydrated `pullRequest` doesn't re-fetch on view entry). The
|
|
24592
|
+
// dedicated PR view shows its own loading state while this is in
|
|
24593
|
+
// flight; everywhere else (header glyph, yank, workflow runner)
|
|
24594
|
+
// already falls through to the slim `provider.currentPullRequest`
|
|
24595
|
+
// so the chrome stays populated immediately on boot.
|
|
24596
|
+
React.useEffect(() => {
|
|
24597
|
+
if (state.activeView !== 'pull-request')
|
|
24598
|
+
return;
|
|
24599
|
+
if (context.pullRequest)
|
|
24600
|
+
return;
|
|
24601
|
+
let active = true;
|
|
24602
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'));
|
|
24603
|
+
void safe(getPullRequestOverview(git)).then((value) => {
|
|
24604
|
+
if (!active)
|
|
24605
|
+
return;
|
|
24606
|
+
setContext((current) => ({
|
|
24607
|
+
...current,
|
|
24608
|
+
pullRequest: value,
|
|
24609
|
+
}));
|
|
24610
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'));
|
|
24611
|
+
});
|
|
24612
|
+
return () => {
|
|
24613
|
+
active = false;
|
|
24614
|
+
};
|
|
24615
|
+
}, [git, state.activeView, context.pullRequest]);
|
|
24397
24616
|
React.useEffect(() => {
|
|
24398
24617
|
let active = true;
|
|
24399
24618
|
async function loadDetail() {
|
|
@@ -24415,12 +24634,27 @@ function LogInkApp(deps) {
|
|
|
24415
24634
|
}, [git, selected?.hash]);
|
|
24416
24635
|
// #806 follow-up — auto-jump the history view to whichever branch /
|
|
24417
24636
|
// tag the user is currently cursoring in the sidebar (or the
|
|
24418
|
-
// dedicated branches / tags view).
|
|
24419
|
-
//
|
|
24637
|
+
// dedicated branches / tags view).
|
|
24638
|
+
//
|
|
24639
|
+
// Originally this fired on a 150ms trailing-edge debounce. The user
|
|
24640
|
+
// reported the sync feeling inconsistent (#839) — the trailing
|
|
24641
|
+
// pattern means a fast scroll through a long branch list cancels
|
|
24642
|
+
// the timer on every keystroke and only fires once on release; the
|
|
24643
|
+
// user never sees the cursor follow their navigation. Switched to
|
|
24644
|
+
// synchronous fire-on-effect so each cursor move snaps the history
|
|
24645
|
+
// graph immediately. The dispatch is cheap (O(n) findIndex on the
|
|
24646
|
+
// filtered commits + a state spread); React batches the re-renders
|
|
24647
|
+
// so even rapid scroll only paints the final position. Tracks the
|
|
24648
|
+
// last-dispatched hash via a ref so we don't fire setStatus
|
|
24649
|
+
// repeatedly when several adjacent branches all point at the same
|
|
24650
|
+
// commit (very common with squash-merged feature branches that all
|
|
24651
|
+
// converge on `main`'s tip).
|
|
24652
|
+
//
|
|
24420
24653
|
// No-op when the cursored ref's tip isn't in the loaded commit
|
|
24421
24654
|
// window (under compact mode the cursored branch's tip may not be
|
|
24422
24655
|
// fetched yet); a status hint surfaces in that case so the user
|
|
24423
24656
|
// knows to toggle full graph or load older commits.
|
|
24657
|
+
const lastSyncedHashRef = React.useRef(undefined);
|
|
24424
24658
|
React.useEffect(() => {
|
|
24425
24659
|
const onBranchTab = state.activeView === 'branches' ||
|
|
24426
24660
|
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
@@ -24428,58 +24662,58 @@ function LogInkApp(deps) {
|
|
|
24428
24662
|
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
24429
24663
|
if (!onBranchTab && !onTagTab)
|
|
24430
24664
|
return;
|
|
24431
|
-
let
|
|
24432
|
-
|
|
24433
|
-
|
|
24434
|
-
|
|
24435
|
-
|
|
24436
|
-
|
|
24437
|
-
|
|
24438
|
-
|
|
24439
|
-
|
|
24440
|
-
|
|
24441
|
-
|
|
24442
|
-
const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
|
|
24443
|
-
if (branch) {
|
|
24444
|
-
targetHash = branch.hash;
|
|
24445
|
-
targetLabel = `branch ${branch.shortName}`;
|
|
24446
|
-
}
|
|
24447
|
-
}
|
|
24448
|
-
else if (onTagTab) {
|
|
24449
|
-
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24450
|
-
const visible = state.filter
|
|
24451
|
-
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
|
|
24452
|
-
: all;
|
|
24453
|
-
const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
|
|
24454
|
-
if (tag) {
|
|
24455
|
-
targetHash = tag.hash;
|
|
24456
|
-
targetLabel = `tag ${tag.name}`;
|
|
24457
|
-
}
|
|
24458
|
-
}
|
|
24459
|
-
if (!targetHash)
|
|
24460
|
-
return;
|
|
24461
|
-
const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
|
|
24462
|
-
if (loaded) {
|
|
24463
|
-
dispatch({ type: 'selectCommitByHash', hash: targetHash });
|
|
24464
|
-
// Confirmation status message so the user gets feedback even
|
|
24465
|
-
// when the dedicated branches / tags view is occupying the
|
|
24466
|
-
// main panel and the history cursor moves invisibly behind it.
|
|
24467
|
-
dispatch({
|
|
24468
|
-
type: 'setStatus',
|
|
24469
|
-
value: `Synced history to ${targetLabel} tip`,
|
|
24470
|
-
});
|
|
24665
|
+
let targetHash;
|
|
24666
|
+
let targetLabel;
|
|
24667
|
+
if (onBranchTab) {
|
|
24668
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
24669
|
+
const visible = state.filter
|
|
24670
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
|
|
24671
|
+
: all;
|
|
24672
|
+
const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
|
|
24673
|
+
if (branch) {
|
|
24674
|
+
targetHash = branch.hash;
|
|
24675
|
+
targetLabel = `branch ${branch.shortName}`;
|
|
24471
24676
|
}
|
|
24472
|
-
|
|
24473
|
-
|
|
24474
|
-
|
|
24475
|
-
|
|
24476
|
-
|
|
24677
|
+
}
|
|
24678
|
+
else if (onTagTab) {
|
|
24679
|
+
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24680
|
+
const visible = state.filter
|
|
24681
|
+
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
|
|
24682
|
+
: all;
|
|
24683
|
+
const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
|
|
24684
|
+
if (tag) {
|
|
24685
|
+
targetHash = tag.hash;
|
|
24686
|
+
targetLabel = `tag ${tag.name}`;
|
|
24477
24687
|
}
|
|
24478
|
-
}
|
|
24479
|
-
|
|
24480
|
-
|
|
24481
|
-
|
|
24482
|
-
|
|
24688
|
+
}
|
|
24689
|
+
if (!targetHash)
|
|
24690
|
+
return;
|
|
24691
|
+
// Skip the dispatch + status churn when the cursor hasn't
|
|
24692
|
+
// actually changed which commit it's targeting (the case for
|
|
24693
|
+
// rapid navigation through a cluster of branches that all point
|
|
24694
|
+
// at the same commit). Without this guard the user sees a stream
|
|
24695
|
+
// of "Synced history to <branch> tip" status messages even
|
|
24696
|
+
// though the history cursor never moved.
|
|
24697
|
+
if (targetHash === lastSyncedHashRef.current)
|
|
24698
|
+
return;
|
|
24699
|
+
const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
|
|
24700
|
+
if (loaded) {
|
|
24701
|
+
lastSyncedHashRef.current = targetHash;
|
|
24702
|
+
dispatch({ type: 'selectCommitByHash', hash: targetHash });
|
|
24703
|
+
// Confirmation status message so the user gets feedback even
|
|
24704
|
+
// when the dedicated branches / tags view is occupying the
|
|
24705
|
+
// main panel and the history cursor moves invisibly behind it.
|
|
24706
|
+
dispatch({
|
|
24707
|
+
type: 'setStatus',
|
|
24708
|
+
value: `Synced history to ${targetLabel} tip`,
|
|
24709
|
+
});
|
|
24710
|
+
}
|
|
24711
|
+
else {
|
|
24712
|
+
dispatch({
|
|
24713
|
+
type: 'setStatus',
|
|
24714
|
+
value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
|
|
24715
|
+
});
|
|
24716
|
+
}
|
|
24483
24717
|
}, [
|
|
24484
24718
|
dispatch, context.branches, context.tags,
|
|
24485
24719
|
state.activeView, state.focus, state.sidebarTab,
|
|
@@ -24487,6 +24721,18 @@ function LogInkApp(deps) {
|
|
|
24487
24721
|
state.branchSort, state.tagSort, state.filter,
|
|
24488
24722
|
state.filteredCommits,
|
|
24489
24723
|
]);
|
|
24724
|
+
// Reset the dedup ref when the user moves focus away from the
|
|
24725
|
+
// sidebar branches / tags tab so re-entering re-fires the sync
|
|
24726
|
+
// even if the cursored branch is the same as before.
|
|
24727
|
+
React.useEffect(() => {
|
|
24728
|
+
const onBranchTab = state.activeView === 'branches' ||
|
|
24729
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
24730
|
+
const onTagTab = state.activeView === 'tags' ||
|
|
24731
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
24732
|
+
if (!onBranchTab && !onTagTab) {
|
|
24733
|
+
lastSyncedHashRef.current = undefined;
|
|
24734
|
+
}
|
|
24735
|
+
}, [state.activeView, state.focus, state.sidebarTab]);
|
|
24490
24736
|
React.useEffect(() => {
|
|
24491
24737
|
let active = true;
|
|
24492
24738
|
async function loadWorktreeDiff() {
|
|
@@ -24926,6 +25172,29 @@ function LogInkApp(deps) {
|
|
|
24926
25172
|
}
|
|
24927
25173
|
return removeWorktree(git, cursorTarget);
|
|
24928
25174
|
},
|
|
25175
|
+
'remove-worktree-and-branch': async () => {
|
|
25176
|
+
const all = context.worktreeList?.worktrees || [];
|
|
25177
|
+
const visible = state.filter
|
|
25178
|
+
? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
|
|
25179
|
+
: all;
|
|
25180
|
+
const cursorTarget = visible.length
|
|
25181
|
+
? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
|
|
25182
|
+
: all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
|
|
25183
|
+
if (!cursorTarget)
|
|
25184
|
+
return { ok: false, message: 'No worktree selected' };
|
|
25185
|
+
if (cursorTarget.current) {
|
|
25186
|
+
return {
|
|
25187
|
+
ok: false,
|
|
25188
|
+
message: 'Cannot remove the current worktree — switch to another worktree first.',
|
|
25189
|
+
};
|
|
25190
|
+
}
|
|
25191
|
+
// The chained helper handles the worktree removal AND the
|
|
25192
|
+
// safe branch delete in one call. Branch refs come from the
|
|
25193
|
+
// live context so the underlying deleteBranch helper sees
|
|
25194
|
+
// the current/local flags it needs to refuse the destructive
|
|
25195
|
+
// path on the wrong target.
|
|
25196
|
+
return removeWorktreeAndBranch(git, cursorTarget, context.branches?.localBranches || []);
|
|
25197
|
+
},
|
|
24929
25198
|
'abort-operation': async () => {
|
|
24930
25199
|
const operation = context.operation?.operation;
|
|
24931
25200
|
if (!operation) {
|
|
@@ -25145,8 +25414,9 @@ function LogInkApp(deps) {
|
|
|
25145
25414
|
else if (state.diffSource === 'stash' && stashDiffLines) {
|
|
25146
25415
|
// Walk back to the most recent file header at or before the
|
|
25147
25416
|
// current preview offset — same logic the input-context block
|
|
25148
|
-
// uses to expose stashDiffSelectedPath.
|
|
25149
|
-
|
|
25417
|
+
// uses to expose stashDiffSelectedPath. Reads the memoized
|
|
25418
|
+
// parse so the yank handler doesn't re-walk the entire patch.
|
|
25419
|
+
const current = findStashFileForOffset(stashDiffParsedFiles, state.diffPreviewOffset);
|
|
25150
25420
|
if (current) {
|
|
25151
25421
|
value = current.path;
|
|
25152
25422
|
label = `path ${current.path}`;
|
|
@@ -25189,6 +25459,7 @@ function LogInkApp(deps) {
|
|
|
25189
25459
|
selected,
|
|
25190
25460
|
selectedDetailFile,
|
|
25191
25461
|
stashDiffLines,
|
|
25462
|
+
stashDiffParsedFiles,
|
|
25192
25463
|
state.activeView,
|
|
25193
25464
|
state.branchSort,
|
|
25194
25465
|
state.diffPreviewOffset,
|
|
@@ -25403,43 +25674,25 @@ function LogInkApp(deps) {
|
|
|
25403
25674
|
// P4.5: navigation in branches/tags/stash uses the FILTERED list
|
|
25404
25675
|
// length when a filter is active so j/k stay live instead of getting
|
|
25405
25676
|
// stuck against a full-list count that no longer matches what's on
|
|
25406
|
-
// screen.
|
|
25407
|
-
|
|
25408
|
-
|
|
25409
|
-
|
|
25410
|
-
|
|
25411
|
-
|
|
25412
|
-
const
|
|
25413
|
-
|
|
25414
|
-
.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
|
|
25415
|
-
.length
|
|
25416
|
-
: context.tags?.tags.length;
|
|
25417
|
-
const visibleStashes = state.filter
|
|
25418
|
-
? (context.stashes?.stashes || [])
|
|
25419
|
-
.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
|
|
25420
|
-
: (context.stashes?.stashes || []);
|
|
25421
|
-
const stashVisibleCount = visibleStashes.length;
|
|
25422
|
-
const stashSelectedRef = visibleStashes[Math.min(state.selectedStashIndex, Math.max(0, visibleStashes.length - 1))]?.ref;
|
|
25423
|
-
// The worktrees promoted view is filterable; mirror the branches /
|
|
25424
|
-
// tags / stash pattern and feed the filtered count into the input
|
|
25425
|
-
// dispatcher so ↑/↓ stay synchronized with the visible rows.
|
|
25426
|
-
const worktreeVisibleCount = state.filter
|
|
25427
|
-
? (context.worktreeList?.worktrees || [])
|
|
25428
|
-
.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
|
|
25429
|
-
.length
|
|
25430
|
-
: context.worktreeList?.worktrees.length;
|
|
25677
|
+
// screen. The filtered lists are memoized at LogInkApp scope (#808
|
|
25678
|
+
// perf pass) — reading them here is O(1) instead of O(branches +
|
|
25679
|
+
// tags + stashes + worktrees) per keystroke.
|
|
25680
|
+
const branchVisibleCount = filteredBranchList.length;
|
|
25681
|
+
const tagVisibleCount = filteredTagList.length;
|
|
25682
|
+
const stashVisibleCount = filteredStashList.length;
|
|
25683
|
+
const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
|
|
25684
|
+
const worktreeVisibleCount = filteredWorktreeList.length;
|
|
25431
25685
|
// When the diff view is showing a stash patch, swap the previewLineCount
|
|
25432
25686
|
// to the stash diff length so the existing pageDetailPreview path
|
|
25433
25687
|
// (j/k, PgUp/PgDn) scrolls through it without a parallel pipeline.
|
|
25434
25688
|
const diffPreviewLineCount = state.diffSource === 'stash'
|
|
25435
25689
|
? stashDiffLines?.length
|
|
25436
25690
|
: filePreview?.hunks.length;
|
|
25437
|
-
//
|
|
25438
|
-
//
|
|
25439
|
-
//
|
|
25440
|
-
|
|
25441
|
-
|
|
25442
|
-
: [];
|
|
25691
|
+
// Per-file segmentation for stash diffs reads the LogInkApp-scoped
|
|
25692
|
+
// memo so navigation keys + the input-context derivation share a
|
|
25693
|
+
// single parse pass per stash patch instead of re-walking the
|
|
25694
|
+
// entire patch text on every keystroke.
|
|
25695
|
+
const stashDiffFiles = state.diffSource === 'stash' ? stashDiffParsedFiles : [];
|
|
25443
25696
|
const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
|
|
25444
25697
|
const stashDiffSelectedPath = state.diffSource === 'stash'
|
|
25445
25698
|
? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
|
|
@@ -25536,6 +25789,7 @@ function LogInkApp(deps) {
|
|
|
25536
25789
|
rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
|
|
25537
25790
|
sidebarFocused: state.focus === 'sidebar',
|
|
25538
25791
|
inspectorFocused: state.focus === 'detail',
|
|
25792
|
+
helpOverlayActive: state.showHelp,
|
|
25539
25793
|
});
|
|
25540
25794
|
if (layout.tooSmall) {
|
|
25541
25795
|
return h(Box, {
|
|
@@ -25565,7 +25819,13 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
|
|
|
25565
25819
|
? `PR #${prInfo.number} ${prInfo.isDraft ? 'DRAFT' : prInfo.state}`
|
|
25566
25820
|
: 'no PR';
|
|
25567
25821
|
const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
|
|
25568
|
-
|
|
25822
|
+
// Boot loading wins over the per-context loading hint because it
|
|
25823
|
+
// tells the user the headline thing they care about (commits aren't
|
|
25824
|
+
// ready yet) — the context fetches finish independently and surface
|
|
25825
|
+
// their own per-section loading copy in the sidebars.
|
|
25826
|
+
const loading = state.bootLoading
|
|
25827
|
+
? ' loading commits'
|
|
25828
|
+
: isLogInkContextLoading(contextStatus) ? ' loading context' : '';
|
|
25569
25829
|
const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
|
|
25570
25830
|
const view = breadcrumb ? ` ${breadcrumb}` : '';
|
|
25571
25831
|
// Mode indicator (P2.2) — surfaces the current input mode so users
|
|
@@ -25872,22 +26132,30 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
25872
26132
|
...(state.historyFetchArgs
|
|
25873
26133
|
? [h(Text, { key: 'history-fetch-indicator', dimColor: true }, `filter: ${formatHistoryFetchArgs(state.historyFetchArgs)} (ctrl+u in / to clear)`)]
|
|
25874
26134
|
: []), ...(pendingNode ? [pendingNode] : []), visible.items.length === 0
|
|
25875
|
-
? h(Text, { dimColor: true },
|
|
25876
|
-
|
|
25877
|
-
|
|
25878
|
-
|
|
26135
|
+
? h(Text, { dimColor: true }, state.bootLoading
|
|
26136
|
+
? formatLogInkLoading({ resource: 'commits' })
|
|
26137
|
+
: formatLogInkHistoryEmpty({
|
|
26138
|
+
filter: state.filter,
|
|
26139
|
+
totalCommits: state.commits.length,
|
|
26140
|
+
}))
|
|
25879
26141
|
: visible.items.map((item, index) => {
|
|
25880
26142
|
if (item.type === 'graph') {
|
|
26143
|
+
// Graph-only rows are git's lane-closure scaffolding (`|/`,
|
|
26144
|
+
// `|\`, etc.) — they're real topology but visually they look
|
|
26145
|
+
// like blank rows that the user might wonder if they
|
|
26146
|
+
// accidentally skipped a commit on (#831). Render dim-on-dim
|
|
26147
|
+
// so they retreat as connectors rather than competing with
|
|
26148
|
+
// commit rows for the eye's attention.
|
|
25881
26149
|
if (item.laneSegments && !theme.ascii) {
|
|
25882
|
-
return h(Text, { key: `graph-${index}-${item.graph}
|
|
26150
|
+
return h(Text, { key: `graph-${index}-${item.graph}`, dimColor: true }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`, { forceDim: true }));
|
|
25883
26151
|
}
|
|
25884
26152
|
return h(Text, {
|
|
25885
26153
|
key: `graph-${index}-${item.graph}`,
|
|
25886
26154
|
color: theme.noColor ? undefined : theme.colors.muted,
|
|
25887
|
-
dimColor:
|
|
25888
|
-
}, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }),
|
|
26155
|
+
dimColor: true,
|
|
26156
|
+
}, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), Math.max(8, width - 4)));
|
|
25889
26157
|
}
|
|
25890
|
-
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, item.laneSegments);
|
|
26158
|
+
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, item.laneSegments);
|
|
25891
26159
|
}));
|
|
25892
26160
|
}
|
|
25893
26161
|
/**
|
|
@@ -25900,7 +26168,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
25900
26168
|
* Final padding is appended as its own span so callers do not need to
|
|
25901
26169
|
* pre-pad the graph string before computing lane segments.
|
|
25902
26170
|
*/
|
|
25903
|
-
function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
26171
|
+
function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, options = {}) {
|
|
25904
26172
|
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25905
26173
|
const elements = [];
|
|
25906
26174
|
let totalLen = 0;
|
|
@@ -25909,7 +26177,12 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
|
25909
26177
|
elements.push(h(Text, {
|
|
25910
26178
|
key: `${keyPrefix}-${idx}`,
|
|
25911
26179
|
color: laneColor ?? muted,
|
|
25912
|
-
dimColor
|
|
26180
|
+
// Ink does not cascade dimColor from a parent Text to children,
|
|
26181
|
+
// so the caller's "this whole row should fade" intent has to
|
|
26182
|
+
// travel here as an explicit flag (#831). Used for graph-only
|
|
26183
|
+
// lane-closure rows, where the lane colors otherwise compete
|
|
26184
|
+
// for attention with the commits they connect.
|
|
26185
|
+
dimColor: options.forceDim || (theme.noColor && seg.laneId === undefined),
|
|
25913
26186
|
}, seg.text));
|
|
25914
26187
|
totalLen += seg.text.length;
|
|
25915
26188
|
});
|
|
@@ -25930,11 +26203,22 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
|
25930
26203
|
* Truncation is per-segment so the variable-length message field gets
|
|
25931
26204
|
* the leftover budget after fixed segments are accounted for.
|
|
25932
26205
|
*/
|
|
25933
|
-
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
|
|
26206
|
+
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, laneSegments) {
|
|
25934
26207
|
const refs = formatInkRefLabels(commit.refs);
|
|
25935
|
-
|
|
26208
|
+
// Total cells available to the row content. Earlier revisions used a
|
|
26209
|
+
// hardcoded 140 here, which let row content overflow whenever the
|
|
26210
|
+
// panel was narrower than that — Ink would wrap onto a second visual
|
|
26211
|
+
// line and the next commit's graph indicator landed against the wrap
|
|
26212
|
+
// continuation rather than its own commit (#830). Subtracting 4
|
|
26213
|
+
// accounts for the panel's left + right border + 1-cell padding.
|
|
26214
|
+
const totalWidth = Math.max(20, panelWidth - 4);
|
|
25936
26215
|
const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
|
|
25937
|
-
|
|
26216
|
+
// Refs trail the message and shrink first when the row is narrow:
|
|
26217
|
+
// the user can always see the full ref list in the inspector, so
|
|
26218
|
+
// the headline subject keeps priority over decoration.
|
|
26219
|
+
const refsRoom = Math.max(0, totalWidth - fixedWidth - 8);
|
|
26220
|
+
const refsTrunc = refs ? truncate$1(refs, refsRoom) : '';
|
|
26221
|
+
const messageRoom = Math.max(8, totalWidth - fixedWidth - cellWidth(refsTrunc));
|
|
25938
26222
|
const message = truncate$1(commit.message, messageRoom);
|
|
25939
26223
|
const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
|
|
25940
26224
|
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
@@ -25949,7 +26233,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
|
|
|
25949
26233
|
key: `${commit.hash}-${index}`,
|
|
25950
26234
|
backgroundColor: selectedBg,
|
|
25951
26235
|
inverse: selected,
|
|
25952
|
-
}, ...graphChildren, ' ', h(Text, { color: accent, bold: selected }, commit.shortHash), ' ', h(Text, { dimColor: true }, commit.date), ' ', h(Text, undefined, message),
|
|
26236
|
+
}, ...graphChildren, ' ', h(Text, { color: accent, bold: selected }, commit.shortHash), ' ', h(Text, { dimColor: true }, commit.date), ' ', h(Text, undefined, message), refsTrunc ? h(Text, { color: accent }, refsTrunc) : null);
|
|
25953
26237
|
}
|
|
25954
26238
|
/**
|
|
25955
26239
|
* Render the synthetic "(+) new commit" affordance shown above the real
|
|
@@ -26223,6 +26507,15 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
26223
26507
|
: `${localBranches.length}/${sortedAll.length} local | current: ${branches?.currentBranch || '<detached>'}${filterLabel}${sortLabel}`;
|
|
26224
26508
|
const emptyLabel = formatLogInkBranchesEmpty({ filter: state.filter });
|
|
26225
26509
|
const loadingLabel = formatLogInkLoading({ resource: 'branches' });
|
|
26510
|
+
// Per-column width derived from the visible window (#833) so columns
|
|
26511
|
+
// align across rows regardless of name length. Padded to the longest
|
|
26512
|
+
// name in view so short rows fill out instead of leaving a gutter;
|
|
26513
|
+
// capped at 40 cells so one runaway long branch name doesn't blow
|
|
26514
|
+
// out the timestamp column entirely (longer names get truncated and
|
|
26515
|
+
// the timestamp stays where the user expects it).
|
|
26516
|
+
const nameColWidth = visible.length === 0
|
|
26517
|
+
? 28
|
|
26518
|
+
: Math.min(40, Math.max(8, ...visible.map((branch) => branch.shortName.length)));
|
|
26226
26519
|
const lines = loading
|
|
26227
26520
|
? [h(Text, { key: 'branches-loading', dimColor: true }, loadingLabel)]
|
|
26228
26521
|
: localBranches.length === 0
|
|
@@ -26236,18 +26529,18 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
26236
26529
|
const lastTouched = formatBranchLastTouched(branch.date, new Date());
|
|
26237
26530
|
// Split the row into spans so the timestamp stays dim even on the
|
|
26238
26531
|
// currently-selected (bold) row. The leading marker + name keep
|
|
26239
|
-
// their
|
|
26240
|
-
// the divergence column stays aligned across rows.
|
|
26241
|
-
const namePadded = branch.shortName.padEnd(
|
|
26532
|
+
// their per-window-derived column widths; the timestamp is
|
|
26533
|
+
// right-padded so the divergence column stays aligned across rows.
|
|
26534
|
+
const namePadded = truncate$1(branch.shortName, nameColWidth).padEnd(nameColWidth);
|
|
26242
26535
|
const timestampPadded = lastTouched.padEnd(8);
|
|
26243
26536
|
const lineDim = !isSelected && !branch.current;
|
|
26244
26537
|
const head = `${cursor} ${marker} ${namePadded} `;
|
|
26245
26538
|
const trailingDivergence = divergence ? ` ${divergence}` : '';
|
|
26246
|
-
// Truncate the assembled line
|
|
26247
|
-
//
|
|
26248
|
-
//
|
|
26539
|
+
// Truncate the assembled line to the actual panel width so a
|
|
26540
|
+
// narrow inspector / sidebar focus doesn't push branch rows
|
|
26541
|
+
// onto a second visual line (#830).
|
|
26249
26542
|
const fullText = `${head}${timestampPadded}${trailingDivergence}`;
|
|
26250
|
-
const truncated = truncate$1(fullText,
|
|
26543
|
+
const truncated = truncate$1(fullText, Math.max(20, width - 4));
|
|
26251
26544
|
// If truncation chopped into the timestamp/divergence portion,
|
|
26252
26545
|
// fall back to a single Text to keep the visible width honest.
|
|
26253
26546
|
if (truncated !== fullText) {
|
|
@@ -26291,6 +26584,13 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
26291
26584
|
: `${tags.length}/${sortedAll.length} tags${filterLabel}${sortLabel}`;
|
|
26292
26585
|
const emptyLabel = formatLogInkTagsEmpty({ filter: state.filter });
|
|
26293
26586
|
const loadingLabel = formatLogInkLoading({ resource: 'tags' });
|
|
26587
|
+
// Per-window name column width (#833) so short tags don't leave a
|
|
26588
|
+
// wide gutter and long tags don't push the subject off-screen. Cap
|
|
26589
|
+
// matches the branches surface for visual consistency across the
|
|
26590
|
+
// promoted views.
|
|
26591
|
+
const tagNameColWidth = visible.length === 0
|
|
26592
|
+
? 20
|
|
26593
|
+
: Math.min(40, Math.max(8, ...visible.map((tag) => tag.name.length)));
|
|
26294
26594
|
const lines = loading
|
|
26295
26595
|
? [h(Text, { key: 'tags-loading', dimColor: true }, loadingLabel)]
|
|
26296
26596
|
: tags.length === 0
|
|
@@ -26304,8 +26604,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
26304
26604
|
// formatHyperlink wraps just the tag name, leaving width math
|
|
26305
26605
|
// intact.
|
|
26306
26606
|
const url = buildRefUrl(context.provider?.repository, tag.name);
|
|
26307
|
-
const namePadded = tag.name.padEnd(
|
|
26308
|
-
const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`,
|
|
26607
|
+
const namePadded = truncate$1(tag.name, tagNameColWidth).padEnd(tagNameColWidth);
|
|
26608
|
+
const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`, Math.max(20, width - 4));
|
|
26309
26609
|
if (!url || lineText.indexOf(namePadded) < 0) {
|
|
26310
26610
|
return h(Text, {
|
|
26311
26611
|
key: `tag-${index}`,
|
|
@@ -26388,6 +26688,17 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
26388
26688
|
const headerRight = loading
|
|
26389
26689
|
? 'loading worktrees'
|
|
26390
26690
|
: `${worktrees.length}/${allWorktrees.length} worktrees${filterLabel}`;
|
|
26691
|
+
// Per-window branch column width (#833). Worktrees often track
|
|
26692
|
+
// branches with names varying widely in length (`main` vs.
|
|
26693
|
+
// `feat/tui-something-long`); fixed-width padding either left a
|
|
26694
|
+
// huge gutter on short rows or pushed the path column off-screen on
|
|
26695
|
+
// long ones. Cap matches the other promoted surfaces.
|
|
26696
|
+
const branchColWidth = visible.length === 0
|
|
26697
|
+
? 28
|
|
26698
|
+
: Math.min(40, Math.max(8, ...visible.map((entry) => {
|
|
26699
|
+
const label = entry.branch ? entry.branch : entry.head || '<detached>';
|
|
26700
|
+
return label.length;
|
|
26701
|
+
})));
|
|
26391
26702
|
const lines = loading
|
|
26392
26703
|
? [h(Text, { key: 'worktrees-loading', dimColor: true }, formatLogInkLoading({ resource: 'worktrees' }))]
|
|
26393
26704
|
: worktrees.length === 0
|
|
@@ -26399,11 +26710,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
26399
26710
|
const marker = entry.current ? '*' : ' ';
|
|
26400
26711
|
const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
|
|
26401
26712
|
const stateLabel = entry.dirty ? 'dirty' : 'clean';
|
|
26713
|
+
const branchPadded = truncate$1(branchLabel, branchColWidth).padEnd(branchColWidth);
|
|
26402
26714
|
return h(Text, {
|
|
26403
26715
|
key: `worktree-${index}`,
|
|
26404
26716
|
bold: isSelected,
|
|
26405
26717
|
dimColor: !isSelected && !entry.current,
|
|
26406
|
-
}, truncate$1(`${cursor} ${marker} ${
|
|
26718
|
+
}, truncate$1(`${cursor} ${marker} ${branchPadded} ${stateLabel.padEnd(6)} ${entry.path}`, width - 4));
|
|
26407
26719
|
});
|
|
26408
26720
|
return h(Box, {
|
|
26409
26721
|
borderColor: focusBorderColor(theme, focused),
|
|
@@ -27061,7 +27373,12 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
27061
27373
|
}, truncate$1(theme.ascii ? '[...] AI draft in progress' : '⏳ AI draft in progress', width - 4))]
|
|
27062
27374
|
: []), ...(stagedFiles.length
|
|
27063
27375
|
? [
|
|
27064
|
-
|
|
27376
|
+
// Section header carries the total count to match the status
|
|
27377
|
+
// surface's "▾ Staged (n)" treatment (#840). The visible
|
|
27378
|
+
// file list is sliced at 12 rows; using `worktree.stagedCount`
|
|
27379
|
+
// (the total) avoids a misleading "Staged (12)" label when
|
|
27380
|
+
// there are actually more staged files below the slice.
|
|
27381
|
+
h(Text, { key: 'compose-context-staged-title', bold: true }, `Staged (${worktree?.stagedCount ?? stagedFiles.length})`),
|
|
27065
27382
|
...stagedFiles.map((file, index) => h(Text, {
|
|
27066
27383
|
key: `compose-context-staged-${index}`,
|
|
27067
27384
|
color: theme.noColor ? undefined : theme.colors.gitAdded,
|
|
@@ -27070,7 +27387,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
27070
27387
|
]
|
|
27071
27388
|
: []), ...(unstagedFiles.length
|
|
27072
27389
|
? [
|
|
27073
|
-
h(Text, { key: 'compose-context-unstaged-title', bold: true },
|
|
27390
|
+
h(Text, { key: 'compose-context-unstaged-title', bold: true }, `Unstaged (${worktree?.unstagedCount ?? unstagedFiles.length})`),
|
|
27074
27391
|
...unstagedFiles.map((file, index) => h(Text, {
|
|
27075
27392
|
key: `compose-context-unstaged-${index}`,
|
|
27076
27393
|
color: theme.noColor ? undefined : theme.colors.gitModified,
|
|
@@ -27483,6 +27800,84 @@ function renderFooter(h, components, state, context, theme, idleTip) {
|
|
|
27483
27800
|
}, h(Text, { color: theme.colors.muted, dimColor: true }, contextualText), h(Text, { color: theme.colors.muted, dimColor: true }, globalText));
|
|
27484
27801
|
}
|
|
27485
27802
|
|
|
27803
|
+
/**
|
|
27804
|
+
* Per-repo disk cache of the last successful commit-log fetch (#808).
|
|
27805
|
+
* Lets the TUI render an immediate stale-but-useful history view on
|
|
27806
|
+
* subsequent boots while the fresh `git log` runs in the background;
|
|
27807
|
+
* once the fresh data lands the runtime swaps it in transparently.
|
|
27808
|
+
*
|
|
27809
|
+
* Strict best-effort: read failures fall back to "no cache" (boot
|
|
27810
|
+
* shows the loading placeholder), and write failures are swallowed
|
|
27811
|
+
* silently (next boot just doesn't have the cache yet). The cache is
|
|
27812
|
+
* never load-bearing.
|
|
27813
|
+
*
|
|
27814
|
+
* Repos are keyed by a short hash of their absolute path. No PII in
|
|
27815
|
+
* the cache filename, and re-creating a repo at the same path keeps
|
|
27816
|
+
* the same cache.
|
|
27817
|
+
*/
|
|
27818
|
+
const CACHE_SCHEMA_VERSION = 1;
|
|
27819
|
+
const CACHE_DIR_NAME = 'overview';
|
|
27820
|
+
/**
|
|
27821
|
+
* Hard cap on rows we'll write per cache entry. The interactive
|
|
27822
|
+
* default limit is 300; this caps growth in case a user opts into a
|
|
27823
|
+
* much larger window. Keeps the cache file under ~200kb on a typical
|
|
27824
|
+
* repo.
|
|
27825
|
+
*/
|
|
27826
|
+
const CACHE_ROW_HARD_CAP = 500;
|
|
27827
|
+
function resolveCacheDir() {
|
|
27828
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
27829
|
+
if (xdg && xdg.trim().length > 0) {
|
|
27830
|
+
return path__namespace$1.join(xdg, 'coco', CACHE_DIR_NAME);
|
|
27831
|
+
}
|
|
27832
|
+
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME);
|
|
27833
|
+
}
|
|
27834
|
+
function repoKey(repoPath) {
|
|
27835
|
+
// sha1 here is a non-security cache-key derivation — we just need a
|
|
27836
|
+
// deterministic short identifier for the cache filename so two repos
|
|
27837
|
+
// at different paths never collide. No PII or auth context is hashed
|
|
27838
|
+
// and no collision-resistance against an adversary is required.
|
|
27839
|
+
// DevSkim DS126858 doesn't apply.
|
|
27840
|
+
// DevSkim: ignore DS126858
|
|
27841
|
+
return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
27842
|
+
}
|
|
27843
|
+
function getOverviewCachePath(repoPath) {
|
|
27844
|
+
return path__namespace$1.join(resolveCacheDir(), `commits.${repoKey(repoPath)}.json`);
|
|
27845
|
+
}
|
|
27846
|
+
function readCachedCommits(repoPath) {
|
|
27847
|
+
try {
|
|
27848
|
+
const raw = fs__namespace$1.readFileSync(getOverviewCachePath(repoPath), 'utf8');
|
|
27849
|
+
const parsed = JSON.parse(raw);
|
|
27850
|
+
if (parsed.version !== CACHE_SCHEMA_VERSION) {
|
|
27851
|
+
// Schema mismatch — quietly drop the stale entry on next write.
|
|
27852
|
+
// Treating it as "no cache" keeps boot behavior predictable
|
|
27853
|
+
// across upgrades.
|
|
27854
|
+
return undefined;
|
|
27855
|
+
}
|
|
27856
|
+
if (!Array.isArray(parsed.rows)) {
|
|
27857
|
+
return undefined;
|
|
27858
|
+
}
|
|
27859
|
+
return parsed.rows;
|
|
27860
|
+
}
|
|
27861
|
+
catch {
|
|
27862
|
+
return undefined;
|
|
27863
|
+
}
|
|
27864
|
+
}
|
|
27865
|
+
function writeCachedCommits(repoPath, rows) {
|
|
27866
|
+
const file = getOverviewCachePath(repoPath);
|
|
27867
|
+
const envelope = {
|
|
27868
|
+
version: CACHE_SCHEMA_VERSION,
|
|
27869
|
+
savedAt: new Date().toISOString(),
|
|
27870
|
+
rows: rows.slice(0, CACHE_ROW_HARD_CAP),
|
|
27871
|
+
};
|
|
27872
|
+
try {
|
|
27873
|
+
fs__namespace$1.mkdirSync(path__namespace$1.dirname(file), { recursive: true });
|
|
27874
|
+
fs__namespace$1.writeFileSync(file, JSON.stringify(envelope));
|
|
27875
|
+
}
|
|
27876
|
+
catch {
|
|
27877
|
+
// Best-effort persistence; swallow.
|
|
27878
|
+
}
|
|
27879
|
+
}
|
|
27880
|
+
|
|
27486
27881
|
function createLogArgvFromUiArgv(argv) {
|
|
27487
27882
|
return {
|
|
27488
27883
|
$0: argv.$0,
|
|
@@ -27507,14 +27902,43 @@ function createUiTheme(config, argv) {
|
|
|
27507
27902
|
preset: argv.theme,
|
|
27508
27903
|
};
|
|
27509
27904
|
}
|
|
27905
|
+
/**
|
|
27906
|
+
* Wrap a fresh-rows loader with the disk-cache write step. Lets the
|
|
27907
|
+
* runtime stay caching-agnostic — it just receives the rows and
|
|
27908
|
+
* doesn't know whether they came from cache or git, while the caller
|
|
27909
|
+
* (which knows the repo path) handles persistence.
|
|
27910
|
+
*/
|
|
27911
|
+
function withCacheWrite(repoPath, loader) {
|
|
27912
|
+
return async () => {
|
|
27913
|
+
const rows = await loader();
|
|
27914
|
+
writeCachedCommits(repoPath, rows);
|
|
27915
|
+
return rows;
|
|
27916
|
+
};
|
|
27917
|
+
}
|
|
27510
27918
|
async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
27511
27919
|
const config = options.config || loadConfig(logArgv);
|
|
27512
27920
|
const git = options.git || getRepo();
|
|
27513
|
-
const
|
|
27514
|
-
|
|
27921
|
+
const repoPath = process.cwd();
|
|
27922
|
+
// Three-stage boot (#808):
|
|
27923
|
+
// 1. Read the disk cache and pass cached rows as the initial set
|
|
27924
|
+
// so the user sees the workstation chrome populated with
|
|
27925
|
+
// commits in the first frame.
|
|
27926
|
+
// 2. Mount Ink immediately with those rows (or [] if no cache).
|
|
27927
|
+
// 3. Run loadRows in the background to refresh — when fresh data
|
|
27928
|
+
// lands the runtime swaps it in transparently and we persist
|
|
27929
|
+
// the new rows back to the cache for next boot.
|
|
27930
|
+
// Caller-provided rows skip the lazy path entirely (caller already
|
|
27931
|
+
// has up-to-date data — no point redoing the fetch).
|
|
27932
|
+
const cachedRows = options.rows ? undefined : readCachedCommits(repoPath);
|
|
27933
|
+
const initialRows = options.rows || cachedRows || [];
|
|
27934
|
+
const loadRows = options.rows
|
|
27935
|
+
? undefined
|
|
27936
|
+
: withCacheWrite(repoPath, () => getLogRows(git, logArgv));
|
|
27937
|
+
await startInkInteractiveLog(git, initialRows, {}, {
|
|
27515
27938
|
appLabel: 'coco',
|
|
27516
27939
|
idleTips: config.logTui?.idleTips,
|
|
27517
27940
|
initialView: 'history',
|
|
27941
|
+
loadRows,
|
|
27518
27942
|
logArgv,
|
|
27519
27943
|
theme: config.logTui?.theme,
|
|
27520
27944
|
});
|
|
@@ -27523,11 +27947,15 @@ async function startCocoUi(argv) {
|
|
|
27523
27947
|
const config = loadConfig(argv);
|
|
27524
27948
|
const git = getRepo();
|
|
27525
27949
|
const logArgv = createLogArgvFromUiArgv(argv);
|
|
27526
|
-
const
|
|
27527
|
-
|
|
27950
|
+
const repoPath = process.cwd();
|
|
27951
|
+
// Same three-stage boot as startCocoUiFromLogArgv — mount with
|
|
27952
|
+
// cached rows for an instant-paint shell, refresh in background.
|
|
27953
|
+
const cachedRows = readCachedCommits(repoPath);
|
|
27954
|
+
await startInkInteractiveLog(git, cachedRows || [], {}, {
|
|
27528
27955
|
appLabel: 'coco',
|
|
27529
27956
|
idleTips: config.logTui?.idleTips,
|
|
27530
27957
|
initialView: argv.view || 'history',
|
|
27958
|
+
loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
|
|
27531
27959
|
logArgv,
|
|
27532
27960
|
theme: createUiTheme(config, argv),
|
|
27533
27961
|
});
|
|
@@ -27637,15 +28065,18 @@ const handler$2 = async (argv) => {
|
|
|
27637
28065
|
});
|
|
27638
28066
|
return;
|
|
27639
28067
|
}
|
|
27640
|
-
|
|
28068
|
+
// Interactive path defers the commit log fetch into the runtime
|
|
28069
|
+
// (#808) so the TUI mounts immediately with a "Loading commits…"
|
|
28070
|
+
// placeholder. The non-interactive (stdout) path still needs rows
|
|
28071
|
+
// up-front because the formatter just dumps a static snapshot.
|
|
27641
28072
|
if (argv.interactive && format === 'table') {
|
|
27642
28073
|
await startCocoUiFromLogArgv(argv, {
|
|
27643
28074
|
config,
|
|
27644
28075
|
git,
|
|
27645
|
-
rows,
|
|
27646
28076
|
});
|
|
27647
28077
|
return;
|
|
27648
28078
|
}
|
|
28079
|
+
const rows = await getLogRows(git, argv);
|
|
27649
28080
|
const result = format === 'json' ? formatLogJson(rows) : formatLogTable(rows);
|
|
27650
28081
|
await handleResult({
|
|
27651
28082
|
result,
|