git-coco 0.54.0 → 0.55.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 +1778 -563
- package/dist/index.js +1778 -563
- package/package.json +1 -1
package/dist/index.esm.mjs
CHANGED
|
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
|
|
|
61
61
|
/**
|
|
62
62
|
* Current build version from package.json
|
|
63
63
|
*/
|
|
64
|
-
const BUILD_VERSION = "0.
|
|
64
|
+
const BUILD_VERSION = "0.55.0";
|
|
65
65
|
|
|
66
66
|
const isInteractive = (config) => {
|
|
67
67
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -18351,7 +18351,15 @@ const FIELD_SEPARATOR$3 = '\x1f';
|
|
|
18351
18351
|
const LOG_FORMAT = `%x1f%h%x1f%H%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s`;
|
|
18352
18352
|
const DETAIL_FORMAT = `%H%x1f%h%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s%x1f%b`;
|
|
18353
18353
|
const LOG_DEFAULT_LIMIT = 30;
|
|
18354
|
-
|
|
18354
|
+
// Bumped from 300 → 1000 in 0.54.2. With the full-graph default
|
|
18355
|
+
// (#1034) the workstation surfaces many more refs (all branches, all
|
|
18356
|
+
// tags, plus stash commits added via `extraRefs`), and on active repos
|
|
18357
|
+
// the 300-commit cap was cutting off year+-old stash bases and old
|
|
18358
|
+
// tag commits — making the cursor-syncs-history effect report "tip
|
|
18359
|
+
// not in loaded window" instead of moving the graph cursor. 1000
|
|
18360
|
+
// fits a year of activity for most repos, git log is still sub-200ms,
|
|
18361
|
+
// and Ink virtualises scroll so render cost stays flat.
|
|
18362
|
+
const LOG_INTERACTIVE_DEFAULT_LIMIT = 1000;
|
|
18355
18363
|
function toArray(value) {
|
|
18356
18364
|
if (!value) {
|
|
18357
18365
|
return [];
|
|
@@ -18531,12 +18539,77 @@ function buildLogArgs(argv, options = {}) {
|
|
|
18531
18539
|
else if (argv.branch) {
|
|
18532
18540
|
args.push(argv.branch);
|
|
18533
18541
|
}
|
|
18542
|
+
// Extra refs (stash commits etc.) — append after the --all / branch
|
|
18543
|
+
// selector but BEFORE the path separator. Git treats them as
|
|
18544
|
+
// additional graph roots, so the traversal includes them alongside
|
|
18545
|
+
// whatever --all / --branch already covers.
|
|
18546
|
+
if (options.extraRefs && options.extraRefs.length > 0) {
|
|
18547
|
+
args.push(...options.extraRefs);
|
|
18548
|
+
}
|
|
18534
18549
|
const paths = toArray(argv.path);
|
|
18535
18550
|
if (paths.length > 0) {
|
|
18536
18551
|
args.push('--', ...paths);
|
|
18537
18552
|
}
|
|
18538
18553
|
return args;
|
|
18539
18554
|
}
|
|
18555
|
+
/**
|
|
18556
|
+
* Default size of a targeted-context window. Sized to comfortably
|
|
18557
|
+
* cover a year of activity on most repos so the cursor-sync's
|
|
18558
|
+
* "jump to commit anchored on a ref I just selected" can succeed
|
|
18559
|
+
* without paginating through the whole history.
|
|
18560
|
+
*/
|
|
18561
|
+
const COMMIT_CONTEXT_DEFAULT_LIMIT = 5000;
|
|
18562
|
+
/**
|
|
18563
|
+
* Load a window of commits anchored on a specific hash. Used by the
|
|
18564
|
+
* cursor-sync effect when the user selects a ref (branch / tag /
|
|
18565
|
+
* stash) whose target commit isn't in the loaded graph window.
|
|
18566
|
+
*
|
|
18567
|
+
* Critical detail: this walks **only from the target** (and its
|
|
18568
|
+
* ancestors), NOT from `--all`. Why: when you combine `--all` with
|
|
18569
|
+
* `<targetHash>` AND `--max-count=N`, git unions the walks, sorts
|
|
18570
|
+
* the result by date, and slices the newest N rows. If the target
|
|
18571
|
+
* is older than the Nth newest commit across all refs (very common
|
|
18572
|
+
* for year-old tags / branches on active repos), it falls off the
|
|
18573
|
+
* slice even though it was passed as a root. Walking from the
|
|
18574
|
+
* target alone guarantees the target IS the first row of the
|
|
18575
|
+
* output and its ancestors fill the rest.
|
|
18576
|
+
*
|
|
18577
|
+
* The caller merges the result via the `appendRows` reducer action
|
|
18578
|
+
* which deduplicates by hash, so the target's ancestry slots into
|
|
18579
|
+
* the existing `--all` graph cleanly. The user's loaded view ends
|
|
18580
|
+
* up as the union of: the original `--all` window + target's
|
|
18581
|
+
* ancestry — exactly what's needed for the cursor to land.
|
|
18582
|
+
*
|
|
18583
|
+
* Capped at `options.limit` (default 5000) to keep one targeted
|
|
18584
|
+
* fetch bounded. For most refs, even a 100-commit limit would be
|
|
18585
|
+
* enough to surface the target; we go higher to also pull in the
|
|
18586
|
+
* surrounding context so the user can scroll around the landed
|
|
18587
|
+
* cursor.
|
|
18588
|
+
*/
|
|
18589
|
+
async function getLogRowsAnchoredOn(git, argv, targetHash, options = {}) {
|
|
18590
|
+
// Strip every "walk many refs" toggle so buildLogArgs produces a
|
|
18591
|
+
// clean `git log <flags> <targetHash>` — exactly the walk that
|
|
18592
|
+
// guarantees the target's inclusion.
|
|
18593
|
+
const merged = {
|
|
18594
|
+
...argv,
|
|
18595
|
+
all: false,
|
|
18596
|
+
view: 'compact', // suppresses 'full' → '--all' mapping
|
|
18597
|
+
branch: undefined,
|
|
18598
|
+
path: undefined,
|
|
18599
|
+
};
|
|
18600
|
+
// Also drop --first-parent / --no-merges so the target's ancestry
|
|
18601
|
+
// renders with full topology (matters for stash commits which are
|
|
18602
|
+
// merges by construction).
|
|
18603
|
+
const baseArgs = buildLogArgs(merged, {
|
|
18604
|
+
limit: options.limit ?? COMMIT_CONTEXT_DEFAULT_LIMIT,
|
|
18605
|
+
}).filter((arg) => arg !== '--first-parent' && arg !== '--no-merges');
|
|
18606
|
+
// Splice the target as the positional ref. `buildLogArgs` already
|
|
18607
|
+
// appended any `--all`/`--branch`/`<extraRefs>` it considered;
|
|
18608
|
+
// since we cleared all those above, the only positional ref we
|
|
18609
|
+
// add is the target.
|
|
18610
|
+
baseArgs.push(targetHash);
|
|
18611
|
+
return parseLogOutput(await git.raw(baseArgs));
|
|
18612
|
+
}
|
|
18540
18613
|
/**
|
|
18541
18614
|
* Build merged `LogArgv` for the interactive TUI's `g` graph toggle.
|
|
18542
18615
|
*
|
|
@@ -18644,6 +18717,178 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
|
|
|
18644
18717
|
};
|
|
18645
18718
|
}
|
|
18646
18719
|
|
|
18720
|
+
function parseStashSubject(subject) {
|
|
18721
|
+
const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
|
|
18722
|
+
if (!match) {
|
|
18723
|
+
return {
|
|
18724
|
+
branch: '<unknown>',
|
|
18725
|
+
message: subject,
|
|
18726
|
+
};
|
|
18727
|
+
}
|
|
18728
|
+
return {
|
|
18729
|
+
branch: match[1],
|
|
18730
|
+
message: match[2] || subject,
|
|
18731
|
+
};
|
|
18732
|
+
}
|
|
18733
|
+
function parseStashList(output) {
|
|
18734
|
+
return output
|
|
18735
|
+
.split('\n')
|
|
18736
|
+
.map((line) => line.trim())
|
|
18737
|
+
.filter(Boolean)
|
|
18738
|
+
.map((line) => {
|
|
18739
|
+
const [ref, hash, parents, date, subject] = line.split('\x1f');
|
|
18740
|
+
const parsedSubject = parseStashSubject(subject || '');
|
|
18741
|
+
// `%P` returns space-separated parent hashes. Stash commits are
|
|
18742
|
+
// merges with 2-3 parents; the FIRST is the base (HEAD at stash
|
|
18743
|
+
// time). Empty parents string (legacy / corrupted entries) maps
|
|
18744
|
+
// to an empty baseHash; the cursor-sync caller treats that as
|
|
18745
|
+
// "no base available, fall back to stash hash."
|
|
18746
|
+
const baseHash = parents ? (parents.split(' ')[0] || '') : '';
|
|
18747
|
+
return {
|
|
18748
|
+
ref,
|
|
18749
|
+
hash,
|
|
18750
|
+
baseHash,
|
|
18751
|
+
date,
|
|
18752
|
+
branch: parsedSubject.branch,
|
|
18753
|
+
message: parsedSubject.message,
|
|
18754
|
+
};
|
|
18755
|
+
});
|
|
18756
|
+
}
|
|
18757
|
+
function parseStashFiles(output) {
|
|
18758
|
+
return output
|
|
18759
|
+
.split('\n')
|
|
18760
|
+
.map((line) => line.trim())
|
|
18761
|
+
.filter(Boolean);
|
|
18762
|
+
}
|
|
18763
|
+
/**
|
|
18764
|
+
* Resolve the commit hashes for every stash, in `stash@{N}` order.
|
|
18765
|
+
*
|
|
18766
|
+
* Used by the workstation's history loader to include older stashes
|
|
18767
|
+
* as graph roots — `git log --all` only walks `refs/stash` (the
|
|
18768
|
+
* latest stash) by default, so stash@{1+} commits live off-graph
|
|
18769
|
+
* unless explicitly referenced. Passing this list as positional refs
|
|
18770
|
+
* to `git log` makes every stash appear as a graph node, which lets
|
|
18771
|
+
* the cursor-syncs-history effect actually land on them when the
|
|
18772
|
+
* user navigates the stashes sidebar.
|
|
18773
|
+
*
|
|
18774
|
+
* Cheap: one `git stash list` call, no per-stash fan-out. Returns
|
|
18775
|
+
* an empty array when there are no stashes — callers can pass the
|
|
18776
|
+
* result through unconditionally.
|
|
18777
|
+
*/
|
|
18778
|
+
async function getStashCommitHashes(git) {
|
|
18779
|
+
const raw = await git.raw(['stash', 'list', '--format=%H']).catch(() => '');
|
|
18780
|
+
return raw
|
|
18781
|
+
.split('\n')
|
|
18782
|
+
.map((line) => line.trim())
|
|
18783
|
+
.filter(Boolean);
|
|
18784
|
+
}
|
|
18785
|
+
async function getStashOverview(git) {
|
|
18786
|
+
// Format fields (separated by 0x1f / unit separator):
|
|
18787
|
+
// %gd — stash reflog selector (stash@{N})
|
|
18788
|
+
// %H — stash commit hash
|
|
18789
|
+
// %P — space-separated parent hashes (first = base, see StashEntry.baseHash)
|
|
18790
|
+
// %ci — committer date, ISO format
|
|
18791
|
+
// %gs — reflog subject ("WIP on main: <subject>")
|
|
18792
|
+
const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%P%x1f%ci%x1f%gs']));
|
|
18793
|
+
return {
|
|
18794
|
+
stashes: await Promise.all(stashes.map(async (stash) => ({
|
|
18795
|
+
...stash,
|
|
18796
|
+
files: parseStashFiles(await git.raw(['stash', 'show', '--name-only', stash.ref])),
|
|
18797
|
+
}))),
|
|
18798
|
+
};
|
|
18799
|
+
}
|
|
18800
|
+
/**
|
|
18801
|
+
* Full unified-patch diff for a stash. Used by the diff surface when
|
|
18802
|
+
* `state.diffSource === 'stash'` to render the stash's changes inline.
|
|
18803
|
+
*
|
|
18804
|
+
* Empty stashes (e.g. created by `git stash --keep-index` against an
|
|
18805
|
+
* already-clean tree) return [] rather than throwing — surfaces fall
|
|
18806
|
+
* back to a "no diff to display" message.
|
|
18807
|
+
*/
|
|
18808
|
+
async function getStashDiff(git, stashRef) {
|
|
18809
|
+
return (await git.raw(['stash', 'show', '-p', stashRef]))
|
|
18810
|
+
.split('\n')
|
|
18811
|
+
.map((line) => line.replace(/\r$/, ''));
|
|
18812
|
+
}
|
|
18813
|
+
/**
|
|
18814
|
+
* Slice a unified-patch into per-file sections. Each entry records the
|
|
18815
|
+
* file path and the offset of its `diff --git` header within `lines`.
|
|
18816
|
+
* Used by the stash explorer to build a per-file cursor + cherry-pick
|
|
18817
|
+
* the file at the cursor.
|
|
18818
|
+
*
|
|
18819
|
+
* Renames / moves return the destination path (the `b/` side); the
|
|
18820
|
+
* action surface treats that as the path to materialize from the stash.
|
|
18821
|
+
*
|
|
18822
|
+
* Path quoting: git wraps paths containing spaces or special characters
|
|
18823
|
+
* in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
|
|
18824
|
+
* The parser handles both the unquoted and quoted forms; without that,
|
|
18825
|
+
* stash-file navigation and cherry-pick silently broke for any file
|
|
18826
|
+
* whose path contained a space.
|
|
18827
|
+
*/
|
|
18828
|
+
function parseStashDiffFiles(lines) {
|
|
18829
|
+
const files = [];
|
|
18830
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
18831
|
+
const line = lines[i];
|
|
18832
|
+
const parsed = parseDiffGitHeader(line);
|
|
18833
|
+
if (parsed) {
|
|
18834
|
+
files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
|
|
18835
|
+
}
|
|
18836
|
+
}
|
|
18837
|
+
return files;
|
|
18838
|
+
}
|
|
18839
|
+
/**
|
|
18840
|
+
* Resolve which stash file *contains* a given line offset — the user's
|
|
18841
|
+
* cursor scrolls through a concatenated multi-file patch, and this is
|
|
18842
|
+
* what powers the "File N/M: <path>" panel header, the inline header
|
|
18843
|
+
* highlighting (#791 follow-up), and the cherry-pick / open-in-editor
|
|
18844
|
+
* dispatchers' "what file is the cursor on" lookup.
|
|
18845
|
+
*
|
|
18846
|
+
* Returns `undefined` when the file list is empty *or* the offset
|
|
18847
|
+
* lands before the very first file's `diff --git` header (e.g. when
|
|
18848
|
+
* `--stat` summary lines lead the patch). Callers fall through to a
|
|
18849
|
+
* "no file selected" state in that case.
|
|
18850
|
+
*/
|
|
18851
|
+
function findStashFileForOffset(files, offset) {
|
|
18852
|
+
if (files.length === 0)
|
|
18853
|
+
return undefined;
|
|
18854
|
+
let current;
|
|
18855
|
+
for (const file of files) {
|
|
18856
|
+
if (file.startLine <= offset) {
|
|
18857
|
+
current = file;
|
|
18858
|
+
}
|
|
18859
|
+
else {
|
|
18860
|
+
break;
|
|
18861
|
+
}
|
|
18862
|
+
}
|
|
18863
|
+
// First file is the canonical fallback — even if the offset lands
|
|
18864
|
+
// before its header (rare), we want the cursor to be "in" something
|
|
18865
|
+
// so the user's actions have a target.
|
|
18866
|
+
return current ?? files[0];
|
|
18867
|
+
}
|
|
18868
|
+
const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
|
|
18869
|
+
function parseDiffGitHeader(line) {
|
|
18870
|
+
const match = line.match(DIFF_GIT_HEADER);
|
|
18871
|
+
if (!match)
|
|
18872
|
+
return undefined;
|
|
18873
|
+
const aPath = unescapeGitQuoted(match[1]) || match[2];
|
|
18874
|
+
const bPath = unescapeGitQuoted(match[3]) || match[4];
|
|
18875
|
+
if (!aPath || !bPath)
|
|
18876
|
+
return undefined;
|
|
18877
|
+
return { aPath, bPath };
|
|
18878
|
+
}
|
|
18879
|
+
function unescapeGitQuoted(value) {
|
|
18880
|
+
if (value === undefined)
|
|
18881
|
+
return undefined;
|
|
18882
|
+
// Git's diff header quoting escapes `"`, `\`, and the usual
|
|
18883
|
+
// C-style sequences. Reverse the most common ones so callers get the
|
|
18884
|
+
// raw on-disk path.
|
|
18885
|
+
return value
|
|
18886
|
+
.replace(/\\\\/g, '\\')
|
|
18887
|
+
.replace(/\\"/g, '"')
|
|
18888
|
+
.replace(/\\t/g, '\t')
|
|
18889
|
+
.replace(/\\n/g, '\n');
|
|
18890
|
+
}
|
|
18891
|
+
|
|
18647
18892
|
const FIELD_SEPARATOR$2 = '\x1f';
|
|
18648
18893
|
function parseBranchRefs(output) {
|
|
18649
18894
|
return output
|
|
@@ -18836,143 +19081,6 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
|
|
|
18836
19081
|
}
|
|
18837
19082
|
}
|
|
18838
19083
|
|
|
18839
|
-
function parseStashSubject(subject) {
|
|
18840
|
-
const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
|
|
18841
|
-
if (!match) {
|
|
18842
|
-
return {
|
|
18843
|
-
branch: '<unknown>',
|
|
18844
|
-
message: subject,
|
|
18845
|
-
};
|
|
18846
|
-
}
|
|
18847
|
-
return {
|
|
18848
|
-
branch: match[1],
|
|
18849
|
-
message: match[2] || subject,
|
|
18850
|
-
};
|
|
18851
|
-
}
|
|
18852
|
-
function parseStashList(output) {
|
|
18853
|
-
return output
|
|
18854
|
-
.split('\n')
|
|
18855
|
-
.map((line) => line.trim())
|
|
18856
|
-
.filter(Boolean)
|
|
18857
|
-
.map((line) => {
|
|
18858
|
-
const [ref, hash, date, subject] = line.split('\x1f');
|
|
18859
|
-
const parsedSubject = parseStashSubject(subject || '');
|
|
18860
|
-
return {
|
|
18861
|
-
ref,
|
|
18862
|
-
hash,
|
|
18863
|
-
date,
|
|
18864
|
-
branch: parsedSubject.branch,
|
|
18865
|
-
message: parsedSubject.message,
|
|
18866
|
-
};
|
|
18867
|
-
});
|
|
18868
|
-
}
|
|
18869
|
-
function parseStashFiles(output) {
|
|
18870
|
-
return output
|
|
18871
|
-
.split('\n')
|
|
18872
|
-
.map((line) => line.trim())
|
|
18873
|
-
.filter(Boolean);
|
|
18874
|
-
}
|
|
18875
|
-
async function getStashOverview(git) {
|
|
18876
|
-
const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%ci%x1f%gs']));
|
|
18877
|
-
return {
|
|
18878
|
-
stashes: await Promise.all(stashes.map(async (stash) => ({
|
|
18879
|
-
...stash,
|
|
18880
|
-
files: parseStashFiles(await git.raw(['stash', 'show', '--name-only', stash.ref])),
|
|
18881
|
-
}))),
|
|
18882
|
-
};
|
|
18883
|
-
}
|
|
18884
|
-
/**
|
|
18885
|
-
* Full unified-patch diff for a stash. Used by the diff surface when
|
|
18886
|
-
* `state.diffSource === 'stash'` to render the stash's changes inline.
|
|
18887
|
-
*
|
|
18888
|
-
* Empty stashes (e.g. created by `git stash --keep-index` against an
|
|
18889
|
-
* already-clean tree) return [] rather than throwing — surfaces fall
|
|
18890
|
-
* back to a "no diff to display" message.
|
|
18891
|
-
*/
|
|
18892
|
-
async function getStashDiff(git, stashRef) {
|
|
18893
|
-
return (await git.raw(['stash', 'show', '-p', stashRef]))
|
|
18894
|
-
.split('\n')
|
|
18895
|
-
.map((line) => line.replace(/\r$/, ''));
|
|
18896
|
-
}
|
|
18897
|
-
/**
|
|
18898
|
-
* Slice a unified-patch into per-file sections. Each entry records the
|
|
18899
|
-
* file path and the offset of its `diff --git` header within `lines`.
|
|
18900
|
-
* Used by the stash explorer to build a per-file cursor + cherry-pick
|
|
18901
|
-
* the file at the cursor.
|
|
18902
|
-
*
|
|
18903
|
-
* Renames / moves return the destination path (the `b/` side); the
|
|
18904
|
-
* action surface treats that as the path to materialize from the stash.
|
|
18905
|
-
*
|
|
18906
|
-
* Path quoting: git wraps paths containing spaces or special characters
|
|
18907
|
-
* in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
|
|
18908
|
-
* The parser handles both the unquoted and quoted forms; without that,
|
|
18909
|
-
* stash-file navigation and cherry-pick silently broke for any file
|
|
18910
|
-
* whose path contained a space.
|
|
18911
|
-
*/
|
|
18912
|
-
function parseStashDiffFiles(lines) {
|
|
18913
|
-
const files = [];
|
|
18914
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
18915
|
-
const line = lines[i];
|
|
18916
|
-
const parsed = parseDiffGitHeader(line);
|
|
18917
|
-
if (parsed) {
|
|
18918
|
-
files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
|
|
18919
|
-
}
|
|
18920
|
-
}
|
|
18921
|
-
return files;
|
|
18922
|
-
}
|
|
18923
|
-
/**
|
|
18924
|
-
* Resolve which stash file *contains* a given line offset — the user's
|
|
18925
|
-
* cursor scrolls through a concatenated multi-file patch, and this is
|
|
18926
|
-
* what powers the "File N/M: <path>" panel header, the inline header
|
|
18927
|
-
* highlighting (#791 follow-up), and the cherry-pick / open-in-editor
|
|
18928
|
-
* dispatchers' "what file is the cursor on" lookup.
|
|
18929
|
-
*
|
|
18930
|
-
* Returns `undefined` when the file list is empty *or* the offset
|
|
18931
|
-
* lands before the very first file's `diff --git` header (e.g. when
|
|
18932
|
-
* `--stat` summary lines lead the patch). Callers fall through to a
|
|
18933
|
-
* "no file selected" state in that case.
|
|
18934
|
-
*/
|
|
18935
|
-
function findStashFileForOffset(files, offset) {
|
|
18936
|
-
if (files.length === 0)
|
|
18937
|
-
return undefined;
|
|
18938
|
-
let current;
|
|
18939
|
-
for (const file of files) {
|
|
18940
|
-
if (file.startLine <= offset) {
|
|
18941
|
-
current = file;
|
|
18942
|
-
}
|
|
18943
|
-
else {
|
|
18944
|
-
break;
|
|
18945
|
-
}
|
|
18946
|
-
}
|
|
18947
|
-
// First file is the canonical fallback — even if the offset lands
|
|
18948
|
-
// before its header (rare), we want the cursor to be "in" something
|
|
18949
|
-
// so the user's actions have a target.
|
|
18950
|
-
return current ?? files[0];
|
|
18951
|
-
}
|
|
18952
|
-
const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
|
|
18953
|
-
function parseDiffGitHeader(line) {
|
|
18954
|
-
const match = line.match(DIFF_GIT_HEADER);
|
|
18955
|
-
if (!match)
|
|
18956
|
-
return undefined;
|
|
18957
|
-
const aPath = unescapeGitQuoted(match[1]) || match[2];
|
|
18958
|
-
const bPath = unescapeGitQuoted(match[3]) || match[4];
|
|
18959
|
-
if (!aPath || !bPath)
|
|
18960
|
-
return undefined;
|
|
18961
|
-
return { aPath, bPath };
|
|
18962
|
-
}
|
|
18963
|
-
function unescapeGitQuoted(value) {
|
|
18964
|
-
if (value === undefined)
|
|
18965
|
-
return undefined;
|
|
18966
|
-
// Git's diff header quoting escapes `"`, `\`, and the usual
|
|
18967
|
-
// C-style sequences. Reverse the most common ones so callers get the
|
|
18968
|
-
// raw on-disk path.
|
|
18969
|
-
return value
|
|
18970
|
-
.replace(/\\\\/g, '\\')
|
|
18971
|
-
.replace(/\\"/g, '"')
|
|
18972
|
-
.replace(/\\t/g, '\t')
|
|
18973
|
-
.replace(/\\n/g, '\n');
|
|
18974
|
-
}
|
|
18975
|
-
|
|
18976
19084
|
function fileState(indexStatus, worktreeStatus) {
|
|
18977
19085
|
if (indexStatus === '?' && worktreeStatus === '?') {
|
|
18978
19086
|
return 'untracked';
|
|
@@ -19039,7 +19147,18 @@ function parseTagRefs(output) {
|
|
|
19039
19147
|
.map((line) => line.trimEnd())
|
|
19040
19148
|
.filter(Boolean)
|
|
19041
19149
|
.map((line) => {
|
|
19042
|
-
const [name,
|
|
19150
|
+
const [name, objectHash, derefedHash, date, subject] = line.split(FIELD_SEPARATOR$1);
|
|
19151
|
+
// For annotated tags `%(objectname:short)` returns the TAG
|
|
19152
|
+
// OBJECT's SHA, not the commit it points to — that's the SHA
|
|
19153
|
+
// sitting in `refs/tags/<name>`'s blob. `%(*objectname:short)`
|
|
19154
|
+
// dereferences the tag and yields the commit's SHA, but is
|
|
19155
|
+
// EMPTY for lightweight tags (which are already direct
|
|
19156
|
+
// pointers to commits). Prefer the dereferenced form when
|
|
19157
|
+
// present, fall back to the object SHA otherwise. This is what
|
|
19158
|
+
// lets cursor-sync find the tagged commit in the loaded log
|
|
19159
|
+
// window — anchoring on the tag object's own SHA would never
|
|
19160
|
+
// match a commit row.
|
|
19161
|
+
const hash = derefedHash || objectHash;
|
|
19043
19162
|
return {
|
|
19044
19163
|
name,
|
|
19045
19164
|
hash,
|
|
@@ -19051,7 +19170,7 @@ function parseTagRefs(output) {
|
|
|
19051
19170
|
async function getTagOverview(git) {
|
|
19052
19171
|
const output = await git.raw([
|
|
19053
19172
|
'for-each-ref',
|
|
19054
|
-
`--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
|
|
19173
|
+
`--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(*objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
|
|
19055
19174
|
'--sort=-creatordate',
|
|
19056
19175
|
'refs/tags',
|
|
19057
19176
|
]);
|
|
@@ -19933,6 +20052,92 @@ async function startInteractiveLog(git, rows, streams = {}) {
|
|
|
19933
20052
|
output.write(`${renderInteractiveLog(state, await loadSelectedDetail(), branches, pullRequest, tags, undefined, worktree, {}, { appLabel }, { stashes, worktreeList }, {}, operationOverview, providerOverview)}\n`, 'utf8');
|
|
19934
20053
|
}
|
|
19935
20054
|
|
|
20055
|
+
/**
|
|
20056
|
+
* Shared hash-matching helpers for cross-command lookups.
|
|
20057
|
+
*
|
|
20058
|
+
* Git surfaces the same commit with different short-hash lengths
|
|
20059
|
+
* depending on which command produced the row:
|
|
20060
|
+
*
|
|
20061
|
+
* - `for-each-ref --format=%(objectname:short)` (branches, tags,
|
|
20062
|
+
* stashes) honors `core.abbrev`, typically 7 chars.
|
|
20063
|
+
* - `git log --pretty=format:%h` (history rows) honors the same
|
|
20064
|
+
* setting BUT git auto-extends abbreviations to keep them unique
|
|
20065
|
+
* within the walked set — so the same commit can come back as 7
|
|
20066
|
+
* chars from one command and 8 (or more) from another.
|
|
20067
|
+
*
|
|
20068
|
+
* Consequence: any exact-equality lookup that compares a hash from
|
|
20069
|
+
* `for-each-ref` against a hash from `git log` will miss the match
|
|
20070
|
+
* even when both refer to the same commit. This bit the workstation's
|
|
20071
|
+
* cursor-sync effect twice during 0.54.2 — once in the resolver, once
|
|
20072
|
+
* in the `selectCommitByHash` reducer — and shows up wherever a ref
|
|
20073
|
+
* hash is checked against the loaded log window.
|
|
20074
|
+
*
|
|
20075
|
+
* The fix is bidirectional prefix matching: a hash matches another if
|
|
20076
|
+
* one is a prefix of the other. Below a 4-char floor we refuse to
|
|
20077
|
+
* match — three chars would collide with too many real commits.
|
|
20078
|
+
*
|
|
20079
|
+
* This module is the canonical place for that logic. Import it
|
|
20080
|
+
* anywhere you compare a "hash from one git formatter" against a
|
|
20081
|
+
* "hash from a different git formatter."
|
|
20082
|
+
*
|
|
20083
|
+
* Lives in `src/git/` because both `workstation/` and `commands/log/`
|
|
20084
|
+
* depend on it — `commands/log/` must not depend on `workstation/`,
|
|
20085
|
+
* so this can't live in `workstation/runtime/cursorSyncResolver.ts`.
|
|
20086
|
+
*/
|
|
20087
|
+
/**
|
|
20088
|
+
* Minimum length below which we refuse to prefix-match. Three chars
|
|
20089
|
+
* is too small to be a meaningful unique prefix for any real-world
|
|
20090
|
+
* git history.
|
|
20091
|
+
*/
|
|
20092
|
+
const MIN_PREFIX_LENGTH = 4;
|
|
20093
|
+
/**
|
|
20094
|
+
* True when `a` and `b` refer to the same commit, tolerating
|
|
20095
|
+
* short-hash length differences from different git formatters.
|
|
20096
|
+
*
|
|
20097
|
+
* Symmetric: `hashesMatch(a, b) === hashesMatch(b, a)`. An exact
|
|
20098
|
+
* string equality wins immediately (the common path); otherwise we
|
|
20099
|
+
* test bidirectional `startsWith` and bail when either input is too
|
|
20100
|
+
* short to be a meaningful prefix.
|
|
20101
|
+
*/
|
|
20102
|
+
function hashesMatch(a, b) {
|
|
20103
|
+
if (!a || !b)
|
|
20104
|
+
return false;
|
|
20105
|
+
if (a === b)
|
|
20106
|
+
return true;
|
|
20107
|
+
if (a.length < MIN_PREFIX_LENGTH || b.length < MIN_PREFIX_LENGTH)
|
|
20108
|
+
return false;
|
|
20109
|
+
return a.startsWith(b) || b.startsWith(a);
|
|
20110
|
+
}
|
|
20111
|
+
/**
|
|
20112
|
+
* True when `hash` matches any entry in `candidates`. Convenience
|
|
20113
|
+
* wrapper for the common "is this ref's hash in any of the row's
|
|
20114
|
+
* hash variants?" check.
|
|
20115
|
+
*/
|
|
20116
|
+
function hashesMatchAny(hash, candidates) {
|
|
20117
|
+
if (!hash)
|
|
20118
|
+
return false;
|
|
20119
|
+
return candidates.some((candidate) => hashesMatch(hash, candidate));
|
|
20120
|
+
}
|
|
20121
|
+
/**
|
|
20122
|
+
* True when `hash` is present in the loaded set — exact match first
|
|
20123
|
+
* (the O(1) fast path), then bidirectional `startsWith` over the set
|
|
20124
|
+
* to cover the formatter mismatch.
|
|
20125
|
+
*
|
|
20126
|
+
* The set is small in practice (1k–5k entries) so O(N) iteration on
|
|
20127
|
+
* miss is fine.
|
|
20128
|
+
*/
|
|
20129
|
+
function hashLoaded(hash, loaded) {
|
|
20130
|
+
if (loaded.has(hash))
|
|
20131
|
+
return true;
|
|
20132
|
+
if (hash.length < MIN_PREFIX_LENGTH)
|
|
20133
|
+
return false;
|
|
20134
|
+
for (const entry of loaded) {
|
|
20135
|
+
if (entry.startsWith(hash) || hash.startsWith(entry))
|
|
20136
|
+
return true;
|
|
20137
|
+
}
|
|
20138
|
+
return false;
|
|
20139
|
+
}
|
|
20140
|
+
|
|
19936
20141
|
const EMPTY_STATUS$1 = { enabled: false, patterns: [] };
|
|
19937
20142
|
/**
|
|
19938
20143
|
* Parse a single `.gitattributes` body into the LFS-tracked
|
|
@@ -20220,9 +20425,16 @@ function applyCommitComposeAction(state, action) {
|
|
|
20220
20425
|
field: state.field === 'summary' ? 'body' : 'summary',
|
|
20221
20426
|
};
|
|
20222
20427
|
case 'setEditing':
|
|
20428
|
+
// Audit finding #12: defensively clear `streamingPreview` when
|
|
20429
|
+
// editing toggles off AND no draft is in flight. The current
|
|
20430
|
+
// input pipeline never triggers this combination, but the
|
|
20431
|
+
// reducer is the source of truth — if a future code path
|
|
20432
|
+
// toggles editing off mid-stream, the preview shouldn't linger
|
|
20433
|
+
// below an idle compose panel.
|
|
20223
20434
|
return {
|
|
20224
20435
|
...state,
|
|
20225
20436
|
editing: action.value,
|
|
20437
|
+
streamingPreview: !action.value && !state.loading ? undefined : state.streamingPreview,
|
|
20226
20438
|
};
|
|
20227
20439
|
case 'setLoading':
|
|
20228
20440
|
// Clearing loading also clears any in-flight streaming preview;
|
|
@@ -20234,6 +20446,22 @@ function applyCommitComposeAction(state, action) {
|
|
|
20234
20446
|
streamingPreview: action.value ? state.streamingPreview : undefined,
|
|
20235
20447
|
};
|
|
20236
20448
|
case 'setDraft':
|
|
20449
|
+
// Audit finding #7: if the user has typed content in summary or
|
|
20450
|
+
// body, the AI draft would silently clobber their work with no
|
|
20451
|
+
// undo. Route the result to `pendingAiDraft` instead and surface
|
|
20452
|
+
// a confirmation message; the user accepts with `R` (replace)
|
|
20453
|
+
// or dismisses with Esc. Empty fields = safe to replace as
|
|
20454
|
+
// before, since there's nothing to lose.
|
|
20455
|
+
if (state.summary.trim() || state.body.trim()) {
|
|
20456
|
+
return {
|
|
20457
|
+
...state,
|
|
20458
|
+
loading: false,
|
|
20459
|
+
streamingPreview: undefined,
|
|
20460
|
+
pendingAiDraft: action.value,
|
|
20461
|
+
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20462
|
+
details: undefined,
|
|
20463
|
+
};
|
|
20464
|
+
}
|
|
20237
20465
|
// No `message` here — the loader → filled fields are the confirmation
|
|
20238
20466
|
// that the AI generated something. A lingering "AI draft ready for
|
|
20239
20467
|
// editing" line in the panel reads as stale state. The runtime still
|
|
@@ -20248,6 +20476,7 @@ function applyCommitComposeAction(state, action) {
|
|
|
20248
20476
|
message: undefined,
|
|
20249
20477
|
details: undefined,
|
|
20250
20478
|
streamingPreview: undefined,
|
|
20479
|
+
pendingAiDraft: undefined,
|
|
20251
20480
|
};
|
|
20252
20481
|
case 'setResult':
|
|
20253
20482
|
return {
|
|
@@ -20267,6 +20496,46 @@ function applyCommitComposeAction(state, action) {
|
|
|
20267
20496
|
...state,
|
|
20268
20497
|
streamingPreview: action.value,
|
|
20269
20498
|
};
|
|
20499
|
+
case 'setPendingAiDraft':
|
|
20500
|
+
// Audit finding #7: route the AI draft here (instead of straight
|
|
20501
|
+
// to summary/body via `setDraft`) when the user has unsaved
|
|
20502
|
+
// typing the draft would clobber. The dispatcher does the
|
|
20503
|
+
// user-content check; this reducer just stashes the draft and
|
|
20504
|
+
// surfaces a message inviting the user to accept or dismiss.
|
|
20505
|
+
return {
|
|
20506
|
+
...state,
|
|
20507
|
+
loading: false,
|
|
20508
|
+
streamingPreview: undefined,
|
|
20509
|
+
pendingAiDraft: action.value,
|
|
20510
|
+
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20511
|
+
details: undefined,
|
|
20512
|
+
};
|
|
20513
|
+
case 'acceptPendingAiDraft':
|
|
20514
|
+
// Swap the pending draft into the editable fields and clear it.
|
|
20515
|
+
// Mirrors `setDraft`'s field positioning (focus on summary,
|
|
20516
|
+
// editing on) so the user lands in the same place whether they
|
|
20517
|
+
// accepted immediately or after deliberation.
|
|
20518
|
+
if (!state.pendingAiDraft)
|
|
20519
|
+
return state;
|
|
20520
|
+
return {
|
|
20521
|
+
...state,
|
|
20522
|
+
...splitCommitDraft(state.pendingAiDraft),
|
|
20523
|
+
field: 'summary',
|
|
20524
|
+
editing: true,
|
|
20525
|
+
loading: false,
|
|
20526
|
+
message: undefined,
|
|
20527
|
+
details: undefined,
|
|
20528
|
+
streamingPreview: undefined,
|
|
20529
|
+
pendingAiDraft: undefined,
|
|
20530
|
+
};
|
|
20531
|
+
case 'dismissPendingAiDraft':
|
|
20532
|
+
// User chose to keep their typing; drop the AI draft.
|
|
20533
|
+
return {
|
|
20534
|
+
...state,
|
|
20535
|
+
pendingAiDraft: undefined,
|
|
20536
|
+
message: undefined,
|
|
20537
|
+
details: undefined,
|
|
20538
|
+
};
|
|
20270
20539
|
case 'reset':
|
|
20271
20540
|
// Drop message/details too — the post-commit "Created commit ..."
|
|
20272
20541
|
// notification is already on the runtime status line (footer); a
|
|
@@ -20454,6 +20723,14 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
|
|
|
20454
20723
|
// classify below.
|
|
20455
20724
|
const stream = await chain.stream(variables, signal ? { signal } : undefined);
|
|
20456
20725
|
let chunkCount = 0;
|
|
20726
|
+
let callbackFailureCount = 0;
|
|
20727
|
+
// Audit finding #13: cap consecutive callback failures so a
|
|
20728
|
+
// genuinely broken render handler can't tie up the LLM call
|
|
20729
|
+
// silently for the user's entire wait. Five strikes (out of an
|
|
20730
|
+
// expected ~50-500 chunks for a normal commit message) is enough
|
|
20731
|
+
// to ride out a transient blip but small enough to bail before
|
|
20732
|
+
// the user finishes waiting on a useless stream.
|
|
20733
|
+
const MAX_CALLBACK_FAILURES = 5;
|
|
20457
20734
|
for await (const messageChunk of stream) {
|
|
20458
20735
|
const text = coerceChunkText(messageChunk);
|
|
20459
20736
|
if (!text)
|
|
@@ -20462,12 +20739,20 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
|
|
|
20462
20739
|
chunkCount += 1;
|
|
20463
20740
|
try {
|
|
20464
20741
|
onChunk({ text, accumulated });
|
|
20742
|
+
// Successful callback resets the consecutive-failure counter —
|
|
20743
|
+
// we only bail on a STREAK of failures, not on isolated ones.
|
|
20744
|
+
callbackFailureCount = 0;
|
|
20465
20745
|
}
|
|
20466
20746
|
catch (callbackError) {
|
|
20467
20747
|
// Deliberately swallow callback errors so a bad render handler
|
|
20468
20748
|
// can't tank the entire LLM call. Log at verbose so users with
|
|
20469
20749
|
// verbose mode on can still see what happened.
|
|
20470
|
-
|
|
20750
|
+
callbackFailureCount += 1;
|
|
20751
|
+
logger?.verbose(`executeChainStreaming: onChunk handler threw (${callbackFailureCount}/${MAX_CALLBACK_FAILURES}): ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
|
|
20752
|
+
if (callbackFailureCount >= MAX_CALLBACK_FAILURES) {
|
|
20753
|
+
logger?.verbose(`executeChainStreaming: bailing stream — ${MAX_CALLBACK_FAILURES} consecutive callback failures suggest a broken render handler.`, { color: 'red' });
|
|
20754
|
+
throw new LangChainExecutionError(`executeChainStreaming: render handler failed ${MAX_CALLBACK_FAILURES} times in a row; aborting stream so the failure surfaces to the caller.`, { accumulatedLength: accumulated.length, chunkCount });
|
|
20755
|
+
}
|
|
20471
20756
|
}
|
|
20472
20757
|
}
|
|
20473
20758
|
if (!accumulated) {
|
|
@@ -20501,15 +20786,22 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
|
|
|
20501
20786
|
}
|
|
20502
20787
|
catch (error) {
|
|
20503
20788
|
// Cancellation classifier (#881 phase 3). Three signals: an
|
|
20504
|
-
// explicitly aborted user signal (post-throw check)
|
|
20505
|
-
// standard DOM
|
|
20506
|
-
//
|
|
20507
|
-
//
|
|
20508
|
-
//
|
|
20509
|
-
//
|
|
20510
|
-
//
|
|
20789
|
+
// explicitly aborted user signal (post-throw check) or a thrown
|
|
20790
|
+
// `AbortError` from the standard DOM API. Either means "user
|
|
20791
|
+
// wanted out," not "the call failed." Wrap the raw error so
|
|
20792
|
+
// callers can pattern-match on `LangChainCancelledError` and
|
|
20793
|
+
// carry the partial accumulated text in case the caller wants
|
|
20794
|
+
// to salvage anything.
|
|
20795
|
+
//
|
|
20796
|
+
// Audit finding #8: an earlier implementation also fell back to
|
|
20797
|
+
// `error.message.includes('aborted')` as a third signal. That
|
|
20798
|
+
// substring heuristic is footgun-shaped — legitimate provider
|
|
20799
|
+
// errors ("model not aborted properly", future API copy) would
|
|
20800
|
+
// misclassify as user cancels. Dropped; rely on the structured
|
|
20801
|
+
// signal (`signal.aborted`) and the standard error class
|
|
20802
|
+
// (`name === 'AbortError'`).
|
|
20511
20803
|
const aborted = signal?.aborted ||
|
|
20512
|
-
(error instanceof Error &&
|
|
20804
|
+
(error instanceof Error && error.name === 'AbortError');
|
|
20513
20805
|
if (aborted) {
|
|
20514
20806
|
throw new LangChainCancelledError(error instanceof Error ? error.message : 'Streaming aborted by user', accumulated, {
|
|
20515
20807
|
provider: effectiveProvider,
|
|
@@ -20768,6 +21060,12 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20768
21060
|
// schema-validated retry — paying for a second LLM call only
|
|
20769
21061
|
// on the edge case where the streamed output is unsalvageable.
|
|
20770
21062
|
const streamingParser = createSchemaParser(schema, llm);
|
|
21063
|
+
// Capture the final accumulated text out-of-band so we can
|
|
21064
|
+
// attempt salvage if the parser throws on completion (audit
|
|
21065
|
+
// finding #1). Updated on every chunk; the last value is
|
|
21066
|
+
// whatever the stream produced before the parser ran. Empty
|
|
21067
|
+
// string when streaming throws before any chunks arrived.
|
|
21068
|
+
let streamedAccumulated = '';
|
|
20771
21069
|
let salvaged;
|
|
20772
21070
|
try {
|
|
20773
21071
|
// `executeChainStreaming` runs the parser on the accumulated
|
|
@@ -20781,6 +21079,7 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20781
21079
|
variables: budgetedPrompt.variables,
|
|
20782
21080
|
parser: streamingParser,
|
|
20783
21081
|
onChunk: ({ text, accumulated }) => {
|
|
21082
|
+
streamedAccumulated = accumulated;
|
|
20784
21083
|
onStreamChunk(text, accumulated);
|
|
20785
21084
|
},
|
|
20786
21085
|
signal,
|
|
@@ -20810,13 +21109,24 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20810
21109
|
cancelled: true,
|
|
20811
21110
|
};
|
|
20812
21111
|
}
|
|
20813
|
-
//
|
|
20814
|
-
//
|
|
20815
|
-
//
|
|
20816
|
-
// to
|
|
20817
|
-
//
|
|
20818
|
-
|
|
20819
|
-
|
|
21112
|
+
// Audit finding #1: try the lossy salvager on the accumulated
|
|
21113
|
+
// text before paying for a second LLM call. The salvager
|
|
21114
|
+
// strips code fences, attempts strict JSON parse, and falls
|
|
21115
|
+
// back to "first line is title, rest is body." We only accept
|
|
21116
|
+
// its output when it produced a real title — the placeholder
|
|
21117
|
+
// title ("Auto-generated commit") means the salvager
|
|
21118
|
+
// couldn't extract anything meaningful and the non-streaming
|
|
21119
|
+
// retry is the better choice.
|
|
21120
|
+
if (streamedAccumulated) {
|
|
21121
|
+
const candidate = salvageCommitMessageFromText(streamedAccumulated);
|
|
21122
|
+
if (candidate.title && candidate.title !== 'Auto-generated commit') {
|
|
21123
|
+
salvaged = candidate;
|
|
21124
|
+
logger.verbose(`Streaming parser failed but salvager recovered a draft from ${streamedAccumulated.length} accumulated chars; skipping non-streaming retry.`, { color: 'green' });
|
|
21125
|
+
}
|
|
21126
|
+
}
|
|
21127
|
+
if (!salvaged) {
|
|
21128
|
+
logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
|
|
21129
|
+
}
|
|
20820
21130
|
}
|
|
20821
21131
|
// Type-narrow: commitMsg is set inside try{}, but TS doesn't
|
|
20822
21132
|
// see that across the catch. Re-init through the salvage path
|
|
@@ -20825,10 +21135,12 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20825
21135
|
commitMsg = salvaged;
|
|
20826
21136
|
}
|
|
20827
21137
|
else if (!(commitMsg)) {
|
|
20828
|
-
// Streaming threw
|
|
20829
|
-
//
|
|
20830
|
-
//
|
|
20831
|
-
//
|
|
21138
|
+
// Streaming threw AND the salvager couldn't recover anything
|
|
21139
|
+
// useful; fall back to the standard non-streaming flow.
|
|
21140
|
+
// Documented trade-off from the issue: streaming gives us a
|
|
21141
|
+
// preview but the validated result still comes from the
|
|
21142
|
+
// schema-aware retry path when both streaming AND salvage
|
|
21143
|
+
// fail.
|
|
20832
21144
|
commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
|
|
20833
21145
|
logger,
|
|
20834
21146
|
tokenizer,
|
|
@@ -23518,7 +23830,16 @@ function createLogInkState(rows, options = {}) {
|
|
|
23518
23830
|
worktreeDiffOffset: 0,
|
|
23519
23831
|
filter: '',
|
|
23520
23832
|
filterMode: false,
|
|
23521
|
-
|
|
23833
|
+
// Default to the full multi-ref graph (`git log --all`) so users
|
|
23834
|
+
// see how branches, tags, and stashes weave through the history
|
|
23835
|
+
// out of the box. Pre-0.54.x this defaulted to false (current
|
|
23836
|
+
// branch only); user feedback consistently asked for the
|
|
23837
|
+
// GitKraken-style "see everything" view as the starting state.
|
|
23838
|
+
// The `\` toggle still flips back to compact / current-branch
|
|
23839
|
+
// mode for users who want the cleaner single-line graph. Tests
|
|
23840
|
+
// override via `options.fullGraph` when they need the compact
|
|
23841
|
+
// case explicitly.
|
|
23842
|
+
fullGraph: options.fullGraph ?? true,
|
|
23522
23843
|
showHelp: false,
|
|
23523
23844
|
helpScrollOffset: 0,
|
|
23524
23845
|
showCommandPalette: false,
|
|
@@ -23627,8 +23948,17 @@ function applyLogInkAction(state, action) {
|
|
|
23627
23948
|
// branch's tip without the user manually scrolling. No-op when
|
|
23628
23949
|
// the hash isn't in the loaded list (the runtime surfaces a
|
|
23629
23950
|
// status hint in that case).
|
|
23951
|
+
//
|
|
23952
|
+
// Uses the shared `hashesMatchAny` helper to cover the
|
|
23953
|
+
// short-hash auto-extension mismatch between
|
|
23954
|
+
// `for-each-ref --format=%(objectname:short)` (cursored ref)
|
|
23955
|
+
// and `git log --pretty=format:%h` (history row). Without that
|
|
23956
|
+
// tolerance the resolver could decide "jump" but this reducer
|
|
23957
|
+
// would silently no-op — the status updates but the cursor
|
|
23958
|
+
// doesn't move, exactly the branch-cursor bug surfaced in 0.54.1
|
|
23959
|
+
// testing. See `src/git/hashes.ts` for the matching rules.
|
|
23630
23960
|
const target = action.hash;
|
|
23631
|
-
const index = state.filteredCommits.findIndex((commit) => commit.hash
|
|
23961
|
+
const index = state.filteredCommits.findIndex((commit) => hashesMatchAny(target, [commit.hash, commit.shortHash]));
|
|
23632
23962
|
if (index < 0) {
|
|
23633
23963
|
return state;
|
|
23634
23964
|
}
|
|
@@ -24271,10 +24601,14 @@ function applyLogInkAction(state, action) {
|
|
|
24271
24601
|
// Cache the result so re-entry (or `c` to PR) reuses it instead of
|
|
24272
24602
|
// re-running the LLM. Keyed by branch so a checkout naturally
|
|
24273
24603
|
// produces a fresh generation.
|
|
24604
|
+
// Audit finding #9: `generatedAt` arrives on the action payload
|
|
24605
|
+
// instead of being read from `Date.now()` here, so the reducer
|
|
24606
|
+
// stays pure. Dispatchers (currently `runChangelogView` in
|
|
24607
|
+
// app.ts) call `Date.now()` at dispatch time.
|
|
24274
24608
|
const cached = {
|
|
24275
24609
|
text: action.text,
|
|
24276
24610
|
baseLabel: action.baseLabel,
|
|
24277
|
-
generatedAt:
|
|
24611
|
+
generatedAt: action.generatedAt,
|
|
24278
24612
|
};
|
|
24279
24613
|
return {
|
|
24280
24614
|
...state,
|
|
@@ -24328,7 +24662,8 @@ function applyLogInkAction(state, action) {
|
|
|
24328
24662
|
// Updated-at timestamp reflects the edit. Not the original
|
|
24329
24663
|
// generation time — `r` (regenerate) is the explicit knob
|
|
24330
24664
|
// for "I want fresh LLM output, not my edits".
|
|
24331
|
-
|
|
24665
|
+
// Audit finding #9: timestamp arrives on the action.
|
|
24666
|
+
generatedAt: action.generatedAt,
|
|
24332
24667
|
},
|
|
24333
24668
|
},
|
|
24334
24669
|
pendingKey: undefined,
|
|
@@ -24364,7 +24699,9 @@ function applyLogInkAction(state, action) {
|
|
|
24364
24699
|
}
|
|
24365
24700
|
return {
|
|
24366
24701
|
...state,
|
|
24367
|
-
|
|
24702
|
+
// Audit finding #9: timestamp arrives on the action payload
|
|
24703
|
+
// instead of being read from `Date.now()` here.
|
|
24704
|
+
recentCommitHashes: { hashes: action.hashes, markedAt: action.markedAt },
|
|
24368
24705
|
pendingKey: undefined,
|
|
24369
24706
|
};
|
|
24370
24707
|
case 'clearRecentCommits':
|
|
@@ -24558,7 +24895,7 @@ function getInspectorActionExecuteEvents(inspectorAction, state) {
|
|
|
24558
24895
|
const commit = state.filteredCommits[state.selectedIndex];
|
|
24559
24896
|
const requireCommit = (fn) => {
|
|
24560
24897
|
if (!commit) {
|
|
24561
|
-
return [action({ type: 'setStatus', value: 'No commit selected' })];
|
|
24898
|
+
return [action({ type: 'setStatus', value: 'No commit selected', kind: 'warning' })];
|
|
24562
24899
|
}
|
|
24563
24900
|
return fn(commit.hash, state.selectedIndex);
|
|
24564
24901
|
};
|
|
@@ -24597,6 +24934,7 @@ function getInspectorActionExecuteEvents(inspectorAction, state) {
|
|
|
24597
24934
|
return [action({
|
|
24598
24935
|
type: 'setStatus',
|
|
24599
24936
|
value: `Action ${inspectorAction.key} not yet wired`,
|
|
24937
|
+
kind: 'warning',
|
|
24600
24938
|
})];
|
|
24601
24939
|
}
|
|
24602
24940
|
}
|
|
@@ -24811,6 +25149,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
24811
25149
|
return [action({
|
|
24812
25150
|
type: 'setStatus',
|
|
24813
25151
|
value: 'open the diff view and press [ or ] to jump hunks',
|
|
25152
|
+
kind: 'warning',
|
|
24814
25153
|
})];
|
|
24815
25154
|
case 'focusNext':
|
|
24816
25155
|
return [action({ type: 'focusNext' })];
|
|
@@ -24859,6 +25198,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
24859
25198
|
return [action({
|
|
24860
25199
|
type: 'setStatus',
|
|
24861
25200
|
value: 'open branches / tags / history and press m on the cursored ref',
|
|
25201
|
+
kind: 'warning',
|
|
24862
25202
|
})];
|
|
24863
25203
|
case 'navigateBack':
|
|
24864
25204
|
// Mirror the Esc / `<` semantics (#931): drain the frame's view
|
|
@@ -24934,6 +25274,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
24934
25274
|
return [action({
|
|
24935
25275
|
type: 'setStatus',
|
|
24936
25276
|
value: 'Sort cycle is available in the branches and tags views',
|
|
25277
|
+
kind: 'warning',
|
|
24937
25278
|
})];
|
|
24938
25279
|
case 'yankClipboard':
|
|
24939
25280
|
// The runtime resolves the value/label against the live filtered
|
|
@@ -24990,7 +25331,7 @@ function submitInputPrompt(state) {
|
|
|
24990
25331
|
return [];
|
|
24991
25332
|
const value = state.inputPrompt.value.trim();
|
|
24992
25333
|
if (!value) {
|
|
24993
|
-
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
|
|
25334
|
+
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
|
|
24994
25335
|
}
|
|
24995
25336
|
if (state.inputPrompt.kind === 'reset-mode') {
|
|
24996
25337
|
const mode = value.toLowerCase();
|
|
@@ -24998,6 +25339,7 @@ function submitInputPrompt(state) {
|
|
|
24998
25339
|
return [action({
|
|
24999
25340
|
type: 'setStatus',
|
|
25000
25341
|
value: `Unknown reset mode: ${value}. Use soft, mixed, or hard.`,
|
|
25342
|
+
kind: 'warning',
|
|
25001
25343
|
})];
|
|
25002
25344
|
}
|
|
25003
25345
|
return [
|
|
@@ -25011,6 +25353,7 @@ function submitInputPrompt(state) {
|
|
|
25011
25353
|
return [action({
|
|
25012
25354
|
type: 'setStatus',
|
|
25013
25355
|
value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
|
|
25356
|
+
kind: 'warning',
|
|
25014
25357
|
})];
|
|
25015
25358
|
}
|
|
25016
25359
|
return [
|
|
@@ -25074,6 +25417,7 @@ function submitInputPrompt(state) {
|
|
|
25074
25417
|
return [action({
|
|
25075
25418
|
type: 'setStatus',
|
|
25076
25419
|
value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
|
|
25420
|
+
kind: 'warning',
|
|
25077
25421
|
})];
|
|
25078
25422
|
}
|
|
25079
25423
|
return [
|
|
@@ -25164,16 +25508,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25164
25508
|
return [];
|
|
25165
25509
|
}
|
|
25166
25510
|
// Cancel in-flight AI commit draft (#881 phase 3). When the compose
|
|
25167
|
-
//
|
|
25168
|
-
// and the runtime handler cleans up (clear loading, clear
|
|
25169
|
-
// status line shows "AI draft cancelled.").
|
|
25170
|
-
// / view handlers so the cancel keystroke can't fall through to
|
|
25171
|
-
// "leave compose" or anything else.
|
|
25511
|
+
// state has a draft in flight (loading === true), Esc aborts the
|
|
25512
|
+
// LLM call and the runtime handler cleans up (clear loading, clear
|
|
25513
|
+
// preview, status line shows "AI draft cancelled.").
|
|
25172
25514
|
//
|
|
25173
|
-
//
|
|
25174
|
-
//
|
|
25175
|
-
// the
|
|
25176
|
-
|
|
25515
|
+
// Audit finding #5: the `activeView === 'compose'` gate from the
|
|
25516
|
+
// original phase 3 implementation made the cancel keystroke
|
|
25517
|
+
// unreachable after the user chord-navigated away from compose
|
|
25518
|
+
// mid-stream (Esc would fall through to popView etc., consuming
|
|
25519
|
+
// the navigation intent while the LLM call silently ran to
|
|
25520
|
+
// completion). Cancel should work wherever the user is — they
|
|
25521
|
+
// can always navigate back to compose afterwards.
|
|
25522
|
+
//
|
|
25523
|
+
// Sits above the editing / view handlers so the cancel keystroke
|
|
25524
|
+
// can't fall through to "leave compose" or anything else. Loading
|
|
25525
|
+
// and editing are mutually exclusive in practice (the user can't
|
|
25526
|
+
// type while the AI is generating), but the order here makes the
|
|
25527
|
+
// precedence explicit if that ever changes.
|
|
25528
|
+
if (state.commitCompose.loading && key.escape) {
|
|
25177
25529
|
return [{ type: 'cancelAiCommitDraft' }];
|
|
25178
25530
|
}
|
|
25179
25531
|
// Cancel in-flight PR body draft (#881 phase 4). The `C` keystroke
|
|
@@ -25193,6 +25545,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25193
25545
|
if (state.pendingPullRequestBodyDraft && key.escape) {
|
|
25194
25546
|
return [{ type: 'cancelPullRequestBodyDraft' }];
|
|
25195
25547
|
}
|
|
25548
|
+
// Pending AI draft confirmation (audit finding #7). When the AI
|
|
25549
|
+
// draft completes against a non-empty compose surface, it lands in
|
|
25550
|
+
// `pendingAiDraft` instead of overwriting the user's typing. `R`
|
|
25551
|
+
// accepts the swap (user's typing is lost, AI draft becomes the
|
|
25552
|
+
// new content). `Esc` dismisses the AI draft (typing is preserved,
|
|
25553
|
+
// AI draft is lost — the user paid for the tokens but explicitly
|
|
25554
|
+
// chose not to use them).
|
|
25555
|
+
//
|
|
25556
|
+
// Gated on `activeView === 'compose'` because the pending draft is
|
|
25557
|
+
// only meaningful on the compose surface (where the message line
|
|
25558
|
+
// surfaces the prompt). A user who chord-navigated away while the
|
|
25559
|
+
// draft was pending should see the original `R` / Esc semantics of
|
|
25560
|
+
// wherever they are now.
|
|
25561
|
+
if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
|
|
25562
|
+
if (inputValue === 'R' && !key.ctrl && !key.meta) {
|
|
25563
|
+
return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
|
|
25564
|
+
}
|
|
25565
|
+
if (key.escape) {
|
|
25566
|
+
return [action({ type: 'commitCompose', action: { type: 'dismissPendingAiDraft' } })];
|
|
25567
|
+
}
|
|
25568
|
+
}
|
|
25196
25569
|
if (state.commitCompose.editing) {
|
|
25197
25570
|
if (key.escape) {
|
|
25198
25571
|
return [action({ type: 'commitCompose', action: { type: 'setEditing', value: false } })];
|
|
@@ -25642,7 +26015,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25642
26015
|
}
|
|
25643
26016
|
return [
|
|
25644
26017
|
action({ type: 'setPendingKey', value: undefined }),
|
|
25645
|
-
action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
|
|
26018
|
+
action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view', kind: 'warning' }),
|
|
25646
26019
|
];
|
|
25647
26020
|
}
|
|
25648
26021
|
// `gT` chord: create a lightweight tag at the cursored commit on the
|
|
@@ -25666,7 +26039,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25666
26039
|
}
|
|
25667
26040
|
return [
|
|
25668
26041
|
action({ type: 'setPendingKey', value: undefined }),
|
|
25669
|
-
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
|
|
26042
|
+
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view', kind: 'warning' }),
|
|
25670
26043
|
];
|
|
25671
26044
|
}
|
|
25672
26045
|
// #784 — bisect view action keys. Scoped to `state.activeView ===
|
|
@@ -25781,6 +26154,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25781
26154
|
value: next === 'split'
|
|
25782
26155
|
? 'Switched to side-by-side diff'
|
|
25783
26156
|
: 'Switched to unified diff',
|
|
26157
|
+
kind: 'success',
|
|
25784
26158
|
}),
|
|
25785
26159
|
];
|
|
25786
26160
|
}
|
|
@@ -26231,10 +26605,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26231
26605
|
if (key.return && state.compareBase && isCompareFlowTarget(state)) {
|
|
26232
26606
|
const head = getCursoredCompareRef(state, context);
|
|
26233
26607
|
if (!head) {
|
|
26234
|
-
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
|
|
26608
|
+
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first', kind: 'warning' })];
|
|
26235
26609
|
}
|
|
26236
26610
|
if (head.ref === state.compareBase.ref && head.kind === state.compareBase.kind) {
|
|
26237
|
-
return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one' })];
|
|
26611
|
+
return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one', kind: 'warning' })];
|
|
26238
26612
|
}
|
|
26239
26613
|
return [
|
|
26240
26614
|
action({
|
|
@@ -26437,7 +26811,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26437
26811
|
action({ type: 'setFocus', value: 'commits' }),
|
|
26438
26812
|
];
|
|
26439
26813
|
}
|
|
26440
|
-
return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
|
|
26814
|
+
return [action({ type: 'setStatus', value: 'no detail view for this tab', kind: 'warning' })];
|
|
26441
26815
|
}
|
|
26442
26816
|
// Fall through — per-entity Enter handler below claims the keystroke.
|
|
26443
26817
|
}
|
|
@@ -26548,7 +26922,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26548
26922
|
if (inputValue === 'm' && isCompareFlowTarget(state)) {
|
|
26549
26923
|
const ref = getCursoredCompareRef(state, context);
|
|
26550
26924
|
if (!ref) {
|
|
26551
|
-
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
|
|
26925
|
+
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first', kind: 'warning' })];
|
|
26552
26926
|
}
|
|
26553
26927
|
if (state.compareBase && state.compareBase.ref === ref.ref && state.compareBase.kind === ref.kind) {
|
|
26554
26928
|
return [
|
|
@@ -26779,7 +27153,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26779
27153
|
// Always intercept `C` on the conflicts view to prevent fallthrough to
|
|
26780
27154
|
// the global `C` (Create PR) binding when conflicts remain.
|
|
26781
27155
|
if (inputValue === 'C' && state.activeView === 'conflicts') {
|
|
26782
|
-
return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing' })];
|
|
27156
|
+
return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing', kind: 'warning' })];
|
|
26783
27157
|
}
|
|
26784
27158
|
// Global `C` — create a pull request from the current branch. The
|
|
26785
27159
|
// runtime callback handles pre-flight (current branch resolution,
|
|
@@ -26795,6 +27169,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26795
27169
|
return [action({
|
|
26796
27170
|
type: 'setStatus',
|
|
26797
27171
|
value: 'Finish or cancel the commit draft before creating a PR.',
|
|
27172
|
+
kind: 'warning',
|
|
26798
27173
|
})];
|
|
26799
27174
|
}
|
|
26800
27175
|
if (inputValue === 'C' && state.activeView !== 'conflicts') {
|
|
@@ -26854,7 +27229,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26854
27229
|
return events;
|
|
26855
27230
|
}
|
|
26856
27231
|
if (state.activeView === 'diff' && (state.diffSource === 'commit' || state.diffSource === 'stash')) {
|
|
26857
|
-
return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first' })];
|
|
27232
|
+
return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first', kind: 'warning' })];
|
|
26858
27233
|
}
|
|
26859
27234
|
}
|
|
26860
27235
|
// `c` on the history view cherry-picks the full selected commit on
|
|
@@ -27739,7 +28114,7 @@ const SIDEBAR_AT_REST_BY_TIER = {
|
|
|
27739
28114
|
rail: { min: 22, max: 28, fraction: 0.24 }, // unused — rail collapses to LAYOUT_RAIL_PANEL_WIDTH
|
|
27740
28115
|
tight: { min: 22, max: 28, fraction: 0.24 },
|
|
27741
28116
|
normal: { min: 22, max: 30, fraction: 0.22 },
|
|
27742
|
-
wide: { min: 28, max:
|
|
28117
|
+
wide: { min: 28, max: 32, fraction: 0.20 },
|
|
27743
28118
|
};
|
|
27744
28119
|
function calcSidebarAtRestWidth(columns, density) {
|
|
27745
28120
|
const config = SIDEBAR_AT_REST_BY_TIER[density];
|
|
@@ -29692,18 +30067,105 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
|
|
|
29692
30067
|
];
|
|
29693
30068
|
}
|
|
29694
30069
|
|
|
30070
|
+
function resolveCursorSyncDecision(input) {
|
|
30071
|
+
if (!input.target) {
|
|
30072
|
+
return { type: 'noop', reason: 'no-target' };
|
|
30073
|
+
}
|
|
30074
|
+
if (input.target.hash === input.lastSyncedHash) {
|
|
30075
|
+
return { type: 'noop', reason: 'duplicate-of-last' };
|
|
30076
|
+
}
|
|
30077
|
+
if (isHashLoaded(input.target.hash, input.loadedHashes)) {
|
|
30078
|
+
return {
|
|
30079
|
+
type: 'jump',
|
|
30080
|
+
hash: input.target.hash,
|
|
30081
|
+
label: input.target.label,
|
|
30082
|
+
};
|
|
30083
|
+
}
|
|
30084
|
+
if (input.attemptedContextHashes.has(input.target.hash)) {
|
|
30085
|
+
return { type: 'unreachable', target: input.target };
|
|
30086
|
+
}
|
|
30087
|
+
return { type: 'load-context', target: input.target };
|
|
30088
|
+
}
|
|
30089
|
+
/**
|
|
30090
|
+
* Re-export of the shared `hashLoaded` helper under the resolver's
|
|
30091
|
+
* historical name. Kept exported so existing tests (and any external
|
|
30092
|
+
* importers) keep working unchanged — see `src/git/hashes.ts` for the
|
|
30093
|
+
* canonical implementation and the rationale behind bidirectional
|
|
30094
|
+
* prefix matching.
|
|
30095
|
+
*/
|
|
30096
|
+
function isHashLoaded(hash, loadedHashes) {
|
|
30097
|
+
return hashLoaded(hash, loadedHashes);
|
|
30098
|
+
}
|
|
30099
|
+
/**
|
|
30100
|
+
* Build the membership set the resolver expects. Includes BOTH the
|
|
30101
|
+
* full hash and the short hash for every commit so the caller can
|
|
30102
|
+
* match either form (refs sometimes carry only the short hash and
|
|
30103
|
+
* `state.filteredCommits` items always have both).
|
|
30104
|
+
*
|
|
30105
|
+
* Exported so the cursor-sync effect can build the set once per
|
|
30106
|
+
* re-render and pass it down without leaking the implementation
|
|
30107
|
+
* detail. Tests use it to construct realistic inputs without
|
|
30108
|
+
* hand-rolling the dual-hash logic.
|
|
30109
|
+
*/
|
|
30110
|
+
function buildLoadedHashSet(commits) {
|
|
30111
|
+
const set = new Set();
|
|
30112
|
+
for (const commit of commits) {
|
|
30113
|
+
if (commit.hash)
|
|
30114
|
+
set.add(commit.hash);
|
|
30115
|
+
if (commit.shortHash)
|
|
30116
|
+
set.add(commit.shortHash);
|
|
30117
|
+
}
|
|
30118
|
+
return set;
|
|
30119
|
+
}
|
|
30120
|
+
|
|
29695
30121
|
/**
|
|
29696
|
-
* Status-bar / footer renderer. Two-
|
|
29697
|
-
*
|
|
29698
|
-
*
|
|
29699
|
-
*
|
|
29700
|
-
*
|
|
30122
|
+
* Status-bar / footer renderer. Two-row layout, using the full
|
|
30123
|
+
* `height: 2` the footer already reserves:
|
|
30124
|
+
*
|
|
30125
|
+
* Row 1 — keyboard hint band:
|
|
30126
|
+
* ┌──── contextual hints ────┐ ┌──── globals ────┐
|
|
30127
|
+
* ↑/↓ branches ←/→ tab … ? help · : cmds · q
|
|
30128
|
+
*
|
|
30129
|
+
* Row 2 — status / feedback band:
|
|
30130
|
+
* ⠋ main has no upstream — nothing to fetch.
|
|
30131
|
+
*
|
|
30132
|
+
* Row 2 is empty when there's no status message, idle tip, or error.
|
|
30133
|
+
* This is a behaviour change from the pre-0.54.2 single-row layout
|
|
30134
|
+
* where the status message sat awkwardly between the contextual and
|
|
30135
|
+
* global hints, getting visually crushed.
|
|
29701
30136
|
*
|
|
29702
|
-
*
|
|
30137
|
+
* The separation matters because:
|
|
30138
|
+
* - status text and key hints serve different cognitive purposes
|
|
30139
|
+
* (read vs. scan) and competing for the same row makes both
|
|
30140
|
+
* harder to use,
|
|
30141
|
+
* - long status messages (especially errors / multi-clause loading
|
|
30142
|
+
* copy) no longer push global hints off screen or wrap into the
|
|
30143
|
+
* hint cluster,
|
|
30144
|
+
* - errors now keep the global hints visible — the user often
|
|
30145
|
+
* needs `?` / `:` / `q` to *recover* from the error.
|
|
30146
|
+
*
|
|
30147
|
+
* Idle tips fill row 2 only when no real status message is set so the
|
|
29703
30148
|
* tip cycle never overwrites genuine workflow feedback.
|
|
29704
30149
|
*
|
|
29705
|
-
*
|
|
29706
|
-
*
|
|
30150
|
+
* Row 2 styling is kind-aware. Each statusKind gets its own theme
|
|
30151
|
+
* color and glyph prefix so the message is identifiable at a glance
|
|
30152
|
+
* — even with NO_COLOR set, the glyph alone communicates kind:
|
|
30153
|
+
*
|
|
30154
|
+
* loading → spinner + accent + bold
|
|
30155
|
+
* error → ✗ / ! + danger + bold
|
|
30156
|
+
* warning → ⚠ / ! + warning + bold
|
|
30157
|
+
* success → ✓ / + + success + bold
|
|
30158
|
+
* info → ℹ / i + info + bold
|
|
30159
|
+
* idle tip → no glyph + dim muted (passive)
|
|
30160
|
+
*
|
|
30161
|
+
* Pre-redesign success and loading both used `accent` (cyan), so the
|
|
30162
|
+
* user couldn't tell "done" from "in progress" by color alone. Each
|
|
30163
|
+
* kind now uses its dedicated theme color and ships an ASCII glyph
|
|
30164
|
+
* fallback for `theme.ascii` mode (TERM=dumb / vt100).
|
|
30165
|
+
*
|
|
30166
|
+
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase
|
|
30167
|
+
* 5a.7 of #890. Two-row layout introduced post-0.54.2; per-kind
|
|
30168
|
+
* colors + glyphs added in the same pass.
|
|
29707
30169
|
*/
|
|
29708
30170
|
function renderFooter(h, components, state, context, theme, idleTip, spinnerFrame = 0) {
|
|
29709
30171
|
const { Box, Text } = components;
|
|
@@ -29735,50 +30197,268 @@ function renderFooter(h, components, state, context, theme, idleTip, spinnerFram
|
|
|
29735
30197
|
});
|
|
29736
30198
|
// Real status messages always win; idle tips only fill the slot when it
|
|
29737
30199
|
// would otherwise be empty.
|
|
29738
|
-
const
|
|
29739
|
-
const
|
|
29740
|
-
// Loading status gets a spinner prefix in front of the message —
|
|
29741
|
-
// motion makes transient LLM calls (create-PR body, PR fetches,
|
|
29742
|
-
// etc.) feel less frozen even when they're sub-second.
|
|
29743
|
-
const spinnerPrefix = isLoading ? `${pickSpinnerFrame(spinnerFrame)} ` : '';
|
|
29744
|
-
const trailingWithSpinner = trailing ? `${spinnerPrefix}${trailing}` : '';
|
|
29745
|
-
const status = trailingWithSpinner ? ` ${trailingWithSpinner}` : '';
|
|
30200
|
+
const hasStatusMessage = Boolean(state.statusMessage);
|
|
30201
|
+
const isLoading = Boolean(state.statusLoading && hasStatusMessage);
|
|
29746
30202
|
const isError = state.statusKind === 'error';
|
|
30203
|
+
const isWarning = state.statusKind === 'warning';
|
|
29747
30204
|
const isSuccess = state.statusKind === 'success';
|
|
29748
|
-
|
|
29749
|
-
|
|
29750
|
-
|
|
29751
|
-
|
|
29752
|
-
|
|
29753
|
-
|
|
29754
|
-
|
|
29755
|
-
|
|
30205
|
+
// 'info' is the implicit kind when statusKind is undefined but
|
|
30206
|
+
// statusMessage is set — it's a deliberate status update, not an
|
|
30207
|
+
// idle tip, so it gets info treatment rather than the dim fallback.
|
|
30208
|
+
const isInfo = hasStatusMessage && !isError && !isWarning && !isSuccess && !isLoading;
|
|
30209
|
+
const rawTrailing = state.statusMessage || idleTip || '';
|
|
30210
|
+
// Glyphs per kind so the message is identifiable even before reading
|
|
30211
|
+
// the color — improves scan-ability and degrades gracefully when the
|
|
30212
|
+
// terminal lacks color. ASCII fallback for `theme.ascii` mode (TERM
|
|
30213
|
+
// = dumb / vt100) where unicode glyphs render as garbage.
|
|
30214
|
+
// loading → spinner (animated)
|
|
30215
|
+
// error → ✗ / !
|
|
30216
|
+
// warning → ⚠ / !
|
|
30217
|
+
// success → ✓ / +
|
|
30218
|
+
// info → ℹ / i
|
|
30219
|
+
// idle tip → no glyph (passive)
|
|
30220
|
+
const glyph = (() => {
|
|
30221
|
+
if (isLoading)
|
|
30222
|
+
return pickSpinnerFrame(spinnerFrame);
|
|
30223
|
+
if (isError)
|
|
30224
|
+
return theme.ascii ? '!' : '✗';
|
|
30225
|
+
if (isWarning)
|
|
30226
|
+
return theme.ascii ? '!' : '⚠';
|
|
30227
|
+
if (isSuccess)
|
|
30228
|
+
return theme.ascii ? '+' : '✓';
|
|
30229
|
+
if (isInfo)
|
|
30230
|
+
return theme.ascii ? 'i' : 'ℹ';
|
|
30231
|
+
return '';
|
|
30232
|
+
})();
|
|
30233
|
+
const statusBody = rawTrailing
|
|
30234
|
+
? glyph
|
|
30235
|
+
? `${glyph} ${rawTrailing}`
|
|
30236
|
+
: rawTrailing
|
|
30237
|
+
: '';
|
|
30238
|
+
// Row 2 color picks. Each kind gets its own theme color so success
|
|
30239
|
+
// and loading are visually distinct (was conflated under `accent`
|
|
30240
|
+
// pre-redesign — users couldn't tell "done" from "in progress").
|
|
30241
|
+
// loading → accent (cyan / preset blue)
|
|
30242
|
+
// error → danger (red / preset red)
|
|
30243
|
+
// warning → warning (yellow)
|
|
30244
|
+
// success → success (green)
|
|
30245
|
+
// info → info (blue / preset accent in light themes)
|
|
30246
|
+
// idle → undefined + dim (passive, blends with chrome)
|
|
30247
|
+
const statusColor = isError
|
|
30248
|
+
? theme.colors.danger
|
|
30249
|
+
: isWarning
|
|
30250
|
+
? theme.colors.warning
|
|
30251
|
+
: isSuccess
|
|
30252
|
+
? theme.colors.success
|
|
30253
|
+
: isLoading
|
|
30254
|
+
? theme.colors.accent
|
|
30255
|
+
: isInfo
|
|
30256
|
+
? theme.colors.info
|
|
30257
|
+
: undefined;
|
|
30258
|
+
const statusBold = isError || isWarning || isSuccess || isLoading || isInfo;
|
|
30259
|
+
const statusDim = !statusBold;
|
|
30260
|
+
const hintsText = hints.contextual.join(' ');
|
|
29756
30261
|
const globalText = hints.global.join(' · ');
|
|
29757
|
-
|
|
29758
|
-
//
|
|
29759
|
-
//
|
|
29760
|
-
//
|
|
29761
|
-
|
|
29762
|
-
|
|
29763
|
-
|
|
29764
|
-
|
|
29765
|
-
|
|
29766
|
-
|
|
29767
|
-
|
|
29768
|
-
|
|
29769
|
-
|
|
29770
|
-
|
|
29771
|
-
|
|
29772
|
-
|
|
29773
|
-
|
|
29774
|
-
|
|
29775
|
-
|
|
29776
|
-
|
|
29777
|
-
|
|
29778
|
-
|
|
29779
|
-
|
|
29780
|
-
|
|
29781
|
-
|
|
30262
|
+
return h(Box, { flexDirection: 'column', height: 2, paddingX: 1 },
|
|
30263
|
+
// Row 1: contextual ↔ global hints. justifyContent pushes them
|
|
30264
|
+
// to opposite edges so the eye can scan each cluster as one
|
|
30265
|
+
// block instead of hunting through a single concatenated line.
|
|
30266
|
+
h(Box, { flexDirection: 'row', justifyContent: 'space-between' }, h(Text, { color: theme.colors.muted, dimColor: true }, hintsText), h(Text, { color: theme.colors.muted, dimColor: true }, globalText)),
|
|
30267
|
+
// Row 2: status / loading / idle tip / error. Empty Text keeps
|
|
30268
|
+
// the row reserved when nothing's set so the surrounding layout
|
|
30269
|
+
// doesn't shift as status flips on/off.
|
|
30270
|
+
h(Text, {
|
|
30271
|
+
color: statusColor,
|
|
30272
|
+
dimColor: statusDim,
|
|
30273
|
+
bold: statusBold,
|
|
30274
|
+
}, statusBody));
|
|
30275
|
+
}
|
|
30276
|
+
|
|
30277
|
+
const COMBINING_MARK_RANGES = [
|
|
30278
|
+
[0x0300, 0x036f],
|
|
30279
|
+
[0x1ab0, 0x1aff],
|
|
30280
|
+
[0x1dc0, 0x1dff],
|
|
30281
|
+
[0x20d0, 0x20ff],
|
|
30282
|
+
[0xfe20, 0xfe2f],
|
|
30283
|
+
];
|
|
30284
|
+
const WIDE_CHARACTER_RANGES = [
|
|
30285
|
+
[0x1100, 0x115f],
|
|
30286
|
+
[0x2329, 0x232a],
|
|
30287
|
+
[0x2e80, 0xa4cf],
|
|
30288
|
+
[0xac00, 0xd7a3],
|
|
30289
|
+
[0xf900, 0xfaff],
|
|
30290
|
+
[0xfe10, 0xfe19],
|
|
30291
|
+
[0xfe30, 0xfe6f],
|
|
30292
|
+
[0xff00, 0xff60],
|
|
30293
|
+
[0xffe0, 0xffe6],
|
|
30294
|
+
[0x2600, 0x27bf],
|
|
30295
|
+
[0x1f000, 0x1f9ff],
|
|
30296
|
+
[0x20000, 0x3fffd],
|
|
30297
|
+
];
|
|
30298
|
+
function isInRange(codePoint, ranges) {
|
|
30299
|
+
return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
|
|
30300
|
+
}
|
|
30301
|
+
function characterWidth(character) {
|
|
30302
|
+
const codePoint = character.codePointAt(0) || 0;
|
|
30303
|
+
if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
|
|
30304
|
+
return 0;
|
|
30305
|
+
}
|
|
30306
|
+
if (codePoint === 0x200d ||
|
|
30307
|
+
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
|
30308
|
+
isInRange(codePoint, COMBINING_MARK_RANGES)) {
|
|
30309
|
+
return 0;
|
|
30310
|
+
}
|
|
30311
|
+
return isInRange(codePoint, WIDE_CHARACTER_RANGES) ? 2 : 1;
|
|
30312
|
+
}
|
|
30313
|
+
function cellWidth(value) {
|
|
30314
|
+
return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
|
|
30315
|
+
}
|
|
30316
|
+
/**
|
|
30317
|
+
* Word-wrap `value` into lines that each fit within `width` cells. Breaks
|
|
30318
|
+
* on whitespace where possible; falls back to mid-word splits when a single
|
|
30319
|
+
* word is wider than the budget. Preserves blank input as a single empty
|
|
30320
|
+
* line so `value.split('\n').flatMap(wrapCells)` round-trips cleanly.
|
|
30321
|
+
*/
|
|
30322
|
+
function wrapCells(value, width) {
|
|
30323
|
+
if (width < 1) {
|
|
30324
|
+
return [value];
|
|
30325
|
+
}
|
|
30326
|
+
if (cellWidth(value) <= width) {
|
|
30327
|
+
return [value];
|
|
30328
|
+
}
|
|
30329
|
+
const lines = [];
|
|
30330
|
+
let current = '';
|
|
30331
|
+
let currentWidth = 0;
|
|
30332
|
+
const flush = () => {
|
|
30333
|
+
if (current.length > 0) {
|
|
30334
|
+
lines.push(current);
|
|
30335
|
+
current = '';
|
|
30336
|
+
currentWidth = 0;
|
|
30337
|
+
}
|
|
30338
|
+
};
|
|
30339
|
+
// Tokenize into runs of whitespace + non-whitespace so we can keep word
|
|
30340
|
+
// boundaries when possible.
|
|
30341
|
+
const tokens = value.match(/\s+|\S+/g) || [];
|
|
30342
|
+
for (const token of tokens) {
|
|
30343
|
+
const tokenWidth = cellWidth(token);
|
|
30344
|
+
if (currentWidth + tokenWidth <= width) {
|
|
30345
|
+
current += token;
|
|
30346
|
+
currentWidth += tokenWidth;
|
|
30347
|
+
continue;
|
|
30348
|
+
}
|
|
30349
|
+
if (/^\s+$/.test(token)) {
|
|
30350
|
+
// Drop boundary whitespace at line breaks.
|
|
30351
|
+
flush();
|
|
30352
|
+
continue;
|
|
30353
|
+
}
|
|
30354
|
+
flush();
|
|
30355
|
+
if (tokenWidth <= width) {
|
|
30356
|
+
current = token;
|
|
30357
|
+
currentWidth = tokenWidth;
|
|
30358
|
+
continue;
|
|
30359
|
+
}
|
|
30360
|
+
// Word longer than budget — hard-split into chunks.
|
|
30361
|
+
let remaining = token;
|
|
30362
|
+
while (cellWidth(remaining) > width) {
|
|
30363
|
+
let chunk = '';
|
|
30364
|
+
let chunkWidth = 0;
|
|
30365
|
+
for (const character of Array.from(remaining)) {
|
|
30366
|
+
const charW = characterWidth(character);
|
|
30367
|
+
if (chunkWidth + charW > width)
|
|
30368
|
+
break;
|
|
30369
|
+
chunk += character;
|
|
30370
|
+
chunkWidth += charW;
|
|
30371
|
+
}
|
|
30372
|
+
lines.push(chunk);
|
|
30373
|
+
remaining = remaining.slice(chunk.length);
|
|
30374
|
+
}
|
|
30375
|
+
if (remaining.length > 0) {
|
|
30376
|
+
current = remaining;
|
|
30377
|
+
currentWidth = cellWidth(remaining);
|
|
30378
|
+
}
|
|
30379
|
+
}
|
|
30380
|
+
flush();
|
|
30381
|
+
return lines.length > 0 ? lines : [value];
|
|
30382
|
+
}
|
|
30383
|
+
function truncateCells(value, width) {
|
|
30384
|
+
if (width < 1) {
|
|
30385
|
+
return '';
|
|
30386
|
+
}
|
|
30387
|
+
if (cellWidth(value) <= width) {
|
|
30388
|
+
return value;
|
|
30389
|
+
}
|
|
30390
|
+
const suffix = width > 3 ? '...' : '';
|
|
30391
|
+
const available = width - cellWidth(suffix);
|
|
30392
|
+
let used = 0;
|
|
30393
|
+
let output = '';
|
|
30394
|
+
for (const character of Array.from(value)) {
|
|
30395
|
+
const nextWidth = characterWidth(character);
|
|
30396
|
+
if (used + nextWidth > available) {
|
|
30397
|
+
break;
|
|
30398
|
+
}
|
|
30399
|
+
output += character;
|
|
30400
|
+
used += nextWidth;
|
|
30401
|
+
}
|
|
30402
|
+
return `${output}${suffix}`;
|
|
30403
|
+
}
|
|
30404
|
+
/**
|
|
30405
|
+
* Truncate a file path so the filename (last segment) is preserved,
|
|
30406
|
+
* eliding middle directory segments with `…/` instead of dropping
|
|
30407
|
+
* end-of-string characters.
|
|
30408
|
+
*
|
|
30409
|
+
* `truncateCells` is the wrong tool for paths because it preserves the
|
|
30410
|
+
* START of the string and drops the END — losing the filename, which
|
|
30411
|
+
* is the most useful part. Example with `truncateCells`:
|
|
30412
|
+
*
|
|
30413
|
+
* "src/commands/log/data.ts" (24) at width 18 → "src/commands/lo..."
|
|
30414
|
+
*
|
|
30415
|
+
* `truncatePathCells` preserves the filename and elides middle:
|
|
30416
|
+
*
|
|
30417
|
+
* "src/commands/log/data.ts" (24) at width 18 → "src/…/log/data.ts"
|
|
30418
|
+
*
|
|
30419
|
+
* The algorithm tries successively-smaller prefixes (keeping the start
|
|
30420
|
+
* of the path, the filename, and replacing the dropped middle segments
|
|
30421
|
+
* with `…`) and returns the largest variant that fits. When even
|
|
30422
|
+
* `…/<filename>` doesn't fit, falls back to plain `truncateCells` on
|
|
30423
|
+
* the abbreviated form — better to show end-of-name than start-of-path.
|
|
30424
|
+
*
|
|
30425
|
+
* For inputs without `/` separators, behaves identically to
|
|
30426
|
+
* `truncateCells`. Empty / width-0 cases match `truncateCells` too.
|
|
30427
|
+
*
|
|
30428
|
+
* @example
|
|
30429
|
+
* truncatePathCells('src/commands/log/data.ts', 18) // 'src/…/log/data.ts'
|
|
30430
|
+
* truncatePathCells('src/commands/log/data.ts', 12) // '…/data.ts'
|
|
30431
|
+
* truncatePathCells('a/b/c.ts', 100) // 'a/b/c.ts' (fits)
|
|
30432
|
+
* truncatePathCells('plainname.ts', 8) // 'plain...'
|
|
30433
|
+
*/
|
|
30434
|
+
function truncatePathCells(value, width) {
|
|
30435
|
+
if (width < 1)
|
|
30436
|
+
return '';
|
|
30437
|
+
if (cellWidth(value) <= width)
|
|
30438
|
+
return value;
|
|
30439
|
+
// No path structure to exploit — fall through to plain truncation.
|
|
30440
|
+
if (!value.includes('/'))
|
|
30441
|
+
return truncateCells(value, width);
|
|
30442
|
+
const segments = value.split('/');
|
|
30443
|
+
const filename = segments[segments.length - 1] ?? '';
|
|
30444
|
+
const prefix = segments.slice(0, -1);
|
|
30445
|
+
// Path is just '/filename' or has only the filename — no middle to
|
|
30446
|
+
// elide. Defer to plain truncation.
|
|
30447
|
+
if (prefix.length === 0)
|
|
30448
|
+
return truncateCells(value, width);
|
|
30449
|
+
// Walk from "keep all prefix segments except the deepest" down to
|
|
30450
|
+
// "keep no prefix segments." First variant that fits wins.
|
|
30451
|
+
for (let keep = prefix.length - 1; keep >= 0; keep--) {
|
|
30452
|
+
const candidate = keep === 0
|
|
30453
|
+
? `…/${filename}`
|
|
30454
|
+
: `${prefix.slice(0, keep).join('/')}/…/${filename}`;
|
|
30455
|
+
if (cellWidth(candidate) <= width)
|
|
30456
|
+
return candidate;
|
|
30457
|
+
}
|
|
30458
|
+
// Even `…/<filename>` doesn't fit. Use plain truncation on that
|
|
30459
|
+
// form — preserves the leading `…/` so the user knows a path was
|
|
30460
|
+
// elided, then ellipsis-truncates the filename.
|
|
30461
|
+
return truncateCells(`…/${filename}`, width);
|
|
29782
30462
|
}
|
|
29783
30463
|
|
|
29784
30464
|
/**
|
|
@@ -30005,218 +30685,318 @@ function sidebarTabCount(tab, context) {
|
|
|
30005
30685
|
}
|
|
30006
30686
|
}
|
|
30007
30687
|
|
|
30008
|
-
const COMBINING_MARK_RANGES = [
|
|
30009
|
-
[0x0300, 0x036f],
|
|
30010
|
-
[0x1ab0, 0x1aff],
|
|
30011
|
-
[0x1dc0, 0x1dff],
|
|
30012
|
-
[0x20d0, 0x20ff],
|
|
30013
|
-
[0xfe20, 0xfe2f],
|
|
30014
|
-
];
|
|
30015
|
-
const WIDE_CHARACTER_RANGES = [
|
|
30016
|
-
[0x1100, 0x115f],
|
|
30017
|
-
[0x2329, 0x232a],
|
|
30018
|
-
[0x2e80, 0xa4cf],
|
|
30019
|
-
[0xac00, 0xd7a3],
|
|
30020
|
-
[0xf900, 0xfaff],
|
|
30021
|
-
[0xfe10, 0xfe19],
|
|
30022
|
-
[0xfe30, 0xfe6f],
|
|
30023
|
-
[0xff00, 0xff60],
|
|
30024
|
-
[0xffe0, 0xffe6],
|
|
30025
|
-
[0x2600, 0x27bf],
|
|
30026
|
-
[0x1f000, 0x1f9ff],
|
|
30027
|
-
[0x20000, 0x3fffd],
|
|
30028
|
-
];
|
|
30029
|
-
function isInRange(codePoint, ranges) {
|
|
30030
|
-
return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
|
|
30031
|
-
}
|
|
30032
|
-
function characterWidth(character) {
|
|
30033
|
-
const codePoint = character.codePointAt(0) || 0;
|
|
30034
|
-
if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
|
|
30035
|
-
return 0;
|
|
30036
|
-
}
|
|
30037
|
-
if (codePoint === 0x200d ||
|
|
30038
|
-
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
|
30039
|
-
isInRange(codePoint, COMBINING_MARK_RANGES)) {
|
|
30040
|
-
return 0;
|
|
30041
|
-
}
|
|
30042
|
-
return isInRange(codePoint, WIDE_CHARACTER_RANGES) ? 2 : 1;
|
|
30043
|
-
}
|
|
30044
|
-
function cellWidth(value) {
|
|
30045
|
-
return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
|
|
30046
|
-
}
|
|
30047
30688
|
/**
|
|
30048
|
-
*
|
|
30049
|
-
*
|
|
30050
|
-
*
|
|
30051
|
-
*
|
|
30689
|
+
* Header chip builder. Turns the workstation's title-bar state into an
|
|
30690
|
+
* ordered list of small visually-distinct chips:
|
|
30691
|
+
*
|
|
30692
|
+
* coco · gfargo/coco · ⎇ main · ✓ clean · ⊘ no PR · [NORMAL]
|
|
30693
|
+
*
|
|
30694
|
+
* Pre-refactor the title bar concatenated every segment into a single
|
|
30695
|
+
* Text span, which made the eye read the whole thing as one run of
|
|
30696
|
+
* words (the same problem the footer had). Splitting into chips with a
|
|
30697
|
+
* fixed separator lets each segment carry its own color and lets the
|
|
30698
|
+
* user scan the bar in chunks — "what app, what repo, what branch,
|
|
30699
|
+
* how clean, what PR state, what mode" — instead of parsing left-to-
|
|
30700
|
+
* right.
|
|
30701
|
+
*
|
|
30702
|
+
* Why a separate module: the header runtime renders chips and handles
|
|
30703
|
+
* truncation; chip construction is pure transformation of state +
|
|
30704
|
+
* context + theme. Splitting them keeps the chips testable in
|
|
30705
|
+
* isolation and keeps the runtime small.
|
|
30706
|
+
*
|
|
30707
|
+
* Truncation strategy lives in the consumer, not here — when the total
|
|
30708
|
+
* width exceeds the column budget, the header falls back to the
|
|
30709
|
+
* pre-redesign single-fragment truncated string so the ellipsis can't
|
|
30710
|
+
* land mid-glyph. We always return the FULL chip list; the consumer
|
|
30711
|
+
* decides whether to drop chips, fall back, or render all of them.
|
|
30052
30712
|
*/
|
|
30053
|
-
|
|
30054
|
-
|
|
30055
|
-
|
|
30713
|
+
/**
|
|
30714
|
+
* Default separator inserted between chips by the consumer. Exported as
|
|
30715
|
+
* a constant so tests and width math agree on what they're measuring.
|
|
30716
|
+
* The trailing/leading spaces are part of the separator — `·` alone
|
|
30717
|
+
* would butt against adjacent chip labels.
|
|
30718
|
+
*/
|
|
30719
|
+
const HEADER_CHIP_SEPARATOR = ' · ';
|
|
30720
|
+
/**
|
|
30721
|
+
* Build the ordered chip list for the header. Chips not relevant to the
|
|
30722
|
+
* current state (no PR loaded, no breadcrumb, no search input, …) are
|
|
30723
|
+
* omitted entirely rather than rendered as empty placeholders, so the
|
|
30724
|
+
* consumer can just `chips.map(render)` without checking for empties.
|
|
30725
|
+
*/
|
|
30726
|
+
function buildHeaderChips(input) {
|
|
30727
|
+
const { theme } = input;
|
|
30728
|
+
const chips = [];
|
|
30729
|
+
// App label — the constant identity. Accent + bold so it anchors the
|
|
30730
|
+
// left edge of the bar.
|
|
30731
|
+
chips.push({
|
|
30732
|
+
id: 'app',
|
|
30733
|
+
label: input.appLabel,
|
|
30734
|
+
color: theme.colors.accent,
|
|
30735
|
+
dim: false,
|
|
30736
|
+
bold: true,
|
|
30737
|
+
});
|
|
30738
|
+
// Repo. Default color — it's contextual but not the headline.
|
|
30739
|
+
chips.push({
|
|
30740
|
+
id: 'repo',
|
|
30741
|
+
label: input.repo,
|
|
30742
|
+
color: undefined,
|
|
30743
|
+
dim: false,
|
|
30744
|
+
bold: false,
|
|
30745
|
+
});
|
|
30746
|
+
// Branch. Carries the branch glyph (⎇ / ASCII fallback) so the chip
|
|
30747
|
+
// is identifiable even when the branch name is generic ("main" /
|
|
30748
|
+
// "master").
|
|
30749
|
+
const branchGlyph = theme.ascii ? 'git:' : '⎇';
|
|
30750
|
+
chips.push({
|
|
30751
|
+
id: 'branch',
|
|
30752
|
+
label: `${branchGlyph} ${input.branch}`,
|
|
30753
|
+
color: theme.colors.accent,
|
|
30754
|
+
dim: false,
|
|
30755
|
+
bold: true,
|
|
30756
|
+
});
|
|
30757
|
+
// Dirty/clean. Positive framing on clean (success color + ✓), warning
|
|
30758
|
+
// on dirty (warning color + ●). ASCII fallbacks keep the chip
|
|
30759
|
+
// identifiable on dumb terminals.
|
|
30760
|
+
const dirtyChip = input.dirty
|
|
30761
|
+
? {
|
|
30762
|
+
id: 'dirty',
|
|
30763
|
+
label: theme.ascii ? '* dirty' : '● dirty',
|
|
30764
|
+
color: theme.colors.warning,
|
|
30765
|
+
dim: false,
|
|
30766
|
+
bold: false,
|
|
30767
|
+
}
|
|
30768
|
+
: {
|
|
30769
|
+
id: 'dirty',
|
|
30770
|
+
label: theme.ascii ? '+ clean' : '✓ clean',
|
|
30771
|
+
color: theme.colors.success,
|
|
30772
|
+
dim: false,
|
|
30773
|
+
bold: false,
|
|
30774
|
+
};
|
|
30775
|
+
chips.push(dirtyChip);
|
|
30776
|
+
// Bisect — only when active. Distinct chip so users entering the TUI
|
|
30777
|
+
// mid-bisect see it immediately (#784). Warning color because bisect
|
|
30778
|
+
// is an "in progress, requires user action" state.
|
|
30779
|
+
if (input.bisecting) {
|
|
30780
|
+
chips.push({
|
|
30781
|
+
id: 'bisecting',
|
|
30782
|
+
label: theme.ascii ? '! BISECTING' : '⚠ BISECTING',
|
|
30783
|
+
color: theme.colors.warning,
|
|
30784
|
+
dim: false,
|
|
30785
|
+
bold: true,
|
|
30786
|
+
});
|
|
30056
30787
|
}
|
|
30057
|
-
|
|
30058
|
-
|
|
30788
|
+
// PR state. When present, the chip uses the PR-state glyph + a short
|
|
30789
|
+
// label ("PR #1234 OPEN" / "PR #1234 DRAFT"). When absent, a muted
|
|
30790
|
+
// "no PR" chip so users know the system DID look (vs. the bar just
|
|
30791
|
+
// being blank).
|
|
30792
|
+
if (input.pullRequest) {
|
|
30793
|
+
const prGlyph = getPullRequestStateGlyph({ ...input.pullRequest, isDraft: Boolean(input.pullRequest.isDraft) }, theme);
|
|
30794
|
+
const stateLabel = input.pullRequest.isDraft
|
|
30795
|
+
? 'DRAFT'
|
|
30796
|
+
: input.pullRequest.state.toUpperCase();
|
|
30797
|
+
const label = prGlyph.glyph
|
|
30798
|
+
? `${prGlyph.glyph} PR #${input.pullRequest.number} ${stateLabel}`
|
|
30799
|
+
: `PR #${input.pullRequest.number} ${stateLabel}`;
|
|
30800
|
+
chips.push({
|
|
30801
|
+
id: 'pr',
|
|
30802
|
+
label,
|
|
30803
|
+
color: prGlyph.color,
|
|
30804
|
+
dim: prGlyph.dim,
|
|
30805
|
+
bold: false,
|
|
30806
|
+
});
|
|
30059
30807
|
}
|
|
30060
|
-
|
|
30061
|
-
|
|
30062
|
-
|
|
30063
|
-
|
|
30064
|
-
|
|
30065
|
-
|
|
30066
|
-
|
|
30067
|
-
|
|
30068
|
-
}
|
|
30069
|
-
};
|
|
30070
|
-
// Tokenize into runs of whitespace + non-whitespace so we can keep word
|
|
30071
|
-
// boundaries when possible.
|
|
30072
|
-
const tokens = value.match(/\s+|\S+/g) || [];
|
|
30073
|
-
for (const token of tokens) {
|
|
30074
|
-
const tokenWidth = cellWidth(token);
|
|
30075
|
-
if (currentWidth + tokenWidth <= width) {
|
|
30076
|
-
current += token;
|
|
30077
|
-
currentWidth += tokenWidth;
|
|
30078
|
-
continue;
|
|
30079
|
-
}
|
|
30080
|
-
if (/^\s+$/.test(token)) {
|
|
30081
|
-
// Drop boundary whitespace at line breaks.
|
|
30082
|
-
flush();
|
|
30083
|
-
continue;
|
|
30084
|
-
}
|
|
30085
|
-
flush();
|
|
30086
|
-
if (tokenWidth <= width) {
|
|
30087
|
-
current = token;
|
|
30088
|
-
currentWidth = tokenWidth;
|
|
30089
|
-
continue;
|
|
30090
|
-
}
|
|
30091
|
-
// Word longer than budget — hard-split into chunks.
|
|
30092
|
-
let remaining = token;
|
|
30093
|
-
while (cellWidth(remaining) > width) {
|
|
30094
|
-
let chunk = '';
|
|
30095
|
-
let chunkWidth = 0;
|
|
30096
|
-
for (const character of Array.from(remaining)) {
|
|
30097
|
-
const charW = characterWidth(character);
|
|
30098
|
-
if (chunkWidth + charW > width)
|
|
30099
|
-
break;
|
|
30100
|
-
chunk += character;
|
|
30101
|
-
chunkWidth += charW;
|
|
30102
|
-
}
|
|
30103
|
-
lines.push(chunk);
|
|
30104
|
-
remaining = remaining.slice(chunk.length);
|
|
30105
|
-
}
|
|
30106
|
-
if (remaining.length > 0) {
|
|
30107
|
-
current = remaining;
|
|
30108
|
-
currentWidth = cellWidth(remaining);
|
|
30109
|
-
}
|
|
30808
|
+
else {
|
|
30809
|
+
chips.push({
|
|
30810
|
+
id: 'pr',
|
|
30811
|
+
label: theme.ascii ? '- no PR' : '⊘ no PR',
|
|
30812
|
+
color: theme.colors.muted,
|
|
30813
|
+
dim: true,
|
|
30814
|
+
bold: false,
|
|
30815
|
+
});
|
|
30110
30816
|
}
|
|
30111
|
-
|
|
30112
|
-
|
|
30113
|
-
|
|
30114
|
-
|
|
30115
|
-
|
|
30116
|
-
|
|
30817
|
+
// View breadcrumb. Rendered only when there's content (`coco ui`
|
|
30818
|
+
// root view → no breadcrumb chip; pushed into a sub-view → chip
|
|
30819
|
+
// appears). Comes AFTER PR so the "state" group (app/repo/branch/
|
|
30820
|
+
// dirty/PR) reads as one cluster and the "navigation" group (view
|
|
30821
|
+
// breadcrumb / loading) reads as a separate cluster.
|
|
30822
|
+
if (input.breadcrumb) {
|
|
30823
|
+
chips.push({
|
|
30824
|
+
id: 'view',
|
|
30825
|
+
label: input.breadcrumb,
|
|
30826
|
+
color: theme.colors.muted,
|
|
30827
|
+
dim: true,
|
|
30828
|
+
bold: false,
|
|
30829
|
+
});
|
|
30117
30830
|
}
|
|
30118
|
-
if (
|
|
30119
|
-
|
|
30831
|
+
if (input.loading) {
|
|
30832
|
+
chips.push({
|
|
30833
|
+
id: 'loading',
|
|
30834
|
+
label: input.loading.trim(),
|
|
30835
|
+
color: theme.colors.muted,
|
|
30836
|
+
dim: true,
|
|
30837
|
+
bold: false,
|
|
30838
|
+
});
|
|
30120
30839
|
}
|
|
30121
|
-
|
|
30122
|
-
|
|
30123
|
-
|
|
30124
|
-
|
|
30125
|
-
|
|
30126
|
-
|
|
30127
|
-
|
|
30128
|
-
|
|
30129
|
-
|
|
30130
|
-
|
|
30131
|
-
|
|
30840
|
+
// Mode — the explicit input-mode indicator (#P2.2). Always present
|
|
30841
|
+
// so users never wonder why `q` doesn't quit while they're editing.
|
|
30842
|
+
// EDIT / FILTER use the warning color to signal "your keystrokes
|
|
30843
|
+
// mean something different right now"; NORMAL uses accent (matches
|
|
30844
|
+
// the app chip's home base).
|
|
30845
|
+
const modeColor = input.mode === 'NORMAL'
|
|
30846
|
+
? theme.colors.accent
|
|
30847
|
+
: theme.colors.warning;
|
|
30848
|
+
chips.push({
|
|
30849
|
+
id: 'mode',
|
|
30850
|
+
label: `[${input.mode}]`,
|
|
30851
|
+
color: modeColor,
|
|
30852
|
+
dim: false,
|
|
30853
|
+
bold: true,
|
|
30854
|
+
});
|
|
30855
|
+
// Search — only when active. Dim so it doesn't compete with the
|
|
30856
|
+
// identity chips for attention; the user knows it's there because
|
|
30857
|
+
// they're typing into it.
|
|
30858
|
+
if (input.search) {
|
|
30859
|
+
chips.push({
|
|
30860
|
+
id: 'search',
|
|
30861
|
+
label: input.search,
|
|
30862
|
+
color: theme.colors.muted,
|
|
30863
|
+
dim: true,
|
|
30864
|
+
bold: false,
|
|
30865
|
+
});
|
|
30132
30866
|
}
|
|
30133
|
-
return
|
|
30867
|
+
return chips;
|
|
30868
|
+
}
|
|
30869
|
+
/**
|
|
30870
|
+
* Total rendered width of a chip list assuming `HEADER_CHIP_SEPARATOR`
|
|
30871
|
+
* between every pair. Used by the consumer to decide whether the
|
|
30872
|
+
* chip layout fits the column budget or whether to fall back to the
|
|
30873
|
+
* single-fragment truncated path.
|
|
30874
|
+
*/
|
|
30875
|
+
function measureHeaderChipsWidth(chips) {
|
|
30876
|
+
if (chips.length === 0)
|
|
30877
|
+
return 0;
|
|
30878
|
+
const labels = chips.map((chip) => cellWidth(chip.label));
|
|
30879
|
+
const separators = (chips.length - 1) * cellWidth(HEADER_CHIP_SEPARATOR);
|
|
30880
|
+
return labels.reduce((sum, w) => sum + w, 0) + separators;
|
|
30134
30881
|
}
|
|
30135
30882
|
|
|
30136
30883
|
/**
|
|
30137
|
-
* Title-bar renderer. Surfaces
|
|
30138
|
-
*
|
|
30139
|
-
* - current repo owner/name (or "local repository")
|
|
30140
|
-
* - current branch + dirty / BISECTING flag
|
|
30141
|
-
* - PR glyph + label when one is detected
|
|
30142
|
-
* - breadcrumb of the view stack
|
|
30143
|
-
* - loading hint for boot / context fetches
|
|
30144
|
-
* - mode indicator: [NORMAL] / [EDIT] / [FILTER]
|
|
30145
|
-
* - active filter / search input
|
|
30884
|
+
* Title-bar renderer. Surfaces the workstation's identity + navigation
|
|
30885
|
+
* state as a row of small visually-distinct chips:
|
|
30146
30886
|
*
|
|
30147
|
-
*
|
|
30148
|
-
* fall back to a single-fragment Text (truncating the joined string) so
|
|
30149
|
-
* the ellipsis can't land mid-glyph. The split-fragment path keeps the PR
|
|
30150
|
-
* glyph in its own colored span when there's headroom.
|
|
30887
|
+
* coco · gfargo/coco · ⎇ main · ✓ clean · ⊘ no PR · [NORMAL]
|
|
30151
30888
|
*
|
|
30152
|
-
*
|
|
30153
|
-
*
|
|
30889
|
+
* Per-chip color/glyph treatment lets the user scan in chunks ("what
|
|
30890
|
+
* app, what repo, what branch, how clean, what PR state, what mode")
|
|
30891
|
+
* instead of parsing one long sentence. Chip construction is in
|
|
30892
|
+
* `chrome/headerChips.ts`; this runtime just renders.
|
|
30893
|
+
*
|
|
30894
|
+
* Truncation: when the assembled chip row overruns the available
|
|
30895
|
+
* columns we fall back to a single Text fragment (truncating the
|
|
30896
|
+
* joined chip labels) so the ellipsis can't land mid-glyph. This is
|
|
30897
|
+
* the same defensive pattern the pre-redesign single-fragment code
|
|
30898
|
+
* used, applied at the chip-list level instead of the inline glyph
|
|
30899
|
+
* split.
|
|
30900
|
+
*
|
|
30901
|
+
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase
|
|
30902
|
+
* 5a.7 of #890. Chip restructuring introduced post-0.54.2.
|
|
30154
30903
|
*/
|
|
30155
30904
|
function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
|
|
30156
30905
|
const { Box, Text } = components;
|
|
30906
|
+
// Pull the source state into the small "describe what to render"
|
|
30907
|
+
// shape the chip builder expects. Keeps the runtime decoupled from
|
|
30908
|
+
// the chip layout — the builder doesn't know about LogInkState /
|
|
30909
|
+
// LogInkContext, just plain values.
|
|
30157
30910
|
const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
|
|
30158
|
-
|
|
30159
|
-
|
|
30160
|
-
const dirtyBase = context.branches?.dirty ? 'dirty' : 'clean';
|
|
30161
|
-
const dirty = context.bisect?.active ? `${dirtyBase} · BISECTING` : dirtyBase;
|
|
30911
|
+
const dirty = Boolean(context.branches?.dirty);
|
|
30912
|
+
const bisecting = Boolean(context.bisect?.active);
|
|
30162
30913
|
const repo = context.provider?.repository.owner && context.provider.repository.name
|
|
30163
30914
|
? `${context.provider.repository.owner}/${context.provider.repository.name}`
|
|
30164
30915
|
: 'local repository';
|
|
30165
30916
|
const prInfo = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
|
|
30166
|
-
|
|
30167
|
-
|
|
30168
|
-
|
|
30169
|
-
: 'no PR';
|
|
30170
|
-
const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
|
|
30171
|
-
// Boot loading wins over the per-context loading hint because it
|
|
30172
|
-
// tells the user the headline thing they care about (commits aren't
|
|
30173
|
-
// ready yet) — the context fetches finish independently and surface
|
|
30174
|
-
// their own per-section loading copy in the sidebars.
|
|
30917
|
+
// Boot loading wins over the per-context loading hint — same
|
|
30918
|
+
// priority as pre-redesign. Context fetches still surface their own
|
|
30919
|
+
// copy in the sidebars.
|
|
30175
30920
|
const loading = state.bootLoading
|
|
30176
|
-
? '
|
|
30177
|
-
: isLogInkContextLoading(contextStatus) ? '
|
|
30921
|
+
? 'loading commits'
|
|
30922
|
+
: isLogInkContextLoading(contextStatus) ? 'loading context' : '';
|
|
30178
30923
|
const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
|
|
30179
30924
|
const repoCrumb = formatLogInkRepoBreadcrumb(state.repoStack);
|
|
30180
|
-
// Repo breadcrumb (when nested) comes first so the user sees which
|
|
30181
|
-
// submodule they're in at a glance, then the view breadcrumb (when
|
|
30182
|
-
// pushed deeper than the root view). The truncate fallback in the
|
|
30183
|
-
// title row still applies — when both fight for space, the ellipsis
|
|
30184
|
-
// lands at the end of whichever segment overflows.
|
|
30185
30925
|
const view = combineLogInkBreadcrumbSegments(repoCrumb, breadcrumb);
|
|
30186
|
-
// Mode indicator (P2.2) — surfaces the current input mode so users
|
|
30187
|
-
// never wonder why `q` doesn't quit while they're editing or filtering.
|
|
30188
30926
|
const mode = state.commitCompose.editing
|
|
30189
|
-
? '
|
|
30927
|
+
? 'EDIT'
|
|
30190
30928
|
: state.filterMode
|
|
30191
|
-
? '
|
|
30192
|
-
: '
|
|
30193
|
-
const
|
|
30194
|
-
|
|
30195
|
-
|
|
30196
|
-
|
|
30197
|
-
|
|
30198
|
-
const
|
|
30199
|
-
|
|
30200
|
-
|
|
30201
|
-
|
|
30202
|
-
|
|
30203
|
-
|
|
30204
|
-
?
|
|
30205
|
-
|
|
30206
|
-
|
|
30207
|
-
:
|
|
30929
|
+
? 'FILTER'
|
|
30930
|
+
: 'NORMAL';
|
|
30931
|
+
const search = state.filterMode
|
|
30932
|
+
? `search: ${state.filter}_`
|
|
30933
|
+
: state.filter
|
|
30934
|
+
? `filter: ${state.filter}`
|
|
30935
|
+
: '';
|
|
30936
|
+
const chips = buildHeaderChips({
|
|
30937
|
+
appLabel,
|
|
30938
|
+
repo,
|
|
30939
|
+
branch,
|
|
30940
|
+
dirty,
|
|
30941
|
+
bisecting,
|
|
30942
|
+
pullRequest: prInfo ? {
|
|
30943
|
+
number: prInfo.number,
|
|
30944
|
+
state: prInfo.state,
|
|
30945
|
+
isDraft: prInfo.isDraft,
|
|
30946
|
+
} : undefined,
|
|
30947
|
+
breadcrumb: view,
|
|
30948
|
+
loading,
|
|
30949
|
+
mode,
|
|
30950
|
+
search: search ? truncateCells(search, 36) : '',
|
|
30951
|
+
theme,
|
|
30952
|
+
});
|
|
30953
|
+
// Truncation budget. Header line gets the full terminal width minus
|
|
30954
|
+
// the box's horizontal padding (2 cells) and a small safety margin.
|
|
30955
|
+
const budget = Math.max(0, columns - 4);
|
|
30956
|
+
const chipsWidth = measureHeaderChipsWidth(chips);
|
|
30208
30957
|
return h(Box, {
|
|
30209
30958
|
borderColor: theme.colors.border,
|
|
30210
30959
|
borderStyle: theme.borderStyle,
|
|
30211
30960
|
height: 3,
|
|
30212
30961
|
paddingX: 1,
|
|
30213
|
-
},
|
|
30214
|
-
? h
|
|
30215
|
-
: h
|
|
30216
|
-
|
|
30217
|
-
|
|
30218
|
-
|
|
30219
|
-
|
|
30962
|
+
}, chipsWidth <= budget
|
|
30963
|
+
? renderChipRow(h, Text, chips)
|
|
30964
|
+
: renderFallback(h, Text, chips, theme, budget));
|
|
30965
|
+
}
|
|
30966
|
+
/**
|
|
30967
|
+
* Render every chip as its own Text span with its own color/style,
|
|
30968
|
+
* interleaved with dim separator spans. This is the path used when
|
|
30969
|
+
* everything fits — the eye gets the full chip treatment.
|
|
30970
|
+
*/
|
|
30971
|
+
function renderChipRow(h, Text, chips) {
|
|
30972
|
+
const nodes = [];
|
|
30973
|
+
chips.forEach((chip, index) => {
|
|
30974
|
+
if (index > 0) {
|
|
30975
|
+
// Separator is intentionally dim so the eye can use it as a
|
|
30976
|
+
// visual delimiter without it competing with chip labels for
|
|
30977
|
+
// attention.
|
|
30978
|
+
nodes.push(h(Text, { key: `sep-${index}`, dimColor: true }, HEADER_CHIP_SEPARATOR));
|
|
30979
|
+
}
|
|
30980
|
+
nodes.push(h(Text, {
|
|
30981
|
+
key: chip.id,
|
|
30982
|
+
color: chip.color,
|
|
30983
|
+
dimColor: chip.dim,
|
|
30984
|
+
bold: chip.bold,
|
|
30985
|
+
}, chip.label));
|
|
30986
|
+
});
|
|
30987
|
+
return nodes;
|
|
30988
|
+
}
|
|
30989
|
+
/**
|
|
30990
|
+
* Fallback path for narrow terminals. Concatenates every chip label
|
|
30991
|
+
* with separators, then truncates the whole string with
|
|
30992
|
+
* `truncateCells` so the ellipsis lands at a cell boundary. Loses the
|
|
30993
|
+
* per-chip color treatment in exchange for guaranteed legibility on
|
|
30994
|
+
* narrow displays — the same trade-off the pre-redesign single-
|
|
30995
|
+
* fragment code made for its inline glyph color split.
|
|
30996
|
+
*/
|
|
30997
|
+
function renderFallback(h, Text, chips, theme, budget) {
|
|
30998
|
+
const joined = chips.map((chip) => chip.label).join(HEADER_CHIP_SEPARATOR);
|
|
30999
|
+
return h(Text, { bold: true, color: theme.colors.accent }, truncateCells(joined, budget));
|
|
30220
31000
|
}
|
|
30221
31001
|
|
|
30222
31002
|
/**
|
|
@@ -30439,10 +31219,21 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
|
|
|
30439
31219
|
const summaryRow = (count, label, key, kind) => h(Text, { key }, ' ', h(Text, { color: colorOf(kind), bold: count > 0 }, `${count} ${label}`));
|
|
30440
31220
|
const fileRows = worktree.files.slice(0, 12).map((file, index) => {
|
|
30441
31221
|
const codes = `${file.indexStatus}${file.worktreeStatus}`;
|
|
31222
|
+
// Smart path truncation: keep the leading status codes and elide
|
|
31223
|
+
// middle directory segments to preserve the filename. Falls back
|
|
31224
|
+
// to plain truncation when the codes + a meaningful filename
|
|
31225
|
+
// don't both fit. Same shape as the detail surface so all the
|
|
31226
|
+
// status-row renderings elide consistently.
|
|
31227
|
+
const prefix = ` ${codes} `;
|
|
31228
|
+
const totalBudget = width - 4;
|
|
31229
|
+
const pathBudget = totalBudget - cellWidth(prefix);
|
|
31230
|
+
const label = pathBudget >= 8
|
|
31231
|
+
? `${prefix}${truncatePathCells(file.path, pathBudget)}`
|
|
31232
|
+
: truncateCells(`${prefix}${file.path}`, totalBudget);
|
|
30442
31233
|
return h(Text, {
|
|
30443
31234
|
key: `tab-status-file-${index}`,
|
|
30444
31235
|
color: colorOf(file.state),
|
|
30445
|
-
},
|
|
31236
|
+
}, label);
|
|
30446
31237
|
});
|
|
30447
31238
|
return [
|
|
30448
31239
|
summaryRow(worktree.stagedCount, 'staged', 'tab-status-staged', 'staged'),
|
|
@@ -31785,7 +32576,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
31785
32576
|
color: theme.noColor ? undefined : theme.colors.accent,
|
|
31786
32577
|
backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
|
|
31787
32578
|
inverse: isActive && focused,
|
|
31788
|
-
},
|
|
32579
|
+
}, (() => {
|
|
32580
|
+
// Smart path truncation for the diff file header: keep
|
|
32581
|
+
// the leading arrow glyph and elide middle path
|
|
32582
|
+
// segments so the filename is never lost. Falls back to
|
|
32583
|
+
// plain truncation when there isn't room for a
|
|
32584
|
+
// meaningful filename.
|
|
32585
|
+
const pathBudget = (width - 4) - cellWidth(arrow);
|
|
32586
|
+
return pathBudget >= 8
|
|
32587
|
+
? `${arrow}${truncatePathCells(headerFile.path, pathBudget)}`
|
|
32588
|
+
: truncateCells(`${arrow}${headerFile.path}`, width - 4);
|
|
32589
|
+
})());
|
|
31789
32590
|
}
|
|
31790
32591
|
return h(Text, {
|
|
31791
32592
|
key: `stash-diff-line-${absoluteIndex}`,
|
|
@@ -35266,6 +36067,24 @@ function renderInspectorRefs(h, Text, refs, repository) {
|
|
|
35266
36067
|
});
|
|
35267
36068
|
return out;
|
|
35268
36069
|
}
|
|
36070
|
+
/**
|
|
36071
|
+
* Compose a `<prefix><path><suffix>` line where the path gets smart
|
|
36072
|
+
* middle-elision truncation if needed, while the fixed prefix/suffix
|
|
36073
|
+
* decorations stay intact. Falls back to plain whole-line truncation
|
|
36074
|
+
* when the suffix decorations consume too much of the budget for the
|
|
36075
|
+
* path-aware variant to leave a meaningful filename.
|
|
36076
|
+
*
|
|
36077
|
+
* Used by the changed-files list AND the compose-context staged /
|
|
36078
|
+
* unstaged sections so all three places elide identically — same
|
|
36079
|
+
* floor (8 cells), same fallback shape.
|
|
36080
|
+
*/
|
|
36081
|
+
function smartPathLabel(prefix, path, suffix, totalBudget) {
|
|
36082
|
+
const pathBudget = totalBudget - cellWidth(prefix) - cellWidth(suffix);
|
|
36083
|
+
if (pathBudget >= 8) {
|
|
36084
|
+
return `${prefix}${truncatePathCells(path, pathBudget)}${suffix}`;
|
|
36085
|
+
}
|
|
36086
|
+
return truncateCells(`${prefix}${path}${suffix}`, totalBudget);
|
|
36087
|
+
}
|
|
35269
36088
|
/**
|
|
35270
36089
|
* Render a list of changed files with status-code colors and stats. Used
|
|
35271
36090
|
* by both the history inspector and the commit-diff detail panel so the
|
|
@@ -35293,13 +36112,21 @@ function renderCommitFileList(h, Text, files, selectedIndex, focused, maxRows, w
|
|
|
35293
36112
|
// in `lfsPointer.ts` so even rename / mode-only rows are
|
|
35294
36113
|
// flagged.
|
|
35295
36114
|
const lfsBadge = lfsStatus && isPathLfsTracked(lfsStatus, file.path) ? ' [LFS]' : '';
|
|
35296
|
-
|
|
36115
|
+
// Smart path truncation via `smartPathLabel`: keeps the cursor +
|
|
36116
|
+
// status-code prefix and the stats/badge suffix intact, gives
|
|
36117
|
+
// the path's remaining width budget to middle-elision so the
|
|
36118
|
+
// filename survives instead of getting blunt-truncated off the
|
|
36119
|
+
// end (the issue users hit when inspector paths read like
|
|
36120
|
+
// `src/commands/log/da...`).
|
|
36121
|
+
const labelPrefix = `${cursor} ${statusCode} `;
|
|
36122
|
+
const labelSuffix = `${renamed}${lfsBadge}${stats ? ` ${stats}` : ''}`;
|
|
36123
|
+
const label = smartPathLabel(labelPrefix, file.path, labelSuffix, width - 4);
|
|
35297
36124
|
return h(Text, {
|
|
35298
36125
|
key: `commit-file-${index}`,
|
|
35299
36126
|
color: statusCodeColor(file.status, theme),
|
|
35300
36127
|
inverse: isSelected && focused && !theme.noColor,
|
|
35301
36128
|
bold: isSelected,
|
|
35302
|
-
},
|
|
36129
|
+
}, label);
|
|
35303
36130
|
});
|
|
35304
36131
|
}
|
|
35305
36132
|
function renderPreviewPanel(h, components, title, lines, width, theme, focused) {
|
|
@@ -35575,7 +36402,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
35575
36402
|
...stagedFiles.map((file, index) => h(Text, {
|
|
35576
36403
|
key: `compose-context-staged-${index}`,
|
|
35577
36404
|
color: theme.noColor ? undefined : theme.colors.gitAdded,
|
|
35578
|
-
},
|
|
36405
|
+
}, smartPathLabel(` ${file.indexStatus} `, file.path, '', width - 4))),
|
|
35579
36406
|
h(Text, { key: 'compose-context-staged-spacer' }, ''),
|
|
35580
36407
|
]
|
|
35581
36408
|
: []), ...(unstagedFiles.length
|
|
@@ -35584,7 +36411,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
35584
36411
|
...unstagedFiles.map((file, index) => h(Text, {
|
|
35585
36412
|
key: `compose-context-unstaged-${index}`,
|
|
35586
36413
|
color: theme.noColor ? undefined : theme.colors.gitModified,
|
|
35587
|
-
},
|
|
36414
|
+
}, smartPathLabel(` ${file.worktreeStatus} `, file.path, '', width - 4))),
|
|
35588
36415
|
]
|
|
35589
36416
|
: !stagedFiles.length && !loadingWorktree
|
|
35590
36417
|
? [h(Text, { dimColor: true }, 'No worktree changes detected.')]
|
|
@@ -36251,6 +37078,14 @@ function LogInkApp(deps) {
|
|
|
36251
37078
|
// workdirs for submodule paths recorded in `.gitmodules` (which
|
|
36252
37079
|
// are repo-relative). Undefined during the brief moment between
|
|
36253
37080
|
// git swap and the revparse callback resolving.
|
|
37081
|
+
//
|
|
37082
|
+
// Audit finding #10: rapid frame push/pop races are prevented by
|
|
37083
|
+
// the per-effect `cancelled` flag — React fires the cleanup
|
|
37084
|
+
// synchronously BEFORE running the next effect body, so any
|
|
37085
|
+
// pending revparse from the old `git` sees `cancelled === true`
|
|
37086
|
+
// and skips its write. The `git` reference itself is captured by
|
|
37087
|
+
// closure, so each effect run resolves against the right binding.
|
|
37088
|
+
// No additional depth tagging is needed.
|
|
36254
37089
|
const [activeRepoRoot, setActiveRepoRoot] = React.useState(undefined);
|
|
36255
37090
|
React.useEffect(() => {
|
|
36256
37091
|
let cancelled = false;
|
|
@@ -36476,7 +37311,7 @@ function LogInkApp(deps) {
|
|
|
36476
37311
|
if (cancelled || !mountedRef.current)
|
|
36477
37312
|
return;
|
|
36478
37313
|
const message = error instanceof Error ? error.message : String(error);
|
|
36479
|
-
dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}
|
|
37314
|
+
dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}`, kind: 'error' });
|
|
36480
37315
|
dispatch({ type: 'setBootLoading', value: false });
|
|
36481
37316
|
});
|
|
36482
37317
|
return () => {
|
|
@@ -36544,8 +37379,15 @@ function LogInkApp(deps) {
|
|
|
36544
37379
|
...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
|
|
36545
37380
|
...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
|
|
36546
37381
|
};
|
|
37382
|
+
// Stash commits as graph roots so post-operation refreshes
|
|
37383
|
+
// keep the same rich graph the boot loader assembled. Without
|
|
37384
|
+
// this, every commit / split-apply / etc. would drop stash
|
|
37385
|
+
// anchors and the cursor-syncs-history effect would degrade
|
|
37386
|
+
// back to "tip not in loaded window" for older stashes.
|
|
37387
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
36547
37388
|
const fresh = await getLogRows(git, mergedArgv, {
|
|
36548
37389
|
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
37390
|
+
extraRefs: stashHashes,
|
|
36549
37391
|
});
|
|
36550
37392
|
if (mountedRef.current && fresh) {
|
|
36551
37393
|
dispatch({ type: 'replaceRows', rows: fresh });
|
|
@@ -36681,18 +37523,50 @@ function LogInkApp(deps) {
|
|
|
36681
37523
|
})();
|
|
36682
37524
|
return () => { cancelled = true; };
|
|
36683
37525
|
}, [git, dispatch]);
|
|
37526
|
+
// Audit finding #2: re-resolve the repo root inline on every save
|
|
37527
|
+
// and key the deps off `git` + the saved value. The original
|
|
37528
|
+
// implementation read from `repoRootRef.current`, which is async-
|
|
37529
|
+
// populated by the resolver effect above and can lag behind a git
|
|
37530
|
+
// swap. After #995's synchronous pop-restore, the parent's freshly
|
|
37531
|
+
// restored sidebar tab was being written into the submodule's
|
|
37532
|
+
// cache because the ref still held the submodule root during the
|
|
37533
|
+
// brief window before the resolver settled.
|
|
37534
|
+
//
|
|
37535
|
+
// The extra `revparse` cost per save is negligible (saves fire
|
|
37536
|
+
// once per user-initiated tab change, not per render) and the
|
|
37537
|
+
// cancellation flag prevents a stale resolution from racing a
|
|
37538
|
+
// newer one in flight.
|
|
36684
37539
|
React.useEffect(() => {
|
|
36685
|
-
|
|
36686
|
-
|
|
36687
|
-
|
|
36688
|
-
|
|
36689
|
-
|
|
37540
|
+
let cancelled = false;
|
|
37541
|
+
void (async () => {
|
|
37542
|
+
try {
|
|
37543
|
+
const root = (await git.revparse(['--show-toplevel'])).trim();
|
|
37544
|
+
if (cancelled || !root)
|
|
37545
|
+
return;
|
|
37546
|
+
saveSidebarTab(root, state.userSidebarTab);
|
|
37547
|
+
}
|
|
37548
|
+
catch {
|
|
37549
|
+
// Not in a worktree, or revparse failed — silently skip.
|
|
37550
|
+
// The next save attempt will retry.
|
|
37551
|
+
}
|
|
37552
|
+
})();
|
|
37553
|
+
return () => { cancelled = true; };
|
|
37554
|
+
}, [state.userSidebarTab, git]);
|
|
36690
37555
|
React.useEffect(() => {
|
|
36691
|
-
|
|
36692
|
-
|
|
36693
|
-
|
|
36694
|
-
|
|
36695
|
-
|
|
37556
|
+
let cancelled = false;
|
|
37557
|
+
void (async () => {
|
|
37558
|
+
try {
|
|
37559
|
+
const root = (await git.revparse(['--show-toplevel'])).trim();
|
|
37560
|
+
if (cancelled || !root)
|
|
37561
|
+
return;
|
|
37562
|
+
saveDiffViewMode(root, state.diffViewMode);
|
|
37563
|
+
}
|
|
37564
|
+
catch {
|
|
37565
|
+
// Same as above.
|
|
37566
|
+
}
|
|
37567
|
+
})();
|
|
37568
|
+
return () => { cancelled = true; };
|
|
37569
|
+
}, [state.diffViewMode, git]);
|
|
36696
37570
|
// P-stash-explorer: load `git stash show -p <ref>` once the diff view
|
|
36697
37571
|
// becomes active with diffSource='stash'. Best-effort — empty stashes
|
|
36698
37572
|
// or read errors fall through to a "no diff" hint at the render site.
|
|
@@ -37079,12 +37953,33 @@ function LogInkApp(deps) {
|
|
|
37079
37953
|
// fetched yet); a status hint surfaces in that case so the user
|
|
37080
37954
|
// knows to toggle full graph or load older commits.
|
|
37081
37955
|
const lastSyncedHashRef = React.useRef(undefined);
|
|
37956
|
+
// Tracks which target hashes we've already anchored a `git log`
|
|
37957
|
+
// fetch on (#1034 follow-up). When the cursor-syncs-history effect
|
|
37958
|
+
// sees a target whose hash isn't in the loaded window AND isn't in
|
|
37959
|
+
// this set, it kicks off `getLogRowsAnchoredOn` and adds the hash
|
|
37960
|
+
// here. After the fetch resolves and rows are appended, the effect
|
|
37961
|
+
// re-fires; if the target STILL isn't loaded the resolver sees the
|
|
37962
|
+
// hash in this set and returns `unreachable` instead of looping.
|
|
37963
|
+
//
|
|
37964
|
+
// Stored as a ref because (a) the resolver only ever reads it and
|
|
37965
|
+
// (b) component re-renders on state.filteredCommits change are the
|
|
37966
|
+
// re-fire trigger; storing here in state would add a redundant
|
|
37967
|
+
// render per attempt.
|
|
37968
|
+
const attemptedContextHashesRef = React.useRef(new Set());
|
|
37969
|
+
const loadCommitContextRef = React.useRef(null);
|
|
37082
37970
|
React.useEffect(() => {
|
|
37083
37971
|
const onBranchTab = state.activeView === 'branches' ||
|
|
37084
37972
|
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
37085
37973
|
const onTagTab = state.activeView === 'tags' ||
|
|
37086
37974
|
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
37087
|
-
|
|
37975
|
+
// User-reported gap: cursoring a stash didn't sync the history
|
|
37976
|
+
// cursor the way cursoring a branch / tag did. Same auto-jump
|
|
37977
|
+
// affordance now extends to stashes; the stash's commit hash IS
|
|
37978
|
+
// the row to land on (stashes are commits living off the
|
|
37979
|
+
// `refs/stash` tree, visible under `--all` / fullGraph).
|
|
37980
|
+
const onStashTab = state.activeView === 'stash' ||
|
|
37981
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'stashes');
|
|
37982
|
+
if (!onBranchTab && !onTagTab && !onStashTab)
|
|
37088
37983
|
return;
|
|
37089
37984
|
let targetHash;
|
|
37090
37985
|
let targetLabel;
|
|
@@ -37110,51 +38005,117 @@ function LogInkApp(deps) {
|
|
|
37110
38005
|
targetLabel = `tag ${tag.name}`;
|
|
37111
38006
|
}
|
|
37112
38007
|
}
|
|
37113
|
-
if (
|
|
37114
|
-
|
|
37115
|
-
|
|
37116
|
-
|
|
37117
|
-
|
|
37118
|
-
|
|
37119
|
-
|
|
37120
|
-
|
|
37121
|
-
|
|
37122
|
-
|
|
37123
|
-
|
|
37124
|
-
|
|
37125
|
-
|
|
37126
|
-
|
|
37127
|
-
|
|
37128
|
-
|
|
37129
|
-
|
|
37130
|
-
|
|
37131
|
-
|
|
37132
|
-
|
|
37133
|
-
|
|
38008
|
+
else if (onStashTab) {
|
|
38009
|
+
const all = context.stashes?.stashes || [];
|
|
38010
|
+
const visible = state.filter
|
|
38011
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
|
|
38012
|
+
: all;
|
|
38013
|
+
const stash = visible[Math.min(state.selectedStashIndex, Math.max(0, visible.length - 1))];
|
|
38014
|
+
if (stash) {
|
|
38015
|
+
// Two-step fallback chain for stash cursor sync:
|
|
38016
|
+
//
|
|
38017
|
+
// 1. Try `baseHash` (the branch tip the stash was created
|
|
38018
|
+
// from). This answers the user-visible question "where
|
|
38019
|
+
// in larger git history was this stash made?" — that's
|
|
38020
|
+
// the branch origin point, not the stash's own merge-
|
|
38021
|
+
// commit row off in `refs/stash`. Base commits live on
|
|
38022
|
+
// regular branches so they're almost always in the
|
|
38023
|
+
// loaded window.
|
|
38024
|
+
//
|
|
38025
|
+
// 2. If `baseHash` isn't in the loaded window (the stash's
|
|
38026
|
+
// base branch was deleted, or the base is older than
|
|
38027
|
+
// the 1000-commit cap), fall back to `stash.hash`
|
|
38028
|
+
// itself. The stash commit was added as an extraRef so
|
|
38029
|
+
// it's reachable from the graph if it fits the window.
|
|
38030
|
+
//
|
|
38031
|
+
// Only after BOTH miss does the effect report "tip not in
|
|
38032
|
+
// loaded window." The label flips to mention "base" vs the
|
|
38033
|
+
// stash commit so the user knows what they're looking at.
|
|
38034
|
+
// hashesMatchAny handles the short-hash auto-extension
|
|
38035
|
+
// mismatch between `git stash list --format=%h` (stash hash)
|
|
38036
|
+
// and `git log --pretty=format:%h` (history row). Same
|
|
38037
|
+
// hazard as the branch/tag cursor sync — see src/git/hashes.ts.
|
|
38038
|
+
const baseLoaded = Boolean(stash.baseHash) && state.filteredCommits.some((c) => hashesMatchAny(stash.baseHash, [c.hash, c.shortHash]));
|
|
38039
|
+
const hashLoaded = state.filteredCommits.some((c) => hashesMatchAny(stash.hash, [c.hash, c.shortHash]));
|
|
38040
|
+
if (baseLoaded) {
|
|
38041
|
+
targetHash = stash.baseHash;
|
|
38042
|
+
targetLabel = `${stash.ref}'s base`;
|
|
38043
|
+
}
|
|
38044
|
+
else if (hashLoaded) {
|
|
38045
|
+
targetHash = stash.hash;
|
|
38046
|
+
targetLabel = stash.ref;
|
|
38047
|
+
}
|
|
38048
|
+
else {
|
|
38049
|
+
// Neither in window — set to baseHash so the standard
|
|
38050
|
+
// "not in loaded window" message fires with a meaningful
|
|
38051
|
+
// label (the base is what the user actually wants to see).
|
|
38052
|
+
targetHash = stash.baseHash || stash.hash;
|
|
38053
|
+
targetLabel = stash.ref;
|
|
38054
|
+
}
|
|
38055
|
+
}
|
|
37134
38056
|
}
|
|
37135
|
-
|
|
37136
|
-
|
|
37137
|
-
|
|
37138
|
-
|
|
37139
|
-
}
|
|
38057
|
+
// Delegate the actual decision to the pure resolver so the
|
|
38058
|
+
// logic is testable in isolation. The effect just performs the
|
|
38059
|
+
// resolver's chosen action.
|
|
38060
|
+
const decision = resolveCursorSyncDecision({
|
|
38061
|
+
target: targetHash ? { hash: targetHash, label: targetLabel || targetHash } : undefined,
|
|
38062
|
+
loadedHashes: buildLoadedHashSet(state.filteredCommits),
|
|
38063
|
+
lastSyncedHash: lastSyncedHashRef.current,
|
|
38064
|
+
attemptedContextHashes: attemptedContextHashesRef.current,
|
|
38065
|
+
});
|
|
38066
|
+
switch (decision.type) {
|
|
38067
|
+
case 'noop':
|
|
38068
|
+
return;
|
|
38069
|
+
case 'jump':
|
|
38070
|
+
lastSyncedHashRef.current = decision.hash;
|
|
38071
|
+
dispatch({ type: 'selectCommitByHash', hash: decision.hash });
|
|
38072
|
+
dispatch({
|
|
38073
|
+
type: 'setStatus',
|
|
38074
|
+
value: `Synced history to ${decision.label} tip`,
|
|
38075
|
+
});
|
|
38076
|
+
return;
|
|
38077
|
+
case 'load-context':
|
|
38078
|
+
// Mark the hash as attempted BEFORE firing the load so a
|
|
38079
|
+
// re-fire of this effect (state.filteredCommits change while
|
|
38080
|
+
// the load is in flight) doesn't kick off a duplicate
|
|
38081
|
+
// request. The resolver sees the hash in the set and
|
|
38082
|
+
// returns `noop` until the load completes; on completion the
|
|
38083
|
+
// appendRows triggers a final re-fire that either jumps or
|
|
38084
|
+
// returns `unreachable`.
|
|
38085
|
+
attemptedContextHashesRef.current.add(decision.target.hash);
|
|
38086
|
+
void loadCommitContextRef.current?.(decision.target);
|
|
38087
|
+
return;
|
|
38088
|
+
case 'unreachable':
|
|
38089
|
+
dispatch({
|
|
38090
|
+
type: 'setStatus',
|
|
38091
|
+
value: `${decision.target.label} target commit is unreachable — not in any walked ref's history.`,
|
|
38092
|
+
kind: 'warning',
|
|
38093
|
+
});
|
|
38094
|
+
return;
|
|
37140
38095
|
}
|
|
37141
38096
|
}, [
|
|
37142
|
-
dispatch, context.branches, context.tags,
|
|
38097
|
+
dispatch, context.branches, context.tags, context.stashes,
|
|
37143
38098
|
state.activeView, state.focus, state.sidebarTab,
|
|
37144
|
-
state.selectedBranchIndex, state.selectedTagIndex,
|
|
38099
|
+
state.selectedBranchIndex, state.selectedTagIndex, state.selectedStashIndex,
|
|
37145
38100
|
state.branchSort, state.tagSort, state.filter,
|
|
37146
38101
|
state.filteredCommits,
|
|
37147
38102
|
]);
|
|
37148
38103
|
// Reset the dedup ref when the user moves focus away from the
|
|
37149
|
-
// sidebar branches / tags tab so re-entering re-fires the
|
|
37150
|
-
// even if the cursored
|
|
38104
|
+
// sidebar branches / tags / stashes tab so re-entering re-fires the
|
|
38105
|
+
// sync even if the cursored row is the same as before.
|
|
37151
38106
|
React.useEffect(() => {
|
|
37152
38107
|
const onBranchTab = state.activeView === 'branches' ||
|
|
37153
38108
|
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
37154
38109
|
const onTagTab = state.activeView === 'tags' ||
|
|
37155
38110
|
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
37156
|
-
|
|
38111
|
+
const onStashTab = state.activeView === 'stash' ||
|
|
38112
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'stashes');
|
|
38113
|
+
if (!onBranchTab && !onTagTab && !onStashTab) {
|
|
37157
38114
|
lastSyncedHashRef.current = undefined;
|
|
38115
|
+
// Drop any context-load attempt tracking too. If the user
|
|
38116
|
+
// navigates back later we want to retry rather than show
|
|
38117
|
+
// "unreachable" based on a stale attempted-set.
|
|
38118
|
+
attemptedContextHashesRef.current = new Set();
|
|
37158
38119
|
}
|
|
37159
38120
|
}, [state.activeView, state.focus, state.sidebarTab]);
|
|
37160
38121
|
React.useEffect(() => {
|
|
@@ -37185,7 +38146,7 @@ function LogInkApp(deps) {
|
|
|
37185
38146
|
]);
|
|
37186
38147
|
const toggleSelectedFileStage = React.useCallback(async () => {
|
|
37187
38148
|
if (!selectedWorktreeFile) {
|
|
37188
|
-
dispatch({ type: 'setStatus', value: 'no worktree file selected' });
|
|
38149
|
+
dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
|
|
37189
38150
|
return;
|
|
37190
38151
|
}
|
|
37191
38152
|
dispatch({ type: 'setStatus', value: 'updating file stage state' });
|
|
@@ -37200,7 +38161,7 @@ function LogInkApp(deps) {
|
|
|
37200
38161
|
const toggleSelectedHunkStage = React.useCallback(async () => {
|
|
37201
38162
|
const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
|
|
37202
38163
|
if (!selectedHunk) {
|
|
37203
|
-
dispatch({ type: 'setStatus', value: 'no hunk selected' });
|
|
38164
|
+
dispatch({ type: 'setStatus', value: 'no hunk selected', kind: 'warning' });
|
|
37204
38165
|
return;
|
|
37205
38166
|
}
|
|
37206
38167
|
dispatch({ type: 'setStatus', value: 'updating hunk stage state' });
|
|
@@ -37214,6 +38175,7 @@ function LogInkApp(deps) {
|
|
|
37214
38175
|
dispatch({
|
|
37215
38176
|
type: 'setStatus',
|
|
37216
38177
|
value: `${selectedHunk.state === 'staged' ? 'Unstaged' : 'Staged'} hunk`,
|
|
38178
|
+
kind: 'success',
|
|
37217
38179
|
});
|
|
37218
38180
|
await refreshWorktreeContext();
|
|
37219
38181
|
setWorktreeDiff(undefined);
|
|
@@ -37223,12 +38185,13 @@ function LogInkApp(deps) {
|
|
|
37223
38185
|
dispatch({
|
|
37224
38186
|
type: 'setStatus',
|
|
37225
38187
|
value: error.message || 'failed to update hunk stage state',
|
|
38188
|
+
kind: 'error',
|
|
37226
38189
|
});
|
|
37227
38190
|
}
|
|
37228
38191
|
}, [dispatch, git, refreshWorktreeContext, state.selectedWorktreeHunkIndex, worktreeHunks]);
|
|
37229
38192
|
const revertSelectedFile = React.useCallback(async () => {
|
|
37230
38193
|
if (!selectedWorktreeFile) {
|
|
37231
|
-
dispatch({ type: 'setStatus', value: 'no worktree file selected' });
|
|
38194
|
+
dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
|
|
37232
38195
|
return;
|
|
37233
38196
|
}
|
|
37234
38197
|
dispatch({ type: 'setStatus', value: 'reverting selected file' });
|
|
@@ -37241,13 +38204,13 @@ function LogInkApp(deps) {
|
|
|
37241
38204
|
const revertSelectedHunk = React.useCallback(async () => {
|
|
37242
38205
|
const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
|
|
37243
38206
|
if (!selectedHunk) {
|
|
37244
|
-
dispatch({ type: 'setStatus', value: 'no hunk selected' });
|
|
38207
|
+
dispatch({ type: 'setStatus', value: 'no hunk selected', kind: 'warning' });
|
|
37245
38208
|
return;
|
|
37246
38209
|
}
|
|
37247
38210
|
dispatch({ type: 'setStatus', value: 'reverting selected hunk' });
|
|
37248
38211
|
try {
|
|
37249
38212
|
await revertHunk(git, selectedHunk);
|
|
37250
|
-
dispatch({ type: 'setStatus', value: `Reverted hunk in ${selectedHunk.filePath}
|
|
38213
|
+
dispatch({ type: 'setStatus', value: `Reverted hunk in ${selectedHunk.filePath}`, kind: 'success' });
|
|
37251
38214
|
await refreshWorktreeContext();
|
|
37252
38215
|
setWorktreeDiff(undefined);
|
|
37253
38216
|
setWorktreeHunks(undefined);
|
|
@@ -37256,13 +38219,14 @@ function LogInkApp(deps) {
|
|
|
37256
38219
|
dispatch({
|
|
37257
38220
|
type: 'setStatus',
|
|
37258
38221
|
value: error.message || 'failed to revert hunk',
|
|
38222
|
+
kind: 'error',
|
|
37259
38223
|
});
|
|
37260
38224
|
}
|
|
37261
38225
|
}, [dispatch, git, refreshWorktreeContext, state.selectedWorktreeHunkIndex, worktreeHunks]);
|
|
37262
38226
|
const createCommitFromCompose = React.useCallback(async () => {
|
|
37263
38227
|
const stagedCount = context.worktree?.stagedCount || 0;
|
|
37264
38228
|
if (!stagedCount) {
|
|
37265
|
-
dispatch({ type: 'setStatus', value: 'stage changes before committing' });
|
|
38229
|
+
dispatch({ type: 'setStatus', value: 'stage changes before committing', kind: 'warning' });
|
|
37266
38230
|
return;
|
|
37267
38231
|
}
|
|
37268
38232
|
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: true } });
|
|
@@ -37326,6 +38290,12 @@ function LogInkApp(deps) {
|
|
|
37326
38290
|
git,
|
|
37327
38291
|
signal: controller.signal,
|
|
37328
38292
|
onStreamChunk: (_text, accumulated) => {
|
|
38293
|
+
// Audit finding #4: skip dispatching into a torn-down
|
|
38294
|
+
// tree. If the user quit (or otherwise unmounted the
|
|
38295
|
+
// workstation) mid-stream, React warns about updates on
|
|
38296
|
+
// an unmounted component. Drop the chunk silently.
|
|
38297
|
+
if (!mountedRef.current)
|
|
38298
|
+
return;
|
|
37329
38299
|
// Dispatch the full accumulated text — the preview chrome
|
|
37330
38300
|
// helper does the last-N-lines slicing at render time, so
|
|
37331
38301
|
// re-doing the slice here would be wasted work. Per-chunk
|
|
@@ -37337,18 +38307,23 @@ function LogInkApp(deps) {
|
|
|
37337
38307
|
});
|
|
37338
38308
|
},
|
|
37339
38309
|
});
|
|
38310
|
+
// Audit finding #4 (unmount race): bail out before any
|
|
38311
|
+
// post-await dispatch if the user quit while the LLM call was
|
|
38312
|
+
// in flight. Same pattern as `refreshHistoryRows` upstream.
|
|
38313
|
+
if (!mountedRef.current)
|
|
38314
|
+
return;
|
|
37340
38315
|
// Cancel path (#881 phase 3). User pressed Esc during the
|
|
37341
38316
|
// stream; reducer drops loading + preview, status line shows
|
|
37342
38317
|
// a neutral "cancelled" message. Skip the result / failure
|
|
37343
38318
|
// dispatches because the user already knows what happened.
|
|
37344
38319
|
if (result.cancelled) {
|
|
37345
38320
|
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
|
|
37346
|
-
dispatch({ type: 'setStatus', value: 'AI draft cancelled.' });
|
|
38321
|
+
dispatch({ type: 'setStatus', value: 'AI draft cancelled.', kind: 'info' });
|
|
37347
38322
|
return;
|
|
37348
38323
|
}
|
|
37349
38324
|
if (result.ok && result.draft) {
|
|
37350
38325
|
dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: result.draft } });
|
|
37351
|
-
dispatch({ type: 'setStatus', value: 'AI draft ready for editing' });
|
|
38326
|
+
dispatch({ type: 'setStatus', value: 'AI draft ready for editing', kind: 'success' });
|
|
37352
38327
|
return;
|
|
37353
38328
|
}
|
|
37354
38329
|
dispatch({
|
|
@@ -37357,6 +38332,23 @@ function LogInkApp(deps) {
|
|
|
37357
38332
|
});
|
|
37358
38333
|
dispatch({ type: 'setStatus', value: result.message });
|
|
37359
38334
|
}
|
|
38335
|
+
catch (error) {
|
|
38336
|
+
// Audit finding #3: defensive recovery for unexpected throws
|
|
38337
|
+
// from the workflow. The workflow catches its own errors
|
|
38338
|
+
// today, so this catch is latent — but any future refactor
|
|
38339
|
+
// that lets an error escape would otherwise strand the
|
|
38340
|
+
// spinner permanently with no user-facing recovery short of
|
|
38341
|
+
// quitting. Surface a generic failure and clear the loading
|
|
38342
|
+
// state so the user can re-try.
|
|
38343
|
+
if (mountedRef.current) {
|
|
38344
|
+
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
|
|
38345
|
+
dispatch({
|
|
38346
|
+
type: 'setStatus',
|
|
38347
|
+
value: `AI draft failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
|
|
38348
|
+
kind: 'error',
|
|
38349
|
+
});
|
|
38350
|
+
}
|
|
38351
|
+
}
|
|
37360
38352
|
finally {
|
|
37361
38353
|
// Clear the ref only if it still points at OUR controller — a
|
|
37362
38354
|
// rapid second invocation could have already replaced it, in
|
|
@@ -37415,7 +38407,7 @@ function LogInkApp(deps) {
|
|
|
37415
38407
|
const startCreatePullRequest = React.useCallback(async () => {
|
|
37416
38408
|
const head = context.branches?.currentBranch || context.provider?.currentBranch;
|
|
37417
38409
|
if (!head) {
|
|
37418
|
-
dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.' });
|
|
38410
|
+
dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.', kind: 'warning' });
|
|
37419
38411
|
return;
|
|
37420
38412
|
}
|
|
37421
38413
|
const defaultBranch = context.provider?.repository.defaultBranch;
|
|
@@ -37423,11 +38415,12 @@ function LogInkApp(deps) {
|
|
|
37423
38415
|
dispatch({
|
|
37424
38416
|
type: 'setStatus',
|
|
37425
38417
|
value: 'No default branch detected. Set origin/HEAD or ensure main/master exists locally.',
|
|
38418
|
+
kind: 'warning',
|
|
37426
38419
|
});
|
|
37427
38420
|
return;
|
|
37428
38421
|
}
|
|
37429
38422
|
if (head === defaultBranch) {
|
|
37430
|
-
dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first
|
|
38423
|
+
dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first.`, kind: 'warning' });
|
|
37431
38424
|
return;
|
|
37432
38425
|
}
|
|
37433
38426
|
if (context.pullRequest?.currentPullRequest || context.provider?.currentPullRequest) {
|
|
@@ -37437,6 +38430,7 @@ function LogInkApp(deps) {
|
|
|
37437
38430
|
value: existing
|
|
37438
38431
|
? `PR #${existing.number} already open for ${head}. Use the PR view to manage it.`
|
|
37439
38432
|
: `A pull request is already open for ${head}.`,
|
|
38433
|
+
kind: 'warning',
|
|
37440
38434
|
});
|
|
37441
38435
|
return;
|
|
37442
38436
|
}
|
|
@@ -37448,9 +38442,14 @@ function LogInkApp(deps) {
|
|
|
37448
38442
|
const cancelHandle = { cancelled: false };
|
|
37449
38443
|
pullRequestBodyCancelRef.current = cancelHandle;
|
|
37450
38444
|
dispatch({ type: 'setPendingPullRequestBodyDraft', value: true });
|
|
38445
|
+
// Audit finding #6: soft cancel today — Esc skips opening the
|
|
38446
|
+
// follow-up prompt, but the LLM call itself keeps running to
|
|
38447
|
+
// completion (no AbortSignal threaded through the changelog CLI
|
|
38448
|
+
// chain). Status copy reflects that honestly so the user isn't
|
|
38449
|
+
// misled into thinking they're saving tokens.
|
|
37451
38450
|
dispatch({
|
|
37452
38451
|
type: 'setStatus',
|
|
37453
|
-
value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to
|
|
38452
|
+
value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to skip prompt`,
|
|
37454
38453
|
loading: true,
|
|
37455
38454
|
});
|
|
37456
38455
|
try {
|
|
@@ -37472,11 +38471,20 @@ function LogInkApp(deps) {
|
|
|
37472
38471
|
const initialBody = body.body || '';
|
|
37473
38472
|
const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
|
|
37474
38473
|
if (!body.ok) {
|
|
37475
|
-
dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually
|
|
38474
|
+
dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.`, kind: 'error' });
|
|
37476
38475
|
}
|
|
37477
38476
|
else {
|
|
37478
|
-
dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
|
|
37479
|
-
}
|
|
38477
|
+
dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.', kind: 'success' });
|
|
38478
|
+
}
|
|
38479
|
+
// Audit finding #11: clear the pending flag BEFORE opening the
|
|
38480
|
+
// prompt. If a future refactor adds an `await` between the flag
|
|
38481
|
+
// clear (currently in `finally`) and the `openInputPrompt`
|
|
38482
|
+
// dispatch, an Esc keystroke in the gap would dispatch
|
|
38483
|
+
// `cancelPullRequestBodyDraft` AFTER the prompt opens, leaving
|
|
38484
|
+
// the prompt visible with a stale "cancelled" message. Clearing
|
|
38485
|
+
// here moves the flag teardown into the same React batch as the
|
|
38486
|
+
// prompt open, eliminating the race.
|
|
38487
|
+
dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
|
|
37480
38488
|
dispatch({
|
|
37481
38489
|
type: 'openInputPrompt',
|
|
37482
38490
|
kind: 'create-pr',
|
|
@@ -37486,11 +38494,14 @@ function LogInkApp(deps) {
|
|
|
37486
38494
|
});
|
|
37487
38495
|
}
|
|
37488
38496
|
finally {
|
|
37489
|
-
//
|
|
38497
|
+
// Belt-and-suspenders: the `try` block clears the flag on the
|
|
38498
|
+
// success path (audit finding #11). This duplicate clear handles
|
|
38499
|
+
// the error / cancel paths where the early-returns skip the
|
|
38500
|
+
// success-path dispatch. Safe to no-op when already false.
|
|
38501
|
+
dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
|
|
37490
38502
|
// Only clear the ref if we still own it — a second invocation
|
|
37491
38503
|
// would have already taken ownership in which case the cancel
|
|
37492
38504
|
// duty has rolled over.
|
|
37493
|
-
dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
|
|
37494
38505
|
if (pullRequestBodyCancelRef.current === cancelHandle) {
|
|
37495
38506
|
pullRequestBodyCancelRef.current = null;
|
|
37496
38507
|
}
|
|
@@ -37530,17 +38541,18 @@ function LogInkApp(deps) {
|
|
|
37530
38541
|
const yankText = React.useCallback(async (value, label) => {
|
|
37531
38542
|
const clipboard = clipboardRunner || defaultClipboardRunner;
|
|
37532
38543
|
if (!value) {
|
|
37533
|
-
dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty
|
|
38544
|
+
dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty.`, kind: 'warning' });
|
|
37534
38545
|
return;
|
|
37535
38546
|
}
|
|
37536
38547
|
try {
|
|
37537
38548
|
await clipboard(value);
|
|
37538
|
-
dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard
|
|
38549
|
+
dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard.`, kind: 'success' });
|
|
37539
38550
|
}
|
|
37540
38551
|
catch (error) {
|
|
37541
38552
|
dispatch({
|
|
37542
38553
|
type: 'setStatus',
|
|
37543
38554
|
value: `Copy failed (${label}): ${error.message}`,
|
|
38555
|
+
kind: 'error',
|
|
37544
38556
|
});
|
|
37545
38557
|
}
|
|
37546
38558
|
}, [clipboardRunner, dispatch]);
|
|
@@ -37564,7 +38576,7 @@ function LogInkApp(deps) {
|
|
|
37564
38576
|
const startChangelogView = React.useCallback(async (options = {}) => {
|
|
37565
38577
|
const head = context.branches?.currentBranch || context.provider?.currentBranch;
|
|
37566
38578
|
if (!head) {
|
|
37567
|
-
dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.' });
|
|
38579
|
+
dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.', kind: 'warning' });
|
|
37568
38580
|
return;
|
|
37569
38581
|
}
|
|
37570
38582
|
const defaultBranch = context.provider?.repository.defaultBranch;
|
|
@@ -37591,6 +38603,11 @@ function LogInkApp(deps) {
|
|
|
37591
38603
|
branch: head,
|
|
37592
38604
|
baseLabel: cached.baseLabel,
|
|
37593
38605
|
text: cached.text,
|
|
38606
|
+
// Audit finding #9: cache-hit path preserves the original
|
|
38607
|
+
// generation timestamp rather than minting a fresh one — the
|
|
38608
|
+
// "X ago" header should reflect when the LLM ran, not when
|
|
38609
|
+
// the cached entry was re-displayed.
|
|
38610
|
+
generatedAt: cached.generatedAt,
|
|
37594
38611
|
});
|
|
37595
38612
|
dispatch({
|
|
37596
38613
|
type: 'setStatus',
|
|
@@ -37611,7 +38628,7 @@ function LogInkApp(deps) {
|
|
|
37611
38628
|
baseLabel,
|
|
37612
38629
|
error: result.message,
|
|
37613
38630
|
});
|
|
37614
|
-
dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}
|
|
38631
|
+
dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}`, kind: 'error' });
|
|
37615
38632
|
return;
|
|
37616
38633
|
}
|
|
37617
38634
|
dispatch({
|
|
@@ -37619,10 +38636,14 @@ function LogInkApp(deps) {
|
|
|
37619
38636
|
branch: head,
|
|
37620
38637
|
baseLabel,
|
|
37621
38638
|
text: result.text,
|
|
38639
|
+
// Audit finding #9: timestamp captured at dispatch time, not
|
|
38640
|
+
// inside the reducer.
|
|
38641
|
+
generatedAt: Date.now(),
|
|
37622
38642
|
});
|
|
37623
38643
|
dispatch({
|
|
37624
38644
|
type: 'setStatus',
|
|
37625
38645
|
value: 'Changelog ready — y yank · E $EDITOR · c PR · r regen · < back.',
|
|
38646
|
+
kind: 'success',
|
|
37626
38647
|
});
|
|
37627
38648
|
}, [
|
|
37628
38649
|
context.branches?.currentBranch,
|
|
@@ -37643,7 +38664,7 @@ function LogInkApp(deps) {
|
|
|
37643
38664
|
const yankChangelog = React.useCallback(() => {
|
|
37644
38665
|
const text = state.changelogView.text;
|
|
37645
38666
|
if (!text) {
|
|
37646
|
-
dispatch({ type: 'setStatus', value: 'No changelog text to copy.' });
|
|
38667
|
+
dispatch({ type: 'setStatus', value: 'No changelog text to copy.', kind: 'warning' });
|
|
37647
38668
|
return;
|
|
37648
38669
|
}
|
|
37649
38670
|
void yankText(text, 'changelog');
|
|
@@ -37656,7 +38677,7 @@ function LogInkApp(deps) {
|
|
|
37656
38677
|
const openChangelogInEditor = React.useCallback(() => {
|
|
37657
38678
|
const current = state.changelogView.text;
|
|
37658
38679
|
if (current === undefined) {
|
|
37659
|
-
dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.' });
|
|
38680
|
+
dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.', kind: 'warning' });
|
|
37660
38681
|
return;
|
|
37661
38682
|
}
|
|
37662
38683
|
let dir;
|
|
@@ -37667,6 +38688,7 @@ function LogInkApp(deps) {
|
|
|
37667
38688
|
dispatch({
|
|
37668
38689
|
type: 'setStatus',
|
|
37669
38690
|
value: `Failed to create temp file for editor: ${error.message}`,
|
|
38691
|
+
kind: 'error',
|
|
37670
38692
|
});
|
|
37671
38693
|
return;
|
|
37672
38694
|
}
|
|
@@ -37678,6 +38700,7 @@ function LogInkApp(deps) {
|
|
|
37678
38700
|
dispatch({
|
|
37679
38701
|
type: 'setStatus',
|
|
37680
38702
|
value: `Failed to seed temp file: ${error.message}`,
|
|
38703
|
+
kind: 'error',
|
|
37681
38704
|
});
|
|
37682
38705
|
try {
|
|
37683
38706
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -37701,13 +38724,13 @@ function LogInkApp(deps) {
|
|
|
37701
38724
|
out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
|
|
37702
38725
|
const result = spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
|
|
37703
38726
|
if (result.error) {
|
|
37704
|
-
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}
|
|
38727
|
+
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
|
|
37705
38728
|
}
|
|
37706
38729
|
else if (result.signal) {
|
|
37707
|
-
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}
|
|
38730
|
+
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
|
|
37708
38731
|
}
|
|
37709
38732
|
else if (typeof result.status === 'number' && result.status !== 0) {
|
|
37710
|
-
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}
|
|
38733
|
+
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
|
|
37711
38734
|
}
|
|
37712
38735
|
else {
|
|
37713
38736
|
editorOk = true;
|
|
@@ -37721,13 +38744,14 @@ function LogInkApp(deps) {
|
|
|
37721
38744
|
if (editorOk) {
|
|
37722
38745
|
try {
|
|
37723
38746
|
const content = readFileSync$1(file, 'utf8');
|
|
37724
|
-
dispatch({ type: 'setChangelogText', text: content });
|
|
37725
|
-
dispatch({ type: 'setStatus', value: 'Changelog updated from editor.' });
|
|
38747
|
+
dispatch({ type: 'setChangelogText', text: content, generatedAt: Date.now() });
|
|
38748
|
+
dispatch({ type: 'setStatus', value: 'Changelog updated from editor.', kind: 'success' });
|
|
37726
38749
|
}
|
|
37727
38750
|
catch (error) {
|
|
37728
38751
|
dispatch({
|
|
37729
38752
|
type: 'setStatus',
|
|
37730
38753
|
value: `Failed to read back edited changelog: ${error.message}`,
|
|
38754
|
+
kind: 'error',
|
|
37731
38755
|
});
|
|
37732
38756
|
}
|
|
37733
38757
|
}
|
|
@@ -37768,19 +38792,19 @@ function LogInkApp(deps) {
|
|
|
37768
38792
|
out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
|
|
37769
38793
|
const result = spawnSync(editor, [...editorPrefixArgs, path], { stdio: 'inherit' });
|
|
37770
38794
|
if (result.error) {
|
|
37771
|
-
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}
|
|
38795
|
+
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
|
|
37772
38796
|
}
|
|
37773
38797
|
else if (result.signal) {
|
|
37774
38798
|
// Editor was killed by a signal (e.g. ^C, SIGTERM). status is
|
|
37775
38799
|
// null in this case, so the old `status !== 0` check would
|
|
37776
38800
|
// mistakenly fall through to the success branch.
|
|
37777
|
-
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}
|
|
38801
|
+
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
|
|
37778
38802
|
}
|
|
37779
38803
|
else if (typeof result.status === 'number' && result.status !== 0) {
|
|
37780
|
-
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}
|
|
38804
|
+
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
|
|
37781
38805
|
}
|
|
37782
38806
|
else {
|
|
37783
|
-
dispatch({ type: 'setStatus', value: `Edited ${path}
|
|
38807
|
+
dispatch({ type: 'setStatus', value: `Edited ${path}`, kind: 'success' });
|
|
37784
38808
|
}
|
|
37785
38809
|
}
|
|
37786
38810
|
finally {
|
|
@@ -37827,6 +38851,7 @@ function LogInkApp(deps) {
|
|
|
37827
38851
|
dispatch({
|
|
37828
38852
|
type: 'setStatus',
|
|
37829
38853
|
value: `Failed to create temp file for editor: ${error.message}`,
|
|
38854
|
+
kind: 'error',
|
|
37830
38855
|
});
|
|
37831
38856
|
return;
|
|
37832
38857
|
}
|
|
@@ -37838,6 +38863,7 @@ function LogInkApp(deps) {
|
|
|
37838
38863
|
dispatch({
|
|
37839
38864
|
type: 'setStatus',
|
|
37840
38865
|
value: `Failed to seed temp file: ${error.message}`,
|
|
38866
|
+
kind: 'error',
|
|
37841
38867
|
});
|
|
37842
38868
|
try {
|
|
37843
38869
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -37861,13 +38887,13 @@ function LogInkApp(deps) {
|
|
|
37861
38887
|
out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
|
|
37862
38888
|
const result = spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
|
|
37863
38889
|
if (result.error) {
|
|
37864
|
-
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}
|
|
38890
|
+
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
|
|
37865
38891
|
}
|
|
37866
38892
|
else if (result.signal) {
|
|
37867
|
-
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}
|
|
38893
|
+
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
|
|
37868
38894
|
}
|
|
37869
38895
|
else if (typeof result.status === 'number' && result.status !== 0) {
|
|
37870
|
-
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}
|
|
38896
|
+
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
|
|
37871
38897
|
}
|
|
37872
38898
|
else {
|
|
37873
38899
|
editorOk = true;
|
|
@@ -37886,12 +38912,13 @@ function LogInkApp(deps) {
|
|
|
37886
38912
|
try {
|
|
37887
38913
|
const content = readFileSync$1(file, 'utf8');
|
|
37888
38914
|
dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: content } });
|
|
37889
|
-
dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.' });
|
|
38915
|
+
dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.', kind: 'success' });
|
|
37890
38916
|
}
|
|
37891
38917
|
catch (error) {
|
|
37892
38918
|
dispatch({
|
|
37893
38919
|
type: 'setStatus',
|
|
37894
38920
|
value: `Failed to read back edited draft: ${error.message}`,
|
|
38921
|
+
kind: 'error',
|
|
37895
38922
|
});
|
|
37896
38923
|
}
|
|
37897
38924
|
}
|
|
@@ -37967,7 +38994,7 @@ function LogInkApp(deps) {
|
|
|
37967
38994
|
const applyCommitSplit = React.useCallback(async () => {
|
|
37968
38995
|
const splitPlan = state.splitPlan;
|
|
37969
38996
|
if (!splitPlan?.plan || !splitPlan.planContext) {
|
|
37970
|
-
dispatch({ type: 'setStatus', value: 'No split plan loaded yet — wait for generation.' });
|
|
38997
|
+
dispatch({ type: 'setStatus', value: 'No split plan loaded yet — wait for generation.', kind: 'warning' });
|
|
37971
38998
|
return;
|
|
37972
38999
|
}
|
|
37973
39000
|
// Diagnostic dump for the silent-failure bug surfaced in #944
|
|
@@ -38076,7 +39103,8 @@ function LogInkApp(deps) {
|
|
|
38076
39103
|
// that could disagree with reality on partial-apply.
|
|
38077
39104
|
const commitHashes = result.commitHashes || [];
|
|
38078
39105
|
if (commitHashes.length > 0) {
|
|
38079
|
-
|
|
39106
|
+
// Audit finding #9: timestamp captured at dispatch time.
|
|
39107
|
+
dispatch({ type: 'markRecentCommits', hashes: commitHashes, markedAt: Date.now() });
|
|
38080
39108
|
// DevSkim: ignore DS172411 — function literal, fixed delay,
|
|
38081
39109
|
// no caller-supplied data flowing through.
|
|
38082
39110
|
setTimeout(() => dispatch({ type: 'clearRecentCommits' }), 5000);
|
|
@@ -38937,7 +39965,7 @@ function LogInkApp(deps) {
|
|
|
38937
39965
|
};
|
|
38938
39966
|
const handler = handlers[id];
|
|
38939
39967
|
if (!handler) {
|
|
38940
|
-
dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired
|
|
39968
|
+
dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired`, kind: 'warning' });
|
|
38941
39969
|
return;
|
|
38942
39970
|
}
|
|
38943
39971
|
const result = await handler();
|
|
@@ -38982,7 +40010,37 @@ function LogInkApp(deps) {
|
|
|
38982
40010
|
// without flickering the surfaces through a 'loading' phase.
|
|
38983
40011
|
await refreshContext({ silent: true });
|
|
38984
40012
|
}
|
|
38985
|
-
|
|
40013
|
+
// Stash workflow follow-up. Two distinct behaviours.
|
|
40014
|
+
//
|
|
40015
|
+
// **apply / pop**: the user brought stashed content back into the
|
|
40016
|
+
// worktree, but the sidebar still has them on the stash view.
|
|
40017
|
+
// Expected next move is "look at what landed in my worktree", so
|
|
40018
|
+
// jump them to history view (where the worktree counts in the
|
|
40019
|
+
// sidebar are visible) AND refresh worktree context explicitly so
|
|
40020
|
+
// the staged / unstaged / untracked numbers reflect the changes.
|
|
40021
|
+
//
|
|
40022
|
+
// **drop**: the silent context refresh above already re-fetched
|
|
40023
|
+
// the stash list, BUT users reported it feeling like nothing
|
|
40024
|
+
// happened. Fix two things: refresh worktree alongside (drops can
|
|
40025
|
+
// affect untracked files when the stash held `-u` state), and
|
|
40026
|
+
// surface the new stash count on the status line so there's
|
|
40027
|
+
// unambiguous feedback that the drop landed and the list shrank.
|
|
40028
|
+
if (result?.ok && (id === 'apply-stash' || id === 'pop-stash')) {
|
|
40029
|
+
dispatch({ type: 'pushView', value: 'history' });
|
|
40030
|
+
await refreshWorktreeContext();
|
|
40031
|
+
}
|
|
40032
|
+
if (result?.ok && id === 'drop-stash') {
|
|
40033
|
+
// Explicit worktree refresh in case the dropped stash carried
|
|
40034
|
+
// untracked-file state that's now collected.
|
|
40035
|
+
await refreshWorktreeContext();
|
|
40036
|
+
// The silent context refresh already replaced `context.stashes`;
|
|
40037
|
+
// reading the count back here would be stale because closures
|
|
40038
|
+
// capture the pre-refresh value. Status message stays generic
|
|
40039
|
+
// ("Dropped stash@{N}") — the visible list shrinking is the
|
|
40040
|
+
// unambiguous signal that the operation landed.
|
|
40041
|
+
}
|
|
40042
|
+
}, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
|
|
40043
|
+
state.branchSort, state.filter, state.selectedBranchIndex,
|
|
38986
40044
|
state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
|
|
38987
40045
|
state.statusFilterMask, state.tagSort]);
|
|
38988
40046
|
// Resolve the active view's "yank target" (commit hash / branch /
|
|
@@ -39144,15 +40202,15 @@ function LogInkApp(deps) {
|
|
|
39144
40202
|
}
|
|
39145
40203
|
}
|
|
39146
40204
|
if (!value || !label) {
|
|
39147
|
-
dispatch({ type: 'setStatus', value: 'Nothing to yank in this view' });
|
|
40205
|
+
dispatch({ type: 'setStatus', value: 'Nothing to yank in this view', kind: 'warning' });
|
|
39148
40206
|
return;
|
|
39149
40207
|
}
|
|
39150
40208
|
try {
|
|
39151
40209
|
await clipboard(value);
|
|
39152
|
-
dispatch({ type: 'setStatus', value: `Copied ${label}
|
|
40210
|
+
dispatch({ type: 'setStatus', value: `Copied ${label}`, kind: 'success' });
|
|
39153
40211
|
}
|
|
39154
40212
|
catch (error) {
|
|
39155
|
-
dispatch({ type: 'setStatus', value: `Copy failed: ${error.message}
|
|
40213
|
+
dispatch({ type: 'setStatus', value: `Copy failed: ${error.message}`, kind: 'error' });
|
|
39156
40214
|
}
|
|
39157
40215
|
}, [
|
|
39158
40216
|
clipboardRunner,
|
|
@@ -39212,63 +40270,175 @@ function LogInkApp(deps) {
|
|
|
39212
40270
|
React.useEffect(() => {
|
|
39213
40271
|
loadingMoreCommitsRef.current = loadingMoreCommits;
|
|
39214
40272
|
}, [loadingMoreCommits]);
|
|
40273
|
+
// STABLE useCallback (empty deps) for loadMoreCommits. The function
|
|
40274
|
+
// reads the volatile state (commit counts, fetch args, hasMore) via
|
|
40275
|
+
// refs that update on every render so the identity stays constant.
|
|
40276
|
+
//
|
|
40277
|
+
// Why stable matters: the cursor-syncs-history auto-load chain
|
|
40278
|
+
// calls this through a forward-reference ref (loadMoreCommitsRef).
|
|
40279
|
+
// If loadMoreCommits regenerated on every render — as the previous
|
|
40280
|
+
// implementation did via state deps — there was a render-order
|
|
40281
|
+
// race: the cursor sync effect would call the PREVIOUS render's
|
|
40282
|
+
// callback (still in the ref because the ref-setter useEffect runs
|
|
40283
|
+
// after the cursor-sync effect in declaration order), which had
|
|
40284
|
+
// captured a stale `state.commits.length` and re-fetched the same
|
|
40285
|
+
// window. The auto-load chain appeared to fire but never advanced
|
|
40286
|
+
// through history.
|
|
40287
|
+
//
|
|
40288
|
+
// Stable identity + refs sidesteps the race entirely: the function
|
|
40289
|
+
// never changes, and every call reads the latest state.
|
|
40290
|
+
const loadMoreStateRef = React.useRef({
|
|
40291
|
+
commitsLength: state.commits.length,
|
|
40292
|
+
filteredCommitsLength: state.filteredCommits.length,
|
|
40293
|
+
historyFetchArgs: state.historyFetchArgs,
|
|
40294
|
+
hasMoreCommits,
|
|
40295
|
+
logArgv,
|
|
40296
|
+
});
|
|
40297
|
+
loadMoreStateRef.current = {
|
|
40298
|
+
commitsLength: state.commits.length,
|
|
40299
|
+
filteredCommitsLength: state.filteredCommits.length,
|
|
40300
|
+
historyFetchArgs: state.historyFetchArgs,
|
|
40301
|
+
hasMoreCommits,
|
|
40302
|
+
logArgv,
|
|
40303
|
+
};
|
|
40304
|
+
const loadMoreCommits = React.useCallback(async (options = {}) => {
|
|
40305
|
+
const snap = loadMoreStateRef.current;
|
|
40306
|
+
if (!snap.logArgv || snap.logArgv.limit || loadingMoreCommitsRef.current || !snap.hasMoreCommits) {
|
|
40307
|
+
return { fired: false, addedCommits: 0 };
|
|
40308
|
+
}
|
|
40309
|
+
if (snap.filteredCommitsLength === 0) {
|
|
40310
|
+
return { fired: false, addedCommits: 0 };
|
|
40311
|
+
}
|
|
40312
|
+
loadingMoreCommitsRef.current = true;
|
|
40313
|
+
const requestId = loadMoreRequestRef.current + 1;
|
|
40314
|
+
loadMoreRequestRef.current = requestId;
|
|
40315
|
+
setLoadingMoreCommits(true);
|
|
40316
|
+
dispatch({
|
|
40317
|
+
type: 'setStatus',
|
|
40318
|
+
value: options.statusMessage || 'loading older commits',
|
|
40319
|
+
loading: true,
|
|
40320
|
+
});
|
|
40321
|
+
const fetchArgs = snap.historyFetchArgs;
|
|
40322
|
+
const mergedArgv = {
|
|
40323
|
+
...snap.logArgv,
|
|
40324
|
+
...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
|
|
40325
|
+
...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
|
|
40326
|
+
};
|
|
40327
|
+
// Load-more paths a fresh page from git AFTER what's already
|
|
40328
|
+
// loaded; pass the stash hashes again so the additional rows
|
|
40329
|
+
// stay graph-consistent with the boot fetch (a window that
|
|
40330
|
+
// dropped stashes mid-stream would render with broken junctions).
|
|
40331
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
40332
|
+
const nextRows = await safe(getLogRows(git, mergedArgv, {
|
|
40333
|
+
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
40334
|
+
skip: snap.commitsLength,
|
|
40335
|
+
extraRefs: stashHashes,
|
|
40336
|
+
}));
|
|
40337
|
+
if (!mountedRef.current || loadMoreRequestRef.current !== requestId) {
|
|
40338
|
+
return { fired: false, addedCommits: 0 };
|
|
40339
|
+
}
|
|
40340
|
+
loadingMoreCommitsRef.current = false;
|
|
40341
|
+
setLoadingMoreCommits(false);
|
|
40342
|
+
const nextCommitCount = nextRows ? getCommitRows(nextRows).length : 0;
|
|
40343
|
+
if (!nextRows) {
|
|
40344
|
+
dispatch({ type: 'setStatus', value: 'failed to load older commits', kind: 'error' });
|
|
40345
|
+
return { fired: false, addedCommits: 0 };
|
|
40346
|
+
}
|
|
40347
|
+
if (nextRows?.length) {
|
|
40348
|
+
dispatch({ type: 'appendRows', rows: nextRows });
|
|
40349
|
+
}
|
|
40350
|
+
setHasMoreCommits(nextCommitCount >= LOG_INTERACTIVE_DEFAULT_LIMIT);
|
|
40351
|
+
return { fired: true, addedCommits: nextCommitCount };
|
|
40352
|
+
// Empty deps — the function is intentionally stable. State is
|
|
40353
|
+
// read via `loadMoreStateRef.current` at call time, and `dispatch`
|
|
40354
|
+
// / `git` / `setLoadingMoreCommits` / `setHasMoreCommits` are
|
|
40355
|
+
// already stable across renders by React's contract.
|
|
40356
|
+
}, [dispatch, git]);
|
|
40357
|
+
// Scroll-near-bottom auto-trigger. Fires when the user's cursor is
|
|
40358
|
+
// within 20 rows of the last loaded commit so older history is
|
|
40359
|
+
// already on its way by the time they reach the bottom.
|
|
39215
40360
|
React.useEffect(() => {
|
|
39216
40361
|
const remaining = state.filteredCommits.length - state.selectedIndex - 1;
|
|
39217
|
-
|
|
39218
|
-
|
|
39219
|
-
|
|
39220
|
-
|
|
39221
|
-
|
|
39222
|
-
|
|
39223
|
-
|
|
39224
|
-
|
|
39225
|
-
|
|
39226
|
-
|
|
39227
|
-
setLoadingMoreCommits(true);
|
|
39228
|
-
dispatch({ type: 'setStatus', value: 'loading older commits' });
|
|
39229
|
-
const fetchArgs = state.historyFetchArgs;
|
|
39230
|
-
const mergedArgv = {
|
|
39231
|
-
...logArgv,
|
|
39232
|
-
...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
|
|
39233
|
-
...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
|
|
39234
|
-
};
|
|
39235
|
-
const nextRows = await safe(getLogRows(git, mergedArgv, {
|
|
39236
|
-
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
39237
|
-
skip: state.commits.length,
|
|
39238
|
-
}));
|
|
39239
|
-
if (!mountedRef.current || loadMoreRequestRef.current !== requestId) {
|
|
39240
|
-
return;
|
|
39241
|
-
}
|
|
39242
|
-
loadingMoreCommitsRef.current = false;
|
|
39243
|
-
setLoadingMoreCommits(false);
|
|
39244
|
-
const nextCommitCount = nextRows ? getCommitRows(nextRows).length : 0;
|
|
39245
|
-
if (!nextRows) {
|
|
39246
|
-
dispatch({ type: 'setStatus', value: 'failed to load older commits' });
|
|
39247
|
-
return;
|
|
39248
|
-
}
|
|
39249
|
-
if (nextRows?.length) {
|
|
39250
|
-
dispatch({ type: 'appendRows', rows: nextRows });
|
|
40362
|
+
if (remaining > 20)
|
|
40363
|
+
return;
|
|
40364
|
+
void loadMoreCommits().then((result) => {
|
|
40365
|
+
if (result.fired) {
|
|
40366
|
+
dispatch({
|
|
40367
|
+
type: 'setStatus',
|
|
40368
|
+
value: result.addedCommits
|
|
40369
|
+
? `loaded ${result.addedCommits} older commits`
|
|
40370
|
+
: 'end of history',
|
|
40371
|
+
});
|
|
39251
40372
|
}
|
|
39252
|
-
|
|
39253
|
-
dispatch({
|
|
39254
|
-
type: 'setStatus',
|
|
39255
|
-
value: nextCommitCount
|
|
39256
|
-
? `loaded ${nextCommitCount} older commits`
|
|
39257
|
-
: 'end of history',
|
|
39258
|
-
});
|
|
39259
|
-
}
|
|
39260
|
-
void loadMoreCommits();
|
|
40373
|
+
});
|
|
39261
40374
|
}, [
|
|
39262
40375
|
dispatch,
|
|
39263
|
-
|
|
39264
|
-
hasMoreCommits,
|
|
39265
|
-
loadingMoreCommits,
|
|
39266
|
-
logArgv,
|
|
39267
|
-
state.commits.length,
|
|
40376
|
+
loadMoreCommits,
|
|
39268
40377
|
state.filteredCommits.length,
|
|
39269
|
-
state.historyFetchArgs,
|
|
39270
40378
|
state.selectedIndex,
|
|
39271
40379
|
]);
|
|
40380
|
+
/**
|
|
40381
|
+
* Targeted-context loader for the cursor-syncs-history effect. Called
|
|
40382
|
+
* when the resolver returns `load-context` — the user cursored a
|
|
40383
|
+
* branch / tag / stash whose target commit isn't in the loaded
|
|
40384
|
+
* window, so we run a `git log` anchored on that commit (guaranteed
|
|
40385
|
+
* to include it) and merge the result via `appendRows` (which
|
|
40386
|
+
* already deduplicates by hash).
|
|
40387
|
+
*
|
|
40388
|
+
* Stable identity (empty deps) for the same reason as
|
|
40389
|
+
* `loadMoreCommits` — the cursor-sync effect calls this through a
|
|
40390
|
+
* forward-reference ref, and a regenerating callback would
|
|
40391
|
+
* reintroduce the render-order race that bit the previous chain.
|
|
40392
|
+
* All volatile state (logArgv, mostly) is read via refs.
|
|
40393
|
+
*/
|
|
40394
|
+
const loadCommitContextStateRef = React.useRef({ logArgv });
|
|
40395
|
+
loadCommitContextStateRef.current = { logArgv };
|
|
40396
|
+
const loadCommitContext = React.useCallback(async (target) => {
|
|
40397
|
+
const snap = loadCommitContextStateRef.current;
|
|
40398
|
+
if (!snap.logArgv)
|
|
40399
|
+
return;
|
|
40400
|
+
dispatch({
|
|
40401
|
+
type: 'setStatus',
|
|
40402
|
+
value: `Loading commits around ${target.label}…`,
|
|
40403
|
+
loading: true,
|
|
40404
|
+
});
|
|
40405
|
+
try {
|
|
40406
|
+
// No stashHashes here — `getLogRowsAnchoredOn` walks only from
|
|
40407
|
+
// the target so it can guarantee the target's inclusion.
|
|
40408
|
+
// Stashes are already in the loaded graph from boot's
|
|
40409
|
+
// `loadRowsWithStashes`; `appendRows` deduplicates by hash so
|
|
40410
|
+
// the merged result keeps both views without double-counting.
|
|
40411
|
+
const rows = await getLogRowsAnchoredOn(git, snap.logArgv, target.hash, {});
|
|
40412
|
+
if (!mountedRef.current)
|
|
40413
|
+
return;
|
|
40414
|
+
if (rows.length > 0) {
|
|
40415
|
+
dispatch({ type: 'appendRows', rows });
|
|
40416
|
+
// Don't dispatch a setStatus here — the cursor-sync effect
|
|
40417
|
+
// will re-fire on the appendRows-driven filteredCommits
|
|
40418
|
+
// change and either jump (success) or report unreachable
|
|
40419
|
+
// (failure), surfacing the right message.
|
|
40420
|
+
}
|
|
40421
|
+
else {
|
|
40422
|
+
dispatch({
|
|
40423
|
+
type: 'setStatus',
|
|
40424
|
+
value: `${target.label} target commit returned no rows — orphan ref?`,
|
|
40425
|
+
kind: 'warning',
|
|
40426
|
+
});
|
|
40427
|
+
}
|
|
40428
|
+
}
|
|
40429
|
+
catch (error) {
|
|
40430
|
+
if (mountedRef.current) {
|
|
40431
|
+
dispatch({
|
|
40432
|
+
type: 'setStatus',
|
|
40433
|
+
value: `Failed to load context for ${target.label}: ${error instanceof Error ? error.message : String(error)}`,
|
|
40434
|
+
kind: 'error',
|
|
40435
|
+
});
|
|
40436
|
+
}
|
|
40437
|
+
}
|
|
40438
|
+
}, [dispatch, git]);
|
|
40439
|
+
React.useEffect(() => {
|
|
40440
|
+
loadCommitContextRef.current = loadCommitContext;
|
|
40441
|
+
}, [loadCommitContext]);
|
|
39272
40442
|
// Server-side history filter (#776). When the user submits `path:foo`
|
|
39273
40443
|
// or `author:foo`, the filter parser dispatches setHistoryFetchArgs;
|
|
39274
40444
|
// this effect picks up the change, re-runs `getLogRows` with merged
|
|
@@ -39304,12 +40474,16 @@ function LogInkApp(deps) {
|
|
|
39304
40474
|
value: description ? `Refetching with ${description}` : 'Restoring full log',
|
|
39305
40475
|
});
|
|
39306
40476
|
void (async () => {
|
|
39307
|
-
const
|
|
40477
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
40478
|
+
const nextRows = await safe(getLogRows(git, merged, {
|
|
40479
|
+
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
40480
|
+
extraRefs: stashHashes,
|
|
40481
|
+
}));
|
|
39308
40482
|
if (!mountedRef.current || historyFetchRequestRef.current !== requestId) {
|
|
39309
40483
|
return;
|
|
39310
40484
|
}
|
|
39311
40485
|
if (!nextRows) {
|
|
39312
|
-
dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter' });
|
|
40486
|
+
dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter', kind: 'error' });
|
|
39313
40487
|
return;
|
|
39314
40488
|
}
|
|
39315
40489
|
dispatch({ type: 'replaceRows', rows: nextRows });
|
|
@@ -39320,6 +40494,7 @@ function LogInkApp(deps) {
|
|
|
39320
40494
|
value: description
|
|
39321
40495
|
? `Showing ${matched} commits matching ${description}`
|
|
39322
40496
|
: 'Showing full log',
|
|
40497
|
+
kind: 'success',
|
|
39323
40498
|
});
|
|
39324
40499
|
})();
|
|
39325
40500
|
}, [dispatch, git, logArgv, state.historyFetchArgs]);
|
|
@@ -39349,12 +40524,20 @@ function LogInkApp(deps) {
|
|
|
39349
40524
|
: 'Loading compact history…',
|
|
39350
40525
|
});
|
|
39351
40526
|
void (async () => {
|
|
39352
|
-
|
|
40527
|
+
// Include stash commits as graph roots so the toggle's re-fetch
|
|
40528
|
+
// sees the same rich graph the boot loader assembles. Without
|
|
40529
|
+
// this, flipping `\` into full mode and back loses the stash
|
|
40530
|
+
// anchors that loadRowsWithStashes seeded on boot.
|
|
40531
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
40532
|
+
const nextRows = await safe(getLogRows(git, merged, {
|
|
40533
|
+
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
40534
|
+
extraRefs: stashHashes,
|
|
40535
|
+
}));
|
|
39353
40536
|
if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
|
|
39354
40537
|
return;
|
|
39355
40538
|
}
|
|
39356
40539
|
if (!nextRows) {
|
|
39357
|
-
dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows' });
|
|
40540
|
+
dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows', kind: 'error' });
|
|
39358
40541
|
return;
|
|
39359
40542
|
}
|
|
39360
40543
|
dispatch({ type: 'replaceRows', rows: nextRows });
|
|
@@ -39365,6 +40548,7 @@ function LogInkApp(deps) {
|
|
|
39365
40548
|
value: state.fullGraph
|
|
39366
40549
|
? `Showing ${matched} commits across all branches`
|
|
39367
40550
|
: `Showing ${matched} commits (compact)`,
|
|
40551
|
+
kind: 'success',
|
|
39368
40552
|
});
|
|
39369
40553
|
})();
|
|
39370
40554
|
}, [dispatch, git, logArgv, state.fullGraph]);
|
|
@@ -40059,6 +41243,17 @@ function createLogArgvFromUiArgv(argv) {
|
|
|
40059
41243
|
return {
|
|
40060
41244
|
$0: argv.$0,
|
|
40061
41245
|
_: ['log'],
|
|
41246
|
+
// Pass `--all` through from the CLI. The yargs default is `true`
|
|
41247
|
+
// since 0.54.x — user feedback consistently asked for the
|
|
41248
|
+
// GitKraken-style "see all branches, tags, stashes" view as the
|
|
41249
|
+
// starting state. `coco ui --no-all` opts back to
|
|
41250
|
+
// current-branch-only.
|
|
41251
|
+
//
|
|
41252
|
+
// Note: passing `--branch foo` does NOT automatically scope away
|
|
41253
|
+
// from --all. If the user wants strictly that branch, they pass
|
|
41254
|
+
// `coco ui --branch foo --no-all`. We considered the implicit
|
|
41255
|
+
// scope-narrowing but it surprises users who pass `--branch` as
|
|
41256
|
+
// a "highlight this branch in the all-refs view" hint.
|
|
40062
41257
|
all: argv.all,
|
|
40063
41258
|
branch: argv.branch,
|
|
40064
41259
|
format: 'table',
|
|
@@ -40095,6 +41290,26 @@ function withCacheWrite(repoPath, loader) {
|
|
|
40095
41290
|
return rows;
|
|
40096
41291
|
};
|
|
40097
41292
|
}
|
|
41293
|
+
/**
|
|
41294
|
+
* Workstation-aware log loader (#1034 follow-up). Calls `git stash
|
|
41295
|
+
* list` first to collect every stash's commit hash, then passes them
|
|
41296
|
+
* as extra refs to `getLogRows` so the graph includes every stash as
|
|
41297
|
+
* a node — not just the latest (which is the only one `refs/stash`
|
|
41298
|
+
* points at and the only one `git log --all` walks).
|
|
41299
|
+
*
|
|
41300
|
+
* Without this, the stash → history cursor sync added in #1034 only
|
|
41301
|
+
* worked for `stash@{0}`; cursoring any older stash row reported
|
|
41302
|
+
* "tip not in loaded window" because that stash's commit hash was
|
|
41303
|
+
* never in the loaded graph window in the first place.
|
|
41304
|
+
*
|
|
41305
|
+
* The extra git call is cheap (one `git stash list --format=%H`,
|
|
41306
|
+
* usually sub-50ms). It's only an additive cost when stashes exist;
|
|
41307
|
+
* users on stash-free repos pay nothing.
|
|
41308
|
+
*/
|
|
41309
|
+
async function loadRowsWithStashes(git, logArgv) {
|
|
41310
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
41311
|
+
return getLogRows(git, logArgv, { extraRefs: stashHashes });
|
|
41312
|
+
}
|
|
40098
41313
|
async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
40099
41314
|
const config = options.config || loadConfig(logArgv);
|
|
40100
41315
|
const git = options.git || getRepo();
|
|
@@ -40113,7 +41328,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
|
40113
41328
|
const initialRows = options.rows || cachedRows || [];
|
|
40114
41329
|
const loadRows = options.rows
|
|
40115
41330
|
? undefined
|
|
40116
|
-
: withCacheWrite(repoPath, () =>
|
|
41331
|
+
: withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv));
|
|
40117
41332
|
await startInkInteractiveLog(git, initialRows, {}, {
|
|
40118
41333
|
appLabel: 'coco',
|
|
40119
41334
|
idleTips: config.logTui?.idleTips,
|
|
@@ -40141,7 +41356,7 @@ async function startCocoUi(argv) {
|
|
|
40141
41356
|
idleTips: config.logTui?.idleTips,
|
|
40142
41357
|
dateBucketing: config.logTui?.dateBucketing,
|
|
40143
41358
|
initialView: argv.view || 'history',
|
|
40144
|
-
loadRows: withCacheWrite(repoPath, () =>
|
|
41359
|
+
loadRows: withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv)),
|
|
40145
41360
|
logArgv,
|
|
40146
41361
|
theme: createUiTheme(config, argv),
|
|
40147
41362
|
});
|
|
@@ -41344,9 +42559,9 @@ const options = {
|
|
|
41344
42559
|
default: 'history',
|
|
41345
42560
|
},
|
|
41346
42561
|
all: {
|
|
41347
|
-
description: 'Load commits from all local and remote refs in history mode',
|
|
42562
|
+
description: 'Load commits from all local and remote refs in history mode. Defaults to true so the history view shows the full multi-ref graph (branches, tags, stashes) out of the box; pass `--no-all` to scope to the current branch only.',
|
|
41348
42563
|
type: 'boolean',
|
|
41349
|
-
default:
|
|
42564
|
+
default: true,
|
|
41350
42565
|
},
|
|
41351
42566
|
branch: {
|
|
41352
42567
|
description: 'Load history reachable from a branch or ref',
|