git-coco 0.40.1 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.esm.mjs +387 -84
- package/dist/index.js +387 -84
- 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.0";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -16338,6 +16338,11 @@ function replaceRows(state, rows) {
|
|
|
16338
16338
|
selectedFileIndex: 0,
|
|
16339
16339
|
pendingCommitFocused: false,
|
|
16340
16340
|
pendingKey: undefined,
|
|
16341
|
+
// Rows just landed — clear the boot-loading flag so the history
|
|
16342
|
+
// surface drops the "Loading commits…" placeholder. Safe to clear
|
|
16343
|
+
// unconditionally because `replaceRows` only fires after a real
|
|
16344
|
+
// git log returns.
|
|
16345
|
+
bootLoading: false,
|
|
16341
16346
|
};
|
|
16342
16347
|
}
|
|
16343
16348
|
function appendRows(state, rows) {
|
|
@@ -16428,6 +16433,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
16428
16433
|
diffViewMode: 'unified',
|
|
16429
16434
|
inspectorTab: 'inspector',
|
|
16430
16435
|
inspectorActionIndex: 0,
|
|
16436
|
+
bootLoading: options.bootLoading ?? false,
|
|
16431
16437
|
};
|
|
16432
16438
|
}
|
|
16433
16439
|
function getSelectedInkCommit(state) {
|
|
@@ -16629,6 +16635,12 @@ function applyLogInkAction(state, action) {
|
|
|
16629
16635
|
inspectorActionIndex: 0,
|
|
16630
16636
|
pendingKey: undefined,
|
|
16631
16637
|
};
|
|
16638
|
+
case 'setBootLoading':
|
|
16639
|
+
return {
|
|
16640
|
+
...state,
|
|
16641
|
+
bootLoading: action.value,
|
|
16642
|
+
pendingKey: undefined,
|
|
16643
|
+
};
|
|
16632
16644
|
case 'moveTag':
|
|
16633
16645
|
return {
|
|
16634
16646
|
...state,
|
|
@@ -18705,7 +18717,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18705
18717
|
* fall back to "already seen" so we never block startup.
|
|
18706
18718
|
*/
|
|
18707
18719
|
const MARKER_BASENAME = 'onboarding.seen';
|
|
18708
|
-
function resolveCacheDir$
|
|
18720
|
+
function resolveCacheDir$3() {
|
|
18709
18721
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
18710
18722
|
if (xdg && xdg.trim().length > 0) {
|
|
18711
18723
|
return path__namespace$1.join(xdg, 'coco');
|
|
@@ -18713,7 +18725,7 @@ function resolveCacheDir$2() {
|
|
|
18713
18725
|
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
|
|
18714
18726
|
}
|
|
18715
18727
|
function getOnboardingMarkerPath() {
|
|
18716
|
-
return path__namespace$1.join(resolveCacheDir$
|
|
18728
|
+
return path__namespace$1.join(resolveCacheDir$3(), MARKER_BASENAME);
|
|
18717
18729
|
}
|
|
18718
18730
|
function hasSeenOnboarding() {
|
|
18719
18731
|
try {
|
|
@@ -18744,14 +18756,14 @@ function markOnboardingSeen() {
|
|
|
18744
18756
|
* settings: best-effort, XDG-friendly, no PII in the cache filename.
|
|
18745
18757
|
*/
|
|
18746
18758
|
const VALID_MODES = ['unified', 'split'];
|
|
18747
|
-
function resolveCacheDir$
|
|
18759
|
+
function resolveCacheDir$2() {
|
|
18748
18760
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
18749
18761
|
if (xdg && xdg.trim().length > 0) {
|
|
18750
18762
|
return path__namespace$1.join(xdg, 'coco');
|
|
18751
18763
|
}
|
|
18752
18764
|
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
|
|
18753
18765
|
}
|
|
18754
|
-
function repoKey$
|
|
18766
|
+
function repoKey$2(repoPath) {
|
|
18755
18767
|
// sha1 is used here as a non-security cache-key derivation — we just
|
|
18756
18768
|
// need a deterministic short identifier for the marker filename. No
|
|
18757
18769
|
// PII or auth context is hashed.
|
|
@@ -18759,7 +18771,7 @@ function repoKey$1(repoPath) {
|
|
|
18759
18771
|
return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
18760
18772
|
}
|
|
18761
18773
|
function getDiffViewModeMarkerPath(repoPath) {
|
|
18762
|
-
return path__namespace$1.join(resolveCacheDir$
|
|
18774
|
+
return path__namespace$1.join(resolveCacheDir$2(), `diff-view-mode.${repoKey$2(repoPath)}`);
|
|
18763
18775
|
}
|
|
18764
18776
|
function getSavedDiffViewMode(repoPath) {
|
|
18765
18777
|
try {
|
|
@@ -18801,14 +18813,14 @@ const VALID_TABS = [
|
|
|
18801
18813
|
'stashes',
|
|
18802
18814
|
'worktrees',
|
|
18803
18815
|
];
|
|
18804
|
-
function resolveCacheDir() {
|
|
18816
|
+
function resolveCacheDir$1() {
|
|
18805
18817
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
18806
18818
|
if (xdg && xdg.trim().length > 0) {
|
|
18807
18819
|
return path__namespace$1.join(xdg, 'coco');
|
|
18808
18820
|
}
|
|
18809
18821
|
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
|
|
18810
18822
|
}
|
|
18811
|
-
function repoKey(repoPath) {
|
|
18823
|
+
function repoKey$1(repoPath) {
|
|
18812
18824
|
// sha1 is used here as a non-security cache-key derivation — we just
|
|
18813
18825
|
// need a deterministic short identifier for the marker filename so
|
|
18814
18826
|
// re-creating a repo at the same path keeps the same preference.
|
|
@@ -18818,7 +18830,7 @@ function repoKey(repoPath) {
|
|
|
18818
18830
|
return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
18819
18831
|
}
|
|
18820
18832
|
function getSidebarTabMarkerPath(repoPath) {
|
|
18821
|
-
return path__namespace$1.join(resolveCacheDir(), `sidebar-tab.${repoKey(repoPath)}`);
|
|
18833
|
+
return path__namespace$1.join(resolveCacheDir$1(), `sidebar-tab.${repoKey$1(repoPath)}`);
|
|
18822
18834
|
}
|
|
18823
18835
|
function getSavedSidebarTab(repoPath) {
|
|
18824
18836
|
try {
|
|
@@ -19245,9 +19257,16 @@ function getLogInkLayout(input) {
|
|
|
19245
19257
|
// graph dominant; focus expansion gives the inspector room for long
|
|
19246
19258
|
// commit bodies / file lists / action labels. Mirrors the sidebar
|
|
19247
19259
|
// pattern (sidebarFocused above): instant transition per render.
|
|
19248
|
-
|
|
19249
|
-
|
|
19250
|
-
|
|
19260
|
+
//
|
|
19261
|
+
// Help overlay overrides both — it borrows ~50% of the terminal so
|
|
19262
|
+
// hotkey descriptions render in full instead of truncating to
|
|
19263
|
+
// "Move focus...". Capped at 100 cells so a wide terminal doesn't
|
|
19264
|
+
// waste an absurd amount of horizontal space on the cheat sheet.
|
|
19265
|
+
const detailWidth = input.helpOverlayActive
|
|
19266
|
+
? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
|
|
19267
|
+
: input.inspectorFocused
|
|
19268
|
+
? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
|
|
19269
|
+
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
19251
19270
|
// Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
|
|
19252
19271
|
// (~36% of width). The transition is instant per render — focus tab to
|
|
19253
19272
|
// expand, focus away to collapse.
|
|
@@ -23739,15 +23758,20 @@ async function loadLogInkContext(git) {
|
|
|
23739
23758
|
};
|
|
23740
23759
|
}
|
|
23741
23760
|
function loadLogInkContextEntries(git) {
|
|
23761
|
+
// Boot-time per-key fetches. Each load() runs in parallel from
|
|
23762
|
+
// `LogInkApp`'s mount effect. `pullRequest` is intentionally
|
|
23763
|
+
// omitted (#808) — its `gh pr view --json` call duplicates the
|
|
23764
|
+
// slim PR fetch already happening inside `getProviderOverview`,
|
|
23765
|
+
// and the only consumer that needs the *full* enriched response is
|
|
23766
|
+
// the dedicated PR view (`g p`). Lazy-loaded by a separate effect
|
|
23767
|
+
// when the user actually navigates there. Header / yank / workflow
|
|
23768
|
+
// paths read the slim version off `provider.currentPullRequest` so
|
|
23769
|
+
// the chrome stays populated immediately on boot.
|
|
23742
23770
|
return [
|
|
23743
23771
|
{
|
|
23744
23772
|
key: 'branches',
|
|
23745
23773
|
load: () => safe(getBranchOverview(git)),
|
|
23746
23774
|
},
|
|
23747
|
-
{
|
|
23748
|
-
key: 'pullRequest',
|
|
23749
|
-
load: () => safe(getPullRequestOverview(git)),
|
|
23750
|
-
},
|
|
23751
23775
|
{
|
|
23752
23776
|
key: 'tags',
|
|
23753
23777
|
load: () => safe(getTagOverview(git)),
|
|
@@ -23960,8 +23984,15 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
|
23960
23984
|
const input = streams.input || process.stdin;
|
|
23961
23985
|
const output = streams.output || process.stdout;
|
|
23962
23986
|
const error = streams.error || process.stderr;
|
|
23987
|
+
// Non-TTY fallback (CI logs, piped output) needs the rows up-front
|
|
23988
|
+
// because the renderer just dumps a static snapshot. Run the
|
|
23989
|
+
// deferred loader synchronously here when present so callers get
|
|
23990
|
+
// the same shape regardless of the entry path.
|
|
23963
23991
|
if (!canStartLogInkTui(input, output)) {
|
|
23964
|
-
|
|
23992
|
+
const fallbackRows = options.loadRows && rows.length === 0
|
|
23993
|
+
? await options.loadRows()
|
|
23994
|
+
: rows;
|
|
23995
|
+
await startInteractiveLog(git, fallbackRows, {
|
|
23965
23996
|
appLabel: options.appLabel,
|
|
23966
23997
|
input,
|
|
23967
23998
|
output,
|
|
@@ -23980,6 +24011,7 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
|
23980
24011
|
ink,
|
|
23981
24012
|
initialView: options.initialView || 'history',
|
|
23982
24013
|
logArgv: options.logArgv,
|
|
24014
|
+
loadRows: options.loadRows,
|
|
23983
24015
|
React,
|
|
23984
24016
|
rows,
|
|
23985
24017
|
theme: createLogInkTheme(options.theme),
|
|
@@ -24076,7 +24108,7 @@ function enrichFilterActionWithRectification(action, state, context) {
|
|
|
24076
24108
|
}
|
|
24077
24109
|
}
|
|
24078
24110
|
function LogInkApp(deps) {
|
|
24079
|
-
const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, logArgv, React, resumeRef, rows, theme } = deps;
|
|
24111
|
+
const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
|
|
24080
24112
|
const { Box, Text, useApp, useInput, useWindowSize } = ink;
|
|
24081
24113
|
const h = React.createElement;
|
|
24082
24114
|
const { exit } = useApp();
|
|
@@ -24097,9 +24129,26 @@ function LogInkApp(deps) {
|
|
|
24097
24129
|
// user's cache dir so the tip never reappears once dismissed. Lazy
|
|
24098
24130
|
// initializer so the fs check only runs on mount, not every render.
|
|
24099
24131
|
const [showOnboarding, setShowOnboarding] = React.useState(() => !hasSeenOnboarding());
|
|
24100
|
-
const [state, setState] = React.useState(() => createLogInkState(rows, {
|
|
24132
|
+
const [state, setState] = React.useState(() => createLogInkState(rows, {
|
|
24133
|
+
activeView: initialView,
|
|
24134
|
+
// Boot loader is in flight whenever the caller passed
|
|
24135
|
+
// `loadRows`, regardless of whether `rows` was empty or
|
|
24136
|
+
// pre-populated from the disk cache (#808). The history
|
|
24137
|
+
// surface only shows the "Loading commits…" placeholder when
|
|
24138
|
+
// there are zero visible commits, so cached data renders
|
|
24139
|
+
// immediately while the chrome still flags the refresh.
|
|
24140
|
+
bootLoading: Boolean(loadRows),
|
|
24141
|
+
}));
|
|
24101
24142
|
const [context, setContext] = React.useState({});
|
|
24102
|
-
const [contextStatus, setContextStatus] = React.useState(() =>
|
|
24143
|
+
const [contextStatus, setContextStatus] = React.useState(() => {
|
|
24144
|
+
// Boot starts every fetched key in 'loading' so the surfaces show
|
|
24145
|
+
// their loading hints immediately. `pullRequest` is the exception
|
|
24146
|
+
// (#808) — it isn't part of the boot fetch entries; it lazy-loads
|
|
24147
|
+
// when the user enters the PR view. Marking it 'idle' avoids a
|
|
24148
|
+
// permanent "loading" flag in the chrome and lets the dedicated
|
|
24149
|
+
// PR view's own load effect drive its loading state.
|
|
24150
|
+
return updateLogInkContextStatus(createLogInkContextStatus('loading'), 'pullRequest', 'idle');
|
|
24151
|
+
});
|
|
24103
24152
|
const [detail, setDetail] = React.useState(undefined);
|
|
24104
24153
|
const [detailLoading, setDetailLoading] = React.useState(false);
|
|
24105
24154
|
const [filePreview, setFilePreview] = React.useState(undefined);
|
|
@@ -24170,9 +24219,78 @@ function LogInkApp(deps) {
|
|
|
24170
24219
|
const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
|
|
24171
24220
|
const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
|
|
24172
24221
|
const selectedWorktreeFile = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex];
|
|
24222
|
+
// Stash patch per-file segmentation (#808). Hoisted out of the
|
|
24223
|
+
// useInput callback (was running on every keystroke), the yank
|
|
24224
|
+
// handler (was running per `y` press), and renderDiffSurface (was
|
|
24225
|
+
// running per paint) into a single LogInkApp-scoped memo. When the
|
|
24226
|
+
// active stash diff has hundreds of files, the prior fan-out was
|
|
24227
|
+
// re-walking the entire patch text 2-3x per keystroke for no
|
|
24228
|
+
// observable reason — the parsed list is purely a function of the
|
|
24229
|
+
// line array, which only changes when the user opens a different
|
|
24230
|
+
// stash.
|
|
24231
|
+
const stashDiffParsedFiles = React.useMemo(() => stashDiffLines ? parseStashDiffFiles(stashDiffLines) : [], [stashDiffLines]);
|
|
24232
|
+
// Filtered promoted-view lists (#808). These were recomputed inline
|
|
24233
|
+
// inside useInput on every keystroke — for a repo with hundreds of
|
|
24234
|
+
// branches / tags and an active filter, that's hundreds of regex
|
|
24235
|
+
// matches per arrow-key press. Memoizing on (raw list, filter)
|
|
24236
|
+
// collapses the work to one pass per filter / data change.
|
|
24237
|
+
const filteredBranchList = React.useMemo(() => {
|
|
24238
|
+
const all = context.branches?.localBranches || [];
|
|
24239
|
+
if (!state.filter)
|
|
24240
|
+
return all;
|
|
24241
|
+
return all.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter));
|
|
24242
|
+
}, [context.branches?.localBranches, state.filter]);
|
|
24243
|
+
const filteredTagList = React.useMemo(() => {
|
|
24244
|
+
const all = context.tags?.tags || [];
|
|
24245
|
+
if (!state.filter)
|
|
24246
|
+
return all;
|
|
24247
|
+
return all.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter));
|
|
24248
|
+
}, [context.tags?.tags, state.filter]);
|
|
24249
|
+
const filteredStashList = React.useMemo(() => {
|
|
24250
|
+
const all = context.stashes?.stashes || [];
|
|
24251
|
+
if (!state.filter)
|
|
24252
|
+
return all;
|
|
24253
|
+
return all.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter));
|
|
24254
|
+
}, [context.stashes?.stashes, state.filter]);
|
|
24255
|
+
const filteredWorktreeList = React.useMemo(() => {
|
|
24256
|
+
const all = context.worktreeList?.worktrees || [];
|
|
24257
|
+
if (!state.filter)
|
|
24258
|
+
return all;
|
|
24259
|
+
return all.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || ''], state.filter));
|
|
24260
|
+
}, [context.worktreeList?.worktrees, state.filter]);
|
|
24173
24261
|
const dispatch = React.useCallback((action) => {
|
|
24174
24262
|
setState((current) => applyLogInkAction(current, action));
|
|
24175
24263
|
}, []);
|
|
24264
|
+
// Deferred commit-log loader (#808). Runs once on mount when the
|
|
24265
|
+
// caller opted into the lazy boot path. The Ink tree is already on
|
|
24266
|
+
// screen at this point — without this the user stares at a black
|
|
24267
|
+
// terminal during the synchronous git log pre-mount fetch. The
|
|
24268
|
+
// mounted-ref guard prevents a late-resolving promise from
|
|
24269
|
+
// dispatching after the user `q` quits before rows arrive.
|
|
24270
|
+
React.useEffect(() => {
|
|
24271
|
+
if (!loadRows)
|
|
24272
|
+
return;
|
|
24273
|
+
let cancelled = false;
|
|
24274
|
+
void loadRows()
|
|
24275
|
+
.then((nextRows) => {
|
|
24276
|
+
if (cancelled || !mountedRef.current)
|
|
24277
|
+
return;
|
|
24278
|
+
dispatch({ type: 'replaceRows', rows: nextRows });
|
|
24279
|
+
})
|
|
24280
|
+
.catch((error) => {
|
|
24281
|
+
if (cancelled || !mountedRef.current)
|
|
24282
|
+
return;
|
|
24283
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
24284
|
+
dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}` });
|
|
24285
|
+
dispatch({ type: 'setBootLoading', value: false });
|
|
24286
|
+
});
|
|
24287
|
+
return () => {
|
|
24288
|
+
cancelled = true;
|
|
24289
|
+
};
|
|
24290
|
+
// Intentionally one-shot — re-running the boot load on hot
|
|
24291
|
+
// dispatch / loader changes would refetch the entire log on every
|
|
24292
|
+
// re-render. The loader fires once per app mount and that's it.
|
|
24293
|
+
}, []);
|
|
24176
24294
|
// Auto-dismiss status messages after a short window so transient
|
|
24177
24295
|
// confirmations ("Pulled current branch", "Edited foo.ts") don't
|
|
24178
24296
|
// linger forever. Each new message resets the timer; clearing the
|
|
@@ -24394,6 +24512,34 @@ function LogInkApp(deps) {
|
|
|
24394
24512
|
active = false;
|
|
24395
24513
|
};
|
|
24396
24514
|
}, [git]);
|
|
24515
|
+
// Lazy-load the full pullRequest overview (#808). Only fires when
|
|
24516
|
+
// the user actually navigates to the PR view, and only when we
|
|
24517
|
+
// don't already have data (so a workflow-triggered refresh that
|
|
24518
|
+
// hydrated `pullRequest` doesn't re-fetch on view entry). The
|
|
24519
|
+
// dedicated PR view shows its own loading state while this is in
|
|
24520
|
+
// flight; everywhere else (header glyph, yank, workflow runner)
|
|
24521
|
+
// already falls through to the slim `provider.currentPullRequest`
|
|
24522
|
+
// so the chrome stays populated immediately on boot.
|
|
24523
|
+
React.useEffect(() => {
|
|
24524
|
+
if (state.activeView !== 'pull-request')
|
|
24525
|
+
return;
|
|
24526
|
+
if (context.pullRequest)
|
|
24527
|
+
return;
|
|
24528
|
+
let active = true;
|
|
24529
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'));
|
|
24530
|
+
void safe(getPullRequestOverview(git)).then((value) => {
|
|
24531
|
+
if (!active)
|
|
24532
|
+
return;
|
|
24533
|
+
setContext((current) => ({
|
|
24534
|
+
...current,
|
|
24535
|
+
pullRequest: value,
|
|
24536
|
+
}));
|
|
24537
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'));
|
|
24538
|
+
});
|
|
24539
|
+
return () => {
|
|
24540
|
+
active = false;
|
|
24541
|
+
};
|
|
24542
|
+
}, [git, state.activeView, context.pullRequest]);
|
|
24397
24543
|
React.useEffect(() => {
|
|
24398
24544
|
let active = true;
|
|
24399
24545
|
async function loadDetail() {
|
|
@@ -25145,8 +25291,9 @@ function LogInkApp(deps) {
|
|
|
25145
25291
|
else if (state.diffSource === 'stash' && stashDiffLines) {
|
|
25146
25292
|
// Walk back to the most recent file header at or before the
|
|
25147
25293
|
// current preview offset — same logic the input-context block
|
|
25148
|
-
// uses to expose stashDiffSelectedPath.
|
|
25149
|
-
|
|
25294
|
+
// uses to expose stashDiffSelectedPath. Reads the memoized
|
|
25295
|
+
// parse so the yank handler doesn't re-walk the entire patch.
|
|
25296
|
+
const current = findStashFileForOffset(stashDiffParsedFiles, state.diffPreviewOffset);
|
|
25150
25297
|
if (current) {
|
|
25151
25298
|
value = current.path;
|
|
25152
25299
|
label = `path ${current.path}`;
|
|
@@ -25189,6 +25336,7 @@ function LogInkApp(deps) {
|
|
|
25189
25336
|
selected,
|
|
25190
25337
|
selectedDetailFile,
|
|
25191
25338
|
stashDiffLines,
|
|
25339
|
+
stashDiffParsedFiles,
|
|
25192
25340
|
state.activeView,
|
|
25193
25341
|
state.branchSort,
|
|
25194
25342
|
state.diffPreviewOffset,
|
|
@@ -25403,43 +25551,25 @@ function LogInkApp(deps) {
|
|
|
25403
25551
|
// P4.5: navigation in branches/tags/stash uses the FILTERED list
|
|
25404
25552
|
// length when a filter is active so j/k stay live instead of getting
|
|
25405
25553
|
// 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;
|
|
25554
|
+
// screen. The filtered lists are memoized at LogInkApp scope (#808
|
|
25555
|
+
// perf pass) — reading them here is O(1) instead of O(branches +
|
|
25556
|
+
// tags + stashes + worktrees) per keystroke.
|
|
25557
|
+
const branchVisibleCount = filteredBranchList.length;
|
|
25558
|
+
const tagVisibleCount = filteredTagList.length;
|
|
25559
|
+
const stashVisibleCount = filteredStashList.length;
|
|
25560
|
+
const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
|
|
25561
|
+
const worktreeVisibleCount = filteredWorktreeList.length;
|
|
25431
25562
|
// When the diff view is showing a stash patch, swap the previewLineCount
|
|
25432
25563
|
// to the stash diff length so the existing pageDetailPreview path
|
|
25433
25564
|
// (j/k, PgUp/PgDn) scrolls through it without a parallel pipeline.
|
|
25434
25565
|
const diffPreviewLineCount = state.diffSource === 'stash'
|
|
25435
25566
|
? stashDiffLines?.length
|
|
25436
25567
|
: filePreview?.hunks.length;
|
|
25437
|
-
//
|
|
25438
|
-
//
|
|
25439
|
-
//
|
|
25440
|
-
|
|
25441
|
-
|
|
25442
|
-
: [];
|
|
25568
|
+
// Per-file segmentation for stash diffs reads the LogInkApp-scoped
|
|
25569
|
+
// memo so navigation keys + the input-context derivation share a
|
|
25570
|
+
// single parse pass per stash patch instead of re-walking the
|
|
25571
|
+
// entire patch text on every keystroke.
|
|
25572
|
+
const stashDiffFiles = state.diffSource === 'stash' ? stashDiffParsedFiles : [];
|
|
25443
25573
|
const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
|
|
25444
25574
|
const stashDiffSelectedPath = state.diffSource === 'stash'
|
|
25445
25575
|
? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
|
|
@@ -25536,6 +25666,7 @@ function LogInkApp(deps) {
|
|
|
25536
25666
|
rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
|
|
25537
25667
|
sidebarFocused: state.focus === 'sidebar',
|
|
25538
25668
|
inspectorFocused: state.focus === 'detail',
|
|
25669
|
+
helpOverlayActive: state.showHelp,
|
|
25539
25670
|
});
|
|
25540
25671
|
if (layout.tooSmall) {
|
|
25541
25672
|
return h(Box, {
|
|
@@ -25565,7 +25696,13 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
|
|
|
25565
25696
|
? `PR #${prInfo.number} ${prInfo.isDraft ? 'DRAFT' : prInfo.state}`
|
|
25566
25697
|
: 'no PR';
|
|
25567
25698
|
const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
|
|
25568
|
-
|
|
25699
|
+
// Boot loading wins over the per-context loading hint because it
|
|
25700
|
+
// tells the user the headline thing they care about (commits aren't
|
|
25701
|
+
// ready yet) — the context fetches finish independently and surface
|
|
25702
|
+
// their own per-section loading copy in the sidebars.
|
|
25703
|
+
const loading = state.bootLoading
|
|
25704
|
+
? ' loading commits'
|
|
25705
|
+
: isLogInkContextLoading(contextStatus) ? ' loading context' : '';
|
|
25569
25706
|
const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
|
|
25570
25707
|
const view = breadcrumb ? ` ${breadcrumb}` : '';
|
|
25571
25708
|
// Mode indicator (P2.2) — surfaces the current input mode so users
|
|
@@ -25872,22 +26009,30 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
25872
26009
|
...(state.historyFetchArgs
|
|
25873
26010
|
? [h(Text, { key: 'history-fetch-indicator', dimColor: true }, `filter: ${formatHistoryFetchArgs(state.historyFetchArgs)} (ctrl+u in / to clear)`)]
|
|
25874
26011
|
: []), ...(pendingNode ? [pendingNode] : []), visible.items.length === 0
|
|
25875
|
-
? h(Text, { dimColor: true },
|
|
25876
|
-
|
|
25877
|
-
|
|
25878
|
-
|
|
26012
|
+
? h(Text, { dimColor: true }, state.bootLoading
|
|
26013
|
+
? formatLogInkLoading({ resource: 'commits' })
|
|
26014
|
+
: formatLogInkHistoryEmpty({
|
|
26015
|
+
filter: state.filter,
|
|
26016
|
+
totalCommits: state.commits.length,
|
|
26017
|
+
}))
|
|
25879
26018
|
: visible.items.map((item, index) => {
|
|
25880
26019
|
if (item.type === 'graph') {
|
|
26020
|
+
// Graph-only rows are git's lane-closure scaffolding (`|/`,
|
|
26021
|
+
// `|\`, etc.) — they're real topology but visually they look
|
|
26022
|
+
// like blank rows that the user might wonder if they
|
|
26023
|
+
// accidentally skipped a commit on (#831). Render dim-on-dim
|
|
26024
|
+
// so they retreat as connectors rather than competing with
|
|
26025
|
+
// commit rows for the eye's attention.
|
|
25881
26026
|
if (item.laneSegments && !theme.ascii) {
|
|
25882
|
-
return h(Text, { key: `graph-${index}-${item.graph}
|
|
26027
|
+
return h(Text, { key: `graph-${index}-${item.graph}`, dimColor: true }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`, { forceDim: true }));
|
|
25883
26028
|
}
|
|
25884
26029
|
return h(Text, {
|
|
25885
26030
|
key: `graph-${index}-${item.graph}`,
|
|
25886
26031
|
color: theme.noColor ? undefined : theme.colors.muted,
|
|
25887
|
-
dimColor:
|
|
25888
|
-
}, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }),
|
|
26032
|
+
dimColor: true,
|
|
26033
|
+
}, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), Math.max(8, width - 4)));
|
|
25889
26034
|
}
|
|
25890
|
-
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, item.laneSegments);
|
|
26035
|
+
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, item.laneSegments);
|
|
25891
26036
|
}));
|
|
25892
26037
|
}
|
|
25893
26038
|
/**
|
|
@@ -25900,7 +26045,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
25900
26045
|
* Final padding is appended as its own span so callers do not need to
|
|
25901
26046
|
* pre-pad the graph string before computing lane segments.
|
|
25902
26047
|
*/
|
|
25903
|
-
function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
26048
|
+
function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, options = {}) {
|
|
25904
26049
|
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25905
26050
|
const elements = [];
|
|
25906
26051
|
let totalLen = 0;
|
|
@@ -25909,7 +26054,12 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
|
25909
26054
|
elements.push(h(Text, {
|
|
25910
26055
|
key: `${keyPrefix}-${idx}`,
|
|
25911
26056
|
color: laneColor ?? muted,
|
|
25912
|
-
dimColor
|
|
26057
|
+
// Ink does not cascade dimColor from a parent Text to children,
|
|
26058
|
+
// so the caller's "this whole row should fade" intent has to
|
|
26059
|
+
// travel here as an explicit flag (#831). Used for graph-only
|
|
26060
|
+
// lane-closure rows, where the lane colors otherwise compete
|
|
26061
|
+
// for attention with the commits they connect.
|
|
26062
|
+
dimColor: options.forceDim || (theme.noColor && seg.laneId === undefined),
|
|
25913
26063
|
}, seg.text));
|
|
25914
26064
|
totalLen += seg.text.length;
|
|
25915
26065
|
});
|
|
@@ -25930,11 +26080,22 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
|
25930
26080
|
* Truncation is per-segment so the variable-length message field gets
|
|
25931
26081
|
* the leftover budget after fixed segments are accounted for.
|
|
25932
26082
|
*/
|
|
25933
|
-
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
|
|
26083
|
+
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, laneSegments) {
|
|
25934
26084
|
const refs = formatInkRefLabels(commit.refs);
|
|
25935
|
-
|
|
26085
|
+
// Total cells available to the row content. Earlier revisions used a
|
|
26086
|
+
// hardcoded 140 here, which let row content overflow whenever the
|
|
26087
|
+
// panel was narrower than that — Ink would wrap onto a second visual
|
|
26088
|
+
// line and the next commit's graph indicator landed against the wrap
|
|
26089
|
+
// continuation rather than its own commit (#830). Subtracting 4
|
|
26090
|
+
// accounts for the panel's left + right border + 1-cell padding.
|
|
26091
|
+
const totalWidth = Math.max(20, panelWidth - 4);
|
|
25936
26092
|
const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
|
|
25937
|
-
|
|
26093
|
+
// Refs trail the message and shrink first when the row is narrow:
|
|
26094
|
+
// the user can always see the full ref list in the inspector, so
|
|
26095
|
+
// the headline subject keeps priority over decoration.
|
|
26096
|
+
const refsRoom = Math.max(0, totalWidth - fixedWidth - 8);
|
|
26097
|
+
const refsTrunc = refs ? truncate$1(refs, refsRoom) : '';
|
|
26098
|
+
const messageRoom = Math.max(8, totalWidth - fixedWidth - cellWidth(refsTrunc));
|
|
25938
26099
|
const message = truncate$1(commit.message, messageRoom);
|
|
25939
26100
|
const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
|
|
25940
26101
|
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
@@ -25949,7 +26110,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
|
|
|
25949
26110
|
key: `${commit.hash}-${index}`,
|
|
25950
26111
|
backgroundColor: selectedBg,
|
|
25951
26112
|
inverse: selected,
|
|
25952
|
-
}, ...graphChildren, ' ', h(Text, { color: accent, bold: selected }, commit.shortHash), ' ', h(Text, { dimColor: true }, commit.date), ' ', h(Text, undefined, message),
|
|
26113
|
+
}, ...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
26114
|
}
|
|
25954
26115
|
/**
|
|
25955
26116
|
* Render the synthetic "(+) new commit" affordance shown above the real
|
|
@@ -26223,6 +26384,15 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
26223
26384
|
: `${localBranches.length}/${sortedAll.length} local | current: ${branches?.currentBranch || '<detached>'}${filterLabel}${sortLabel}`;
|
|
26224
26385
|
const emptyLabel = formatLogInkBranchesEmpty({ filter: state.filter });
|
|
26225
26386
|
const loadingLabel = formatLogInkLoading({ resource: 'branches' });
|
|
26387
|
+
// Per-column width derived from the visible window (#833) so columns
|
|
26388
|
+
// align across rows regardless of name length. Padded to the longest
|
|
26389
|
+
// name in view so short rows fill out instead of leaving a gutter;
|
|
26390
|
+
// capped at 40 cells so one runaway long branch name doesn't blow
|
|
26391
|
+
// out the timestamp column entirely (longer names get truncated and
|
|
26392
|
+
// the timestamp stays where the user expects it).
|
|
26393
|
+
const nameColWidth = visible.length === 0
|
|
26394
|
+
? 28
|
|
26395
|
+
: Math.min(40, Math.max(8, ...visible.map((branch) => branch.shortName.length)));
|
|
26226
26396
|
const lines = loading
|
|
26227
26397
|
? [h(Text, { key: 'branches-loading', dimColor: true }, loadingLabel)]
|
|
26228
26398
|
: localBranches.length === 0
|
|
@@ -26236,18 +26406,18 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
26236
26406
|
const lastTouched = formatBranchLastTouched(branch.date, new Date());
|
|
26237
26407
|
// Split the row into spans so the timestamp stays dim even on the
|
|
26238
26408
|
// 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(
|
|
26409
|
+
// their per-window-derived column widths; the timestamp is
|
|
26410
|
+
// right-padded so the divergence column stays aligned across rows.
|
|
26411
|
+
const namePadded = truncate$1(branch.shortName, nameColWidth).padEnd(nameColWidth);
|
|
26242
26412
|
const timestampPadded = lastTouched.padEnd(8);
|
|
26243
26413
|
const lineDim = !isSelected && !branch.current;
|
|
26244
26414
|
const head = `${cursor} ${marker} ${namePadded} `;
|
|
26245
26415
|
const trailingDivergence = divergence ? ` ${divergence}` : '';
|
|
26246
|
-
// Truncate the assembled line
|
|
26247
|
-
//
|
|
26248
|
-
//
|
|
26416
|
+
// Truncate the assembled line to the actual panel width so a
|
|
26417
|
+
// narrow inspector / sidebar focus doesn't push branch rows
|
|
26418
|
+
// onto a second visual line (#830).
|
|
26249
26419
|
const fullText = `${head}${timestampPadded}${trailingDivergence}`;
|
|
26250
|
-
const truncated = truncate$1(fullText,
|
|
26420
|
+
const truncated = truncate$1(fullText, Math.max(20, width - 4));
|
|
26251
26421
|
// If truncation chopped into the timestamp/divergence portion,
|
|
26252
26422
|
// fall back to a single Text to keep the visible width honest.
|
|
26253
26423
|
if (truncated !== fullText) {
|
|
@@ -26291,6 +26461,13 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
26291
26461
|
: `${tags.length}/${sortedAll.length} tags${filterLabel}${sortLabel}`;
|
|
26292
26462
|
const emptyLabel = formatLogInkTagsEmpty({ filter: state.filter });
|
|
26293
26463
|
const loadingLabel = formatLogInkLoading({ resource: 'tags' });
|
|
26464
|
+
// Per-window name column width (#833) so short tags don't leave a
|
|
26465
|
+
// wide gutter and long tags don't push the subject off-screen. Cap
|
|
26466
|
+
// matches the branches surface for visual consistency across the
|
|
26467
|
+
// promoted views.
|
|
26468
|
+
const tagNameColWidth = visible.length === 0
|
|
26469
|
+
? 20
|
|
26470
|
+
: Math.min(40, Math.max(8, ...visible.map((tag) => tag.name.length)));
|
|
26294
26471
|
const lines = loading
|
|
26295
26472
|
? [h(Text, { key: 'tags-loading', dimColor: true }, loadingLabel)]
|
|
26296
26473
|
: tags.length === 0
|
|
@@ -26304,8 +26481,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
26304
26481
|
// formatHyperlink wraps just the tag name, leaving width math
|
|
26305
26482
|
// intact.
|
|
26306
26483
|
const url = buildRefUrl(context.provider?.repository, tag.name);
|
|
26307
|
-
const namePadded = tag.name.padEnd(
|
|
26308
|
-
const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`,
|
|
26484
|
+
const namePadded = truncate$1(tag.name, tagNameColWidth).padEnd(tagNameColWidth);
|
|
26485
|
+
const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`, Math.max(20, width - 4));
|
|
26309
26486
|
if (!url || lineText.indexOf(namePadded) < 0) {
|
|
26310
26487
|
return h(Text, {
|
|
26311
26488
|
key: `tag-${index}`,
|
|
@@ -26388,6 +26565,17 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
26388
26565
|
const headerRight = loading
|
|
26389
26566
|
? 'loading worktrees'
|
|
26390
26567
|
: `${worktrees.length}/${allWorktrees.length} worktrees${filterLabel}`;
|
|
26568
|
+
// Per-window branch column width (#833). Worktrees often track
|
|
26569
|
+
// branches with names varying widely in length (`main` vs.
|
|
26570
|
+
// `feat/tui-something-long`); fixed-width padding either left a
|
|
26571
|
+
// huge gutter on short rows or pushed the path column off-screen on
|
|
26572
|
+
// long ones. Cap matches the other promoted surfaces.
|
|
26573
|
+
const branchColWidth = visible.length === 0
|
|
26574
|
+
? 28
|
|
26575
|
+
: Math.min(40, Math.max(8, ...visible.map((entry) => {
|
|
26576
|
+
const label = entry.branch ? entry.branch : entry.head || '<detached>';
|
|
26577
|
+
return label.length;
|
|
26578
|
+
})));
|
|
26391
26579
|
const lines = loading
|
|
26392
26580
|
? [h(Text, { key: 'worktrees-loading', dimColor: true }, formatLogInkLoading({ resource: 'worktrees' }))]
|
|
26393
26581
|
: worktrees.length === 0
|
|
@@ -26399,11 +26587,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
26399
26587
|
const marker = entry.current ? '*' : ' ';
|
|
26400
26588
|
const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
|
|
26401
26589
|
const stateLabel = entry.dirty ? 'dirty' : 'clean';
|
|
26590
|
+
const branchPadded = truncate$1(branchLabel, branchColWidth).padEnd(branchColWidth);
|
|
26402
26591
|
return h(Text, {
|
|
26403
26592
|
key: `worktree-${index}`,
|
|
26404
26593
|
bold: isSelected,
|
|
26405
26594
|
dimColor: !isSelected && !entry.current,
|
|
26406
|
-
}, truncate$1(`${cursor} ${marker} ${
|
|
26595
|
+
}, truncate$1(`${cursor} ${marker} ${branchPadded} ${stateLabel.padEnd(6)} ${entry.path}`, width - 4));
|
|
26407
26596
|
});
|
|
26408
26597
|
return h(Box, {
|
|
26409
26598
|
borderColor: focusBorderColor(theme, focused),
|
|
@@ -27483,6 +27672,84 @@ function renderFooter(h, components, state, context, theme, idleTip) {
|
|
|
27483
27672
|
}, h(Text, { color: theme.colors.muted, dimColor: true }, contextualText), h(Text, { color: theme.colors.muted, dimColor: true }, globalText));
|
|
27484
27673
|
}
|
|
27485
27674
|
|
|
27675
|
+
/**
|
|
27676
|
+
* Per-repo disk cache of the last successful commit-log fetch (#808).
|
|
27677
|
+
* Lets the TUI render an immediate stale-but-useful history view on
|
|
27678
|
+
* subsequent boots while the fresh `git log` runs in the background;
|
|
27679
|
+
* once the fresh data lands the runtime swaps it in transparently.
|
|
27680
|
+
*
|
|
27681
|
+
* Strict best-effort: read failures fall back to "no cache" (boot
|
|
27682
|
+
* shows the loading placeholder), and write failures are swallowed
|
|
27683
|
+
* silently (next boot just doesn't have the cache yet). The cache is
|
|
27684
|
+
* never load-bearing.
|
|
27685
|
+
*
|
|
27686
|
+
* Repos are keyed by a short hash of their absolute path. No PII in
|
|
27687
|
+
* the cache filename, and re-creating a repo at the same path keeps
|
|
27688
|
+
* the same cache.
|
|
27689
|
+
*/
|
|
27690
|
+
const CACHE_SCHEMA_VERSION = 1;
|
|
27691
|
+
const CACHE_DIR_NAME = 'overview';
|
|
27692
|
+
/**
|
|
27693
|
+
* Hard cap on rows we'll write per cache entry. The interactive
|
|
27694
|
+
* default limit is 300; this caps growth in case a user opts into a
|
|
27695
|
+
* much larger window. Keeps the cache file under ~200kb on a typical
|
|
27696
|
+
* repo.
|
|
27697
|
+
*/
|
|
27698
|
+
const CACHE_ROW_HARD_CAP = 500;
|
|
27699
|
+
function resolveCacheDir() {
|
|
27700
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
27701
|
+
if (xdg && xdg.trim().length > 0) {
|
|
27702
|
+
return path__namespace$1.join(xdg, 'coco', CACHE_DIR_NAME);
|
|
27703
|
+
}
|
|
27704
|
+
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME);
|
|
27705
|
+
}
|
|
27706
|
+
function repoKey(repoPath) {
|
|
27707
|
+
// sha1 here is a non-security cache-key derivation — we just need a
|
|
27708
|
+
// deterministic short identifier for the cache filename so two repos
|
|
27709
|
+
// at different paths never collide. No PII or auth context is hashed
|
|
27710
|
+
// and no collision-resistance against an adversary is required.
|
|
27711
|
+
// DevSkim DS126858 doesn't apply.
|
|
27712
|
+
// DevSkim: ignore DS126858
|
|
27713
|
+
return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
27714
|
+
}
|
|
27715
|
+
function getOverviewCachePath(repoPath) {
|
|
27716
|
+
return path__namespace$1.join(resolveCacheDir(), `commits.${repoKey(repoPath)}.json`);
|
|
27717
|
+
}
|
|
27718
|
+
function readCachedCommits(repoPath) {
|
|
27719
|
+
try {
|
|
27720
|
+
const raw = fs__namespace$1.readFileSync(getOverviewCachePath(repoPath), 'utf8');
|
|
27721
|
+
const parsed = JSON.parse(raw);
|
|
27722
|
+
if (parsed.version !== CACHE_SCHEMA_VERSION) {
|
|
27723
|
+
// Schema mismatch — quietly drop the stale entry on next write.
|
|
27724
|
+
// Treating it as "no cache" keeps boot behavior predictable
|
|
27725
|
+
// across upgrades.
|
|
27726
|
+
return undefined;
|
|
27727
|
+
}
|
|
27728
|
+
if (!Array.isArray(parsed.rows)) {
|
|
27729
|
+
return undefined;
|
|
27730
|
+
}
|
|
27731
|
+
return parsed.rows;
|
|
27732
|
+
}
|
|
27733
|
+
catch {
|
|
27734
|
+
return undefined;
|
|
27735
|
+
}
|
|
27736
|
+
}
|
|
27737
|
+
function writeCachedCommits(repoPath, rows) {
|
|
27738
|
+
const file = getOverviewCachePath(repoPath);
|
|
27739
|
+
const envelope = {
|
|
27740
|
+
version: CACHE_SCHEMA_VERSION,
|
|
27741
|
+
savedAt: new Date().toISOString(),
|
|
27742
|
+
rows: rows.slice(0, CACHE_ROW_HARD_CAP),
|
|
27743
|
+
};
|
|
27744
|
+
try {
|
|
27745
|
+
fs__namespace$1.mkdirSync(path__namespace$1.dirname(file), { recursive: true });
|
|
27746
|
+
fs__namespace$1.writeFileSync(file, JSON.stringify(envelope));
|
|
27747
|
+
}
|
|
27748
|
+
catch {
|
|
27749
|
+
// Best-effort persistence; swallow.
|
|
27750
|
+
}
|
|
27751
|
+
}
|
|
27752
|
+
|
|
27486
27753
|
function createLogArgvFromUiArgv(argv) {
|
|
27487
27754
|
return {
|
|
27488
27755
|
$0: argv.$0,
|
|
@@ -27507,14 +27774,43 @@ function createUiTheme(config, argv) {
|
|
|
27507
27774
|
preset: argv.theme,
|
|
27508
27775
|
};
|
|
27509
27776
|
}
|
|
27777
|
+
/**
|
|
27778
|
+
* Wrap a fresh-rows loader with the disk-cache write step. Lets the
|
|
27779
|
+
* runtime stay caching-agnostic — it just receives the rows and
|
|
27780
|
+
* doesn't know whether they came from cache or git, while the caller
|
|
27781
|
+
* (which knows the repo path) handles persistence.
|
|
27782
|
+
*/
|
|
27783
|
+
function withCacheWrite(repoPath, loader) {
|
|
27784
|
+
return async () => {
|
|
27785
|
+
const rows = await loader();
|
|
27786
|
+
writeCachedCommits(repoPath, rows);
|
|
27787
|
+
return rows;
|
|
27788
|
+
};
|
|
27789
|
+
}
|
|
27510
27790
|
async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
27511
27791
|
const config = options.config || loadConfig(logArgv);
|
|
27512
27792
|
const git = options.git || getRepo();
|
|
27513
|
-
const
|
|
27514
|
-
|
|
27793
|
+
const repoPath = process.cwd();
|
|
27794
|
+
// Three-stage boot (#808):
|
|
27795
|
+
// 1. Read the disk cache and pass cached rows as the initial set
|
|
27796
|
+
// so the user sees the workstation chrome populated with
|
|
27797
|
+
// commits in the first frame.
|
|
27798
|
+
// 2. Mount Ink immediately with those rows (or [] if no cache).
|
|
27799
|
+
// 3. Run loadRows in the background to refresh — when fresh data
|
|
27800
|
+
// lands the runtime swaps it in transparently and we persist
|
|
27801
|
+
// the new rows back to the cache for next boot.
|
|
27802
|
+
// Caller-provided rows skip the lazy path entirely (caller already
|
|
27803
|
+
// has up-to-date data — no point redoing the fetch).
|
|
27804
|
+
const cachedRows = options.rows ? undefined : readCachedCommits(repoPath);
|
|
27805
|
+
const initialRows = options.rows || cachedRows || [];
|
|
27806
|
+
const loadRows = options.rows
|
|
27807
|
+
? undefined
|
|
27808
|
+
: withCacheWrite(repoPath, () => getLogRows(git, logArgv));
|
|
27809
|
+
await startInkInteractiveLog(git, initialRows, {}, {
|
|
27515
27810
|
appLabel: 'coco',
|
|
27516
27811
|
idleTips: config.logTui?.idleTips,
|
|
27517
27812
|
initialView: 'history',
|
|
27813
|
+
loadRows,
|
|
27518
27814
|
logArgv,
|
|
27519
27815
|
theme: config.logTui?.theme,
|
|
27520
27816
|
});
|
|
@@ -27523,11 +27819,15 @@ async function startCocoUi(argv) {
|
|
|
27523
27819
|
const config = loadConfig(argv);
|
|
27524
27820
|
const git = getRepo();
|
|
27525
27821
|
const logArgv = createLogArgvFromUiArgv(argv);
|
|
27526
|
-
const
|
|
27527
|
-
|
|
27822
|
+
const repoPath = process.cwd();
|
|
27823
|
+
// Same three-stage boot as startCocoUiFromLogArgv — mount with
|
|
27824
|
+
// cached rows for an instant-paint shell, refresh in background.
|
|
27825
|
+
const cachedRows = readCachedCommits(repoPath);
|
|
27826
|
+
await startInkInteractiveLog(git, cachedRows || [], {}, {
|
|
27528
27827
|
appLabel: 'coco',
|
|
27529
27828
|
idleTips: config.logTui?.idleTips,
|
|
27530
27829
|
initialView: argv.view || 'history',
|
|
27830
|
+
loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
|
|
27531
27831
|
logArgv,
|
|
27532
27832
|
theme: createUiTheme(config, argv),
|
|
27533
27833
|
});
|
|
@@ -27637,15 +27937,18 @@ const handler$2 = async (argv) => {
|
|
|
27637
27937
|
});
|
|
27638
27938
|
return;
|
|
27639
27939
|
}
|
|
27640
|
-
|
|
27940
|
+
// Interactive path defers the commit log fetch into the runtime
|
|
27941
|
+
// (#808) so the TUI mounts immediately with a "Loading commits…"
|
|
27942
|
+
// placeholder. The non-interactive (stdout) path still needs rows
|
|
27943
|
+
// up-front because the formatter just dumps a static snapshot.
|
|
27641
27944
|
if (argv.interactive && format === 'table') {
|
|
27642
27945
|
await startCocoUiFromLogArgv(argv, {
|
|
27643
27946
|
config,
|
|
27644
27947
|
git,
|
|
27645
|
-
rows,
|
|
27646
27948
|
});
|
|
27647
27949
|
return;
|
|
27648
27950
|
}
|
|
27951
|
+
const rows = await getLogRows(git, argv);
|
|
27649
27952
|
const result = format === 'json' ? formatLogJson(rows) : formatLogTable(rows);
|
|
27650
27953
|
await handleResult({
|
|
27651
27954
|
result,
|