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.esm.mjs
CHANGED
|
@@ -53,7 +53,7 @@ import { pathToFileURL } from 'url';
|
|
|
53
53
|
/**
|
|
54
54
|
* Current build version from package.json
|
|
55
55
|
*/
|
|
56
|
-
const BUILD_VERSION = "0.
|
|
56
|
+
const BUILD_VERSION = "0.41.1";
|
|
57
57
|
|
|
58
58
|
const isInteractive = (config) => {
|
|
59
59
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -14951,6 +14951,19 @@ function getLogInkWorkflowActions() {
|
|
|
14951
14951
|
kind: 'destructive',
|
|
14952
14952
|
requiresConfirmation: true,
|
|
14953
14953
|
},
|
|
14954
|
+
{
|
|
14955
|
+
// Per-view-only — the inkInput handler scopes this to the
|
|
14956
|
+
// worktrees surface so the global `D` keystroke (delete-branch)
|
|
14957
|
+
// keeps working from elsewhere. The empty `key` keeps the
|
|
14958
|
+
// workflow palette-discoverable but does not register a global
|
|
14959
|
+
// hotkey that would collide with delete-branch.
|
|
14960
|
+
id: 'remove-worktree-and-branch',
|
|
14961
|
+
key: '',
|
|
14962
|
+
label: 'Remove worktree + delete branch',
|
|
14963
|
+
description: 'Remove the selected worktree and delete the branch it was tracking after confirmation.',
|
|
14964
|
+
kind: 'destructive',
|
|
14965
|
+
requiresConfirmation: true,
|
|
14966
|
+
},
|
|
14954
14967
|
{
|
|
14955
14968
|
id: 'abort-operation',
|
|
14956
14969
|
key: 'A',
|
|
@@ -16313,6 +16326,11 @@ function replaceRows(state, rows) {
|
|
|
16313
16326
|
selectedFileIndex: 0,
|
|
16314
16327
|
pendingCommitFocused: false,
|
|
16315
16328
|
pendingKey: undefined,
|
|
16329
|
+
// Rows just landed — clear the boot-loading flag so the history
|
|
16330
|
+
// surface drops the "Loading commits…" placeholder. Safe to clear
|
|
16331
|
+
// unconditionally because `replaceRows` only fires after a real
|
|
16332
|
+
// git log returns.
|
|
16333
|
+
bootLoading: false,
|
|
16316
16334
|
};
|
|
16317
16335
|
}
|
|
16318
16336
|
function appendRows(state, rows) {
|
|
@@ -16403,6 +16421,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
16403
16421
|
diffViewMode: 'unified',
|
|
16404
16422
|
inspectorTab: 'inspector',
|
|
16405
16423
|
inspectorActionIndex: 0,
|
|
16424
|
+
bootLoading: options.bootLoading ?? false,
|
|
16406
16425
|
};
|
|
16407
16426
|
}
|
|
16408
16427
|
function getSelectedInkCommit(state) {
|
|
@@ -16604,6 +16623,12 @@ function applyLogInkAction(state, action) {
|
|
|
16604
16623
|
inspectorActionIndex: 0,
|
|
16605
16624
|
pendingKey: undefined,
|
|
16606
16625
|
};
|
|
16626
|
+
case 'setBootLoading':
|
|
16627
|
+
return {
|
|
16628
|
+
...state,
|
|
16629
|
+
bootLoading: action.value,
|
|
16630
|
+
pendingKey: undefined,
|
|
16631
|
+
};
|
|
16607
16632
|
case 'moveTag':
|
|
16608
16633
|
return {
|
|
16609
16634
|
...state,
|
|
@@ -18403,6 +18428,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18403
18428
|
if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
|
|
18404
18429
|
return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
|
|
18405
18430
|
}
|
|
18431
|
+
// Per-view worktree action: `D` removes the worktree AND deletes
|
|
18432
|
+
// the branch it was tracking (#838). Scoped to the worktrees
|
|
18433
|
+
// surface so it intercepts BEFORE the global workflow-by-key
|
|
18434
|
+
// dispatcher would otherwise route `D` to delete-branch (which
|
|
18435
|
+
// would silently target whatever was last cursored on the branches
|
|
18436
|
+
// surface instead of acting on the worktree under the cursor here).
|
|
18437
|
+
// `W` keeps its existing "remove worktree only" semantics.
|
|
18438
|
+
if (inputValue === 'D' && isWorktreeActionTarget(state) && context.worktreeListCount) {
|
|
18439
|
+
return [action({ type: 'setPendingConfirmation', value: 'remove-worktree-and-branch' })];
|
|
18440
|
+
}
|
|
18406
18441
|
// #783 — full PR action panel keys, scoped to the pull-request view.
|
|
18407
18442
|
// All five wrap a `gh pr <verb>` invocation; merge / request-changes /
|
|
18408
18443
|
// comment open prompts first, the rest route through the y-confirm
|
|
@@ -18680,7 +18715,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18680
18715
|
* fall back to "already seen" so we never block startup.
|
|
18681
18716
|
*/
|
|
18682
18717
|
const MARKER_BASENAME = 'onboarding.seen';
|
|
18683
|
-
function resolveCacheDir$
|
|
18718
|
+
function resolveCacheDir$3() {
|
|
18684
18719
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
18685
18720
|
if (xdg && xdg.trim().length > 0) {
|
|
18686
18721
|
return path$1.join(xdg, 'coco');
|
|
@@ -18688,7 +18723,7 @@ function resolveCacheDir$2() {
|
|
|
18688
18723
|
return path$1.join(os$1.homedir(), '.cache', 'coco');
|
|
18689
18724
|
}
|
|
18690
18725
|
function getOnboardingMarkerPath() {
|
|
18691
|
-
return path$1.join(resolveCacheDir$
|
|
18726
|
+
return path$1.join(resolveCacheDir$3(), MARKER_BASENAME);
|
|
18692
18727
|
}
|
|
18693
18728
|
function hasSeenOnboarding() {
|
|
18694
18729
|
try {
|
|
@@ -18719,14 +18754,14 @@ function markOnboardingSeen() {
|
|
|
18719
18754
|
* settings: best-effort, XDG-friendly, no PII in the cache filename.
|
|
18720
18755
|
*/
|
|
18721
18756
|
const VALID_MODES = ['unified', 'split'];
|
|
18722
|
-
function resolveCacheDir$
|
|
18757
|
+
function resolveCacheDir$2() {
|
|
18723
18758
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
18724
18759
|
if (xdg && xdg.trim().length > 0) {
|
|
18725
18760
|
return path$1.join(xdg, 'coco');
|
|
18726
18761
|
}
|
|
18727
18762
|
return path$1.join(os$1.homedir(), '.cache', 'coco');
|
|
18728
18763
|
}
|
|
18729
|
-
function repoKey$
|
|
18764
|
+
function repoKey$2(repoPath) {
|
|
18730
18765
|
// sha1 is used here as a non-security cache-key derivation — we just
|
|
18731
18766
|
// need a deterministic short identifier for the marker filename. No
|
|
18732
18767
|
// PII or auth context is hashed.
|
|
@@ -18734,7 +18769,7 @@ function repoKey$1(repoPath) {
|
|
|
18734
18769
|
return crypto.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
18735
18770
|
}
|
|
18736
18771
|
function getDiffViewModeMarkerPath(repoPath) {
|
|
18737
|
-
return path$1.join(resolveCacheDir$
|
|
18772
|
+
return path$1.join(resolveCacheDir$2(), `diff-view-mode.${repoKey$2(repoPath)}`);
|
|
18738
18773
|
}
|
|
18739
18774
|
function getSavedDiffViewMode(repoPath) {
|
|
18740
18775
|
try {
|
|
@@ -18776,14 +18811,14 @@ const VALID_TABS = [
|
|
|
18776
18811
|
'stashes',
|
|
18777
18812
|
'worktrees',
|
|
18778
18813
|
];
|
|
18779
|
-
function resolveCacheDir() {
|
|
18814
|
+
function resolveCacheDir$1() {
|
|
18780
18815
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
18781
18816
|
if (xdg && xdg.trim().length > 0) {
|
|
18782
18817
|
return path$1.join(xdg, 'coco');
|
|
18783
18818
|
}
|
|
18784
18819
|
return path$1.join(os$1.homedir(), '.cache', 'coco');
|
|
18785
18820
|
}
|
|
18786
|
-
function repoKey(repoPath) {
|
|
18821
|
+
function repoKey$1(repoPath) {
|
|
18787
18822
|
// sha1 is used here as a non-security cache-key derivation — we just
|
|
18788
18823
|
// need a deterministic short identifier for the marker filename so
|
|
18789
18824
|
// re-creating a repo at the same path keeps the same preference.
|
|
@@ -18793,7 +18828,7 @@ function repoKey(repoPath) {
|
|
|
18793
18828
|
return crypto.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
18794
18829
|
}
|
|
18795
18830
|
function getSidebarTabMarkerPath(repoPath) {
|
|
18796
|
-
return path$1.join(resolveCacheDir(), `sidebar-tab.${repoKey(repoPath)}`);
|
|
18831
|
+
return path$1.join(resolveCacheDir$1(), `sidebar-tab.${repoKey$1(repoPath)}`);
|
|
18797
18832
|
}
|
|
18798
18833
|
function getSavedSidebarTab(repoPath) {
|
|
18799
18834
|
try {
|
|
@@ -19220,9 +19255,16 @@ function getLogInkLayout(input) {
|
|
|
19220
19255
|
// graph dominant; focus expansion gives the inspector room for long
|
|
19221
19256
|
// commit bodies / file lists / action labels. Mirrors the sidebar
|
|
19222
19257
|
// pattern (sidebarFocused above): instant transition per render.
|
|
19223
|
-
|
|
19224
|
-
|
|
19225
|
-
|
|
19258
|
+
//
|
|
19259
|
+
// Help overlay overrides both — it borrows ~50% of the terminal so
|
|
19260
|
+
// hotkey descriptions render in full instead of truncating to
|
|
19261
|
+
// "Move focus...". Capped at 100 cells so a wide terminal doesn't
|
|
19262
|
+
// waste an absurd amount of horizontal space on the cheat sheet.
|
|
19263
|
+
const detailWidth = input.helpOverlayActive
|
|
19264
|
+
? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
|
|
19265
|
+
: input.inspectorFocused
|
|
19266
|
+
? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
|
|
19267
|
+
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
19226
19268
|
// Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
|
|
19227
19269
|
// (~36% of width). The transition is instant per render — focus tab to
|
|
19228
19270
|
// expand, focus away to collapse.
|
|
@@ -21466,6 +21508,56 @@ function worktreePathAction(worktree) {
|
|
|
21466
21508
|
message: `Worktree path: ${worktree.path}`,
|
|
21467
21509
|
};
|
|
21468
21510
|
}
|
|
21511
|
+
/**
|
|
21512
|
+
* Remove a worktree AND delete the branch it was tracking (#838). The
|
|
21513
|
+
* canonical "I'm done with this side branch" wind-down: removes the
|
|
21514
|
+
* worktree directory, then runs `git branch -d` on the previously
|
|
21515
|
+
* checked-out branch.
|
|
21516
|
+
*
|
|
21517
|
+
* Both pre-flight guards inherit from the underlying helpers:
|
|
21518
|
+
* - removeWorktree refuses the current worktree and dirty worktrees
|
|
21519
|
+
* - deleteBranch refuses the current branch and uses `-d` (safe
|
|
21520
|
+
* delete, refuses unmerged commits)
|
|
21521
|
+
*
|
|
21522
|
+
* Aborts cleanly at any failure point and surfaces a message that
|
|
21523
|
+
* names which step broke. When the worktree had no branch (detached
|
|
21524
|
+
* HEAD) the branch step is silently skipped — there's nothing to
|
|
21525
|
+
* delete and the worktree removal alone counts as success.
|
|
21526
|
+
*/
|
|
21527
|
+
async function removeWorktreeAndBranch(git, worktree, branchRefs) {
|
|
21528
|
+
const removeResult = await removeWorktree(git, worktree);
|
|
21529
|
+
if (!removeResult.ok) {
|
|
21530
|
+
return removeResult;
|
|
21531
|
+
}
|
|
21532
|
+
const branchName = worktree.branch;
|
|
21533
|
+
if (!branchName) {
|
|
21534
|
+
return {
|
|
21535
|
+
ok: true,
|
|
21536
|
+
message: `Removed worktree ${worktree.path} (no branch to delete)`,
|
|
21537
|
+
};
|
|
21538
|
+
}
|
|
21539
|
+
// Look up the local BranchRef for the branch this worktree was on.
|
|
21540
|
+
// deleteBranch needs the full ref (not just the name) so its
|
|
21541
|
+
// current-branch and local-only guards apply correctly.
|
|
21542
|
+
const branch = branchRefs.find((entry) => entry.type === 'local' && entry.shortName === branchName);
|
|
21543
|
+
if (!branch) {
|
|
21544
|
+
return {
|
|
21545
|
+
ok: true,
|
|
21546
|
+
message: `Removed worktree ${worktree.path} (branch ${branchName} not found in local branches)`,
|
|
21547
|
+
};
|
|
21548
|
+
}
|
|
21549
|
+
const deleteResult = await deleteBranch(git, branch);
|
|
21550
|
+
if (!deleteResult.ok) {
|
|
21551
|
+
return {
|
|
21552
|
+
ok: false,
|
|
21553
|
+
message: `Removed worktree ${worktree.path}, but branch delete failed: ${deleteResult.message}`,
|
|
21554
|
+
};
|
|
21555
|
+
}
|
|
21556
|
+
return {
|
|
21557
|
+
ok: true,
|
|
21558
|
+
message: `Removed worktree ${worktree.path} and deleted branch ${branchName}`,
|
|
21559
|
+
};
|
|
21560
|
+
}
|
|
21469
21561
|
|
|
21470
21562
|
function shortBranch(branch) {
|
|
21471
21563
|
return branch?.replace(/^refs\/heads\//, '');
|
|
@@ -23714,15 +23806,20 @@ async function loadLogInkContext(git) {
|
|
|
23714
23806
|
};
|
|
23715
23807
|
}
|
|
23716
23808
|
function loadLogInkContextEntries(git) {
|
|
23809
|
+
// Boot-time per-key fetches. Each load() runs in parallel from
|
|
23810
|
+
// `LogInkApp`'s mount effect. `pullRequest` is intentionally
|
|
23811
|
+
// omitted (#808) — its `gh pr view --json` call duplicates the
|
|
23812
|
+
// slim PR fetch already happening inside `getProviderOverview`,
|
|
23813
|
+
// and the only consumer that needs the *full* enriched response is
|
|
23814
|
+
// the dedicated PR view (`g p`). Lazy-loaded by a separate effect
|
|
23815
|
+
// when the user actually navigates there. Header / yank / workflow
|
|
23816
|
+
// paths read the slim version off `provider.currentPullRequest` so
|
|
23817
|
+
// the chrome stays populated immediately on boot.
|
|
23717
23818
|
return [
|
|
23718
23819
|
{
|
|
23719
23820
|
key: 'branches',
|
|
23720
23821
|
load: () => safe(getBranchOverview(git)),
|
|
23721
23822
|
},
|
|
23722
|
-
{
|
|
23723
|
-
key: 'pullRequest',
|
|
23724
|
-
load: () => safe(getPullRequestOverview(git)),
|
|
23725
|
-
},
|
|
23726
23823
|
{
|
|
23727
23824
|
key: 'tags',
|
|
23728
23825
|
load: () => safe(getTagOverview(git)),
|
|
@@ -23935,8 +24032,15 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
|
23935
24032
|
const input = streams.input || process.stdin;
|
|
23936
24033
|
const output = streams.output || process.stdout;
|
|
23937
24034
|
const error = streams.error || process.stderr;
|
|
24035
|
+
// Non-TTY fallback (CI logs, piped output) needs the rows up-front
|
|
24036
|
+
// because the renderer just dumps a static snapshot. Run the
|
|
24037
|
+
// deferred loader synchronously here when present so callers get
|
|
24038
|
+
// the same shape regardless of the entry path.
|
|
23938
24039
|
if (!canStartLogInkTui(input, output)) {
|
|
23939
|
-
|
|
24040
|
+
const fallbackRows = options.loadRows && rows.length === 0
|
|
24041
|
+
? await options.loadRows()
|
|
24042
|
+
: rows;
|
|
24043
|
+
await startInteractiveLog(git, fallbackRows, {
|
|
23940
24044
|
appLabel: options.appLabel,
|
|
23941
24045
|
input,
|
|
23942
24046
|
output,
|
|
@@ -23955,6 +24059,7 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
|
23955
24059
|
ink,
|
|
23956
24060
|
initialView: options.initialView || 'history',
|
|
23957
24061
|
logArgv: options.logArgv,
|
|
24062
|
+
loadRows: options.loadRows,
|
|
23958
24063
|
React,
|
|
23959
24064
|
rows,
|
|
23960
24065
|
theme: createLogInkTheme(options.theme),
|
|
@@ -24051,7 +24156,7 @@ function enrichFilterActionWithRectification(action, state, context) {
|
|
|
24051
24156
|
}
|
|
24052
24157
|
}
|
|
24053
24158
|
function LogInkApp(deps) {
|
|
24054
|
-
const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, logArgv, React, resumeRef, rows, theme } = deps;
|
|
24159
|
+
const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
|
|
24055
24160
|
const { Box, Text, useApp, useInput, useWindowSize } = ink;
|
|
24056
24161
|
const h = React.createElement;
|
|
24057
24162
|
const { exit } = useApp();
|
|
@@ -24072,9 +24177,26 @@ function LogInkApp(deps) {
|
|
|
24072
24177
|
// user's cache dir so the tip never reappears once dismissed. Lazy
|
|
24073
24178
|
// initializer so the fs check only runs on mount, not every render.
|
|
24074
24179
|
const [showOnboarding, setShowOnboarding] = React.useState(() => !hasSeenOnboarding());
|
|
24075
|
-
const [state, setState] = React.useState(() => createLogInkState(rows, {
|
|
24180
|
+
const [state, setState] = React.useState(() => createLogInkState(rows, {
|
|
24181
|
+
activeView: initialView,
|
|
24182
|
+
// Boot loader is in flight whenever the caller passed
|
|
24183
|
+
// `loadRows`, regardless of whether `rows` was empty or
|
|
24184
|
+
// pre-populated from the disk cache (#808). The history
|
|
24185
|
+
// surface only shows the "Loading commits…" placeholder when
|
|
24186
|
+
// there are zero visible commits, so cached data renders
|
|
24187
|
+
// immediately while the chrome still flags the refresh.
|
|
24188
|
+
bootLoading: Boolean(loadRows),
|
|
24189
|
+
}));
|
|
24076
24190
|
const [context, setContext] = React.useState({});
|
|
24077
|
-
const [contextStatus, setContextStatus] = React.useState(() =>
|
|
24191
|
+
const [contextStatus, setContextStatus] = React.useState(() => {
|
|
24192
|
+
// Boot starts every fetched key in 'loading' so the surfaces show
|
|
24193
|
+
// their loading hints immediately. `pullRequest` is the exception
|
|
24194
|
+
// (#808) — it isn't part of the boot fetch entries; it lazy-loads
|
|
24195
|
+
// when the user enters the PR view. Marking it 'idle' avoids a
|
|
24196
|
+
// permanent "loading" flag in the chrome and lets the dedicated
|
|
24197
|
+
// PR view's own load effect drive its loading state.
|
|
24198
|
+
return updateLogInkContextStatus(createLogInkContextStatus('loading'), 'pullRequest', 'idle');
|
|
24199
|
+
});
|
|
24078
24200
|
const [detail, setDetail] = React.useState(undefined);
|
|
24079
24201
|
const [detailLoading, setDetailLoading] = React.useState(false);
|
|
24080
24202
|
const [filePreview, setFilePreview] = React.useState(undefined);
|
|
@@ -24145,9 +24267,78 @@ function LogInkApp(deps) {
|
|
|
24145
24267
|
const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
|
|
24146
24268
|
const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
|
|
24147
24269
|
const selectedWorktreeFile = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex];
|
|
24270
|
+
// Stash patch per-file segmentation (#808). Hoisted out of the
|
|
24271
|
+
// useInput callback (was running on every keystroke), the yank
|
|
24272
|
+
// handler (was running per `y` press), and renderDiffSurface (was
|
|
24273
|
+
// running per paint) into a single LogInkApp-scoped memo. When the
|
|
24274
|
+
// active stash diff has hundreds of files, the prior fan-out was
|
|
24275
|
+
// re-walking the entire patch text 2-3x per keystroke for no
|
|
24276
|
+
// observable reason — the parsed list is purely a function of the
|
|
24277
|
+
// line array, which only changes when the user opens a different
|
|
24278
|
+
// stash.
|
|
24279
|
+
const stashDiffParsedFiles = React.useMemo(() => stashDiffLines ? parseStashDiffFiles(stashDiffLines) : [], [stashDiffLines]);
|
|
24280
|
+
// Filtered promoted-view lists (#808). These were recomputed inline
|
|
24281
|
+
// inside useInput on every keystroke — for a repo with hundreds of
|
|
24282
|
+
// branches / tags and an active filter, that's hundreds of regex
|
|
24283
|
+
// matches per arrow-key press. Memoizing on (raw list, filter)
|
|
24284
|
+
// collapses the work to one pass per filter / data change.
|
|
24285
|
+
const filteredBranchList = React.useMemo(() => {
|
|
24286
|
+
const all = context.branches?.localBranches || [];
|
|
24287
|
+
if (!state.filter)
|
|
24288
|
+
return all;
|
|
24289
|
+
return all.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter));
|
|
24290
|
+
}, [context.branches?.localBranches, state.filter]);
|
|
24291
|
+
const filteredTagList = React.useMemo(() => {
|
|
24292
|
+
const all = context.tags?.tags || [];
|
|
24293
|
+
if (!state.filter)
|
|
24294
|
+
return all;
|
|
24295
|
+
return all.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter));
|
|
24296
|
+
}, [context.tags?.tags, state.filter]);
|
|
24297
|
+
const filteredStashList = React.useMemo(() => {
|
|
24298
|
+
const all = context.stashes?.stashes || [];
|
|
24299
|
+
if (!state.filter)
|
|
24300
|
+
return all;
|
|
24301
|
+
return all.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter));
|
|
24302
|
+
}, [context.stashes?.stashes, state.filter]);
|
|
24303
|
+
const filteredWorktreeList = React.useMemo(() => {
|
|
24304
|
+
const all = context.worktreeList?.worktrees || [];
|
|
24305
|
+
if (!state.filter)
|
|
24306
|
+
return all;
|
|
24307
|
+
return all.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || ''], state.filter));
|
|
24308
|
+
}, [context.worktreeList?.worktrees, state.filter]);
|
|
24148
24309
|
const dispatch = React.useCallback((action) => {
|
|
24149
24310
|
setState((current) => applyLogInkAction(current, action));
|
|
24150
24311
|
}, []);
|
|
24312
|
+
// Deferred commit-log loader (#808). Runs once on mount when the
|
|
24313
|
+
// caller opted into the lazy boot path. The Ink tree is already on
|
|
24314
|
+
// screen at this point — without this the user stares at a black
|
|
24315
|
+
// terminal during the synchronous git log pre-mount fetch. The
|
|
24316
|
+
// mounted-ref guard prevents a late-resolving promise from
|
|
24317
|
+
// dispatching after the user `q` quits before rows arrive.
|
|
24318
|
+
React.useEffect(() => {
|
|
24319
|
+
if (!loadRows)
|
|
24320
|
+
return;
|
|
24321
|
+
let cancelled = false;
|
|
24322
|
+
void loadRows()
|
|
24323
|
+
.then((nextRows) => {
|
|
24324
|
+
if (cancelled || !mountedRef.current)
|
|
24325
|
+
return;
|
|
24326
|
+
dispatch({ type: 'replaceRows', rows: nextRows });
|
|
24327
|
+
})
|
|
24328
|
+
.catch((error) => {
|
|
24329
|
+
if (cancelled || !mountedRef.current)
|
|
24330
|
+
return;
|
|
24331
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
24332
|
+
dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}` });
|
|
24333
|
+
dispatch({ type: 'setBootLoading', value: false });
|
|
24334
|
+
});
|
|
24335
|
+
return () => {
|
|
24336
|
+
cancelled = true;
|
|
24337
|
+
};
|
|
24338
|
+
// Intentionally one-shot — re-running the boot load on hot
|
|
24339
|
+
// dispatch / loader changes would refetch the entire log on every
|
|
24340
|
+
// re-render. The loader fires once per app mount and that's it.
|
|
24341
|
+
}, []);
|
|
24151
24342
|
// Auto-dismiss status messages after a short window so transient
|
|
24152
24343
|
// confirmations ("Pulled current branch", "Edited foo.ts") don't
|
|
24153
24344
|
// linger forever. Each new message resets the timer; clearing the
|
|
@@ -24369,6 +24560,34 @@ function LogInkApp(deps) {
|
|
|
24369
24560
|
active = false;
|
|
24370
24561
|
};
|
|
24371
24562
|
}, [git]);
|
|
24563
|
+
// Lazy-load the full pullRequest overview (#808). Only fires when
|
|
24564
|
+
// the user actually navigates to the PR view, and only when we
|
|
24565
|
+
// don't already have data (so a workflow-triggered refresh that
|
|
24566
|
+
// hydrated `pullRequest` doesn't re-fetch on view entry). The
|
|
24567
|
+
// dedicated PR view shows its own loading state while this is in
|
|
24568
|
+
// flight; everywhere else (header glyph, yank, workflow runner)
|
|
24569
|
+
// already falls through to the slim `provider.currentPullRequest`
|
|
24570
|
+
// so the chrome stays populated immediately on boot.
|
|
24571
|
+
React.useEffect(() => {
|
|
24572
|
+
if (state.activeView !== 'pull-request')
|
|
24573
|
+
return;
|
|
24574
|
+
if (context.pullRequest)
|
|
24575
|
+
return;
|
|
24576
|
+
let active = true;
|
|
24577
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'));
|
|
24578
|
+
void safe(getPullRequestOverview(git)).then((value) => {
|
|
24579
|
+
if (!active)
|
|
24580
|
+
return;
|
|
24581
|
+
setContext((current) => ({
|
|
24582
|
+
...current,
|
|
24583
|
+
pullRequest: value,
|
|
24584
|
+
}));
|
|
24585
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'));
|
|
24586
|
+
});
|
|
24587
|
+
return () => {
|
|
24588
|
+
active = false;
|
|
24589
|
+
};
|
|
24590
|
+
}, [git, state.activeView, context.pullRequest]);
|
|
24372
24591
|
React.useEffect(() => {
|
|
24373
24592
|
let active = true;
|
|
24374
24593
|
async function loadDetail() {
|
|
@@ -24390,12 +24609,27 @@ function LogInkApp(deps) {
|
|
|
24390
24609
|
}, [git, selected?.hash]);
|
|
24391
24610
|
// #806 follow-up — auto-jump the history view to whichever branch /
|
|
24392
24611
|
// tag the user is currently cursoring in the sidebar (or the
|
|
24393
|
-
// dedicated branches / tags view).
|
|
24394
|
-
//
|
|
24612
|
+
// dedicated branches / tags view).
|
|
24613
|
+
//
|
|
24614
|
+
// Originally this fired on a 150ms trailing-edge debounce. The user
|
|
24615
|
+
// reported the sync feeling inconsistent (#839) — the trailing
|
|
24616
|
+
// pattern means a fast scroll through a long branch list cancels
|
|
24617
|
+
// the timer on every keystroke and only fires once on release; the
|
|
24618
|
+
// user never sees the cursor follow their navigation. Switched to
|
|
24619
|
+
// synchronous fire-on-effect so each cursor move snaps the history
|
|
24620
|
+
// graph immediately. The dispatch is cheap (O(n) findIndex on the
|
|
24621
|
+
// filtered commits + a state spread); React batches the re-renders
|
|
24622
|
+
// so even rapid scroll only paints the final position. Tracks the
|
|
24623
|
+
// last-dispatched hash via a ref so we don't fire setStatus
|
|
24624
|
+
// repeatedly when several adjacent branches all point at the same
|
|
24625
|
+
// commit (very common with squash-merged feature branches that all
|
|
24626
|
+
// converge on `main`'s tip).
|
|
24627
|
+
//
|
|
24395
24628
|
// No-op when the cursored ref's tip isn't in the loaded commit
|
|
24396
24629
|
// window (under compact mode the cursored branch's tip may not be
|
|
24397
24630
|
// fetched yet); a status hint surfaces in that case so the user
|
|
24398
24631
|
// knows to toggle full graph or load older commits.
|
|
24632
|
+
const lastSyncedHashRef = React.useRef(undefined);
|
|
24399
24633
|
React.useEffect(() => {
|
|
24400
24634
|
const onBranchTab = state.activeView === 'branches' ||
|
|
24401
24635
|
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
@@ -24403,58 +24637,58 @@ function LogInkApp(deps) {
|
|
|
24403
24637
|
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
24404
24638
|
if (!onBranchTab && !onTagTab)
|
|
24405
24639
|
return;
|
|
24406
|
-
let
|
|
24407
|
-
|
|
24408
|
-
|
|
24409
|
-
|
|
24410
|
-
|
|
24411
|
-
|
|
24412
|
-
|
|
24413
|
-
|
|
24414
|
-
|
|
24415
|
-
|
|
24416
|
-
|
|
24417
|
-
const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
|
|
24418
|
-
if (branch) {
|
|
24419
|
-
targetHash = branch.hash;
|
|
24420
|
-
targetLabel = `branch ${branch.shortName}`;
|
|
24421
|
-
}
|
|
24422
|
-
}
|
|
24423
|
-
else if (onTagTab) {
|
|
24424
|
-
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24425
|
-
const visible = state.filter
|
|
24426
|
-
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
|
|
24427
|
-
: all;
|
|
24428
|
-
const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
|
|
24429
|
-
if (tag) {
|
|
24430
|
-
targetHash = tag.hash;
|
|
24431
|
-
targetLabel = `tag ${tag.name}`;
|
|
24432
|
-
}
|
|
24433
|
-
}
|
|
24434
|
-
if (!targetHash)
|
|
24435
|
-
return;
|
|
24436
|
-
const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
|
|
24437
|
-
if (loaded) {
|
|
24438
|
-
dispatch({ type: 'selectCommitByHash', hash: targetHash });
|
|
24439
|
-
// Confirmation status message so the user gets feedback even
|
|
24440
|
-
// when the dedicated branches / tags view is occupying the
|
|
24441
|
-
// main panel and the history cursor moves invisibly behind it.
|
|
24442
|
-
dispatch({
|
|
24443
|
-
type: 'setStatus',
|
|
24444
|
-
value: `Synced history to ${targetLabel} tip`,
|
|
24445
|
-
});
|
|
24640
|
+
let targetHash;
|
|
24641
|
+
let targetLabel;
|
|
24642
|
+
if (onBranchTab) {
|
|
24643
|
+
const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
|
|
24644
|
+
const visible = state.filter
|
|
24645
|
+
? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
|
|
24646
|
+
: all;
|
|
24647
|
+
const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
|
|
24648
|
+
if (branch) {
|
|
24649
|
+
targetHash = branch.hash;
|
|
24650
|
+
targetLabel = `branch ${branch.shortName}`;
|
|
24446
24651
|
}
|
|
24447
|
-
|
|
24448
|
-
|
|
24449
|
-
|
|
24450
|
-
|
|
24451
|
-
|
|
24652
|
+
}
|
|
24653
|
+
else if (onTagTab) {
|
|
24654
|
+
const all = sortTags(context.tags?.tags || [], state.tagSort);
|
|
24655
|
+
const visible = state.filter
|
|
24656
|
+
? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
|
|
24657
|
+
: all;
|
|
24658
|
+
const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
|
|
24659
|
+
if (tag) {
|
|
24660
|
+
targetHash = tag.hash;
|
|
24661
|
+
targetLabel = `tag ${tag.name}`;
|
|
24452
24662
|
}
|
|
24453
|
-
}
|
|
24454
|
-
|
|
24455
|
-
|
|
24456
|
-
|
|
24457
|
-
|
|
24663
|
+
}
|
|
24664
|
+
if (!targetHash)
|
|
24665
|
+
return;
|
|
24666
|
+
// Skip the dispatch + status churn when the cursor hasn't
|
|
24667
|
+
// actually changed which commit it's targeting (the case for
|
|
24668
|
+
// rapid navigation through a cluster of branches that all point
|
|
24669
|
+
// at the same commit). Without this guard the user sees a stream
|
|
24670
|
+
// of "Synced history to <branch> tip" status messages even
|
|
24671
|
+
// though the history cursor never moved.
|
|
24672
|
+
if (targetHash === lastSyncedHashRef.current)
|
|
24673
|
+
return;
|
|
24674
|
+
const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
|
|
24675
|
+
if (loaded) {
|
|
24676
|
+
lastSyncedHashRef.current = targetHash;
|
|
24677
|
+
dispatch({ type: 'selectCommitByHash', hash: targetHash });
|
|
24678
|
+
// Confirmation status message so the user gets feedback even
|
|
24679
|
+
// when the dedicated branches / tags view is occupying the
|
|
24680
|
+
// main panel and the history cursor moves invisibly behind it.
|
|
24681
|
+
dispatch({
|
|
24682
|
+
type: 'setStatus',
|
|
24683
|
+
value: `Synced history to ${targetLabel} tip`,
|
|
24684
|
+
});
|
|
24685
|
+
}
|
|
24686
|
+
else {
|
|
24687
|
+
dispatch({
|
|
24688
|
+
type: 'setStatus',
|
|
24689
|
+
value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
|
|
24690
|
+
});
|
|
24691
|
+
}
|
|
24458
24692
|
}, [
|
|
24459
24693
|
dispatch, context.branches, context.tags,
|
|
24460
24694
|
state.activeView, state.focus, state.sidebarTab,
|
|
@@ -24462,6 +24696,18 @@ function LogInkApp(deps) {
|
|
|
24462
24696
|
state.branchSort, state.tagSort, state.filter,
|
|
24463
24697
|
state.filteredCommits,
|
|
24464
24698
|
]);
|
|
24699
|
+
// Reset the dedup ref when the user moves focus away from the
|
|
24700
|
+
// sidebar branches / tags tab so re-entering re-fires the sync
|
|
24701
|
+
// even if the cursored branch is the same as before.
|
|
24702
|
+
React.useEffect(() => {
|
|
24703
|
+
const onBranchTab = state.activeView === 'branches' ||
|
|
24704
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
24705
|
+
const onTagTab = state.activeView === 'tags' ||
|
|
24706
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
24707
|
+
if (!onBranchTab && !onTagTab) {
|
|
24708
|
+
lastSyncedHashRef.current = undefined;
|
|
24709
|
+
}
|
|
24710
|
+
}, [state.activeView, state.focus, state.sidebarTab]);
|
|
24465
24711
|
React.useEffect(() => {
|
|
24466
24712
|
let active = true;
|
|
24467
24713
|
async function loadWorktreeDiff() {
|
|
@@ -24901,6 +25147,29 @@ function LogInkApp(deps) {
|
|
|
24901
25147
|
}
|
|
24902
25148
|
return removeWorktree(git, cursorTarget);
|
|
24903
25149
|
},
|
|
25150
|
+
'remove-worktree-and-branch': async () => {
|
|
25151
|
+
const all = context.worktreeList?.worktrees || [];
|
|
25152
|
+
const visible = state.filter
|
|
25153
|
+
? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
|
|
25154
|
+
: all;
|
|
25155
|
+
const cursorTarget = visible.length
|
|
25156
|
+
? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
|
|
25157
|
+
: all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
|
|
25158
|
+
if (!cursorTarget)
|
|
25159
|
+
return { ok: false, message: 'No worktree selected' };
|
|
25160
|
+
if (cursorTarget.current) {
|
|
25161
|
+
return {
|
|
25162
|
+
ok: false,
|
|
25163
|
+
message: 'Cannot remove the current worktree — switch to another worktree first.',
|
|
25164
|
+
};
|
|
25165
|
+
}
|
|
25166
|
+
// The chained helper handles the worktree removal AND the
|
|
25167
|
+
// safe branch delete in one call. Branch refs come from the
|
|
25168
|
+
// live context so the underlying deleteBranch helper sees
|
|
25169
|
+
// the current/local flags it needs to refuse the destructive
|
|
25170
|
+
// path on the wrong target.
|
|
25171
|
+
return removeWorktreeAndBranch(git, cursorTarget, context.branches?.localBranches || []);
|
|
25172
|
+
},
|
|
24904
25173
|
'abort-operation': async () => {
|
|
24905
25174
|
const operation = context.operation?.operation;
|
|
24906
25175
|
if (!operation) {
|
|
@@ -25120,8 +25389,9 @@ function LogInkApp(deps) {
|
|
|
25120
25389
|
else if (state.diffSource === 'stash' && stashDiffLines) {
|
|
25121
25390
|
// Walk back to the most recent file header at or before the
|
|
25122
25391
|
// current preview offset — same logic the input-context block
|
|
25123
|
-
// uses to expose stashDiffSelectedPath.
|
|
25124
|
-
|
|
25392
|
+
// uses to expose stashDiffSelectedPath. Reads the memoized
|
|
25393
|
+
// parse so the yank handler doesn't re-walk the entire patch.
|
|
25394
|
+
const current = findStashFileForOffset(stashDiffParsedFiles, state.diffPreviewOffset);
|
|
25125
25395
|
if (current) {
|
|
25126
25396
|
value = current.path;
|
|
25127
25397
|
label = `path ${current.path}`;
|
|
@@ -25164,6 +25434,7 @@ function LogInkApp(deps) {
|
|
|
25164
25434
|
selected,
|
|
25165
25435
|
selectedDetailFile,
|
|
25166
25436
|
stashDiffLines,
|
|
25437
|
+
stashDiffParsedFiles,
|
|
25167
25438
|
state.activeView,
|
|
25168
25439
|
state.branchSort,
|
|
25169
25440
|
state.diffPreviewOffset,
|
|
@@ -25378,43 +25649,25 @@ function LogInkApp(deps) {
|
|
|
25378
25649
|
// P4.5: navigation in branches/tags/stash uses the FILTERED list
|
|
25379
25650
|
// length when a filter is active so j/k stay live instead of getting
|
|
25380
25651
|
// stuck against a full-list count that no longer matches what's on
|
|
25381
|
-
// screen.
|
|
25382
|
-
|
|
25383
|
-
|
|
25384
|
-
|
|
25385
|
-
|
|
25386
|
-
|
|
25387
|
-
const
|
|
25388
|
-
|
|
25389
|
-
.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
|
|
25390
|
-
.length
|
|
25391
|
-
: context.tags?.tags.length;
|
|
25392
|
-
const visibleStashes = state.filter
|
|
25393
|
-
? (context.stashes?.stashes || [])
|
|
25394
|
-
.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
|
|
25395
|
-
: (context.stashes?.stashes || []);
|
|
25396
|
-
const stashVisibleCount = visibleStashes.length;
|
|
25397
|
-
const stashSelectedRef = visibleStashes[Math.min(state.selectedStashIndex, Math.max(0, visibleStashes.length - 1))]?.ref;
|
|
25398
|
-
// The worktrees promoted view is filterable; mirror the branches /
|
|
25399
|
-
// tags / stash pattern and feed the filtered count into the input
|
|
25400
|
-
// dispatcher so ↑/↓ stay synchronized with the visible rows.
|
|
25401
|
-
const worktreeVisibleCount = state.filter
|
|
25402
|
-
? (context.worktreeList?.worktrees || [])
|
|
25403
|
-
.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
|
|
25404
|
-
.length
|
|
25405
|
-
: context.worktreeList?.worktrees.length;
|
|
25652
|
+
// screen. The filtered lists are memoized at LogInkApp scope (#808
|
|
25653
|
+
// perf pass) — reading them here is O(1) instead of O(branches +
|
|
25654
|
+
// tags + stashes + worktrees) per keystroke.
|
|
25655
|
+
const branchVisibleCount = filteredBranchList.length;
|
|
25656
|
+
const tagVisibleCount = filteredTagList.length;
|
|
25657
|
+
const stashVisibleCount = filteredStashList.length;
|
|
25658
|
+
const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
|
|
25659
|
+
const worktreeVisibleCount = filteredWorktreeList.length;
|
|
25406
25660
|
// When the diff view is showing a stash patch, swap the previewLineCount
|
|
25407
25661
|
// to the stash diff length so the existing pageDetailPreview path
|
|
25408
25662
|
// (j/k, PgUp/PgDn) scrolls through it without a parallel pipeline.
|
|
25409
25663
|
const diffPreviewLineCount = state.diffSource === 'stash'
|
|
25410
25664
|
? stashDiffLines?.length
|
|
25411
25665
|
: filePreview?.hunks.length;
|
|
25412
|
-
//
|
|
25413
|
-
//
|
|
25414
|
-
//
|
|
25415
|
-
|
|
25416
|
-
|
|
25417
|
-
: [];
|
|
25666
|
+
// Per-file segmentation for stash diffs reads the LogInkApp-scoped
|
|
25667
|
+
// memo so navigation keys + the input-context derivation share a
|
|
25668
|
+
// single parse pass per stash patch instead of re-walking the
|
|
25669
|
+
// entire patch text on every keystroke.
|
|
25670
|
+
const stashDiffFiles = state.diffSource === 'stash' ? stashDiffParsedFiles : [];
|
|
25418
25671
|
const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
|
|
25419
25672
|
const stashDiffSelectedPath = state.diffSource === 'stash'
|
|
25420
25673
|
? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
|
|
@@ -25511,6 +25764,7 @@ function LogInkApp(deps) {
|
|
|
25511
25764
|
rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
|
|
25512
25765
|
sidebarFocused: state.focus === 'sidebar',
|
|
25513
25766
|
inspectorFocused: state.focus === 'detail',
|
|
25767
|
+
helpOverlayActive: state.showHelp,
|
|
25514
25768
|
});
|
|
25515
25769
|
if (layout.tooSmall) {
|
|
25516
25770
|
return h(Box, {
|
|
@@ -25540,7 +25794,13 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
|
|
|
25540
25794
|
? `PR #${prInfo.number} ${prInfo.isDraft ? 'DRAFT' : prInfo.state}`
|
|
25541
25795
|
: 'no PR';
|
|
25542
25796
|
const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
|
|
25543
|
-
|
|
25797
|
+
// Boot loading wins over the per-context loading hint because it
|
|
25798
|
+
// tells the user the headline thing they care about (commits aren't
|
|
25799
|
+
// ready yet) — the context fetches finish independently and surface
|
|
25800
|
+
// their own per-section loading copy in the sidebars.
|
|
25801
|
+
const loading = state.bootLoading
|
|
25802
|
+
? ' loading commits'
|
|
25803
|
+
: isLogInkContextLoading(contextStatus) ? ' loading context' : '';
|
|
25544
25804
|
const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
|
|
25545
25805
|
const view = breadcrumb ? ` ${breadcrumb}` : '';
|
|
25546
25806
|
// Mode indicator (P2.2) — surfaces the current input mode so users
|
|
@@ -25847,22 +26107,30 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
25847
26107
|
...(state.historyFetchArgs
|
|
25848
26108
|
? [h(Text, { key: 'history-fetch-indicator', dimColor: true }, `filter: ${formatHistoryFetchArgs(state.historyFetchArgs)} (ctrl+u in / to clear)`)]
|
|
25849
26109
|
: []), ...(pendingNode ? [pendingNode] : []), visible.items.length === 0
|
|
25850
|
-
? h(Text, { dimColor: true },
|
|
25851
|
-
|
|
25852
|
-
|
|
25853
|
-
|
|
26110
|
+
? h(Text, { dimColor: true }, state.bootLoading
|
|
26111
|
+
? formatLogInkLoading({ resource: 'commits' })
|
|
26112
|
+
: formatLogInkHistoryEmpty({
|
|
26113
|
+
filter: state.filter,
|
|
26114
|
+
totalCommits: state.commits.length,
|
|
26115
|
+
}))
|
|
25854
26116
|
: visible.items.map((item, index) => {
|
|
25855
26117
|
if (item.type === 'graph') {
|
|
26118
|
+
// Graph-only rows are git's lane-closure scaffolding (`|/`,
|
|
26119
|
+
// `|\`, etc.) — they're real topology but visually they look
|
|
26120
|
+
// like blank rows that the user might wonder if they
|
|
26121
|
+
// accidentally skipped a commit on (#831). Render dim-on-dim
|
|
26122
|
+
// so they retreat as connectors rather than competing with
|
|
26123
|
+
// commit rows for the eye's attention.
|
|
25856
26124
|
if (item.laneSegments && !theme.ascii) {
|
|
25857
|
-
return h(Text, { key: `graph-${index}-${item.graph}
|
|
26125
|
+
return h(Text, { key: `graph-${index}-${item.graph}`, dimColor: true }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`, { forceDim: true }));
|
|
25858
26126
|
}
|
|
25859
26127
|
return h(Text, {
|
|
25860
26128
|
key: `graph-${index}-${item.graph}`,
|
|
25861
26129
|
color: theme.noColor ? undefined : theme.colors.muted,
|
|
25862
|
-
dimColor:
|
|
25863
|
-
}, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }),
|
|
26130
|
+
dimColor: true,
|
|
26131
|
+
}, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), Math.max(8, width - 4)));
|
|
25864
26132
|
}
|
|
25865
|
-
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, item.laneSegments);
|
|
26133
|
+
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, item.laneSegments);
|
|
25866
26134
|
}));
|
|
25867
26135
|
}
|
|
25868
26136
|
/**
|
|
@@ -25875,7 +26143,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
25875
26143
|
* Final padding is appended as its own span so callers do not need to
|
|
25876
26144
|
* pre-pad the graph string before computing lane segments.
|
|
25877
26145
|
*/
|
|
25878
|
-
function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
26146
|
+
function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, options = {}) {
|
|
25879
26147
|
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25880
26148
|
const elements = [];
|
|
25881
26149
|
let totalLen = 0;
|
|
@@ -25884,7 +26152,12 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
|
25884
26152
|
elements.push(h(Text, {
|
|
25885
26153
|
key: `${keyPrefix}-${idx}`,
|
|
25886
26154
|
color: laneColor ?? muted,
|
|
25887
|
-
dimColor
|
|
26155
|
+
// Ink does not cascade dimColor from a parent Text to children,
|
|
26156
|
+
// so the caller's "this whole row should fade" intent has to
|
|
26157
|
+
// travel here as an explicit flag (#831). Used for graph-only
|
|
26158
|
+
// lane-closure rows, where the lane colors otherwise compete
|
|
26159
|
+
// for attention with the commits they connect.
|
|
26160
|
+
dimColor: options.forceDim || (theme.noColor && seg.laneId === undefined),
|
|
25888
26161
|
}, seg.text));
|
|
25889
26162
|
totalLen += seg.text.length;
|
|
25890
26163
|
});
|
|
@@ -25905,11 +26178,22 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
|
25905
26178
|
* Truncation is per-segment so the variable-length message field gets
|
|
25906
26179
|
* the leftover budget after fixed segments are accounted for.
|
|
25907
26180
|
*/
|
|
25908
|
-
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
|
|
26181
|
+
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, laneSegments) {
|
|
25909
26182
|
const refs = formatInkRefLabels(commit.refs);
|
|
25910
|
-
|
|
26183
|
+
// Total cells available to the row content. Earlier revisions used a
|
|
26184
|
+
// hardcoded 140 here, which let row content overflow whenever the
|
|
26185
|
+
// panel was narrower than that — Ink would wrap onto a second visual
|
|
26186
|
+
// line and the next commit's graph indicator landed against the wrap
|
|
26187
|
+
// continuation rather than its own commit (#830). Subtracting 4
|
|
26188
|
+
// accounts for the panel's left + right border + 1-cell padding.
|
|
26189
|
+
const totalWidth = Math.max(20, panelWidth - 4);
|
|
25911
26190
|
const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
|
|
25912
|
-
|
|
26191
|
+
// Refs trail the message and shrink first when the row is narrow:
|
|
26192
|
+
// the user can always see the full ref list in the inspector, so
|
|
26193
|
+
// the headline subject keeps priority over decoration.
|
|
26194
|
+
const refsRoom = Math.max(0, totalWidth - fixedWidth - 8);
|
|
26195
|
+
const refsTrunc = refs ? truncate$1(refs, refsRoom) : '';
|
|
26196
|
+
const messageRoom = Math.max(8, totalWidth - fixedWidth - cellWidth(refsTrunc));
|
|
25913
26197
|
const message = truncate$1(commit.message, messageRoom);
|
|
25914
26198
|
const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
|
|
25915
26199
|
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
@@ -25924,7 +26208,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
|
|
|
25924
26208
|
key: `${commit.hash}-${index}`,
|
|
25925
26209
|
backgroundColor: selectedBg,
|
|
25926
26210
|
inverse: selected,
|
|
25927
|
-
}, ...graphChildren, ' ', h(Text, { color: accent, bold: selected }, commit.shortHash), ' ', h(Text, { dimColor: true }, commit.date), ' ', h(Text, undefined, message),
|
|
26211
|
+
}, ...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);
|
|
25928
26212
|
}
|
|
25929
26213
|
/**
|
|
25930
26214
|
* Render the synthetic "(+) new commit" affordance shown above the real
|
|
@@ -26198,6 +26482,15 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
26198
26482
|
: `${localBranches.length}/${sortedAll.length} local | current: ${branches?.currentBranch || '<detached>'}${filterLabel}${sortLabel}`;
|
|
26199
26483
|
const emptyLabel = formatLogInkBranchesEmpty({ filter: state.filter });
|
|
26200
26484
|
const loadingLabel = formatLogInkLoading({ resource: 'branches' });
|
|
26485
|
+
// Per-column width derived from the visible window (#833) so columns
|
|
26486
|
+
// align across rows regardless of name length. Padded to the longest
|
|
26487
|
+
// name in view so short rows fill out instead of leaving a gutter;
|
|
26488
|
+
// capped at 40 cells so one runaway long branch name doesn't blow
|
|
26489
|
+
// out the timestamp column entirely (longer names get truncated and
|
|
26490
|
+
// the timestamp stays where the user expects it).
|
|
26491
|
+
const nameColWidth = visible.length === 0
|
|
26492
|
+
? 28
|
|
26493
|
+
: Math.min(40, Math.max(8, ...visible.map((branch) => branch.shortName.length)));
|
|
26201
26494
|
const lines = loading
|
|
26202
26495
|
? [h(Text, { key: 'branches-loading', dimColor: true }, loadingLabel)]
|
|
26203
26496
|
: localBranches.length === 0
|
|
@@ -26211,18 +26504,18 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
26211
26504
|
const lastTouched = formatBranchLastTouched(branch.date, new Date());
|
|
26212
26505
|
// Split the row into spans so the timestamp stays dim even on the
|
|
26213
26506
|
// currently-selected (bold) row. The leading marker + name keep
|
|
26214
|
-
// their
|
|
26215
|
-
// the divergence column stays aligned across rows.
|
|
26216
|
-
const namePadded = branch.shortName.padEnd(
|
|
26507
|
+
// their per-window-derived column widths; the timestamp is
|
|
26508
|
+
// right-padded so the divergence column stays aligned across rows.
|
|
26509
|
+
const namePadded = truncate$1(branch.shortName, nameColWidth).padEnd(nameColWidth);
|
|
26217
26510
|
const timestampPadded = lastTouched.padEnd(8);
|
|
26218
26511
|
const lineDim = !isSelected && !branch.current;
|
|
26219
26512
|
const head = `${cursor} ${marker} ${namePadded} `;
|
|
26220
26513
|
const trailingDivergence = divergence ? ` ${divergence}` : '';
|
|
26221
|
-
// Truncate the assembled line
|
|
26222
|
-
//
|
|
26223
|
-
//
|
|
26514
|
+
// Truncate the assembled line to the actual panel width so a
|
|
26515
|
+
// narrow inspector / sidebar focus doesn't push branch rows
|
|
26516
|
+
// onto a second visual line (#830).
|
|
26224
26517
|
const fullText = `${head}${timestampPadded}${trailingDivergence}`;
|
|
26225
|
-
const truncated = truncate$1(fullText,
|
|
26518
|
+
const truncated = truncate$1(fullText, Math.max(20, width - 4));
|
|
26226
26519
|
// If truncation chopped into the timestamp/divergence portion,
|
|
26227
26520
|
// fall back to a single Text to keep the visible width honest.
|
|
26228
26521
|
if (truncated !== fullText) {
|
|
@@ -26266,6 +26559,13 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
26266
26559
|
: `${tags.length}/${sortedAll.length} tags${filterLabel}${sortLabel}`;
|
|
26267
26560
|
const emptyLabel = formatLogInkTagsEmpty({ filter: state.filter });
|
|
26268
26561
|
const loadingLabel = formatLogInkLoading({ resource: 'tags' });
|
|
26562
|
+
// Per-window name column width (#833) so short tags don't leave a
|
|
26563
|
+
// wide gutter and long tags don't push the subject off-screen. Cap
|
|
26564
|
+
// matches the branches surface for visual consistency across the
|
|
26565
|
+
// promoted views.
|
|
26566
|
+
const tagNameColWidth = visible.length === 0
|
|
26567
|
+
? 20
|
|
26568
|
+
: Math.min(40, Math.max(8, ...visible.map((tag) => tag.name.length)));
|
|
26269
26569
|
const lines = loading
|
|
26270
26570
|
? [h(Text, { key: 'tags-loading', dimColor: true }, loadingLabel)]
|
|
26271
26571
|
: tags.length === 0
|
|
@@ -26279,8 +26579,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
26279
26579
|
// formatHyperlink wraps just the tag name, leaving width math
|
|
26280
26580
|
// intact.
|
|
26281
26581
|
const url = buildRefUrl(context.provider?.repository, tag.name);
|
|
26282
|
-
const namePadded = tag.name.padEnd(
|
|
26283
|
-
const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`,
|
|
26582
|
+
const namePadded = truncate$1(tag.name, tagNameColWidth).padEnd(tagNameColWidth);
|
|
26583
|
+
const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`, Math.max(20, width - 4));
|
|
26284
26584
|
if (!url || lineText.indexOf(namePadded) < 0) {
|
|
26285
26585
|
return h(Text, {
|
|
26286
26586
|
key: `tag-${index}`,
|
|
@@ -26363,6 +26663,17 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
26363
26663
|
const headerRight = loading
|
|
26364
26664
|
? 'loading worktrees'
|
|
26365
26665
|
: `${worktrees.length}/${allWorktrees.length} worktrees${filterLabel}`;
|
|
26666
|
+
// Per-window branch column width (#833). Worktrees often track
|
|
26667
|
+
// branches with names varying widely in length (`main` vs.
|
|
26668
|
+
// `feat/tui-something-long`); fixed-width padding either left a
|
|
26669
|
+
// huge gutter on short rows or pushed the path column off-screen on
|
|
26670
|
+
// long ones. Cap matches the other promoted surfaces.
|
|
26671
|
+
const branchColWidth = visible.length === 0
|
|
26672
|
+
? 28
|
|
26673
|
+
: Math.min(40, Math.max(8, ...visible.map((entry) => {
|
|
26674
|
+
const label = entry.branch ? entry.branch : entry.head || '<detached>';
|
|
26675
|
+
return label.length;
|
|
26676
|
+
})));
|
|
26366
26677
|
const lines = loading
|
|
26367
26678
|
? [h(Text, { key: 'worktrees-loading', dimColor: true }, formatLogInkLoading({ resource: 'worktrees' }))]
|
|
26368
26679
|
: worktrees.length === 0
|
|
@@ -26374,11 +26685,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
26374
26685
|
const marker = entry.current ? '*' : ' ';
|
|
26375
26686
|
const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
|
|
26376
26687
|
const stateLabel = entry.dirty ? 'dirty' : 'clean';
|
|
26688
|
+
const branchPadded = truncate$1(branchLabel, branchColWidth).padEnd(branchColWidth);
|
|
26377
26689
|
return h(Text, {
|
|
26378
26690
|
key: `worktree-${index}`,
|
|
26379
26691
|
bold: isSelected,
|
|
26380
26692
|
dimColor: !isSelected && !entry.current,
|
|
26381
|
-
}, truncate$1(`${cursor} ${marker} ${
|
|
26693
|
+
}, truncate$1(`${cursor} ${marker} ${branchPadded} ${stateLabel.padEnd(6)} ${entry.path}`, width - 4));
|
|
26382
26694
|
});
|
|
26383
26695
|
return h(Box, {
|
|
26384
26696
|
borderColor: focusBorderColor(theme, focused),
|
|
@@ -27036,7 +27348,12 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
27036
27348
|
}, truncate$1(theme.ascii ? '[...] AI draft in progress' : '⏳ AI draft in progress', width - 4))]
|
|
27037
27349
|
: []), ...(stagedFiles.length
|
|
27038
27350
|
? [
|
|
27039
|
-
|
|
27351
|
+
// Section header carries the total count to match the status
|
|
27352
|
+
// surface's "▾ Staged (n)" treatment (#840). The visible
|
|
27353
|
+
// file list is sliced at 12 rows; using `worktree.stagedCount`
|
|
27354
|
+
// (the total) avoids a misleading "Staged (12)" label when
|
|
27355
|
+
// there are actually more staged files below the slice.
|
|
27356
|
+
h(Text, { key: 'compose-context-staged-title', bold: true }, `Staged (${worktree?.stagedCount ?? stagedFiles.length})`),
|
|
27040
27357
|
...stagedFiles.map((file, index) => h(Text, {
|
|
27041
27358
|
key: `compose-context-staged-${index}`,
|
|
27042
27359
|
color: theme.noColor ? undefined : theme.colors.gitAdded,
|
|
@@ -27045,7 +27362,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
27045
27362
|
]
|
|
27046
27363
|
: []), ...(unstagedFiles.length
|
|
27047
27364
|
? [
|
|
27048
|
-
h(Text, { key: 'compose-context-unstaged-title', bold: true },
|
|
27365
|
+
h(Text, { key: 'compose-context-unstaged-title', bold: true }, `Unstaged (${worktree?.unstagedCount ?? unstagedFiles.length})`),
|
|
27049
27366
|
...unstagedFiles.map((file, index) => h(Text, {
|
|
27050
27367
|
key: `compose-context-unstaged-${index}`,
|
|
27051
27368
|
color: theme.noColor ? undefined : theme.colors.gitModified,
|
|
@@ -27458,6 +27775,84 @@ function renderFooter(h, components, state, context, theme, idleTip) {
|
|
|
27458
27775
|
}, h(Text, { color: theme.colors.muted, dimColor: true }, contextualText), h(Text, { color: theme.colors.muted, dimColor: true }, globalText));
|
|
27459
27776
|
}
|
|
27460
27777
|
|
|
27778
|
+
/**
|
|
27779
|
+
* Per-repo disk cache of the last successful commit-log fetch (#808).
|
|
27780
|
+
* Lets the TUI render an immediate stale-but-useful history view on
|
|
27781
|
+
* subsequent boots while the fresh `git log` runs in the background;
|
|
27782
|
+
* once the fresh data lands the runtime swaps it in transparently.
|
|
27783
|
+
*
|
|
27784
|
+
* Strict best-effort: read failures fall back to "no cache" (boot
|
|
27785
|
+
* shows the loading placeholder), and write failures are swallowed
|
|
27786
|
+
* silently (next boot just doesn't have the cache yet). The cache is
|
|
27787
|
+
* never load-bearing.
|
|
27788
|
+
*
|
|
27789
|
+
* Repos are keyed by a short hash of their absolute path. No PII in
|
|
27790
|
+
* the cache filename, and re-creating a repo at the same path keeps
|
|
27791
|
+
* the same cache.
|
|
27792
|
+
*/
|
|
27793
|
+
const CACHE_SCHEMA_VERSION = 1;
|
|
27794
|
+
const CACHE_DIR_NAME = 'overview';
|
|
27795
|
+
/**
|
|
27796
|
+
* Hard cap on rows we'll write per cache entry. The interactive
|
|
27797
|
+
* default limit is 300; this caps growth in case a user opts into a
|
|
27798
|
+
* much larger window. Keeps the cache file under ~200kb on a typical
|
|
27799
|
+
* repo.
|
|
27800
|
+
*/
|
|
27801
|
+
const CACHE_ROW_HARD_CAP = 500;
|
|
27802
|
+
function resolveCacheDir() {
|
|
27803
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
27804
|
+
if (xdg && xdg.trim().length > 0) {
|
|
27805
|
+
return path$1.join(xdg, 'coco', CACHE_DIR_NAME);
|
|
27806
|
+
}
|
|
27807
|
+
return path$1.join(os$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME);
|
|
27808
|
+
}
|
|
27809
|
+
function repoKey(repoPath) {
|
|
27810
|
+
// sha1 here is a non-security cache-key derivation — we just need a
|
|
27811
|
+
// deterministic short identifier for the cache filename so two repos
|
|
27812
|
+
// at different paths never collide. No PII or auth context is hashed
|
|
27813
|
+
// and no collision-resistance against an adversary is required.
|
|
27814
|
+
// DevSkim DS126858 doesn't apply.
|
|
27815
|
+
// DevSkim: ignore DS126858
|
|
27816
|
+
return crypto.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
27817
|
+
}
|
|
27818
|
+
function getOverviewCachePath(repoPath) {
|
|
27819
|
+
return path$1.join(resolveCacheDir(), `commits.${repoKey(repoPath)}.json`);
|
|
27820
|
+
}
|
|
27821
|
+
function readCachedCommits(repoPath) {
|
|
27822
|
+
try {
|
|
27823
|
+
const raw = fs$1.readFileSync(getOverviewCachePath(repoPath), 'utf8');
|
|
27824
|
+
const parsed = JSON.parse(raw);
|
|
27825
|
+
if (parsed.version !== CACHE_SCHEMA_VERSION) {
|
|
27826
|
+
// Schema mismatch — quietly drop the stale entry on next write.
|
|
27827
|
+
// Treating it as "no cache" keeps boot behavior predictable
|
|
27828
|
+
// across upgrades.
|
|
27829
|
+
return undefined;
|
|
27830
|
+
}
|
|
27831
|
+
if (!Array.isArray(parsed.rows)) {
|
|
27832
|
+
return undefined;
|
|
27833
|
+
}
|
|
27834
|
+
return parsed.rows;
|
|
27835
|
+
}
|
|
27836
|
+
catch {
|
|
27837
|
+
return undefined;
|
|
27838
|
+
}
|
|
27839
|
+
}
|
|
27840
|
+
function writeCachedCommits(repoPath, rows) {
|
|
27841
|
+
const file = getOverviewCachePath(repoPath);
|
|
27842
|
+
const envelope = {
|
|
27843
|
+
version: CACHE_SCHEMA_VERSION,
|
|
27844
|
+
savedAt: new Date().toISOString(),
|
|
27845
|
+
rows: rows.slice(0, CACHE_ROW_HARD_CAP),
|
|
27846
|
+
};
|
|
27847
|
+
try {
|
|
27848
|
+
fs$1.mkdirSync(path$1.dirname(file), { recursive: true });
|
|
27849
|
+
fs$1.writeFileSync(file, JSON.stringify(envelope));
|
|
27850
|
+
}
|
|
27851
|
+
catch {
|
|
27852
|
+
// Best-effort persistence; swallow.
|
|
27853
|
+
}
|
|
27854
|
+
}
|
|
27855
|
+
|
|
27461
27856
|
function createLogArgvFromUiArgv(argv) {
|
|
27462
27857
|
return {
|
|
27463
27858
|
$0: argv.$0,
|
|
@@ -27482,14 +27877,43 @@ function createUiTheme(config, argv) {
|
|
|
27482
27877
|
preset: argv.theme,
|
|
27483
27878
|
};
|
|
27484
27879
|
}
|
|
27880
|
+
/**
|
|
27881
|
+
* Wrap a fresh-rows loader with the disk-cache write step. Lets the
|
|
27882
|
+
* runtime stay caching-agnostic — it just receives the rows and
|
|
27883
|
+
* doesn't know whether they came from cache or git, while the caller
|
|
27884
|
+
* (which knows the repo path) handles persistence.
|
|
27885
|
+
*/
|
|
27886
|
+
function withCacheWrite(repoPath, loader) {
|
|
27887
|
+
return async () => {
|
|
27888
|
+
const rows = await loader();
|
|
27889
|
+
writeCachedCommits(repoPath, rows);
|
|
27890
|
+
return rows;
|
|
27891
|
+
};
|
|
27892
|
+
}
|
|
27485
27893
|
async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
27486
27894
|
const config = options.config || loadConfig(logArgv);
|
|
27487
27895
|
const git = options.git || getRepo();
|
|
27488
|
-
const
|
|
27489
|
-
|
|
27896
|
+
const repoPath = process.cwd();
|
|
27897
|
+
// Three-stage boot (#808):
|
|
27898
|
+
// 1. Read the disk cache and pass cached rows as the initial set
|
|
27899
|
+
// so the user sees the workstation chrome populated with
|
|
27900
|
+
// commits in the first frame.
|
|
27901
|
+
// 2. Mount Ink immediately with those rows (or [] if no cache).
|
|
27902
|
+
// 3. Run loadRows in the background to refresh — when fresh data
|
|
27903
|
+
// lands the runtime swaps it in transparently and we persist
|
|
27904
|
+
// the new rows back to the cache for next boot.
|
|
27905
|
+
// Caller-provided rows skip the lazy path entirely (caller already
|
|
27906
|
+
// has up-to-date data — no point redoing the fetch).
|
|
27907
|
+
const cachedRows = options.rows ? undefined : readCachedCommits(repoPath);
|
|
27908
|
+
const initialRows = options.rows || cachedRows || [];
|
|
27909
|
+
const loadRows = options.rows
|
|
27910
|
+
? undefined
|
|
27911
|
+
: withCacheWrite(repoPath, () => getLogRows(git, logArgv));
|
|
27912
|
+
await startInkInteractiveLog(git, initialRows, {}, {
|
|
27490
27913
|
appLabel: 'coco',
|
|
27491
27914
|
idleTips: config.logTui?.idleTips,
|
|
27492
27915
|
initialView: 'history',
|
|
27916
|
+
loadRows,
|
|
27493
27917
|
logArgv,
|
|
27494
27918
|
theme: config.logTui?.theme,
|
|
27495
27919
|
});
|
|
@@ -27498,11 +27922,15 @@ async function startCocoUi(argv) {
|
|
|
27498
27922
|
const config = loadConfig(argv);
|
|
27499
27923
|
const git = getRepo();
|
|
27500
27924
|
const logArgv = createLogArgvFromUiArgv(argv);
|
|
27501
|
-
const
|
|
27502
|
-
|
|
27925
|
+
const repoPath = process.cwd();
|
|
27926
|
+
// Same three-stage boot as startCocoUiFromLogArgv — mount with
|
|
27927
|
+
// cached rows for an instant-paint shell, refresh in background.
|
|
27928
|
+
const cachedRows = readCachedCommits(repoPath);
|
|
27929
|
+
await startInkInteractiveLog(git, cachedRows || [], {}, {
|
|
27503
27930
|
appLabel: 'coco',
|
|
27504
27931
|
idleTips: config.logTui?.idleTips,
|
|
27505
27932
|
initialView: argv.view || 'history',
|
|
27933
|
+
loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
|
|
27506
27934
|
logArgv,
|
|
27507
27935
|
theme: createUiTheme(config, argv),
|
|
27508
27936
|
});
|
|
@@ -27612,15 +28040,18 @@ const handler$2 = async (argv) => {
|
|
|
27612
28040
|
});
|
|
27613
28041
|
return;
|
|
27614
28042
|
}
|
|
27615
|
-
|
|
28043
|
+
// Interactive path defers the commit log fetch into the runtime
|
|
28044
|
+
// (#808) so the TUI mounts immediately with a "Loading commits…"
|
|
28045
|
+
// placeholder. The non-interactive (stdout) path still needs rows
|
|
28046
|
+
// up-front because the formatter just dumps a static snapshot.
|
|
27616
28047
|
if (argv.interactive && format === 'table') {
|
|
27617
28048
|
await startCocoUiFromLogArgv(argv, {
|
|
27618
28049
|
config,
|
|
27619
28050
|
git,
|
|
27620
|
-
rows,
|
|
27621
28051
|
});
|
|
27622
28052
|
return;
|
|
27623
28053
|
}
|
|
28054
|
+
const rows = await getLogRows(git, argv);
|
|
27624
28055
|
const result = format === 'json' ? formatLogJson(rows) : formatLogTable(rows);
|
|
27625
28056
|
await handleResult({
|
|
27626
28057
|
result,
|