git-coco 0.40.0 → 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 +446 -101
- package/dist/index.js +446 -101
- package/package.json +3 -3
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.0";
|
|
57
57
|
|
|
58
58
|
const isInteractive = (config) => {
|
|
59
59
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -2127,11 +2127,21 @@ function formatAuthenticationError(error, logger) {
|
|
|
2127
2127
|
logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
|
|
2128
2128
|
}
|
|
2129
2129
|
/**
|
|
2130
|
-
* Formats a generic error
|
|
2130
|
+
* Formats a generic error.
|
|
2131
|
+
*
|
|
2132
|
+
* The error message prints unconditionally (was previously gated behind
|
|
2133
|
+
* `--verbose`, which left users staring at a "Failed to execute command"
|
|
2134
|
+
* line with no actionable detail when something crashed). The full stack
|
|
2135
|
+
* trace stays under `logger.verbose` so plain output stays focused on the
|
|
2136
|
+
* one-line cause; users running into something they can't diagnose can opt
|
|
2137
|
+
* in with `--verbose` for the trace.
|
|
2131
2138
|
*/
|
|
2132
2139
|
function formatGenericError(error, logger) {
|
|
2133
2140
|
logger.log('\nFailed to execute command', { color: 'yellow' });
|
|
2134
|
-
logger.
|
|
2141
|
+
logger.log(`\nError: ${error.message}`, { color: 'red' });
|
|
2142
|
+
if (error.stack) {
|
|
2143
|
+
logger.verbose(`\n${error.stack}`, { color: 'gray' });
|
|
2144
|
+
}
|
|
2135
2145
|
}
|
|
2136
2146
|
function commandExecutor(handler) {
|
|
2137
2147
|
return async (argv) => {
|
|
@@ -16303,6 +16313,11 @@ function replaceRows(state, rows) {
|
|
|
16303
16313
|
selectedFileIndex: 0,
|
|
16304
16314
|
pendingCommitFocused: false,
|
|
16305
16315
|
pendingKey: undefined,
|
|
16316
|
+
// Rows just landed — clear the boot-loading flag so the history
|
|
16317
|
+
// surface drops the "Loading commits…" placeholder. Safe to clear
|
|
16318
|
+
// unconditionally because `replaceRows` only fires after a real
|
|
16319
|
+
// git log returns.
|
|
16320
|
+
bootLoading: false,
|
|
16306
16321
|
};
|
|
16307
16322
|
}
|
|
16308
16323
|
function appendRows(state, rows) {
|
|
@@ -16393,6 +16408,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
16393
16408
|
diffViewMode: 'unified',
|
|
16394
16409
|
inspectorTab: 'inspector',
|
|
16395
16410
|
inspectorActionIndex: 0,
|
|
16411
|
+
bootLoading: options.bootLoading ?? false,
|
|
16396
16412
|
};
|
|
16397
16413
|
}
|
|
16398
16414
|
function getSelectedInkCommit(state) {
|
|
@@ -16594,6 +16610,12 @@ function applyLogInkAction(state, action) {
|
|
|
16594
16610
|
inspectorActionIndex: 0,
|
|
16595
16611
|
pendingKey: undefined,
|
|
16596
16612
|
};
|
|
16613
|
+
case 'setBootLoading':
|
|
16614
|
+
return {
|
|
16615
|
+
...state,
|
|
16616
|
+
bootLoading: action.value,
|
|
16617
|
+
pendingKey: undefined,
|
|
16618
|
+
};
|
|
16597
16619
|
case 'moveTag':
|
|
16598
16620
|
return {
|
|
16599
16621
|
...state,
|
|
@@ -17932,6 +17954,18 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17932
17954
|
if (key.rightArrow && state.focus === 'sidebar') {
|
|
17933
17955
|
return [action({ type: 'nextSidebarTab' })];
|
|
17934
17956
|
}
|
|
17957
|
+
// ←/→ on the inspector switch between the [Inspector] / [Actions]
|
|
17958
|
+
// tabs, mirroring the sidebar's left/right tab semantics. `[` and
|
|
17959
|
+
// `]` still work as keyboard alternatives, but the visible hint in
|
|
17960
|
+
// the inspector chrome shows ←/→ because the bracketed `[/]`
|
|
17961
|
+
// notation reads as "press the / key" — which is the global filter
|
|
17962
|
+
// trigger and was making users think the binding was busted.
|
|
17963
|
+
if (key.leftArrow && state.focus === 'detail') {
|
|
17964
|
+
return [action({ type: 'setInspectorTab', value: 'inspector' })];
|
|
17965
|
+
}
|
|
17966
|
+
if (key.rightArrow && state.focus === 'detail') {
|
|
17967
|
+
return [action({ type: 'setInspectorTab', value: 'actions' })];
|
|
17968
|
+
}
|
|
17935
17969
|
// ←/→ on the status surface jump between the staged / unstaged /
|
|
17936
17970
|
// untracked groups — the horizontal axis is "between groups", the
|
|
17937
17971
|
// vertical axis (↑/↓ below) is "within the active group's files".
|
|
@@ -18658,7 +18692,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18658
18692
|
* fall back to "already seen" so we never block startup.
|
|
18659
18693
|
*/
|
|
18660
18694
|
const MARKER_BASENAME = 'onboarding.seen';
|
|
18661
|
-
function resolveCacheDir$
|
|
18695
|
+
function resolveCacheDir$3() {
|
|
18662
18696
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
18663
18697
|
if (xdg && xdg.trim().length > 0) {
|
|
18664
18698
|
return path$1.join(xdg, 'coco');
|
|
@@ -18666,7 +18700,7 @@ function resolveCacheDir$2() {
|
|
|
18666
18700
|
return path$1.join(os$1.homedir(), '.cache', 'coco');
|
|
18667
18701
|
}
|
|
18668
18702
|
function getOnboardingMarkerPath() {
|
|
18669
|
-
return path$1.join(resolveCacheDir$
|
|
18703
|
+
return path$1.join(resolveCacheDir$3(), MARKER_BASENAME);
|
|
18670
18704
|
}
|
|
18671
18705
|
function hasSeenOnboarding() {
|
|
18672
18706
|
try {
|
|
@@ -18697,14 +18731,14 @@ function markOnboardingSeen() {
|
|
|
18697
18731
|
* settings: best-effort, XDG-friendly, no PII in the cache filename.
|
|
18698
18732
|
*/
|
|
18699
18733
|
const VALID_MODES = ['unified', 'split'];
|
|
18700
|
-
function resolveCacheDir$
|
|
18734
|
+
function resolveCacheDir$2() {
|
|
18701
18735
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
18702
18736
|
if (xdg && xdg.trim().length > 0) {
|
|
18703
18737
|
return path$1.join(xdg, 'coco');
|
|
18704
18738
|
}
|
|
18705
18739
|
return path$1.join(os$1.homedir(), '.cache', 'coco');
|
|
18706
18740
|
}
|
|
18707
|
-
function repoKey$
|
|
18741
|
+
function repoKey$2(repoPath) {
|
|
18708
18742
|
// sha1 is used here as a non-security cache-key derivation — we just
|
|
18709
18743
|
// need a deterministic short identifier for the marker filename. No
|
|
18710
18744
|
// PII or auth context is hashed.
|
|
@@ -18712,7 +18746,7 @@ function repoKey$1(repoPath) {
|
|
|
18712
18746
|
return crypto.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
18713
18747
|
}
|
|
18714
18748
|
function getDiffViewModeMarkerPath(repoPath) {
|
|
18715
|
-
return path$1.join(resolveCacheDir$
|
|
18749
|
+
return path$1.join(resolveCacheDir$2(), `diff-view-mode.${repoKey$2(repoPath)}`);
|
|
18716
18750
|
}
|
|
18717
18751
|
function getSavedDiffViewMode(repoPath) {
|
|
18718
18752
|
try {
|
|
@@ -18754,14 +18788,14 @@ const VALID_TABS = [
|
|
|
18754
18788
|
'stashes',
|
|
18755
18789
|
'worktrees',
|
|
18756
18790
|
];
|
|
18757
|
-
function resolveCacheDir() {
|
|
18791
|
+
function resolveCacheDir$1() {
|
|
18758
18792
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
18759
18793
|
if (xdg && xdg.trim().length > 0) {
|
|
18760
18794
|
return path$1.join(xdg, 'coco');
|
|
18761
18795
|
}
|
|
18762
18796
|
return path$1.join(os$1.homedir(), '.cache', 'coco');
|
|
18763
18797
|
}
|
|
18764
|
-
function repoKey(repoPath) {
|
|
18798
|
+
function repoKey$1(repoPath) {
|
|
18765
18799
|
// sha1 is used here as a non-security cache-key derivation — we just
|
|
18766
18800
|
// need a deterministic short identifier for the marker filename so
|
|
18767
18801
|
// re-creating a repo at the same path keeps the same preference.
|
|
@@ -18771,7 +18805,7 @@ function repoKey(repoPath) {
|
|
|
18771
18805
|
return crypto.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
18772
18806
|
}
|
|
18773
18807
|
function getSidebarTabMarkerPath(repoPath) {
|
|
18774
|
-
return path$1.join(resolveCacheDir(), `sidebar-tab.${repoKey(repoPath)}`);
|
|
18808
|
+
return path$1.join(resolveCacheDir$1(), `sidebar-tab.${repoKey$1(repoPath)}`);
|
|
18775
18809
|
}
|
|
18776
18810
|
function getSavedSidebarTab(repoPath) {
|
|
18777
18811
|
try {
|
|
@@ -19198,9 +19232,16 @@ function getLogInkLayout(input) {
|
|
|
19198
19232
|
// graph dominant; focus expansion gives the inspector room for long
|
|
19199
19233
|
// commit bodies / file lists / action labels. Mirrors the sidebar
|
|
19200
19234
|
// pattern (sidebarFocused above): instant transition per render.
|
|
19201
|
-
|
|
19202
|
-
|
|
19203
|
-
|
|
19235
|
+
//
|
|
19236
|
+
// Help overlay overrides both — it borrows ~50% of the terminal so
|
|
19237
|
+
// hotkey descriptions render in full instead of truncating to
|
|
19238
|
+
// "Move focus...". Capped at 100 cells so a wide terminal doesn't
|
|
19239
|
+
// waste an absurd amount of horizontal space on the cheat sheet.
|
|
19240
|
+
const detailWidth = input.helpOverlayActive
|
|
19241
|
+
? Math.max(60, Math.min(100, Math.floor(columns * 0.50)))
|
|
19242
|
+
: input.inspectorFocused
|
|
19243
|
+
? Math.max(36, Math.min(60, Math.floor(columns * 0.40)))
|
|
19244
|
+
: Math.max(20, Math.min(32, Math.floor(columns * 0.22)));
|
|
19204
19245
|
// Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
|
|
19205
19246
|
// (~36% of width). The transition is instant per render — focus tab to
|
|
19206
19247
|
// expand, focus away to collapse.
|
|
@@ -23692,15 +23733,20 @@ async function loadLogInkContext(git) {
|
|
|
23692
23733
|
};
|
|
23693
23734
|
}
|
|
23694
23735
|
function loadLogInkContextEntries(git) {
|
|
23736
|
+
// Boot-time per-key fetches. Each load() runs in parallel from
|
|
23737
|
+
// `LogInkApp`'s mount effect. `pullRequest` is intentionally
|
|
23738
|
+
// omitted (#808) — its `gh pr view --json` call duplicates the
|
|
23739
|
+
// slim PR fetch already happening inside `getProviderOverview`,
|
|
23740
|
+
// and the only consumer that needs the *full* enriched response is
|
|
23741
|
+
// the dedicated PR view (`g p`). Lazy-loaded by a separate effect
|
|
23742
|
+
// when the user actually navigates there. Header / yank / workflow
|
|
23743
|
+
// paths read the slim version off `provider.currentPullRequest` so
|
|
23744
|
+
// the chrome stays populated immediately on boot.
|
|
23695
23745
|
return [
|
|
23696
23746
|
{
|
|
23697
23747
|
key: 'branches',
|
|
23698
23748
|
load: () => safe(getBranchOverview(git)),
|
|
23699
23749
|
},
|
|
23700
|
-
{
|
|
23701
|
-
key: 'pullRequest',
|
|
23702
|
-
load: () => safe(getPullRequestOverview(git)),
|
|
23703
|
-
},
|
|
23704
23750
|
{
|
|
23705
23751
|
key: 'tags',
|
|
23706
23752
|
load: () => safe(getTagOverview(git)),
|
|
@@ -23913,8 +23959,15 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
|
23913
23959
|
const input = streams.input || process.stdin;
|
|
23914
23960
|
const output = streams.output || process.stdout;
|
|
23915
23961
|
const error = streams.error || process.stderr;
|
|
23962
|
+
// Non-TTY fallback (CI logs, piped output) needs the rows up-front
|
|
23963
|
+
// because the renderer just dumps a static snapshot. Run the
|
|
23964
|
+
// deferred loader synchronously here when present so callers get
|
|
23965
|
+
// the same shape regardless of the entry path.
|
|
23916
23966
|
if (!canStartLogInkTui(input, output)) {
|
|
23917
|
-
|
|
23967
|
+
const fallbackRows = options.loadRows && rows.length === 0
|
|
23968
|
+
? await options.loadRows()
|
|
23969
|
+
: rows;
|
|
23970
|
+
await startInteractiveLog(git, fallbackRows, {
|
|
23918
23971
|
appLabel: options.appLabel,
|
|
23919
23972
|
input,
|
|
23920
23973
|
output,
|
|
@@ -23933,6 +23986,7 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
|
23933
23986
|
ink,
|
|
23934
23987
|
initialView: options.initialView || 'history',
|
|
23935
23988
|
logArgv: options.logArgv,
|
|
23989
|
+
loadRows: options.loadRows,
|
|
23936
23990
|
React,
|
|
23937
23991
|
rows,
|
|
23938
23992
|
theme: createLogInkTheme(options.theme),
|
|
@@ -24029,7 +24083,7 @@ function enrichFilterActionWithRectification(action, state, context) {
|
|
|
24029
24083
|
}
|
|
24030
24084
|
}
|
|
24031
24085
|
function LogInkApp(deps) {
|
|
24032
|
-
const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, logArgv, React, resumeRef, rows, theme } = deps;
|
|
24086
|
+
const { appLabel, clipboardRunner, git, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme } = deps;
|
|
24033
24087
|
const { Box, Text, useApp, useInput, useWindowSize } = ink;
|
|
24034
24088
|
const h = React.createElement;
|
|
24035
24089
|
const { exit } = useApp();
|
|
@@ -24050,9 +24104,26 @@ function LogInkApp(deps) {
|
|
|
24050
24104
|
// user's cache dir so the tip never reappears once dismissed. Lazy
|
|
24051
24105
|
// initializer so the fs check only runs on mount, not every render.
|
|
24052
24106
|
const [showOnboarding, setShowOnboarding] = React.useState(() => !hasSeenOnboarding());
|
|
24053
|
-
const [state, setState] = React.useState(() => createLogInkState(rows, {
|
|
24107
|
+
const [state, setState] = React.useState(() => createLogInkState(rows, {
|
|
24108
|
+
activeView: initialView,
|
|
24109
|
+
// Boot loader is in flight whenever the caller passed
|
|
24110
|
+
// `loadRows`, regardless of whether `rows` was empty or
|
|
24111
|
+
// pre-populated from the disk cache (#808). The history
|
|
24112
|
+
// surface only shows the "Loading commits…" placeholder when
|
|
24113
|
+
// there are zero visible commits, so cached data renders
|
|
24114
|
+
// immediately while the chrome still flags the refresh.
|
|
24115
|
+
bootLoading: Boolean(loadRows),
|
|
24116
|
+
}));
|
|
24054
24117
|
const [context, setContext] = React.useState({});
|
|
24055
|
-
const [contextStatus, setContextStatus] = React.useState(() =>
|
|
24118
|
+
const [contextStatus, setContextStatus] = React.useState(() => {
|
|
24119
|
+
// Boot starts every fetched key in 'loading' so the surfaces show
|
|
24120
|
+
// their loading hints immediately. `pullRequest` is the exception
|
|
24121
|
+
// (#808) — it isn't part of the boot fetch entries; it lazy-loads
|
|
24122
|
+
// when the user enters the PR view. Marking it 'idle' avoids a
|
|
24123
|
+
// permanent "loading" flag in the chrome and lets the dedicated
|
|
24124
|
+
// PR view's own load effect drive its loading state.
|
|
24125
|
+
return updateLogInkContextStatus(createLogInkContextStatus('loading'), 'pullRequest', 'idle');
|
|
24126
|
+
});
|
|
24056
24127
|
const [detail, setDetail] = React.useState(undefined);
|
|
24057
24128
|
const [detailLoading, setDetailLoading] = React.useState(false);
|
|
24058
24129
|
const [filePreview, setFilePreview] = React.useState(undefined);
|
|
@@ -24123,9 +24194,78 @@ function LogInkApp(deps) {
|
|
|
24123
24194
|
const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
|
|
24124
24195
|
const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
|
|
24125
24196
|
const selectedWorktreeFile = visibleWorktreeFilesGrouped[state.selectedWorktreeFileIndex];
|
|
24197
|
+
// Stash patch per-file segmentation (#808). Hoisted out of the
|
|
24198
|
+
// useInput callback (was running on every keystroke), the yank
|
|
24199
|
+
// handler (was running per `y` press), and renderDiffSurface (was
|
|
24200
|
+
// running per paint) into a single LogInkApp-scoped memo. When the
|
|
24201
|
+
// active stash diff has hundreds of files, the prior fan-out was
|
|
24202
|
+
// re-walking the entire patch text 2-3x per keystroke for no
|
|
24203
|
+
// observable reason — the parsed list is purely a function of the
|
|
24204
|
+
// line array, which only changes when the user opens a different
|
|
24205
|
+
// stash.
|
|
24206
|
+
const stashDiffParsedFiles = React.useMemo(() => stashDiffLines ? parseStashDiffFiles(stashDiffLines) : [], [stashDiffLines]);
|
|
24207
|
+
// Filtered promoted-view lists (#808). These were recomputed inline
|
|
24208
|
+
// inside useInput on every keystroke — for a repo with hundreds of
|
|
24209
|
+
// branches / tags and an active filter, that's hundreds of regex
|
|
24210
|
+
// matches per arrow-key press. Memoizing on (raw list, filter)
|
|
24211
|
+
// collapses the work to one pass per filter / data change.
|
|
24212
|
+
const filteredBranchList = React.useMemo(() => {
|
|
24213
|
+
const all = context.branches?.localBranches || [];
|
|
24214
|
+
if (!state.filter)
|
|
24215
|
+
return all;
|
|
24216
|
+
return all.filter((branch) => matchesPromotedFilter([branch.shortName, branch.upstream || ''], state.filter));
|
|
24217
|
+
}, [context.branches?.localBranches, state.filter]);
|
|
24218
|
+
const filteredTagList = React.useMemo(() => {
|
|
24219
|
+
const all = context.tags?.tags || [];
|
|
24220
|
+
if (!state.filter)
|
|
24221
|
+
return all;
|
|
24222
|
+
return all.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter));
|
|
24223
|
+
}, [context.tags?.tags, state.filter]);
|
|
24224
|
+
const filteredStashList = React.useMemo(() => {
|
|
24225
|
+
const all = context.stashes?.stashes || [];
|
|
24226
|
+
if (!state.filter)
|
|
24227
|
+
return all;
|
|
24228
|
+
return all.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter));
|
|
24229
|
+
}, [context.stashes?.stashes, state.filter]);
|
|
24230
|
+
const filteredWorktreeList = React.useMemo(() => {
|
|
24231
|
+
const all = context.worktreeList?.worktrees || [];
|
|
24232
|
+
if (!state.filter)
|
|
24233
|
+
return all;
|
|
24234
|
+
return all.filter((entry) => matchesPromotedFilter([entry.path, entry.branch || ''], state.filter));
|
|
24235
|
+
}, [context.worktreeList?.worktrees, state.filter]);
|
|
24126
24236
|
const dispatch = React.useCallback((action) => {
|
|
24127
24237
|
setState((current) => applyLogInkAction(current, action));
|
|
24128
24238
|
}, []);
|
|
24239
|
+
// Deferred commit-log loader (#808). Runs once on mount when the
|
|
24240
|
+
// caller opted into the lazy boot path. The Ink tree is already on
|
|
24241
|
+
// screen at this point — without this the user stares at a black
|
|
24242
|
+
// terminal during the synchronous git log pre-mount fetch. The
|
|
24243
|
+
// mounted-ref guard prevents a late-resolving promise from
|
|
24244
|
+
// dispatching after the user `q` quits before rows arrive.
|
|
24245
|
+
React.useEffect(() => {
|
|
24246
|
+
if (!loadRows)
|
|
24247
|
+
return;
|
|
24248
|
+
let cancelled = false;
|
|
24249
|
+
void loadRows()
|
|
24250
|
+
.then((nextRows) => {
|
|
24251
|
+
if (cancelled || !mountedRef.current)
|
|
24252
|
+
return;
|
|
24253
|
+
dispatch({ type: 'replaceRows', rows: nextRows });
|
|
24254
|
+
})
|
|
24255
|
+
.catch((error) => {
|
|
24256
|
+
if (cancelled || !mountedRef.current)
|
|
24257
|
+
return;
|
|
24258
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
24259
|
+
dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}` });
|
|
24260
|
+
dispatch({ type: 'setBootLoading', value: false });
|
|
24261
|
+
});
|
|
24262
|
+
return () => {
|
|
24263
|
+
cancelled = true;
|
|
24264
|
+
};
|
|
24265
|
+
// Intentionally one-shot — re-running the boot load on hot
|
|
24266
|
+
// dispatch / loader changes would refetch the entire log on every
|
|
24267
|
+
// re-render. The loader fires once per app mount and that's it.
|
|
24268
|
+
}, []);
|
|
24129
24269
|
// Auto-dismiss status messages after a short window so transient
|
|
24130
24270
|
// confirmations ("Pulled current branch", "Edited foo.ts") don't
|
|
24131
24271
|
// linger forever. Each new message resets the timer; clearing the
|
|
@@ -24347,6 +24487,34 @@ function LogInkApp(deps) {
|
|
|
24347
24487
|
active = false;
|
|
24348
24488
|
};
|
|
24349
24489
|
}, [git]);
|
|
24490
|
+
// Lazy-load the full pullRequest overview (#808). Only fires when
|
|
24491
|
+
// the user actually navigates to the PR view, and only when we
|
|
24492
|
+
// don't already have data (so a workflow-triggered refresh that
|
|
24493
|
+
// hydrated `pullRequest` doesn't re-fetch on view entry). The
|
|
24494
|
+
// dedicated PR view shows its own loading state while this is in
|
|
24495
|
+
// flight; everywhere else (header glyph, yank, workflow runner)
|
|
24496
|
+
// already falls through to the slim `provider.currentPullRequest`
|
|
24497
|
+
// so the chrome stays populated immediately on boot.
|
|
24498
|
+
React.useEffect(() => {
|
|
24499
|
+
if (state.activeView !== 'pull-request')
|
|
24500
|
+
return;
|
|
24501
|
+
if (context.pullRequest)
|
|
24502
|
+
return;
|
|
24503
|
+
let active = true;
|
|
24504
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'loading'));
|
|
24505
|
+
void safe(getPullRequestOverview(git)).then((value) => {
|
|
24506
|
+
if (!active)
|
|
24507
|
+
return;
|
|
24508
|
+
setContext((current) => ({
|
|
24509
|
+
...current,
|
|
24510
|
+
pullRequest: value,
|
|
24511
|
+
}));
|
|
24512
|
+
setContextStatus((current) => updateLogInkContextStatus(current, 'pullRequest', 'ready'));
|
|
24513
|
+
});
|
|
24514
|
+
return () => {
|
|
24515
|
+
active = false;
|
|
24516
|
+
};
|
|
24517
|
+
}, [git, state.activeView, context.pullRequest]);
|
|
24350
24518
|
React.useEffect(() => {
|
|
24351
24519
|
let active = true;
|
|
24352
24520
|
async function loadDetail() {
|
|
@@ -25098,8 +25266,9 @@ function LogInkApp(deps) {
|
|
|
25098
25266
|
else if (state.diffSource === 'stash' && stashDiffLines) {
|
|
25099
25267
|
// Walk back to the most recent file header at or before the
|
|
25100
25268
|
// current preview offset — same logic the input-context block
|
|
25101
|
-
// uses to expose stashDiffSelectedPath.
|
|
25102
|
-
|
|
25269
|
+
// uses to expose stashDiffSelectedPath. Reads the memoized
|
|
25270
|
+
// parse so the yank handler doesn't re-walk the entire patch.
|
|
25271
|
+
const current = findStashFileForOffset(stashDiffParsedFiles, state.diffPreviewOffset);
|
|
25103
25272
|
if (current) {
|
|
25104
25273
|
value = current.path;
|
|
25105
25274
|
label = `path ${current.path}`;
|
|
@@ -25142,6 +25311,7 @@ function LogInkApp(deps) {
|
|
|
25142
25311
|
selected,
|
|
25143
25312
|
selectedDetailFile,
|
|
25144
25313
|
stashDiffLines,
|
|
25314
|
+
stashDiffParsedFiles,
|
|
25145
25315
|
state.activeView,
|
|
25146
25316
|
state.branchSort,
|
|
25147
25317
|
state.diffPreviewOffset,
|
|
@@ -25356,43 +25526,25 @@ function LogInkApp(deps) {
|
|
|
25356
25526
|
// P4.5: navigation in branches/tags/stash uses the FILTERED list
|
|
25357
25527
|
// length when a filter is active so j/k stay live instead of getting
|
|
25358
25528
|
// stuck against a full-list count that no longer matches what's on
|
|
25359
|
-
// screen.
|
|
25360
|
-
|
|
25361
|
-
|
|
25362
|
-
|
|
25363
|
-
|
|
25364
|
-
|
|
25365
|
-
const
|
|
25366
|
-
|
|
25367
|
-
.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
|
|
25368
|
-
.length
|
|
25369
|
-
: context.tags?.tags.length;
|
|
25370
|
-
const visibleStashes = state.filter
|
|
25371
|
-
? (context.stashes?.stashes || [])
|
|
25372
|
-
.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
|
|
25373
|
-
: (context.stashes?.stashes || []);
|
|
25374
|
-
const stashVisibleCount = visibleStashes.length;
|
|
25375
|
-
const stashSelectedRef = visibleStashes[Math.min(state.selectedStashIndex, Math.max(0, visibleStashes.length - 1))]?.ref;
|
|
25376
|
-
// The worktrees promoted view is filterable; mirror the branches /
|
|
25377
|
-
// tags / stash pattern and feed the filtered count into the input
|
|
25378
|
-
// dispatcher so ↑/↓ stay synchronized with the visible rows.
|
|
25379
|
-
const worktreeVisibleCount = state.filter
|
|
25380
|
-
? (context.worktreeList?.worktrees || [])
|
|
25381
|
-
.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
|
|
25382
|
-
.length
|
|
25383
|
-
: context.worktreeList?.worktrees.length;
|
|
25529
|
+
// screen. The filtered lists are memoized at LogInkApp scope (#808
|
|
25530
|
+
// perf pass) — reading them here is O(1) instead of O(branches +
|
|
25531
|
+
// tags + stashes + worktrees) per keystroke.
|
|
25532
|
+
const branchVisibleCount = filteredBranchList.length;
|
|
25533
|
+
const tagVisibleCount = filteredTagList.length;
|
|
25534
|
+
const stashVisibleCount = filteredStashList.length;
|
|
25535
|
+
const stashSelectedRef = filteredStashList[Math.min(state.selectedStashIndex, Math.max(0, filteredStashList.length - 1))]?.ref;
|
|
25536
|
+
const worktreeVisibleCount = filteredWorktreeList.length;
|
|
25384
25537
|
// When the diff view is showing a stash patch, swap the previewLineCount
|
|
25385
25538
|
// to the stash diff length so the existing pageDetailPreview path
|
|
25386
25539
|
// (j/k, PgUp/PgDn) scrolls through it without a parallel pipeline.
|
|
25387
25540
|
const diffPreviewLineCount = state.diffSource === 'stash'
|
|
25388
25541
|
? stashDiffLines?.length
|
|
25389
25542
|
: filePreview?.hunks.length;
|
|
25390
|
-
//
|
|
25391
|
-
//
|
|
25392
|
-
//
|
|
25393
|
-
|
|
25394
|
-
|
|
25395
|
-
: [];
|
|
25543
|
+
// Per-file segmentation for stash diffs reads the LogInkApp-scoped
|
|
25544
|
+
// memo so navigation keys + the input-context derivation share a
|
|
25545
|
+
// single parse pass per stash patch instead of re-walking the
|
|
25546
|
+
// entire patch text on every keystroke.
|
|
25547
|
+
const stashDiffFiles = state.diffSource === 'stash' ? stashDiffParsedFiles : [];
|
|
25396
25548
|
const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
|
|
25397
25549
|
const stashDiffSelectedPath = state.diffSource === 'stash'
|
|
25398
25550
|
? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
|
|
@@ -25489,6 +25641,7 @@ function LogInkApp(deps) {
|
|
|
25489
25641
|
rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
|
|
25490
25642
|
sidebarFocused: state.focus === 'sidebar',
|
|
25491
25643
|
inspectorFocused: state.focus === 'detail',
|
|
25644
|
+
helpOverlayActive: state.showHelp,
|
|
25492
25645
|
});
|
|
25493
25646
|
if (layout.tooSmall) {
|
|
25494
25647
|
return h(Box, {
|
|
@@ -25518,7 +25671,13 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
|
|
|
25518
25671
|
? `PR #${prInfo.number} ${prInfo.isDraft ? 'DRAFT' : prInfo.state}`
|
|
25519
25672
|
: 'no PR';
|
|
25520
25673
|
const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
|
|
25521
|
-
|
|
25674
|
+
// Boot loading wins over the per-context loading hint because it
|
|
25675
|
+
// tells the user the headline thing they care about (commits aren't
|
|
25676
|
+
// ready yet) — the context fetches finish independently and surface
|
|
25677
|
+
// their own per-section loading copy in the sidebars.
|
|
25678
|
+
const loading = state.bootLoading
|
|
25679
|
+
? ' loading commits'
|
|
25680
|
+
: isLogInkContextLoading(contextStatus) ? ' loading context' : '';
|
|
25522
25681
|
const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
|
|
25523
25682
|
const view = breadcrumb ? ` ${breadcrumb}` : '';
|
|
25524
25683
|
// Mode indicator (P2.2) — surfaces the current input mode so users
|
|
@@ -25825,22 +25984,30 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
25825
25984
|
...(state.historyFetchArgs
|
|
25826
25985
|
? [h(Text, { key: 'history-fetch-indicator', dimColor: true }, `filter: ${formatHistoryFetchArgs(state.historyFetchArgs)} (ctrl+u in / to clear)`)]
|
|
25827
25986
|
: []), ...(pendingNode ? [pendingNode] : []), visible.items.length === 0
|
|
25828
|
-
? h(Text, { dimColor: true },
|
|
25829
|
-
|
|
25830
|
-
|
|
25831
|
-
|
|
25987
|
+
? h(Text, { dimColor: true }, state.bootLoading
|
|
25988
|
+
? formatLogInkLoading({ resource: 'commits' })
|
|
25989
|
+
: formatLogInkHistoryEmpty({
|
|
25990
|
+
filter: state.filter,
|
|
25991
|
+
totalCommits: state.commits.length,
|
|
25992
|
+
}))
|
|
25832
25993
|
: visible.items.map((item, index) => {
|
|
25833
25994
|
if (item.type === 'graph') {
|
|
25995
|
+
// Graph-only rows are git's lane-closure scaffolding (`|/`,
|
|
25996
|
+
// `|\`, etc.) — they're real topology but visually they look
|
|
25997
|
+
// like blank rows that the user might wonder if they
|
|
25998
|
+
// accidentally skipped a commit on (#831). Render dim-on-dim
|
|
25999
|
+
// so they retreat as connectors rather than competing with
|
|
26000
|
+
// commit rows for the eye's attention.
|
|
25834
26001
|
if (item.laneSegments && !theme.ascii) {
|
|
25835
|
-
return h(Text, { key: `graph-${index}-${item.graph}
|
|
26002
|
+
return h(Text, { key: `graph-${index}-${item.graph}`, dimColor: true }, ...renderLaneSegmentSpans(h, Text, item.laneSegments, theme, visible.graphWidth, `g${index}`, { forceDim: true }));
|
|
25836
26003
|
}
|
|
25837
26004
|
return h(Text, {
|
|
25838
26005
|
key: `graph-${index}-${item.graph}`,
|
|
25839
26006
|
color: theme.noColor ? undefined : theme.colors.muted,
|
|
25840
|
-
dimColor:
|
|
25841
|
-
}, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }),
|
|
26007
|
+
dimColor: true,
|
|
26008
|
+
}, truncate$1(substituteGraphChars(item.graph.padEnd(visible.graphWidth), { ascii: theme.ascii }), Math.max(8, width - 4)));
|
|
25842
26009
|
}
|
|
25843
|
-
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, item.laneSegments);
|
|
26010
|
+
return renderCommitHistoryRow(h, Text, item.commit, item.graph, visible.graphWidth, Boolean(item.selected) && !realSelectionSuppressed, theme, index, width, item.laneSegments);
|
|
25844
26011
|
}));
|
|
25845
26012
|
}
|
|
25846
26013
|
/**
|
|
@@ -25853,7 +26020,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
25853
26020
|
* Final padding is appended as its own span so callers do not need to
|
|
25854
26021
|
* pre-pad the graph string before computing lane segments.
|
|
25855
26022
|
*/
|
|
25856
|
-
function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
26023
|
+
function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, options = {}) {
|
|
25857
26024
|
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25858
26025
|
const elements = [];
|
|
25859
26026
|
let totalLen = 0;
|
|
@@ -25862,7 +26029,12 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
|
25862
26029
|
elements.push(h(Text, {
|
|
25863
26030
|
key: `${keyPrefix}-${idx}`,
|
|
25864
26031
|
color: laneColor ?? muted,
|
|
25865
|
-
dimColor
|
|
26032
|
+
// Ink does not cascade dimColor from a parent Text to children,
|
|
26033
|
+
// so the caller's "this whole row should fade" intent has to
|
|
26034
|
+
// travel here as an explicit flag (#831). Used for graph-only
|
|
26035
|
+
// lane-closure rows, where the lane colors otherwise compete
|
|
26036
|
+
// for attention with the commits they connect.
|
|
26037
|
+
dimColor: options.forceDim || (theme.noColor && seg.laneId === undefined),
|
|
25866
26038
|
}, seg.text));
|
|
25867
26039
|
totalLen += seg.text.length;
|
|
25868
26040
|
});
|
|
@@ -25883,11 +26055,22 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
|
25883
26055
|
* Truncation is per-segment so the variable-length message field gets
|
|
25884
26056
|
* the leftover budget after fixed segments are accounted for.
|
|
25885
26057
|
*/
|
|
25886
|
-
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
|
|
26058
|
+
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, laneSegments) {
|
|
25887
26059
|
const refs = formatInkRefLabels(commit.refs);
|
|
25888
|
-
|
|
26060
|
+
// Total cells available to the row content. Earlier revisions used a
|
|
26061
|
+
// hardcoded 140 here, which let row content overflow whenever the
|
|
26062
|
+
// panel was narrower than that — Ink would wrap onto a second visual
|
|
26063
|
+
// line and the next commit's graph indicator landed against the wrap
|
|
26064
|
+
// continuation rather than its own commit (#830). Subtracting 4
|
|
26065
|
+
// accounts for the panel's left + right border + 1-cell padding.
|
|
26066
|
+
const totalWidth = Math.max(20, panelWidth - 4);
|
|
25889
26067
|
const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
|
|
25890
|
-
|
|
26068
|
+
// Refs trail the message and shrink first when the row is narrow:
|
|
26069
|
+
// the user can always see the full ref list in the inspector, so
|
|
26070
|
+
// the headline subject keeps priority over decoration.
|
|
26071
|
+
const refsRoom = Math.max(0, totalWidth - fixedWidth - 8);
|
|
26072
|
+
const refsTrunc = refs ? truncate$1(refs, refsRoom) : '';
|
|
26073
|
+
const messageRoom = Math.max(8, totalWidth - fixedWidth - cellWidth(refsTrunc));
|
|
25891
26074
|
const message = truncate$1(commit.message, messageRoom);
|
|
25892
26075
|
const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
|
|
25893
26076
|
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
@@ -25902,7 +26085,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
|
|
|
25902
26085
|
key: `${commit.hash}-${index}`,
|
|
25903
26086
|
backgroundColor: selectedBg,
|
|
25904
26087
|
inverse: selected,
|
|
25905
|
-
}, ...graphChildren, ' ', h(Text, { color: accent, bold: selected }, commit.shortHash), ' ', h(Text, { dimColor: true }, commit.date), ' ', h(Text, undefined, message),
|
|
26088
|
+
}, ...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);
|
|
25906
26089
|
}
|
|
25907
26090
|
/**
|
|
25908
26091
|
* Render the synthetic "(+) new commit" affordance shown above the real
|
|
@@ -26176,6 +26359,15 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
26176
26359
|
: `${localBranches.length}/${sortedAll.length} local | current: ${branches?.currentBranch || '<detached>'}${filterLabel}${sortLabel}`;
|
|
26177
26360
|
const emptyLabel = formatLogInkBranchesEmpty({ filter: state.filter });
|
|
26178
26361
|
const loadingLabel = formatLogInkLoading({ resource: 'branches' });
|
|
26362
|
+
// Per-column width derived from the visible window (#833) so columns
|
|
26363
|
+
// align across rows regardless of name length. Padded to the longest
|
|
26364
|
+
// name in view so short rows fill out instead of leaving a gutter;
|
|
26365
|
+
// capped at 40 cells so one runaway long branch name doesn't blow
|
|
26366
|
+
// out the timestamp column entirely (longer names get truncated and
|
|
26367
|
+
// the timestamp stays where the user expects it).
|
|
26368
|
+
const nameColWidth = visible.length === 0
|
|
26369
|
+
? 28
|
|
26370
|
+
: Math.min(40, Math.max(8, ...visible.map((branch) => branch.shortName.length)));
|
|
26179
26371
|
const lines = loading
|
|
26180
26372
|
? [h(Text, { key: 'branches-loading', dimColor: true }, loadingLabel)]
|
|
26181
26373
|
: localBranches.length === 0
|
|
@@ -26189,18 +26381,18 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
26189
26381
|
const lastTouched = formatBranchLastTouched(branch.date, new Date());
|
|
26190
26382
|
// Split the row into spans so the timestamp stays dim even on the
|
|
26191
26383
|
// currently-selected (bold) row. The leading marker + name keep
|
|
26192
|
-
// their
|
|
26193
|
-
// the divergence column stays aligned across rows.
|
|
26194
|
-
const namePadded = branch.shortName.padEnd(
|
|
26384
|
+
// their per-window-derived column widths; the timestamp is
|
|
26385
|
+
// right-padded so the divergence column stays aligned across rows.
|
|
26386
|
+
const namePadded = truncate$1(branch.shortName, nameColWidth).padEnd(nameColWidth);
|
|
26195
26387
|
const timestampPadded = lastTouched.padEnd(8);
|
|
26196
26388
|
const lineDim = !isSelected && !branch.current;
|
|
26197
26389
|
const head = `${cursor} ${marker} ${namePadded} `;
|
|
26198
26390
|
const trailingDivergence = divergence ? ` ${divergence}` : '';
|
|
26199
|
-
// Truncate the assembled line
|
|
26200
|
-
//
|
|
26201
|
-
//
|
|
26391
|
+
// Truncate the assembled line to the actual panel width so a
|
|
26392
|
+
// narrow inspector / sidebar focus doesn't push branch rows
|
|
26393
|
+
// onto a second visual line (#830).
|
|
26202
26394
|
const fullText = `${head}${timestampPadded}${trailingDivergence}`;
|
|
26203
|
-
const truncated = truncate$1(fullText,
|
|
26395
|
+
const truncated = truncate$1(fullText, Math.max(20, width - 4));
|
|
26204
26396
|
// If truncation chopped into the timestamp/divergence portion,
|
|
26205
26397
|
// fall back to a single Text to keep the visible width honest.
|
|
26206
26398
|
if (truncated !== fullText) {
|
|
@@ -26244,6 +26436,13 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
26244
26436
|
: `${tags.length}/${sortedAll.length} tags${filterLabel}${sortLabel}`;
|
|
26245
26437
|
const emptyLabel = formatLogInkTagsEmpty({ filter: state.filter });
|
|
26246
26438
|
const loadingLabel = formatLogInkLoading({ resource: 'tags' });
|
|
26439
|
+
// Per-window name column width (#833) so short tags don't leave a
|
|
26440
|
+
// wide gutter and long tags don't push the subject off-screen. Cap
|
|
26441
|
+
// matches the branches surface for visual consistency across the
|
|
26442
|
+
// promoted views.
|
|
26443
|
+
const tagNameColWidth = visible.length === 0
|
|
26444
|
+
? 20
|
|
26445
|
+
: Math.min(40, Math.max(8, ...visible.map((tag) => tag.name.length)));
|
|
26247
26446
|
const lines = loading
|
|
26248
26447
|
? [h(Text, { key: 'tags-loading', dimColor: true }, loadingLabel)]
|
|
26249
26448
|
: tags.length === 0
|
|
@@ -26257,8 +26456,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
26257
26456
|
// formatHyperlink wraps just the tag name, leaving width math
|
|
26258
26457
|
// intact.
|
|
26259
26458
|
const url = buildRefUrl(context.provider?.repository, tag.name);
|
|
26260
|
-
const namePadded = tag.name.padEnd(
|
|
26261
|
-
const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`,
|
|
26459
|
+
const namePadded = truncate$1(tag.name, tagNameColWidth).padEnd(tagNameColWidth);
|
|
26460
|
+
const lineText = truncate$1(`${cursor} ${namePadded} ${tag.subject}`, Math.max(20, width - 4));
|
|
26262
26461
|
if (!url || lineText.indexOf(namePadded) < 0) {
|
|
26263
26462
|
return h(Text, {
|
|
26264
26463
|
key: `tag-${index}`,
|
|
@@ -26341,6 +26540,17 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
26341
26540
|
const headerRight = loading
|
|
26342
26541
|
? 'loading worktrees'
|
|
26343
26542
|
: `${worktrees.length}/${allWorktrees.length} worktrees${filterLabel}`;
|
|
26543
|
+
// Per-window branch column width (#833). Worktrees often track
|
|
26544
|
+
// branches with names varying widely in length (`main` vs.
|
|
26545
|
+
// `feat/tui-something-long`); fixed-width padding either left a
|
|
26546
|
+
// huge gutter on short rows or pushed the path column off-screen on
|
|
26547
|
+
// long ones. Cap matches the other promoted surfaces.
|
|
26548
|
+
const branchColWidth = visible.length === 0
|
|
26549
|
+
? 28
|
|
26550
|
+
: Math.min(40, Math.max(8, ...visible.map((entry) => {
|
|
26551
|
+
const label = entry.branch ? entry.branch : entry.head || '<detached>';
|
|
26552
|
+
return label.length;
|
|
26553
|
+
})));
|
|
26344
26554
|
const lines = loading
|
|
26345
26555
|
? [h(Text, { key: 'worktrees-loading', dimColor: true }, formatLogInkLoading({ resource: 'worktrees' }))]
|
|
26346
26556
|
: worktrees.length === 0
|
|
@@ -26352,11 +26562,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
26352
26562
|
const marker = entry.current ? '*' : ' ';
|
|
26353
26563
|
const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
|
|
26354
26564
|
const stateLabel = entry.dirty ? 'dirty' : 'clean';
|
|
26565
|
+
const branchPadded = truncate$1(branchLabel, branchColWidth).padEnd(branchColWidth);
|
|
26355
26566
|
return h(Text, {
|
|
26356
26567
|
key: `worktree-${index}`,
|
|
26357
26568
|
bold: isSelected,
|
|
26358
26569
|
dimColor: !isSelected && !entry.current,
|
|
26359
|
-
}, truncate$1(`${cursor} ${marker} ${
|
|
26570
|
+
}, truncate$1(`${cursor} ${marker} ${branchPadded} ${stateLabel.padEnd(6)} ${entry.path}`, width - 4));
|
|
26360
26571
|
});
|
|
26361
26572
|
return h(Box, {
|
|
26362
26573
|
borderColor: focusBorderColor(theme, focused),
|
|
@@ -26778,22 +26989,39 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
26778
26989
|
h(Text, { key: 'detail-spacer-3' }, ''),
|
|
26779
26990
|
h(Text, { key: 'detail-files-title' }, 'Changed files:'),
|
|
26780
26991
|
];
|
|
26992
|
+
// Single-cursor invariant: the file list owns the cursor when the
|
|
26993
|
+
// inspector tab is active; the actions list owns it when the actions
|
|
26994
|
+
// tab is active. Pass `focused` only for the matching tab so users
|
|
26995
|
+
// never see two simultaneous selection highlights inside the panel.
|
|
26996
|
+
const fileListFocused = focused && state.inspectorTab === 'inspector';
|
|
26781
26997
|
const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
|
|
26782
|
-
const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex,
|
|
26783
|
-
//
|
|
26784
|
-
//
|
|
26785
|
-
//
|
|
26786
|
-
//
|
|
26787
|
-
//
|
|
26998
|
+
const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, fileListFocused, fileListMaxRows, width, theme);
|
|
26999
|
+
// Tab indicator. Renders in BOTH tabbed (short-terminal) mode and
|
|
27000
|
+
// tall-stacked mode so the user can always see which tab the cursor
|
|
27001
|
+
// owns and learn the `[/]` toggle. Without this on tall terminals,
|
|
27002
|
+
// the actions list looked like a static cheat-sheet — there was no
|
|
27003
|
+
// visible signal that the cursor could move into it.
|
|
27004
|
+
//
|
|
27005
|
+
// Spacing between tab labels comes from the labels' own padding
|
|
27006
|
+
// (the active label is bracketed `[Inspector]` while the inactive
|
|
27007
|
+
// one is space-padded ` Inspector `, so adjacency reads cleanly).
|
|
27008
|
+
// Earlier revisions stuck a raw `' '` between the Text children to
|
|
27009
|
+
// pad them visually — that crashes Ink at first paint with
|
|
27010
|
+
// "Text string ' ' must be rendered inside <Text> component"
|
|
27011
|
+
// because Box only accepts component children, never bare strings.
|
|
27012
|
+
const activeTab = state.inspectorTab;
|
|
27013
|
+
const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
|
|
27014
|
+
bold: activeTab === 'inspector',
|
|
27015
|
+
dimColor: activeTab !== 'inspector',
|
|
27016
|
+
}, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), h(Text, {
|
|
27017
|
+
bold: activeTab === 'actions',
|
|
27018
|
+
dimColor: activeTab !== 'actions',
|
|
27019
|
+
}, activeTab === 'actions' ? '[Actions]' : ' Actions '), ...(focused
|
|
27020
|
+
? [h(Text, { key: 'inspector-tabs-hint', dimColor: true }, ' · ←/→ switch')]
|
|
27021
|
+
: []));
|
|
27022
|
+
// Tabbed mode (short terminals): render only the active tab's
|
|
27023
|
+
// content under the tab header.
|
|
26788
27024
|
if (tabbed) {
|
|
26789
|
-
const activeTab = state.inspectorTab;
|
|
26790
|
-
const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
|
|
26791
|
-
bold: activeTab === 'inspector',
|
|
26792
|
-
dimColor: activeTab !== 'inspector',
|
|
26793
|
-
}, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), ' ', h(Text, {
|
|
26794
|
-
bold: activeTab === 'actions',
|
|
26795
|
-
dimColor: activeTab !== 'actions',
|
|
26796
|
-
}, activeTab === 'actions' ? '[Actions]' : ' Actions '));
|
|
26797
27025
|
return h(Box, {
|
|
26798
27026
|
borderColor: focusBorderColor(theme, focused),
|
|
26799
27027
|
borderStyle: theme.borderStyle,
|
|
@@ -26807,13 +27035,16 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
26807
27035
|
cursorActive: focused,
|
|
26808
27036
|
})));
|
|
26809
27037
|
}
|
|
27038
|
+
// Tall mode: stack both sections so the user can read everything at
|
|
27039
|
+
// once, but show the tab header so the active section (and the
|
|
27040
|
+
// `[/]` switch affordance) is visible.
|
|
26810
27041
|
return h(Box, {
|
|
26811
27042
|
borderColor: focusBorderColor(theme, focused),
|
|
26812
27043
|
borderStyle: theme.borderStyle,
|
|
26813
27044
|
flexDirection: 'column',
|
|
26814
27045
|
width,
|
|
26815
27046
|
paddingX: 1,
|
|
26816
|
-
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
27047
|
+
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26817
27048
|
cursorIndex: state.inspectorActionIndex,
|
|
26818
27049
|
cursorActive: focused && state.inspectorTab === 'actions',
|
|
26819
27050
|
}));
|
|
@@ -27416,6 +27647,84 @@ function renderFooter(h, components, state, context, theme, idleTip) {
|
|
|
27416
27647
|
}, h(Text, { color: theme.colors.muted, dimColor: true }, contextualText), h(Text, { color: theme.colors.muted, dimColor: true }, globalText));
|
|
27417
27648
|
}
|
|
27418
27649
|
|
|
27650
|
+
/**
|
|
27651
|
+
* Per-repo disk cache of the last successful commit-log fetch (#808).
|
|
27652
|
+
* Lets the TUI render an immediate stale-but-useful history view on
|
|
27653
|
+
* subsequent boots while the fresh `git log` runs in the background;
|
|
27654
|
+
* once the fresh data lands the runtime swaps it in transparently.
|
|
27655
|
+
*
|
|
27656
|
+
* Strict best-effort: read failures fall back to "no cache" (boot
|
|
27657
|
+
* shows the loading placeholder), and write failures are swallowed
|
|
27658
|
+
* silently (next boot just doesn't have the cache yet). The cache is
|
|
27659
|
+
* never load-bearing.
|
|
27660
|
+
*
|
|
27661
|
+
* Repos are keyed by a short hash of their absolute path. No PII in
|
|
27662
|
+
* the cache filename, and re-creating a repo at the same path keeps
|
|
27663
|
+
* the same cache.
|
|
27664
|
+
*/
|
|
27665
|
+
const CACHE_SCHEMA_VERSION = 1;
|
|
27666
|
+
const CACHE_DIR_NAME = 'overview';
|
|
27667
|
+
/**
|
|
27668
|
+
* Hard cap on rows we'll write per cache entry. The interactive
|
|
27669
|
+
* default limit is 300; this caps growth in case a user opts into a
|
|
27670
|
+
* much larger window. Keeps the cache file under ~200kb on a typical
|
|
27671
|
+
* repo.
|
|
27672
|
+
*/
|
|
27673
|
+
const CACHE_ROW_HARD_CAP = 500;
|
|
27674
|
+
function resolveCacheDir() {
|
|
27675
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
27676
|
+
if (xdg && xdg.trim().length > 0) {
|
|
27677
|
+
return path$1.join(xdg, 'coco', CACHE_DIR_NAME);
|
|
27678
|
+
}
|
|
27679
|
+
return path$1.join(os$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME);
|
|
27680
|
+
}
|
|
27681
|
+
function repoKey(repoPath) {
|
|
27682
|
+
// sha1 here is a non-security cache-key derivation — we just need a
|
|
27683
|
+
// deterministic short identifier for the cache filename so two repos
|
|
27684
|
+
// at different paths never collide. No PII or auth context is hashed
|
|
27685
|
+
// and no collision-resistance against an adversary is required.
|
|
27686
|
+
// DevSkim DS126858 doesn't apply.
|
|
27687
|
+
// DevSkim: ignore DS126858
|
|
27688
|
+
return crypto.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
27689
|
+
}
|
|
27690
|
+
function getOverviewCachePath(repoPath) {
|
|
27691
|
+
return path$1.join(resolveCacheDir(), `commits.${repoKey(repoPath)}.json`);
|
|
27692
|
+
}
|
|
27693
|
+
function readCachedCommits(repoPath) {
|
|
27694
|
+
try {
|
|
27695
|
+
const raw = fs$1.readFileSync(getOverviewCachePath(repoPath), 'utf8');
|
|
27696
|
+
const parsed = JSON.parse(raw);
|
|
27697
|
+
if (parsed.version !== CACHE_SCHEMA_VERSION) {
|
|
27698
|
+
// Schema mismatch — quietly drop the stale entry on next write.
|
|
27699
|
+
// Treating it as "no cache" keeps boot behavior predictable
|
|
27700
|
+
// across upgrades.
|
|
27701
|
+
return undefined;
|
|
27702
|
+
}
|
|
27703
|
+
if (!Array.isArray(parsed.rows)) {
|
|
27704
|
+
return undefined;
|
|
27705
|
+
}
|
|
27706
|
+
return parsed.rows;
|
|
27707
|
+
}
|
|
27708
|
+
catch {
|
|
27709
|
+
return undefined;
|
|
27710
|
+
}
|
|
27711
|
+
}
|
|
27712
|
+
function writeCachedCommits(repoPath, rows) {
|
|
27713
|
+
const file = getOverviewCachePath(repoPath);
|
|
27714
|
+
const envelope = {
|
|
27715
|
+
version: CACHE_SCHEMA_VERSION,
|
|
27716
|
+
savedAt: new Date().toISOString(),
|
|
27717
|
+
rows: rows.slice(0, CACHE_ROW_HARD_CAP),
|
|
27718
|
+
};
|
|
27719
|
+
try {
|
|
27720
|
+
fs$1.mkdirSync(path$1.dirname(file), { recursive: true });
|
|
27721
|
+
fs$1.writeFileSync(file, JSON.stringify(envelope));
|
|
27722
|
+
}
|
|
27723
|
+
catch {
|
|
27724
|
+
// Best-effort persistence; swallow.
|
|
27725
|
+
}
|
|
27726
|
+
}
|
|
27727
|
+
|
|
27419
27728
|
function createLogArgvFromUiArgv(argv) {
|
|
27420
27729
|
return {
|
|
27421
27730
|
$0: argv.$0,
|
|
@@ -27440,14 +27749,43 @@ function createUiTheme(config, argv) {
|
|
|
27440
27749
|
preset: argv.theme,
|
|
27441
27750
|
};
|
|
27442
27751
|
}
|
|
27752
|
+
/**
|
|
27753
|
+
* Wrap a fresh-rows loader with the disk-cache write step. Lets the
|
|
27754
|
+
* runtime stay caching-agnostic — it just receives the rows and
|
|
27755
|
+
* doesn't know whether they came from cache or git, while the caller
|
|
27756
|
+
* (which knows the repo path) handles persistence.
|
|
27757
|
+
*/
|
|
27758
|
+
function withCacheWrite(repoPath, loader) {
|
|
27759
|
+
return async () => {
|
|
27760
|
+
const rows = await loader();
|
|
27761
|
+
writeCachedCommits(repoPath, rows);
|
|
27762
|
+
return rows;
|
|
27763
|
+
};
|
|
27764
|
+
}
|
|
27443
27765
|
async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
27444
27766
|
const config = options.config || loadConfig(logArgv);
|
|
27445
27767
|
const git = options.git || getRepo();
|
|
27446
|
-
const
|
|
27447
|
-
|
|
27768
|
+
const repoPath = process.cwd();
|
|
27769
|
+
// Three-stage boot (#808):
|
|
27770
|
+
// 1. Read the disk cache and pass cached rows as the initial set
|
|
27771
|
+
// so the user sees the workstation chrome populated with
|
|
27772
|
+
// commits in the first frame.
|
|
27773
|
+
// 2. Mount Ink immediately with those rows (or [] if no cache).
|
|
27774
|
+
// 3. Run loadRows in the background to refresh — when fresh data
|
|
27775
|
+
// lands the runtime swaps it in transparently and we persist
|
|
27776
|
+
// the new rows back to the cache for next boot.
|
|
27777
|
+
// Caller-provided rows skip the lazy path entirely (caller already
|
|
27778
|
+
// has up-to-date data — no point redoing the fetch).
|
|
27779
|
+
const cachedRows = options.rows ? undefined : readCachedCommits(repoPath);
|
|
27780
|
+
const initialRows = options.rows || cachedRows || [];
|
|
27781
|
+
const loadRows = options.rows
|
|
27782
|
+
? undefined
|
|
27783
|
+
: withCacheWrite(repoPath, () => getLogRows(git, logArgv));
|
|
27784
|
+
await startInkInteractiveLog(git, initialRows, {}, {
|
|
27448
27785
|
appLabel: 'coco',
|
|
27449
27786
|
idleTips: config.logTui?.idleTips,
|
|
27450
27787
|
initialView: 'history',
|
|
27788
|
+
loadRows,
|
|
27451
27789
|
logArgv,
|
|
27452
27790
|
theme: config.logTui?.theme,
|
|
27453
27791
|
});
|
|
@@ -27456,11 +27794,15 @@ async function startCocoUi(argv) {
|
|
|
27456
27794
|
const config = loadConfig(argv);
|
|
27457
27795
|
const git = getRepo();
|
|
27458
27796
|
const logArgv = createLogArgvFromUiArgv(argv);
|
|
27459
|
-
const
|
|
27460
|
-
|
|
27797
|
+
const repoPath = process.cwd();
|
|
27798
|
+
// Same three-stage boot as startCocoUiFromLogArgv — mount with
|
|
27799
|
+
// cached rows for an instant-paint shell, refresh in background.
|
|
27800
|
+
const cachedRows = readCachedCommits(repoPath);
|
|
27801
|
+
await startInkInteractiveLog(git, cachedRows || [], {}, {
|
|
27461
27802
|
appLabel: 'coco',
|
|
27462
27803
|
idleTips: config.logTui?.idleTips,
|
|
27463
27804
|
initialView: argv.view || 'history',
|
|
27805
|
+
loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
|
|
27464
27806
|
logArgv,
|
|
27465
27807
|
theme: createUiTheme(config, argv),
|
|
27466
27808
|
});
|
|
@@ -27570,15 +27912,18 @@ const handler$2 = async (argv) => {
|
|
|
27570
27912
|
});
|
|
27571
27913
|
return;
|
|
27572
27914
|
}
|
|
27573
|
-
|
|
27915
|
+
// Interactive path defers the commit log fetch into the runtime
|
|
27916
|
+
// (#808) so the TUI mounts immediately with a "Loading commits…"
|
|
27917
|
+
// placeholder. The non-interactive (stdout) path still needs rows
|
|
27918
|
+
// up-front because the formatter just dumps a static snapshot.
|
|
27574
27919
|
if (argv.interactive && format === 'table') {
|
|
27575
27920
|
await startCocoUiFromLogArgv(argv, {
|
|
27576
27921
|
config,
|
|
27577
27922
|
git,
|
|
27578
|
-
rows,
|
|
27579
27923
|
});
|
|
27580
27924
|
return;
|
|
27581
27925
|
}
|
|
27926
|
+
const rows = await getLogRows(git, argv);
|
|
27582
27927
|
const result = format === 'json' ? formatLogJson(rows) : formatLogTable(rows);
|
|
27583
27928
|
await handleResult({
|
|
27584
27929
|
result,
|