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.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;
|
|
@@ -2152,11 +2152,21 @@ function formatAuthenticationError(error, logger) {
|
|
|
2152
2152
|
logger.verbose(`\nOriginal error: ${error.message}`, { color: 'gray' });
|
|
2153
2153
|
}
|
|
2154
2154
|
/**
|
|
2155
|
-
* Formats a generic error
|
|
2155
|
+
* Formats a generic error.
|
|
2156
|
+
*
|
|
2157
|
+
* The error message prints unconditionally (was previously gated behind
|
|
2158
|
+
* `--verbose`, which left users staring at a "Failed to execute command"
|
|
2159
|
+
* line with no actionable detail when something crashed). The full stack
|
|
2160
|
+
* trace stays under `logger.verbose` so plain output stays focused on the
|
|
2161
|
+
* one-line cause; users running into something they can't diagnose can opt
|
|
2162
|
+
* in with `--verbose` for the trace.
|
|
2156
2163
|
*/
|
|
2157
2164
|
function formatGenericError(error, logger) {
|
|
2158
2165
|
logger.log('\nFailed to execute command', { color: 'yellow' });
|
|
2159
|
-
logger.
|
|
2166
|
+
logger.log(`\nError: ${error.message}`, { color: 'red' });
|
|
2167
|
+
if (error.stack) {
|
|
2168
|
+
logger.verbose(`\n${error.stack}`, { color: 'gray' });
|
|
2169
|
+
}
|
|
2160
2170
|
}
|
|
2161
2171
|
function commandExecutor(handler) {
|
|
2162
2172
|
return async (argv) => {
|
|
@@ -16328,6 +16338,11 @@ function replaceRows(state, rows) {
|
|
|
16328
16338
|
selectedFileIndex: 0,
|
|
16329
16339
|
pendingCommitFocused: false,
|
|
16330
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,
|
|
16331
16346
|
};
|
|
16332
16347
|
}
|
|
16333
16348
|
function appendRows(state, rows) {
|
|
@@ -16418,6 +16433,7 @@ function createLogInkState(rows, options = {}) {
|
|
|
16418
16433
|
diffViewMode: 'unified',
|
|
16419
16434
|
inspectorTab: 'inspector',
|
|
16420
16435
|
inspectorActionIndex: 0,
|
|
16436
|
+
bootLoading: options.bootLoading ?? false,
|
|
16421
16437
|
};
|
|
16422
16438
|
}
|
|
16423
16439
|
function getSelectedInkCommit(state) {
|
|
@@ -16619,6 +16635,12 @@ function applyLogInkAction(state, action) {
|
|
|
16619
16635
|
inspectorActionIndex: 0,
|
|
16620
16636
|
pendingKey: undefined,
|
|
16621
16637
|
};
|
|
16638
|
+
case 'setBootLoading':
|
|
16639
|
+
return {
|
|
16640
|
+
...state,
|
|
16641
|
+
bootLoading: action.value,
|
|
16642
|
+
pendingKey: undefined,
|
|
16643
|
+
};
|
|
16622
16644
|
case 'moveTag':
|
|
16623
16645
|
return {
|
|
16624
16646
|
...state,
|
|
@@ -17957,6 +17979,18 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
17957
17979
|
if (key.rightArrow && state.focus === 'sidebar') {
|
|
17958
17980
|
return [action({ type: 'nextSidebarTab' })];
|
|
17959
17981
|
}
|
|
17982
|
+
// ←/→ on the inspector switch between the [Inspector] / [Actions]
|
|
17983
|
+
// tabs, mirroring the sidebar's left/right tab semantics. `[` and
|
|
17984
|
+
// `]` still work as keyboard alternatives, but the visible hint in
|
|
17985
|
+
// the inspector chrome shows ←/→ because the bracketed `[/]`
|
|
17986
|
+
// notation reads as "press the / key" — which is the global filter
|
|
17987
|
+
// trigger and was making users think the binding was busted.
|
|
17988
|
+
if (key.leftArrow && state.focus === 'detail') {
|
|
17989
|
+
return [action({ type: 'setInspectorTab', value: 'inspector' })];
|
|
17990
|
+
}
|
|
17991
|
+
if (key.rightArrow && state.focus === 'detail') {
|
|
17992
|
+
return [action({ type: 'setInspectorTab', value: 'actions' })];
|
|
17993
|
+
}
|
|
17960
17994
|
// ←/→ on the status surface jump between the staged / unstaged /
|
|
17961
17995
|
// untracked groups — the horizontal axis is "between groups", the
|
|
17962
17996
|
// vertical axis (↑/↓ below) is "within the active group's files".
|
|
@@ -18683,7 +18717,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
18683
18717
|
* fall back to "already seen" so we never block startup.
|
|
18684
18718
|
*/
|
|
18685
18719
|
const MARKER_BASENAME = 'onboarding.seen';
|
|
18686
|
-
function resolveCacheDir$
|
|
18720
|
+
function resolveCacheDir$3() {
|
|
18687
18721
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
18688
18722
|
if (xdg && xdg.trim().length > 0) {
|
|
18689
18723
|
return path__namespace$1.join(xdg, 'coco');
|
|
@@ -18691,7 +18725,7 @@ function resolveCacheDir$2() {
|
|
|
18691
18725
|
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
|
|
18692
18726
|
}
|
|
18693
18727
|
function getOnboardingMarkerPath() {
|
|
18694
|
-
return path__namespace$1.join(resolveCacheDir$
|
|
18728
|
+
return path__namespace$1.join(resolveCacheDir$3(), MARKER_BASENAME);
|
|
18695
18729
|
}
|
|
18696
18730
|
function hasSeenOnboarding() {
|
|
18697
18731
|
try {
|
|
@@ -18722,14 +18756,14 @@ function markOnboardingSeen() {
|
|
|
18722
18756
|
* settings: best-effort, XDG-friendly, no PII in the cache filename.
|
|
18723
18757
|
*/
|
|
18724
18758
|
const VALID_MODES = ['unified', 'split'];
|
|
18725
|
-
function resolveCacheDir$
|
|
18759
|
+
function resolveCacheDir$2() {
|
|
18726
18760
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
18727
18761
|
if (xdg && xdg.trim().length > 0) {
|
|
18728
18762
|
return path__namespace$1.join(xdg, 'coco');
|
|
18729
18763
|
}
|
|
18730
18764
|
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
|
|
18731
18765
|
}
|
|
18732
|
-
function repoKey$
|
|
18766
|
+
function repoKey$2(repoPath) {
|
|
18733
18767
|
// sha1 is used here as a non-security cache-key derivation — we just
|
|
18734
18768
|
// need a deterministic short identifier for the marker filename. No
|
|
18735
18769
|
// PII or auth context is hashed.
|
|
@@ -18737,7 +18771,7 @@ function repoKey$1(repoPath) {
|
|
|
18737
18771
|
return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
18738
18772
|
}
|
|
18739
18773
|
function getDiffViewModeMarkerPath(repoPath) {
|
|
18740
|
-
return path__namespace$1.join(resolveCacheDir$
|
|
18774
|
+
return path__namespace$1.join(resolveCacheDir$2(), `diff-view-mode.${repoKey$2(repoPath)}`);
|
|
18741
18775
|
}
|
|
18742
18776
|
function getSavedDiffViewMode(repoPath) {
|
|
18743
18777
|
try {
|
|
@@ -18779,14 +18813,14 @@ const VALID_TABS = [
|
|
|
18779
18813
|
'stashes',
|
|
18780
18814
|
'worktrees',
|
|
18781
18815
|
];
|
|
18782
|
-
function resolveCacheDir() {
|
|
18816
|
+
function resolveCacheDir$1() {
|
|
18783
18817
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
18784
18818
|
if (xdg && xdg.trim().length > 0) {
|
|
18785
18819
|
return path__namespace$1.join(xdg, 'coco');
|
|
18786
18820
|
}
|
|
18787
18821
|
return path__namespace$1.join(os__namespace$1.homedir(), '.cache', 'coco');
|
|
18788
18822
|
}
|
|
18789
|
-
function repoKey(repoPath) {
|
|
18823
|
+
function repoKey$1(repoPath) {
|
|
18790
18824
|
// sha1 is used here as a non-security cache-key derivation — we just
|
|
18791
18825
|
// need a deterministic short identifier for the marker filename so
|
|
18792
18826
|
// re-creating a repo at the same path keeps the same preference.
|
|
@@ -18796,7 +18830,7 @@ function repoKey(repoPath) {
|
|
|
18796
18830
|
return crypto__namespace.createHash('sha1').update(repoPath).digest('hex').slice(0, 16);
|
|
18797
18831
|
}
|
|
18798
18832
|
function getSidebarTabMarkerPath(repoPath) {
|
|
18799
|
-
return path__namespace$1.join(resolveCacheDir(), `sidebar-tab.${repoKey(repoPath)}`);
|
|
18833
|
+
return path__namespace$1.join(resolveCacheDir$1(), `sidebar-tab.${repoKey$1(repoPath)}`);
|
|
18800
18834
|
}
|
|
18801
18835
|
function getSavedSidebarTab(repoPath) {
|
|
18802
18836
|
try {
|
|
@@ -19223,9 +19257,16 @@ function getLogInkLayout(input) {
|
|
|
19223
19257
|
// graph dominant; focus expansion gives the inspector room for long
|
|
19224
19258
|
// commit bodies / file lists / action labels. Mirrors the sidebar
|
|
19225
19259
|
// pattern (sidebarFocused above): instant transition per render.
|
|
19226
|
-
|
|
19227
|
-
|
|
19228
|
-
|
|
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)));
|
|
19229
19270
|
// Sidebar at rest: 22-34 cells (~24% of width). Focused: 32-50 cells
|
|
19230
19271
|
// (~36% of width). The transition is instant per render — focus tab to
|
|
19231
19272
|
// expand, focus away to collapse.
|
|
@@ -23717,15 +23758,20 @@ async function loadLogInkContext(git) {
|
|
|
23717
23758
|
};
|
|
23718
23759
|
}
|
|
23719
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.
|
|
23720
23770
|
return [
|
|
23721
23771
|
{
|
|
23722
23772
|
key: 'branches',
|
|
23723
23773
|
load: () => safe(getBranchOverview(git)),
|
|
23724
23774
|
},
|
|
23725
|
-
{
|
|
23726
|
-
key: 'pullRequest',
|
|
23727
|
-
load: () => safe(getPullRequestOverview(git)),
|
|
23728
|
-
},
|
|
23729
23775
|
{
|
|
23730
23776
|
key: 'tags',
|
|
23731
23777
|
load: () => safe(getTagOverview(git)),
|
|
@@ -23938,8 +23984,15 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
|
23938
23984
|
const input = streams.input || process.stdin;
|
|
23939
23985
|
const output = streams.output || process.stdout;
|
|
23940
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.
|
|
23941
23991
|
if (!canStartLogInkTui(input, output)) {
|
|
23942
|
-
|
|
23992
|
+
const fallbackRows = options.loadRows && rows.length === 0
|
|
23993
|
+
? await options.loadRows()
|
|
23994
|
+
: rows;
|
|
23995
|
+
await startInteractiveLog(git, fallbackRows, {
|
|
23943
23996
|
appLabel: options.appLabel,
|
|
23944
23997
|
input,
|
|
23945
23998
|
output,
|
|
@@ -23958,6 +24011,7 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
|
23958
24011
|
ink,
|
|
23959
24012
|
initialView: options.initialView || 'history',
|
|
23960
24013
|
logArgv: options.logArgv,
|
|
24014
|
+
loadRows: options.loadRows,
|
|
23961
24015
|
React,
|
|
23962
24016
|
rows,
|
|
23963
24017
|
theme: createLogInkTheme(options.theme),
|
|
@@ -24054,7 +24108,7 @@ function enrichFilterActionWithRectification(action, state, context) {
|
|
|
24054
24108
|
}
|
|
24055
24109
|
}
|
|
24056
24110
|
function LogInkApp(deps) {
|
|
24057
|
-
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;
|
|
24058
24112
|
const { Box, Text, useApp, useInput, useWindowSize } = ink;
|
|
24059
24113
|
const h = React.createElement;
|
|
24060
24114
|
const { exit } = useApp();
|
|
@@ -24075,9 +24129,26 @@ function LogInkApp(deps) {
|
|
|
24075
24129
|
// user's cache dir so the tip never reappears once dismissed. Lazy
|
|
24076
24130
|
// initializer so the fs check only runs on mount, not every render.
|
|
24077
24131
|
const [showOnboarding, setShowOnboarding] = React.useState(() => !hasSeenOnboarding());
|
|
24078
|
-
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
|
+
}));
|
|
24079
24142
|
const [context, setContext] = React.useState({});
|
|
24080
|
-
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
|
+
});
|
|
24081
24152
|
const [detail, setDetail] = React.useState(undefined);
|
|
24082
24153
|
const [detailLoading, setDetailLoading] = React.useState(false);
|
|
24083
24154
|
const [filePreview, setFilePreview] = React.useState(undefined);
|
|
@@ -24148,9 +24219,78 @@ function LogInkApp(deps) {
|
|
|
24148
24219
|
const visibleWorktreeGroups = React.useMemo(() => groupWorktreeFiles(visibleWorktreeFiles), [visibleWorktreeFiles]);
|
|
24149
24220
|
const visibleWorktreeFilesGrouped = React.useMemo(() => flattenWorktreeGroups(visibleWorktreeGroups), [visibleWorktreeGroups]);
|
|
24150
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]);
|
|
24151
24261
|
const dispatch = React.useCallback((action) => {
|
|
24152
24262
|
setState((current) => applyLogInkAction(current, action));
|
|
24153
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
|
+
}, []);
|
|
24154
24294
|
// Auto-dismiss status messages after a short window so transient
|
|
24155
24295
|
// confirmations ("Pulled current branch", "Edited foo.ts") don't
|
|
24156
24296
|
// linger forever. Each new message resets the timer; clearing the
|
|
@@ -24372,6 +24512,34 @@ function LogInkApp(deps) {
|
|
|
24372
24512
|
active = false;
|
|
24373
24513
|
};
|
|
24374
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]);
|
|
24375
24543
|
React.useEffect(() => {
|
|
24376
24544
|
let active = true;
|
|
24377
24545
|
async function loadDetail() {
|
|
@@ -25123,8 +25291,9 @@ function LogInkApp(deps) {
|
|
|
25123
25291
|
else if (state.diffSource === 'stash' && stashDiffLines) {
|
|
25124
25292
|
// Walk back to the most recent file header at or before the
|
|
25125
25293
|
// current preview offset — same logic the input-context block
|
|
25126
|
-
// uses to expose stashDiffSelectedPath.
|
|
25127
|
-
|
|
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);
|
|
25128
25297
|
if (current) {
|
|
25129
25298
|
value = current.path;
|
|
25130
25299
|
label = `path ${current.path}`;
|
|
@@ -25167,6 +25336,7 @@ function LogInkApp(deps) {
|
|
|
25167
25336
|
selected,
|
|
25168
25337
|
selectedDetailFile,
|
|
25169
25338
|
stashDiffLines,
|
|
25339
|
+
stashDiffParsedFiles,
|
|
25170
25340
|
state.activeView,
|
|
25171
25341
|
state.branchSort,
|
|
25172
25342
|
state.diffPreviewOffset,
|
|
@@ -25381,43 +25551,25 @@ function LogInkApp(deps) {
|
|
|
25381
25551
|
// P4.5: navigation in branches/tags/stash uses the FILTERED list
|
|
25382
25552
|
// length when a filter is active so j/k stay live instead of getting
|
|
25383
25553
|
// stuck against a full-list count that no longer matches what's on
|
|
25384
|
-
// screen.
|
|
25385
|
-
|
|
25386
|
-
|
|
25387
|
-
|
|
25388
|
-
|
|
25389
|
-
|
|
25390
|
-
const
|
|
25391
|
-
|
|
25392
|
-
.filter((tag) => matchesPromotedFilter([tag.name, tag.subject], state.filter))
|
|
25393
|
-
.length
|
|
25394
|
-
: context.tags?.tags.length;
|
|
25395
|
-
const visibleStashes = state.filter
|
|
25396
|
-
? (context.stashes?.stashes || [])
|
|
25397
|
-
.filter((stash) => matchesPromotedFilter([stash.ref, stash.message], state.filter))
|
|
25398
|
-
: (context.stashes?.stashes || []);
|
|
25399
|
-
const stashVisibleCount = visibleStashes.length;
|
|
25400
|
-
const stashSelectedRef = visibleStashes[Math.min(state.selectedStashIndex, Math.max(0, visibleStashes.length - 1))]?.ref;
|
|
25401
|
-
// The worktrees promoted view is filterable; mirror the branches /
|
|
25402
|
-
// tags / stash pattern and feed the filtered count into the input
|
|
25403
|
-
// dispatcher so ↑/↓ stay synchronized with the visible rows.
|
|
25404
|
-
const worktreeVisibleCount = state.filter
|
|
25405
|
-
? (context.worktreeList?.worktrees || [])
|
|
25406
|
-
.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
|
|
25407
|
-
.length
|
|
25408
|
-
: 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;
|
|
25409
25562
|
// When the diff view is showing a stash patch, swap the previewLineCount
|
|
25410
25563
|
// to the stash diff length so the existing pageDetailPreview path
|
|
25411
25564
|
// (j/k, PgUp/PgDn) scrolls through it without a parallel pipeline.
|
|
25412
25565
|
const diffPreviewLineCount = state.diffSource === 'stash'
|
|
25413
25566
|
? stashDiffLines?.length
|
|
25414
25567
|
: filePreview?.hunks.length;
|
|
25415
|
-
//
|
|
25416
|
-
//
|
|
25417
|
-
//
|
|
25418
|
-
|
|
25419
|
-
|
|
25420
|
-
: [];
|
|
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 : [];
|
|
25421
25573
|
const stashDiffFileOffsets = stashDiffFiles.map((file) => file.startLine);
|
|
25422
25574
|
const stashDiffSelectedPath = state.diffSource === 'stash'
|
|
25423
25575
|
? findStashFileForOffset(stashDiffFiles, state.diffPreviewOffset)?.path
|
|
@@ -25514,6 +25666,7 @@ function LogInkApp(deps) {
|
|
|
25514
25666
|
rows: windowSize.rows || process.stdout.rows || LOG_INK_DEFAULT_ROWS,
|
|
25515
25667
|
sidebarFocused: state.focus === 'sidebar',
|
|
25516
25668
|
inspectorFocused: state.focus === 'detail',
|
|
25669
|
+
helpOverlayActive: state.showHelp,
|
|
25517
25670
|
});
|
|
25518
25671
|
if (layout.tooSmall) {
|
|
25519
25672
|
return h(Box, {
|
|
@@ -25543,7 +25696,13 @@ function renderHeader(h, components, state, context, contextStatus, columns, the
|
|
|
25543
25696
|
? `PR #${prInfo.number} ${prInfo.isDraft ? 'DRAFT' : prInfo.state}`
|
|
25544
25697
|
: 'no PR';
|
|
25545
25698
|
const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
|
|
25546
|
-
|
|
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' : '';
|
|
25547
25706
|
const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
|
|
25548
25707
|
const view = breadcrumb ? ` ${breadcrumb}` : '';
|
|
25549
25708
|
// Mode indicator (P2.2) — surfaces the current input mode so users
|
|
@@ -25850,22 +26009,30 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
25850
26009
|
...(state.historyFetchArgs
|
|
25851
26010
|
? [h(Text, { key: 'history-fetch-indicator', dimColor: true }, `filter: ${formatHistoryFetchArgs(state.historyFetchArgs)} (ctrl+u in / to clear)`)]
|
|
25852
26011
|
: []), ...(pendingNode ? [pendingNode] : []), visible.items.length === 0
|
|
25853
|
-
? h(Text, { dimColor: true },
|
|
25854
|
-
|
|
25855
|
-
|
|
25856
|
-
|
|
26012
|
+
? h(Text, { dimColor: true }, state.bootLoading
|
|
26013
|
+
? formatLogInkLoading({ resource: 'commits' })
|
|
26014
|
+
: formatLogInkHistoryEmpty({
|
|
26015
|
+
filter: state.filter,
|
|
26016
|
+
totalCommits: state.commits.length,
|
|
26017
|
+
}))
|
|
25857
26018
|
: visible.items.map((item, index) => {
|
|
25858
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.
|
|
25859
26026
|
if (item.laneSegments && !theme.ascii) {
|
|
25860
|
-
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 }));
|
|
25861
26028
|
}
|
|
25862
26029
|
return h(Text, {
|
|
25863
26030
|
key: `graph-${index}-${item.graph}`,
|
|
25864
26031
|
color: theme.noColor ? undefined : theme.colors.muted,
|
|
25865
|
-
dimColor:
|
|
25866
|
-
}, 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)));
|
|
25867
26034
|
}
|
|
25868
|
-
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);
|
|
25869
26036
|
}));
|
|
25870
26037
|
}
|
|
25871
26038
|
/**
|
|
@@ -25878,7 +26045,7 @@ function renderHistoryPanel(h, components, state, context, bodyRows, width, them
|
|
|
25878
26045
|
* Final padding is appended as its own span so callers do not need to
|
|
25879
26046
|
* pre-pad the graph string before computing lane segments.
|
|
25880
26047
|
*/
|
|
25881
|
-
function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
26048
|
+
function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix, options = {}) {
|
|
25882
26049
|
const muted = theme.noColor ? undefined : theme.colors.muted;
|
|
25883
26050
|
const elements = [];
|
|
25884
26051
|
let totalLen = 0;
|
|
@@ -25887,7 +26054,12 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
|
25887
26054
|
elements.push(h(Text, {
|
|
25888
26055
|
key: `${keyPrefix}-${idx}`,
|
|
25889
26056
|
color: laneColor ?? muted,
|
|
25890
|
-
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),
|
|
25891
26063
|
}, seg.text));
|
|
25892
26064
|
totalLen += seg.text.length;
|
|
25893
26065
|
});
|
|
@@ -25908,11 +26080,22 @@ function renderLaneSegmentSpans(h, Text, segments, theme, padTo, keyPrefix) {
|
|
|
25908
26080
|
* Truncation is per-segment so the variable-length message field gets
|
|
25909
26081
|
* the leftover budget after fixed segments are accounted for.
|
|
25910
26082
|
*/
|
|
25911
|
-
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, laneSegments) {
|
|
26083
|
+
function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, theme, index, panelWidth, laneSegments) {
|
|
25912
26084
|
const refs = formatInkRefLabels(commit.refs);
|
|
25913
|
-
|
|
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);
|
|
25914
26092
|
const fixedWidth = graphWidth + 1 + commit.shortHash.length + 1 + commit.date.length + 1;
|
|
25915
|
-
|
|
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));
|
|
25916
26099
|
const message = truncate$1(commit.message, messageRoom);
|
|
25917
26100
|
const selectedBg = selected && !theme.noColor ? theme.colors.selection : undefined;
|
|
25918
26101
|
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
@@ -25927,7 +26110,7 @@ function renderCommitHistoryRow(h, Text, commit, graph, graphWidth, selected, th
|
|
|
25927
26110
|
key: `${commit.hash}-${index}`,
|
|
25928
26111
|
backgroundColor: selectedBg,
|
|
25929
26112
|
inverse: selected,
|
|
25930
|
-
}, ...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);
|
|
25931
26114
|
}
|
|
25932
26115
|
/**
|
|
25933
26116
|
* Render the synthetic "(+) new commit" affordance shown above the real
|
|
@@ -26201,6 +26384,15 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
26201
26384
|
: `${localBranches.length}/${sortedAll.length} local | current: ${branches?.currentBranch || '<detached>'}${filterLabel}${sortLabel}`;
|
|
26202
26385
|
const emptyLabel = formatLogInkBranchesEmpty({ filter: state.filter });
|
|
26203
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)));
|
|
26204
26396
|
const lines = loading
|
|
26205
26397
|
? [h(Text, { key: 'branches-loading', dimColor: true }, loadingLabel)]
|
|
26206
26398
|
: localBranches.length === 0
|
|
@@ -26214,18 +26406,18 @@ function renderBranchesSurface(h, components, state, context, contextStatus, bod
|
|
|
26214
26406
|
const lastTouched = formatBranchLastTouched(branch.date, new Date());
|
|
26215
26407
|
// Split the row into spans so the timestamp stays dim even on the
|
|
26216
26408
|
// currently-selected (bold) row. The leading marker + name keep
|
|
26217
|
-
// their
|
|
26218
|
-
// the divergence column stays aligned across rows.
|
|
26219
|
-
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);
|
|
26220
26412
|
const timestampPadded = lastTouched.padEnd(8);
|
|
26221
26413
|
const lineDim = !isSelected && !branch.current;
|
|
26222
26414
|
const head = `${cursor} ${marker} ${namePadded} `;
|
|
26223
26415
|
const trailingDivergence = divergence ? ` ${divergence}` : '';
|
|
26224
|
-
// Truncate the assembled line
|
|
26225
|
-
//
|
|
26226
|
-
//
|
|
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).
|
|
26227
26419
|
const fullText = `${head}${timestampPadded}${trailingDivergence}`;
|
|
26228
|
-
const truncated = truncate$1(fullText,
|
|
26420
|
+
const truncated = truncate$1(fullText, Math.max(20, width - 4));
|
|
26229
26421
|
// If truncation chopped into the timestamp/divergence portion,
|
|
26230
26422
|
// fall back to a single Text to keep the visible width honest.
|
|
26231
26423
|
if (truncated !== fullText) {
|
|
@@ -26269,6 +26461,13 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
26269
26461
|
: `${tags.length}/${sortedAll.length} tags${filterLabel}${sortLabel}`;
|
|
26270
26462
|
const emptyLabel = formatLogInkTagsEmpty({ filter: state.filter });
|
|
26271
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)));
|
|
26272
26471
|
const lines = loading
|
|
26273
26472
|
? [h(Text, { key: 'tags-loading', dimColor: true }, loadingLabel)]
|
|
26274
26473
|
: tags.length === 0
|
|
@@ -26282,8 +26481,8 @@ function renderTagsSurface(h, components, state, context, contextStatus, bodyRow
|
|
|
26282
26481
|
// formatHyperlink wraps just the tag name, leaving width math
|
|
26283
26482
|
// intact.
|
|
26284
26483
|
const url = buildRefUrl(context.provider?.repository, tag.name);
|
|
26285
|
-
const namePadded = tag.name.padEnd(
|
|
26286
|
-
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));
|
|
26287
26486
|
if (!url || lineText.indexOf(namePadded) < 0) {
|
|
26288
26487
|
return h(Text, {
|
|
26289
26488
|
key: `tag-${index}`,
|
|
@@ -26366,6 +26565,17 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
26366
26565
|
const headerRight = loading
|
|
26367
26566
|
? 'loading worktrees'
|
|
26368
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
|
+
})));
|
|
26369
26579
|
const lines = loading
|
|
26370
26580
|
? [h(Text, { key: 'worktrees-loading', dimColor: true }, formatLogInkLoading({ resource: 'worktrees' }))]
|
|
26371
26581
|
: worktrees.length === 0
|
|
@@ -26377,11 +26587,12 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
26377
26587
|
const marker = entry.current ? '*' : ' ';
|
|
26378
26588
|
const branchLabel = entry.branch ? entry.branch : entry.head || '<detached>';
|
|
26379
26589
|
const stateLabel = entry.dirty ? 'dirty' : 'clean';
|
|
26590
|
+
const branchPadded = truncate$1(branchLabel, branchColWidth).padEnd(branchColWidth);
|
|
26380
26591
|
return h(Text, {
|
|
26381
26592
|
key: `worktree-${index}`,
|
|
26382
26593
|
bold: isSelected,
|
|
26383
26594
|
dimColor: !isSelected && !entry.current,
|
|
26384
|
-
}, truncate$1(`${cursor} ${marker} ${
|
|
26595
|
+
}, truncate$1(`${cursor} ${marker} ${branchPadded} ${stateLabel.padEnd(6)} ${entry.path}`, width - 4));
|
|
26385
26596
|
});
|
|
26386
26597
|
return h(Box, {
|
|
26387
26598
|
borderColor: focusBorderColor(theme, focused),
|
|
@@ -26803,22 +27014,39 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
26803
27014
|
h(Text, { key: 'detail-spacer-3' }, ''),
|
|
26804
27015
|
h(Text, { key: 'detail-files-title' }, 'Changed files:'),
|
|
26805
27016
|
];
|
|
27017
|
+
// Single-cursor invariant: the file list owns the cursor when the
|
|
27018
|
+
// inspector tab is active; the actions list owns it when the actions
|
|
27019
|
+
// tab is active. Pass `focused` only for the matching tab so users
|
|
27020
|
+
// never see two simultaneous selection highlights inside the panel.
|
|
27021
|
+
const fileListFocused = focused && state.inspectorTab === 'inspector';
|
|
26806
27022
|
const fileListMaxRows = Math.max(4, Math.min(detail.files.length, 10));
|
|
26807
|
-
const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex,
|
|
26808
|
-
//
|
|
26809
|
-
//
|
|
26810
|
-
//
|
|
26811
|
-
//
|
|
26812
|
-
//
|
|
27023
|
+
const fileListNodes = renderCommitFileList(h, Text, detail.files, state.selectedFileIndex, fileListFocused, fileListMaxRows, width, theme);
|
|
27024
|
+
// Tab indicator. Renders in BOTH tabbed (short-terminal) mode and
|
|
27025
|
+
// tall-stacked mode so the user can always see which tab the cursor
|
|
27026
|
+
// owns and learn the `[/]` toggle. Without this on tall terminals,
|
|
27027
|
+
// the actions list looked like a static cheat-sheet — there was no
|
|
27028
|
+
// visible signal that the cursor could move into it.
|
|
27029
|
+
//
|
|
27030
|
+
// Spacing between tab labels comes from the labels' own padding
|
|
27031
|
+
// (the active label is bracketed `[Inspector]` while the inactive
|
|
27032
|
+
// one is space-padded ` Inspector `, so adjacency reads cleanly).
|
|
27033
|
+
// Earlier revisions stuck a raw `' '` between the Text children to
|
|
27034
|
+
// pad them visually — that crashes Ink at first paint with
|
|
27035
|
+
// "Text string ' ' must be rendered inside <Text> component"
|
|
27036
|
+
// because Box only accepts component children, never bare strings.
|
|
27037
|
+
const activeTab = state.inspectorTab;
|
|
27038
|
+
const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
|
|
27039
|
+
bold: activeTab === 'inspector',
|
|
27040
|
+
dimColor: activeTab !== 'inspector',
|
|
27041
|
+
}, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), h(Text, {
|
|
27042
|
+
bold: activeTab === 'actions',
|
|
27043
|
+
dimColor: activeTab !== 'actions',
|
|
27044
|
+
}, activeTab === 'actions' ? '[Actions]' : ' Actions '), ...(focused
|
|
27045
|
+
? [h(Text, { key: 'inspector-tabs-hint', dimColor: true }, ' · ←/→ switch')]
|
|
27046
|
+
: []));
|
|
27047
|
+
// Tabbed mode (short terminals): render only the active tab's
|
|
27048
|
+
// content under the tab header.
|
|
26813
27049
|
if (tabbed) {
|
|
26814
|
-
const activeTab = state.inspectorTab;
|
|
26815
|
-
const tabHeader = h(Box, { key: 'inspector-tabs', flexDirection: 'row' }, h(Text, {
|
|
26816
|
-
bold: activeTab === 'inspector',
|
|
26817
|
-
dimColor: activeTab !== 'inspector',
|
|
26818
|
-
}, activeTab === 'inspector' ? '[Inspector]' : ' Inspector '), ' ', h(Text, {
|
|
26819
|
-
bold: activeTab === 'actions',
|
|
26820
|
-
dimColor: activeTab !== 'actions',
|
|
26821
|
-
}, activeTab === 'actions' ? '[Actions]' : ' Actions '));
|
|
26822
27050
|
return h(Box, {
|
|
26823
27051
|
borderColor: focusBorderColor(theme, focused),
|
|
26824
27052
|
borderStyle: theme.borderStyle,
|
|
@@ -26832,13 +27060,16 @@ function renderHistoryInspector(h, components, state, context, _contextStatus, d
|
|
|
26832
27060
|
cursorActive: focused,
|
|
26833
27061
|
})));
|
|
26834
27062
|
}
|
|
27063
|
+
// Tall mode: stack both sections so the user can read everything at
|
|
27064
|
+
// once, but show the tab header so the active section (and the
|
|
27065
|
+
// `[/]` switch affordance) is visible.
|
|
26835
27066
|
return h(Box, {
|
|
26836
27067
|
borderColor: focusBorderColor(theme, focused),
|
|
26837
27068
|
borderStyle: theme.borderStyle,
|
|
26838
27069
|
flexDirection: 'column',
|
|
26839
27070
|
width,
|
|
26840
27071
|
paddingX: 1,
|
|
26841
|
-
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
27072
|
+
}, h(Text, { bold: true }, panelTitle('Inspector', focused)), tabHeader, h(Text, { key: 'inspector-tabs-spacer' }, ''), ...headerNodes, ...fileListNodes, ...renderInspectorActionsSection(h, Text, 'history-commit', width, theme, {
|
|
26842
27073
|
cursorIndex: state.inspectorActionIndex,
|
|
26843
27074
|
cursorActive: focused && state.inspectorTab === 'actions',
|
|
26844
27075
|
}));
|
|
@@ -27441,6 +27672,84 @@ function renderFooter(h, components, state, context, theme, idleTip) {
|
|
|
27441
27672
|
}, h(Text, { color: theme.colors.muted, dimColor: true }, contextualText), h(Text, { color: theme.colors.muted, dimColor: true }, globalText));
|
|
27442
27673
|
}
|
|
27443
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
|
+
|
|
27444
27753
|
function createLogArgvFromUiArgv(argv) {
|
|
27445
27754
|
return {
|
|
27446
27755
|
$0: argv.$0,
|
|
@@ -27465,14 +27774,43 @@ function createUiTheme(config, argv) {
|
|
|
27465
27774
|
preset: argv.theme,
|
|
27466
27775
|
};
|
|
27467
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
|
+
}
|
|
27468
27790
|
async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
27469
27791
|
const config = options.config || loadConfig(logArgv);
|
|
27470
27792
|
const git = options.git || getRepo();
|
|
27471
|
-
const
|
|
27472
|
-
|
|
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, {}, {
|
|
27473
27810
|
appLabel: 'coco',
|
|
27474
27811
|
idleTips: config.logTui?.idleTips,
|
|
27475
27812
|
initialView: 'history',
|
|
27813
|
+
loadRows,
|
|
27476
27814
|
logArgv,
|
|
27477
27815
|
theme: config.logTui?.theme,
|
|
27478
27816
|
});
|
|
@@ -27481,11 +27819,15 @@ async function startCocoUi(argv) {
|
|
|
27481
27819
|
const config = loadConfig(argv);
|
|
27482
27820
|
const git = getRepo();
|
|
27483
27821
|
const logArgv = createLogArgvFromUiArgv(argv);
|
|
27484
|
-
const
|
|
27485
|
-
|
|
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 || [], {}, {
|
|
27486
27827
|
appLabel: 'coco',
|
|
27487
27828
|
idleTips: config.logTui?.idleTips,
|
|
27488
27829
|
initialView: argv.view || 'history',
|
|
27830
|
+
loadRows: withCacheWrite(repoPath, () => getLogRows(git, logArgv)),
|
|
27489
27831
|
logArgv,
|
|
27490
27832
|
theme: createUiTheme(config, argv),
|
|
27491
27833
|
});
|
|
@@ -27595,15 +27937,18 @@ const handler$2 = async (argv) => {
|
|
|
27595
27937
|
});
|
|
27596
27938
|
return;
|
|
27597
27939
|
}
|
|
27598
|
-
|
|
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.
|
|
27599
27944
|
if (argv.interactive && format === 'table') {
|
|
27600
27945
|
await startCocoUiFromLogArgv(argv, {
|
|
27601
27946
|
config,
|
|
27602
27947
|
git,
|
|
27603
|
-
rows,
|
|
27604
27948
|
});
|
|
27605
27949
|
return;
|
|
27606
27950
|
}
|
|
27951
|
+
const rows = await getLogRows(git, argv);
|
|
27607
27952
|
const result = format === 'json' ? formatLogJson(rows) : formatLogTable(rows);
|
|
27608
27953
|
await handleResult({
|
|
27609
27954
|
result,
|