pi-fast-resume 1.3.0 → 1.4.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/README.md +12 -7
- package/fast-resume.ts +301 -85
- package/package.json +2 -1
- package/src/index.ts +3 -0
- package/src/scanner.ts +101 -12
- package/src/search.ts +14 -1
- package/vitest.config.ts +4 -1
package/README.md
CHANGED
|
@@ -47,6 +47,8 @@ Tested with **1,771 sessions, 1.46 GB** of JSONL data on disk.
|
|
|
47
47
|
| **pi-fast-resume (partial read)** | **6 ms** | ~580 ms |
|
|
48
48
|
| DuckDB NDJSON full scan | 2,560 ms | 2,560 ms |
|
|
49
49
|
|
|
50
|
+
> The all-dirs `stat()` pass (~100 ms at scale) now runs **after** first paint, so first paint no longer depends on total session count — only on the current project's session dir.
|
|
51
|
+
|
|
50
52
|
<details>
|
|
51
53
|
<summary><strong>Full benchmark table</strong></summary>
|
|
52
54
|
|
|
@@ -165,8 +167,8 @@ Press `Tab` to switch to **all sessions** — shows every session pi knows about
|
|
|
165
167
|
## How it works
|
|
166
168
|
|
|
167
169
|
```
|
|
168
|
-
stat()
|
|
169
|
-
(~
|
|
170
|
+
stat() current dir's .jsonl ──► sort by mtime ──► stream top 30 forward
|
|
171
|
+
(~cheap, one dir) (recent first) (~6ms)
|
|
170
172
|
│
|
|
171
173
|
▼
|
|
172
174
|
┌─────────────────┐
|
|
@@ -174,15 +176,18 @@ stat() all .jsonl files ──────► sort by mtime ──────
|
|
|
174
176
|
│ immediately │
|
|
175
177
|
└────────┬────────┘
|
|
176
178
|
│
|
|
177
|
-
|
|
178
|
-
|
|
179
|
+
Background (after first paint):
|
|
180
|
+
1. stream the rest of the current dir in batches of 50 (rows appear as they load)
|
|
181
|
+
2. stat() ALL session dirs (~100ms at scale) — deferred off the critical path
|
|
182
|
+
3. forward-load all-scope headers in batches of 50 (non-blocking via setImmediate)
|
|
183
|
+
4. resolve rename names in the background (skip header-only files; bound each tail read)
|
|
179
184
|
```
|
|
180
185
|
|
|
181
|
-
1. **`stat()`
|
|
186
|
+
1. **`stat()` the current project's session dir** — collect paths and mtimes (cheap, one directory)
|
|
182
187
|
2. **Sort by mtime descending** — most recent sessions first
|
|
183
|
-
3. **Stream
|
|
188
|
+
3. **Stream the top 30 forward** line by line until the first user message (~6 ms for the first screen)
|
|
184
189
|
4. **Show picker** — user can navigate, filter, and select immediately
|
|
185
|
-
5. **Background load** — remaining sessions
|
|
190
|
+
5. **Background load** — after first paint, stream the remaining current-scope sessions in batches of 50, then stat every session dir and forward-load the all-scope headers, non-blocking
|
|
186
191
|
6. **Tab to switch scope** — filter to current project or show everything
|
|
187
192
|
|
|
188
193
|
No indexing. No database. No persistent state. Just reads the files on disk.
|
package/fast-resume.ts
CHANGED
|
@@ -73,7 +73,7 @@ import {
|
|
|
73
73
|
scanSessionDir,
|
|
74
74
|
loadSessionHeaders,
|
|
75
75
|
loadSessionHeadersForward,
|
|
76
|
-
|
|
76
|
+
resolveSessionNamesDeferred,
|
|
77
77
|
sortByModified,
|
|
78
78
|
sortByModifiedDesc,
|
|
79
79
|
filterByCwd,
|
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
import {
|
|
85
85
|
parseSearchQuery,
|
|
86
86
|
matchSession,
|
|
87
|
+
invalidateSessionSearchText,
|
|
87
88
|
hasSessionName,
|
|
88
89
|
filterAndSortSessions,
|
|
89
90
|
buildSessionTree,
|
|
@@ -97,6 +98,13 @@ import {
|
|
|
97
98
|
|
|
98
99
|
const HOME = homedir();
|
|
99
100
|
|
|
101
|
+
// #2 — Top-N immediate current-scope load. Forward-load this many most-recent
|
|
102
|
+
// sessions before first paint; the rest stream in from the background in
|
|
103
|
+
// batches of 50. 30 covers the visible page (maxVisible=10) with a scroll
|
|
104
|
+
// buffer. Projects with fewer sessions load fully in the immediate pass and
|
|
105
|
+
// never start the background current load.
|
|
106
|
+
const IMMEDIATE_CURRENT_COUNT = 30;
|
|
107
|
+
|
|
100
108
|
// Config — read from ~/.pi/agent/extensions/pi-fast-resume.json
|
|
101
109
|
// Example: { "hijackResume": false, "shortcut": "alt+u" }
|
|
102
110
|
// By default hijackResume is true — /resume opens the fast picker
|
|
@@ -125,23 +133,30 @@ export interface FastResumeResult {
|
|
|
125
133
|
|
|
126
134
|
type StatusMessage = { type: "info" | "error"; message: string };
|
|
127
135
|
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
136
|
+
// #2 — Load the current-scope sessions in two phases: forward-load the top-N
|
|
137
|
+
// most-recent headers immediately (the first paint), and return the remaining
|
|
138
|
+
// metas for the picker's background current-scope load. `allMetas` is every
|
|
139
|
+
// current-scope meta (used to seed the path→meta lookup for rename-name
|
|
140
|
+
// resolution). For custom session dirs, headers are filtered to the current cwd
|
|
141
|
+
// (matching SessionManager.list); the cwd filter is re-applied to the
|
|
142
|
+
// background batches so the streamed rows stay cwd-correct.
|
|
143
|
+
function loadCurrentSessionsTopN(
|
|
132
144
|
cwd: string,
|
|
133
145
|
sessionDir: string | undefined,
|
|
134
146
|
usesDefaultSessionDir: boolean,
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
147
|
+
immediateCount: number,
|
|
148
|
+
): { headers: SessionHeader[]; remaining: SessionFileMeta[]; allMetas: SessionFileMeta[] } {
|
|
149
|
+
if (!sessionDir) return { headers: [], remaining: [], allMetas: [] };
|
|
150
|
+
const allMetas = sortByModifiedDesc(scanSessionDir(sessionDir));
|
|
151
|
+
const immediateMetas = allMetas.slice(0, immediateCount);
|
|
152
|
+
const remaining = allMetas.slice(immediateCount);
|
|
153
|
+
let headers = loadSessionHeadersForward(immediateMetas);
|
|
139
154
|
if (!usesDefaultSessionDir) {
|
|
140
155
|
// Custom session dirs may contain sessions from multiple cwds; filter to
|
|
141
156
|
// the current one, matching SessionManager.list behavior.
|
|
142
157
|
headers = filterByCwd(headers, cwd);
|
|
143
158
|
}
|
|
144
|
-
return sortByModified(headers);
|
|
159
|
+
return { headers: sortByModified(headers), remaining, allMetas };
|
|
145
160
|
}
|
|
146
161
|
|
|
147
162
|
function loadAllSessionMetas(
|
|
@@ -349,6 +364,17 @@ class FastResumeSessionList implements Component {
|
|
|
349
364
|
nameFilter: NameFilter = "all";
|
|
350
365
|
confirmingDeletePath: string | null = null;
|
|
351
366
|
maxVisible = 10;
|
|
367
|
+
|
|
368
|
+
// #4 — Cache for the threaded-mode tree (no-query path). The tree's shape and
|
|
369
|
+
// order depend only on parentSessionPath (immutable), modified (stable after
|
|
370
|
+
// load), and the session set — NOT on `name`. So a tree built for a given
|
|
371
|
+
// session-array reference stays valid across in-place name mutations (rename
|
|
372
|
+
// resolution) and across query typing/clearing, as long as the array ref is
|
|
373
|
+
// stable. Keyed on the `nameFilter==="all"` array (=== this.allSessions);
|
|
374
|
+
// the "named" subset depends on names and is never cached. setSessions passes
|
|
375
|
+
// a new ref on load/scope/mutation (cache misses → rebuild) and the same ref
|
|
376
|
+
// on name-resolution batches (cache hits → skip rebuild).
|
|
377
|
+
private _treeCache: { sessionsRef: SessionHeader[]; flat: FlatSessionNode[] } | null = null;
|
|
352
378
|
currentSessionCanonicalPath: string | undefined;
|
|
353
379
|
|
|
354
380
|
onSelect?: (sessionPath: string) => void;
|
|
@@ -404,6 +430,15 @@ class FastResumeSessionList implements Component {
|
|
|
404
430
|
this.filterSessions(this.searchInput.getValue());
|
|
405
431
|
}
|
|
406
432
|
|
|
433
|
+
// #5 — Drop the threaded-tree cache so the next filterSessions rebuilds it.
|
|
434
|
+
// The background loads reuse one growing session array (same ref) and append
|
|
435
|
+
// batches in place; without invalidation the ref-keyed cache would hit and
|
|
436
|
+
// serve a stale tree missing the new rows. Call before setSessions whenever
|
|
437
|
+
// the array's CONTENT changed but its REFERENCE did not.
|
|
438
|
+
invalidateTreeCache(): void {
|
|
439
|
+
this._treeCache = null;
|
|
440
|
+
}
|
|
441
|
+
|
|
407
442
|
setConfirmingDeletePath(path: string | null): void {
|
|
408
443
|
this.confirmingDeletePath = path;
|
|
409
444
|
this.onDeleteConfirmationChange?.(path);
|
|
@@ -432,11 +467,23 @@ class FastResumeSessionList implements Component {
|
|
|
432
467
|
const trimmed = query.trim();
|
|
433
468
|
|
|
434
469
|
if (this.sortMode === "threaded" && !trimmed) {
|
|
435
|
-
// Threaded mode without search: show tree structure
|
|
436
|
-
|
|
437
|
-
|
|
470
|
+
// Threaded mode without search: show tree structure. Cache it when the
|
|
471
|
+
// nameFiltered array is the stable all-sessions ref (nameFilter==="all")
|
|
472
|
+
// — the tree doesn't depend on names, so it survives in-place name
|
|
473
|
+
// mutations and query typing/clearing until the session set changes.
|
|
474
|
+
const canCache = this.nameFilter === "all"; // nameFiltered === this.allSessions
|
|
475
|
+
if (canCache && this._treeCache !== null && this._treeCache.sessionsRef === nameFiltered) {
|
|
476
|
+
this.filteredNodes = this._treeCache.flat;
|
|
477
|
+
} else {
|
|
478
|
+
const roots = buildSessionTree(nameFiltered);
|
|
479
|
+
const flat = flattenSessionTree(roots);
|
|
480
|
+
this.filteredNodes = flat;
|
|
481
|
+
if (canCache) this._treeCache = { sessionsRef: nameFiltered, flat };
|
|
482
|
+
}
|
|
438
483
|
} else {
|
|
439
|
-
// Other modes or with search: flat list via filterAndSortSessions
|
|
484
|
+
// Other modes or with search: flat list via filterAndSortSessions. Leave
|
|
485
|
+
// the tree cache in place — a later "threaded + no query" reuses it as
|
|
486
|
+
// long as the session array ref is stable.
|
|
440
487
|
const filtered = filterAndSortSessions(nameFiltered, query, this.sortMode);
|
|
441
488
|
this.filteredNodes = filtered.map((session) => ({
|
|
442
489
|
session,
|
|
@@ -676,14 +723,18 @@ class FastResumePicker extends Container {
|
|
|
676
723
|
private allMetas: SessionFileMeta[] = [];
|
|
677
724
|
private loadingAbort: AbortController | null = null;
|
|
678
725
|
private allLoadSeq = 0;
|
|
726
|
+
private currentLoadSeq = 0;
|
|
727
|
+
// #2 — Current-scope metas not yet forward-loaded (beyond the top-N immediate
|
|
728
|
+
// pass). The background current load drains these in batches of 50.
|
|
729
|
+
private remainingCurrentMetas: SessionFileMeta[] = [];
|
|
679
730
|
|
|
680
731
|
// Deferred rename-name resolution. The picker displays rows immediately
|
|
681
732
|
// with forward-only headers (fast: ~80ms for 2.5k sessions); the latest rename
|
|
682
733
|
// name (which pi appends at EOF, past the forward stop) is resolved per file
|
|
683
734
|
// in the background and applied in-place, so a row's name pops in without
|
|
684
|
-
// blocking the initial render. See
|
|
735
|
+
// blocking the initial render. See resolveSessionNamesDeferred in scanner.ts.
|
|
685
736
|
private metaByPath = new Map<string, SessionFileMeta>();
|
|
686
|
-
private nameResolveQueue:
|
|
737
|
+
private nameResolveQueue: SessionHeader[] = [];
|
|
687
738
|
private nameResolveScheduled = false;
|
|
688
739
|
private nameResolveSeq = 0;
|
|
689
740
|
private nameResolvedPaths = new Set<string>();
|
|
@@ -728,8 +779,8 @@ class FastResumePicker extends Container {
|
|
|
728
779
|
usesDefaultSessionDir: boolean,
|
|
729
780
|
currentSessionPath: string | undefined,
|
|
730
781
|
initialCurrentSessions: SessionHeader[],
|
|
731
|
-
|
|
732
|
-
|
|
782
|
+
remainingCurrentMetas: SessionFileMeta[],
|
|
783
|
+
currentMetas: SessionFileMeta[],
|
|
733
784
|
done: (result: FastResumeResult) => void,
|
|
734
785
|
tuiRequestRender: () => void,
|
|
735
786
|
initialQuery?: string,
|
|
@@ -738,7 +789,9 @@ class FastResumePicker extends Container {
|
|
|
738
789
|
this.theme = theme;
|
|
739
790
|
this.done = done;
|
|
740
791
|
this.tuiRequestRender = tuiRequestRender;
|
|
741
|
-
|
|
792
|
+
// allMetas is populated in the background (the all-dirs stat is deferred
|
|
793
|
+
// off the first-paint critical path); seeded here with current-scope metas.
|
|
794
|
+
this.allMetas = [];
|
|
742
795
|
this.cwd = currentCwd;
|
|
743
796
|
this.sessionDir = sessionDir;
|
|
744
797
|
this.usesDefaultSessionDir = usesDefaultSessionDir;
|
|
@@ -755,7 +808,7 @@ class FastResumePicker extends Container {
|
|
|
755
808
|
// Create session list
|
|
756
809
|
this.sessionList = new FastResumeSessionList(theme, currentSessionPath);
|
|
757
810
|
this.currentSessions = initialCurrentSessions;
|
|
758
|
-
this.allSessions =
|
|
811
|
+
this.allSessions = null; // loaded in the background by startAllLoadBackground
|
|
759
812
|
|
|
760
813
|
// Set initial data into the list
|
|
761
814
|
this.sessionList.setSessions(initialCurrentSessions, false);
|
|
@@ -834,25 +887,28 @@ class FastResumePicker extends Container {
|
|
|
834
887
|
// Build layout
|
|
835
888
|
this.buildBaseLayout(this.sessionList);
|
|
836
889
|
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
// Rows are already visible with the correct firstMessage; names populate
|
|
890
|
+
// Seed the path → meta lookup from the current-scope metas (cheap — one
|
|
891
|
+
// dir). The all-scope metas are merged in by the background all-load. Rows
|
|
892
|
+
// are already visible with the correct firstMessage; rename names populate
|
|
841
893
|
// in-place as their tails resolve.
|
|
842
|
-
for (const m of
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
//
|
|
849
|
-
|
|
894
|
+
for (const m of currentMetas) this.metaByPath.set(m.path, m);
|
|
895
|
+
this.enqueueNameResolution(initialCurrentSessions);
|
|
896
|
+
|
|
897
|
+
// #2 — Current scope shows the top-N headers immediately; the rest stream
|
|
898
|
+
// in from the background (silent: header stays on "◉ Current Folder",
|
|
899
|
+
// rows appear as batches complete). currentLoading tracks this for the
|
|
900
|
+
// rename guard and refresh cancellation; header.loading is NOT flipped so
|
|
901
|
+
// the initial view doesn't flash a loading state over visible rows.
|
|
902
|
+
this.remainingCurrentMetas = remainingCurrentMetas;
|
|
903
|
+
this.currentLoading = remainingCurrentMetas.length > 0;
|
|
904
|
+
if (this.currentLoading) this.startCurrentLoadBackground();
|
|
850
905
|
this.header.loading = false;
|
|
851
906
|
|
|
852
|
-
//
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
907
|
+
// Pre-load the all-scope metas + headers in the background. This stats all
|
|
908
|
+
// session dirs (the ~100ms-at-scale cost that used to block first paint)
|
|
909
|
+
// off the critical path, then forward-loads headers in cooperative batches
|
|
910
|
+
// so switching to "all" scope is instant.
|
|
911
|
+
this.startAllLoadBackground();
|
|
856
912
|
}
|
|
857
913
|
|
|
858
914
|
private enterRenameMode(sessionPath: string, currentName?: string): void {
|
|
@@ -926,9 +982,14 @@ class FastResumePicker extends Container {
|
|
|
926
982
|
private async refreshSessionsAfterMutation(): Promise<void> {
|
|
927
983
|
// Rescan from disk so renames, deletes, and newly created sessions are
|
|
928
984
|
// reflected in the list. This mirrors upstream's loadScope(scope, "refresh").
|
|
929
|
-
// Bump the sequence
|
|
930
|
-
//
|
|
985
|
+
// Bump the sequence numbers first so any in-progress background loads
|
|
986
|
+
// (current top-N remainder or all-scope) stop before they can overwrite
|
|
987
|
+
// the rescanned data. rescanCurrentScope/AllScope do a full load (combined
|
|
988
|
+
// forward+tail) so the refreshed names are accurate.
|
|
931
989
|
this.allLoadSeq++;
|
|
990
|
+
this.currentLoadSeq++;
|
|
991
|
+
this.currentLoading = false;
|
|
992
|
+
this.remainingCurrentMetas = [];
|
|
932
993
|
try {
|
|
933
994
|
if (this.scope === "current") {
|
|
934
995
|
this.currentSessions = this.rescanCurrentScope();
|
|
@@ -951,26 +1012,158 @@ class FastResumePicker extends Container {
|
|
|
951
1012
|
}
|
|
952
1013
|
}
|
|
953
1014
|
|
|
1015
|
+
// #2 — Background current-scope load: forward-load the remaining current
|
|
1016
|
+
// metas (those beyond the top-N immediate pass) in cooperative batches of
|
|
1017
|
+
// 50, merging into currentSessions (re-sorted by modified) and streaming
|
|
1018
|
+
// rows into the visible list when the user is on current scope. Silent — no
|
|
1019
|
+
// header loading indicator (rows are already visible). Idempotent; cancelled
|
|
1020
|
+
// by currentLoadSeq bumps (refresh) or loadingAbort (select/cancel/exit).
|
|
1021
|
+
private startCurrentLoadBackground(): void {
|
|
1022
|
+
if (this.remainingCurrentMetas.length === 0) {
|
|
1023
|
+
this.currentLoading = false;
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
this.currentLoading = true;
|
|
1027
|
+
const seq = ++this.currentLoadSeq;
|
|
1028
|
+
const sorted = this.remainingCurrentMetas;
|
|
1029
|
+
setImmediate(() => this.runCurrentLoadHeaders(seq, sorted));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// #5 — Background current-scope load. Seeds the accumulator with a snapshot
|
|
1033
|
+
// of the top-N immediate headers (one copy, ~30 elements), appends each
|
|
1034
|
+
// batch's cwd-filtered headers in place (no per-batch array copy or re-sort),
|
|
1035
|
+
// and sorts once at completion. Reuses one growing array ref for display, so
|
|
1036
|
+
// the threaded-tree cache is invalidated before each setSessions to rebuild
|
|
1037
|
+
// with the new rows. Intermediate order is insertion order ≈ mtime desc
|
|
1038
|
+
// (batches arrive pre-sorted by mtime). Fixes the prior per-batch
|
|
1039
|
+
// merge that duplicated earlier batches' headers.
|
|
1040
|
+
private runCurrentLoadHeaders(seq: number, sorted: SessionFileMeta[]): void {
|
|
1041
|
+
const BATCH_SIZE = 50;
|
|
1042
|
+
let offset = 0;
|
|
1043
|
+
// Seed with a copy of the immediate top-N headers so appending batches
|
|
1044
|
+
// can't mutate the list's current array out from under a prior render, and
|
|
1045
|
+
// so the accumulator owns its storage independently.
|
|
1046
|
+
const acc: SessionHeader[] = (this.currentSessions ?? []).slice();
|
|
1047
|
+
|
|
1048
|
+
const show = () => {
|
|
1049
|
+
this.currentSessions = acc;
|
|
1050
|
+
if (this.scope === "current") {
|
|
1051
|
+
this.sessionList.invalidateTreeCache();
|
|
1052
|
+
this.sessionList.setSessions(acc, false);
|
|
1053
|
+
this.tuiRequestRender();
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
const loadBatch = () => {
|
|
1058
|
+
if (seq !== this.currentLoadSeq) { this.currentLoading = false; return; } // stale
|
|
1059
|
+
if (this.loadingAbort?.signal.aborted) { this.currentLoading = false; return; }
|
|
1060
|
+
|
|
1061
|
+
const batch = sorted.slice(offset, offset + BATCH_SIZE);
|
|
1062
|
+
if (batch.length === 0) {
|
|
1063
|
+
this.currentLoading = false;
|
|
1064
|
+
sortByModified(acc); // final sort in place (sortByModified returns acc)
|
|
1065
|
+
show();
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
let headers: SessionHeader[];
|
|
1070
|
+
try {
|
|
1071
|
+
headers = loadSessionHeadersForward(batch);
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
this.currentLoading = false;
|
|
1074
|
+
this.handleCurrentLoadError(seq, err);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
if (!this.usesDefaultSessionDir) headers = filterByCwd(headers, this.cwd);
|
|
1079
|
+
acc.push(...headers);
|
|
1080
|
+
this.enqueueNameResolution(headers);
|
|
1081
|
+
show();
|
|
1082
|
+
|
|
1083
|
+
offset += BATCH_SIZE;
|
|
1084
|
+
setImmediate(loadBatch);
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
setImmediate(loadBatch);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
private handleCurrentLoadError(seq: number, err: unknown): void {
|
|
1091
|
+
if (seq !== this.currentLoadSeq) return;
|
|
1092
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1093
|
+
if (this.scope === "current") {
|
|
1094
|
+
this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
|
|
1095
|
+
this.tuiRequestRender();
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Kick off the background all-scope load: first stat all session dirs (the
|
|
1100
|
+
// ~100ms-at-scale cost deferred off the first-paint critical path), then
|
|
1101
|
+
// forward-load headers in cooperative batches. Idempotent — a no-op if a
|
|
1102
|
+
// load is already running or already complete. The constructor calls this
|
|
1103
|
+
// once so switching to "all" scope is instant; toggleScope relies on it.
|
|
954
1104
|
private startAllLoadBackground(): void {
|
|
1105
|
+
if (this.allLoading) return; // already running
|
|
1106
|
+
if (this.allSessions !== null) return; // already complete
|
|
955
1107
|
this.allLoading = true;
|
|
956
1108
|
const seq = ++this.allLoadSeq;
|
|
1109
|
+
setImmediate(() => this.runAllLoadMetas(seq));
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Phase 1: stat all session dirs in the background, merge into metaByPath,
|
|
1113
|
+
// then dispatch phase 2 (header batches). Releases allLoading and aborts if
|
|
1114
|
+
// superseded by a newer load (e.g. a refresh).
|
|
1115
|
+
private runAllLoadMetas(seq: number): void {
|
|
1116
|
+
if (seq !== this.allLoadSeq) { this.allLoading = false; return; }
|
|
1117
|
+
if (this.loadingAbort?.signal.aborted) { this.allLoading = false; return; }
|
|
1118
|
+
|
|
1119
|
+
let allMetas: SessionFileMeta[];
|
|
1120
|
+
try {
|
|
1121
|
+
allMetas = loadAllSessionMetas(this.sessionDir, this.usesDefaultSessionDir);
|
|
1122
|
+
} catch (err) {
|
|
1123
|
+
this.allLoading = false;
|
|
1124
|
+
this.handleAllLoadError(seq, err);
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
if (seq !== this.allLoadSeq) { this.allLoading = false; return; }
|
|
1128
|
+
this.allMetas = allMetas;
|
|
1129
|
+
for (const m of allMetas) {
|
|
1130
|
+
if (!this.metaByPath.has(m.path)) this.metaByPath.set(m.path, m);
|
|
1131
|
+
}
|
|
1132
|
+
this.runAllLoadHeaders(seq, sortByModifiedDesc(allMetas));
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Phase 2: forward-load all-scope headers in cooperative batches of 50,
|
|
1136
|
+
// enqueuing each batch for background rename-name resolution. Updates the
|
|
1137
|
+
// active list + progress only while the user is viewing "all" scope.
|
|
1138
|
+
// #5 — Background all-scope header load. Appends each batch into a single
|
|
1139
|
+
// growing array (no per-batch array copy or re-sort) and sorts once at
|
|
1140
|
+
// completion. Reuses one array ref for display, so the threaded-tree cache
|
|
1141
|
+
// is invalidated before each setSessions to rebuild with the new rows.
|
|
1142
|
+
// Intermediate order is insertion order ≈ mtime desc (batches arrive
|
|
1143
|
+
// pre-sorted by mtime; the threaded tree re-sorts internally regardless).
|
|
1144
|
+
private runAllLoadHeaders(seq: number, sorted: SessionFileMeta[]): void {
|
|
957
1145
|
const BATCH_SIZE = 50;
|
|
958
|
-
const sorted = sortByModifiedDesc([...this.allMetas]);
|
|
959
1146
|
let offset = 0;
|
|
960
1147
|
const allParsed: SessionHeader[] = [];
|
|
961
1148
|
|
|
1149
|
+
if (this.scope === "all") {
|
|
1150
|
+
this.header.loadProgress = { loaded: 0, total: sorted.length };
|
|
1151
|
+
this.tuiRequestRender();
|
|
1152
|
+
}
|
|
1153
|
+
|
|
962
1154
|
const loadBatch = () => {
|
|
963
|
-
if (seq !== this.allLoadSeq) return; //
|
|
964
|
-
if (this.loadingAbort?.signal.aborted) return;
|
|
1155
|
+
if (seq !== this.allLoadSeq) { this.allLoading = false; return; } // stale — release
|
|
1156
|
+
if (this.loadingAbort?.signal.aborted) { this.allLoading = false; return; }
|
|
965
1157
|
|
|
966
1158
|
const batch = sorted.slice(offset, offset + BATCH_SIZE);
|
|
967
1159
|
if (batch.length === 0) {
|
|
968
1160
|
this.allLoading = false;
|
|
969
|
-
this.allSessions = sortByModified(allParsed);
|
|
1161
|
+
this.allSessions = sortByModified(allParsed); // final sort in place
|
|
970
1162
|
|
|
971
1163
|
// If we're currently showing "all" scope, update the list
|
|
972
1164
|
if (this.scope === "all") {
|
|
973
1165
|
this.header.loading = false;
|
|
1166
|
+
this.sessionList.invalidateTreeCache();
|
|
974
1167
|
this.sessionList.setSessions(this.allSessions, true);
|
|
975
1168
|
this.tuiRequestRender();
|
|
976
1169
|
|
|
@@ -984,31 +1177,30 @@ class FastResumePicker extends Container {
|
|
|
984
1177
|
|
|
985
1178
|
let headers: SessionHeader[];
|
|
986
1179
|
try {
|
|
987
|
-
// Forward-only: the rename name
|
|
988
|
-
//
|
|
989
|
-
// firstMessage immediately and names populate in-place.
|
|
1180
|
+
// Forward-only: the rename name resolves in the background via
|
|
1181
|
+
// resolveSessionNamesDeferred (enqueued below), so rows appear with the
|
|
1182
|
+
// correct firstMessage immediately and names populate in-place.
|
|
990
1183
|
headers = loadSessionHeadersForward(batch);
|
|
991
1184
|
} catch (err) {
|
|
992
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
993
1185
|
this.allLoading = false;
|
|
994
|
-
|
|
995
|
-
this.header.loading = false;
|
|
996
|
-
this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
|
|
997
|
-
this.tuiRequestRender();
|
|
998
|
-
}
|
|
1186
|
+
this.handleAllLoadError(seq, err);
|
|
999
1187
|
return;
|
|
1000
1188
|
}
|
|
1001
1189
|
|
|
1002
1190
|
allParsed.push(...headers);
|
|
1003
|
-
// Enqueue this batch's
|
|
1004
|
-
//
|
|
1005
|
-
// "all" scope, newly-named rows
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1191
|
+
// Enqueue this batch's headers (carrying forward-pass bookkeeping) for
|
|
1192
|
+
// background rename-name resolution. Names populate in-place as they
|
|
1193
|
+
// resolve; if the user is viewing "all" scope, newly-named rows reflect
|
|
1194
|
+
// in the active list.
|
|
1195
|
+
this.enqueueNameResolution(headers);
|
|
1196
|
+
|
|
1197
|
+
// If we're currently showing "all" scope, update progress. Reuse the
|
|
1198
|
+
// growing allParsed ref (insertion order ≈ mtime desc); invalidate the
|
|
1199
|
+
// tree cache so it rebuilds with the appended rows.
|
|
1009
1200
|
if (this.scope === "all") {
|
|
1010
1201
|
this.header.loadProgress = { loaded: allParsed.length, total: sorted.length };
|
|
1011
|
-
this.allSessions =
|
|
1202
|
+
this.allSessions = allParsed;
|
|
1203
|
+
this.sessionList.invalidateTreeCache();
|
|
1012
1204
|
this.sessionList.setSessions(this.allSessions, true);
|
|
1013
1205
|
this.tuiRequestRender();
|
|
1014
1206
|
}
|
|
@@ -1020,15 +1212,29 @@ class FastResumePicker extends Container {
|
|
|
1020
1212
|
setImmediate(loadBatch);
|
|
1021
1213
|
}
|
|
1022
1214
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1215
|
+
private handleAllLoadError(seq: number, err: unknown): void {
|
|
1216
|
+
if (seq !== this.allLoadSeq) return;
|
|
1217
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1218
|
+
if (this.scope === "all") {
|
|
1219
|
+
this.header.loading = false;
|
|
1220
|
+
this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000);
|
|
1221
|
+
this.tuiRequestRender();
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Enqueue forward-loaded headers for background rename-name resolution.
|
|
1226
|
+
// Each path is resolved at most once (deduped via nameResolvedPaths). A
|
|
1227
|
+
// header whose forward pass reached EOF already has its final name — it's
|
|
1228
|
+
// marked resolved and skipped (no tail read). The rest carry the forward
|
|
1229
|
+
// pass's consumed bytes as a lower bound so the tail read never re-reads
|
|
1230
|
+
// already-covered bytes. Safe to call for the current-scope sessions at
|
|
1231
|
+
// construction and for each batch of the all-scope background load.
|
|
1232
|
+
private enqueueNameResolution(headers: SessionHeader[]): void {
|
|
1233
|
+
for (const h of headers) {
|
|
1234
|
+
if (this.nameResolvedPaths.has(h.path)) continue;
|
|
1235
|
+
this.nameResolvedPaths.add(h.path);
|
|
1236
|
+
if (h._fwdReachedEof) continue; // forward pass saw every session_info
|
|
1237
|
+
this.nameResolveQueue.push(h);
|
|
1032
1238
|
}
|
|
1033
1239
|
this.scheduleNameResolution();
|
|
1034
1240
|
}
|
|
@@ -1051,13 +1257,14 @@ class FastResumePicker extends Container {
|
|
|
1051
1257
|
const batch = this.nameResolveQueue.splice(0, BATCH);
|
|
1052
1258
|
if (batch.length === 0) return;
|
|
1053
1259
|
|
|
1260
|
+
// Pure core: skip reached-EOF headers, bound each tail by the forward
|
|
1261
|
+
// pass's consumed bytes. Returns only paths whose tail found a session_info.
|
|
1262
|
+
const updates = resolveSessionNamesDeferred(batch, this.metaByPath);
|
|
1263
|
+
|
|
1054
1264
|
let updatedAny = false;
|
|
1055
|
-
for (const
|
|
1265
|
+
for (const [path, name] of updates) {
|
|
1056
1266
|
if (seq !== this.nameResolveSeq) return; // stale — picker exited/aborted
|
|
1057
|
-
|
|
1058
|
-
if (result.found && this.applyNameUpdate(meta.path, result.name)) {
|
|
1059
|
-
updatedAny = true;
|
|
1060
|
-
}
|
|
1267
|
+
if (this.applyNameUpdate(path, name)) updatedAny = true;
|
|
1061
1268
|
}
|
|
1062
1269
|
|
|
1063
1270
|
if (updatedAny) {
|
|
@@ -1081,6 +1288,9 @@ class FastResumePicker extends Container {
|
|
|
1081
1288
|
for (const s of arr) {
|
|
1082
1289
|
if (s.path === path) {
|
|
1083
1290
|
s.name = name;
|
|
1291
|
+
// #4 — name is part of the cached search blob; drop it so the next
|
|
1292
|
+
// matchSession rebuilds with the new name.
|
|
1293
|
+
invalidateSessionSearchText(s);
|
|
1084
1294
|
updated = true;
|
|
1085
1295
|
}
|
|
1086
1296
|
}
|
|
@@ -1096,16 +1306,16 @@ class FastResumePicker extends Container {
|
|
|
1096
1306
|
this.header.scope = "all";
|
|
1097
1307
|
|
|
1098
1308
|
if (this.allSessions !== null) {
|
|
1309
|
+
// All-scope headers are already loaded — show them.
|
|
1099
1310
|
this.header.loading = false;
|
|
1100
1311
|
this.sessionList.setSessions(this.allSessions, true);
|
|
1101
|
-
} else if (!this.allLoading) {
|
|
1102
|
-
// Start loading all sessions
|
|
1103
|
-
this.allLoading = true;
|
|
1104
|
-
this.header.loading = true;
|
|
1105
|
-
this.header.loadProgress = null;
|
|
1106
|
-
this.startAllLoadBackground();
|
|
1107
1312
|
} else {
|
|
1313
|
+
// All-scope load is in progress (started at construction: metas stat
|
|
1314
|
+
// → header batches). Show the loading indicator until it lands; the
|
|
1315
|
+
// background load updates the list as batches complete. startAllLoadBackground
|
|
1316
|
+
// is idempotent, so this also restarts the load if a refresh cancelled it.
|
|
1108
1317
|
this.header.loading = true;
|
|
1318
|
+
this.startAllLoadBackground();
|
|
1109
1319
|
}
|
|
1110
1320
|
} else {
|
|
1111
1321
|
this.scope = "current";
|
|
@@ -1156,15 +1366,21 @@ async function showFastResumePicker(
|
|
|
1156
1366
|
|
|
1157
1367
|
const t0 = Date.now();
|
|
1158
1368
|
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1161
|
-
|
|
1162
|
-
|
|
1369
|
+
// #2 — Forward-load only the top-N most-recent current-scope sessions before
|
|
1370
|
+
// first paint; the rest stream in from the background. The current-scope
|
|
1371
|
+
// stat is cheap (one dir); the all-dirs stat (~100ms at scale) is deferred
|
|
1372
|
+
// off the first-paint critical path — the picker stats all dirs in the
|
|
1373
|
+
// background before it starts the all-scope header load.
|
|
1374
|
+
const {
|
|
1375
|
+
headers: currentSessions,
|
|
1376
|
+
remaining: remainingCurrentMetas,
|
|
1377
|
+
allMetas: currentMetas,
|
|
1378
|
+
} = loadCurrentSessionsTopN(cwd, sessionDir, usesDefaultSessionDir, IMMEDIATE_CURRENT_COUNT);
|
|
1163
1379
|
|
|
1164
1380
|
const loadTime = Date.now() - t0;
|
|
1165
1381
|
|
|
1166
1382
|
ctx.ui.notify(
|
|
1167
|
-
`Fast resume: ${currentSessions.length} current
|
|
1383
|
+
`Fast resume: ${currentSessions.length} current${remainingCurrentMetas.length > 0 ? " (streaming)" : ""}, all-scope in background in ${loadTime}ms`,
|
|
1168
1384
|
"info",
|
|
1169
1385
|
);
|
|
1170
1386
|
|
|
@@ -1177,8 +1393,8 @@ async function showFastResumePicker(
|
|
|
1177
1393
|
usesDefaultSessionDir,
|
|
1178
1394
|
ctx.sessionManager.getSessionFile(),
|
|
1179
1395
|
currentSessions,
|
|
1180
|
-
|
|
1181
|
-
|
|
1396
|
+
remainingCurrentMetas,
|
|
1397
|
+
currentMetas,
|
|
1182
1398
|
(result) => done(result),
|
|
1183
1399
|
() => _tui.requestRender(),
|
|
1184
1400
|
initialQuery,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-fast-resume",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Fast session picker for pi — reads headers + first messages from 16KB partial reads instead of full-file parsing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Tom X Nguyen",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"test": "vitest run",
|
|
51
51
|
"test:watch": "vitest",
|
|
52
52
|
"test:coverage": "vitest run --coverage",
|
|
53
|
+
"bench": "vitest bench",
|
|
53
54
|
"typecheck": "tsc --noEmit",
|
|
54
55
|
"lint:dead": "knip --no-gitignore"
|
|
55
56
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export {
|
|
|
6
6
|
loadSessionHeaders,
|
|
7
7
|
loadSessionHeadersForward,
|
|
8
8
|
resolveSessionName,
|
|
9
|
+
resolveSessionNamesDeferred,
|
|
9
10
|
scanAllSessionDirs,
|
|
10
11
|
scanSessionDir,
|
|
11
12
|
sortByModified,
|
|
@@ -13,11 +14,13 @@ export {
|
|
|
13
14
|
filterByCwd,
|
|
14
15
|
matchQuery,
|
|
15
16
|
canonicalizePath,
|
|
17
|
+
clearCanonicalPathCache,
|
|
16
18
|
} from "./scanner.js";
|
|
17
19
|
export type { SessionHeader, SessionFileMeta, TailSessionInfo } from "./scanner.js";
|
|
18
20
|
export {
|
|
19
21
|
parseSearchQuery,
|
|
20
22
|
matchSession,
|
|
23
|
+
invalidateSessionSearchText,
|
|
21
24
|
hasSessionName,
|
|
22
25
|
filterAndSortSessions,
|
|
23
26
|
buildSessionTree,
|
package/src/scanner.ts
CHANGED
|
@@ -13,6 +13,17 @@ export interface SessionHeader {
|
|
|
13
13
|
messageCount: number;
|
|
14
14
|
firstMessage: string;
|
|
15
15
|
name?: string;
|
|
16
|
+
// Internal forward-pass bookkeeping for the deferred rename-name resolver.
|
|
17
|
+
// Not part of the public contract; only loadSessionHeaderForward sets these.
|
|
18
|
+
/** @internal forward pass consumed the whole file — forward name is final; the deferred tail resolver skips it. */
|
|
19
|
+
_fwdReachedEof?: boolean;
|
|
20
|
+
/** @internal bytes the forward pass consumed — lower bound for the deferred tail read so it never re-reads covered bytes. */
|
|
21
|
+
_fwdConsumedBytes?: number;
|
|
22
|
+
// Internal lazy cache for the search blob (id + name + firstMessage + cwd).
|
|
23
|
+
// Built on first matchSession call, invalidated when `name` mutates (rename
|
|
24
|
+
// resolution). Keeps per-keystroke search from re-concatenating per session.
|
|
25
|
+
/** @internal */
|
|
26
|
+
_searchText?: string;
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
export interface SessionFileMeta {
|
|
@@ -240,6 +251,14 @@ export function scanTailForSessionInfo(
|
|
|
240
251
|
for (const line of lines) {
|
|
241
252
|
const trimmed = line.trim();
|
|
242
253
|
if (!trimmed) continue;
|
|
254
|
+
// #7 — Cheap pre-filter: only session_info entries matter here, so skip
|
|
255
|
+
// JSON.parse for the (common) message lines without a full parse. The
|
|
256
|
+
// quoted marker is conservative — a message whose content literally
|
|
257
|
+
// contains "session_info" false-positives into one parse (harmless); a
|
|
258
|
+
// real session_info entry always carries it. Partial lines at the
|
|
259
|
+
// read-start boundary lack the marker and skip cheaply (JSON.parse would
|
|
260
|
+
// have thrown and been caught anyway).
|
|
261
|
+
if (!trimmed.includes('"session_info"')) continue;
|
|
243
262
|
try {
|
|
244
263
|
const entry = JSON.parse(trimmed);
|
|
245
264
|
if (typeof entry === "object" && entry !== null && entry.type === "session_info") {
|
|
@@ -271,7 +290,10 @@ function forEachLineForward(
|
|
|
271
290
|
onLine: (line: string) => boolean | void,
|
|
272
291
|
): { reachedEof: boolean; consumedBytes: number } {
|
|
273
292
|
const decoder = new StringDecoder("utf8");
|
|
274
|
-
|
|
293
|
+
// #8 — allocUnsafe: readSync fully overwrites [0, bytesRead) before the
|
|
294
|
+
// buffer is read (via decoder.write(subarray(0, bytesRead))), so the
|
|
295
|
+
// zero-fill of Buffer.alloc is wasted work. Skips a 16 KB memset per file.
|
|
296
|
+
const chunk = Buffer.allocUnsafe(READ_CHUNK_SIZE);
|
|
275
297
|
let lineBuf = "";
|
|
276
298
|
let offset = 0;
|
|
277
299
|
let consumedBytes = 0;
|
|
@@ -417,12 +439,20 @@ export function loadSessionHeaderForward(
|
|
|
417
439
|
try {
|
|
418
440
|
fd = openSync(meta.path, "r");
|
|
419
441
|
const acc = newAccumulator();
|
|
420
|
-
const { reachedEof } = forEachLineForward(fd, meta.size, (line) => {
|
|
442
|
+
const { reachedEof, consumedBytes } = forEachLineForward(fd, meta.size, (line) => {
|
|
421
443
|
processEntry(acc, line);
|
|
422
444
|
if (acc.header && acc.foundFirstUser) return false;
|
|
423
445
|
return true;
|
|
424
446
|
});
|
|
425
|
-
|
|
447
|
+
const header = buildHeader(acc, meta.path, meta.mtimeMs, reachedEof);
|
|
448
|
+
if (header) {
|
|
449
|
+
// Carry forward-pass bookkeeping so the deferred rename-name resolver can
|
|
450
|
+
// skip files whose forward pass reached EOF (name already final) and bound
|
|
451
|
+
// its tail read below by the consumed bytes (never re-reads covered bytes).
|
|
452
|
+
header._fwdReachedEof = reachedEof;
|
|
453
|
+
header._fwdConsumedBytes = consumedBytes;
|
|
454
|
+
}
|
|
455
|
+
return header;
|
|
426
456
|
} catch {
|
|
427
457
|
return null;
|
|
428
458
|
} finally {
|
|
@@ -439,15 +469,30 @@ export function loadSessionHeaderForward(
|
|
|
439
469
|
// This is the deferred half of loadSessionHeader, exposed so callers can show
|
|
440
470
|
// a row immediately with the forward name and resolve the rename name in the
|
|
441
471
|
// background. Reading up to TAIL_READ_SIZE bytes from EOF may overlap the
|
|
442
|
-
// forward region for small files
|
|
443
|
-
//
|
|
444
|
-
|
|
472
|
+
// forward region for small files. Pass `consumedBytesLowerBound` (the forward
|
|
473
|
+
// pass's consumedBytes) so the tail starts at/after the bytes the forward pass
|
|
474
|
+
// already parsed — avoiding a redundant re-read and re-parse of that range.
|
|
475
|
+
// When the bound equals the file size (the forward pass reached EOF) there are
|
|
476
|
+
// no bytes left to read and this returns found:false; callers that already
|
|
477
|
+
// know the forward pass reached EOF should skip the call entirely (see
|
|
478
|
+
// resolveSessionNamesDeferred).
|
|
479
|
+
export function resolveSessionName(
|
|
480
|
+
meta: SessionFileMeta,
|
|
481
|
+
options?: { consumedBytesLowerBound?: number },
|
|
482
|
+
): TailSessionInfo {
|
|
445
483
|
if (meta.size <= 0) return { found: false };
|
|
484
|
+
// Bound the tail below by the forward pass's consumed bytes so it never
|
|
485
|
+
// re-reads bytes the forward pass already covered. Matches the combined
|
|
486
|
+
// loadSessionHeader path's tail math: tailReadSize = min(TAIL, size - bound),
|
|
487
|
+
// tailOffset = size - tailReadSize (>= bound).
|
|
488
|
+
const lowerBound = Math.max(0, Math.min(options?.consumedBytesLowerBound ?? 0, meta.size));
|
|
489
|
+
const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size - lowerBound);
|
|
490
|
+
if (tailReadSize <= 0) return { found: false };
|
|
446
491
|
let fd: number | undefined;
|
|
447
492
|
try {
|
|
448
493
|
fd = openSync(meta.path, "r");
|
|
449
|
-
|
|
450
|
-
const tailBuf = Buffer.
|
|
494
|
+
// #8 — allocUnsafe: readSync overwrites [0, bytesRead) before use.
|
|
495
|
+
const tailBuf = Buffer.allocUnsafe(tailReadSize);
|
|
451
496
|
const tailOffset = meta.size - tailReadSize;
|
|
452
497
|
const tailBytesRead = readSync(fd, tailBuf, 0, tailReadSize, tailOffset);
|
|
453
498
|
return scanTailForSessionInfo(tailBuf, tailBytesRead);
|
|
@@ -458,6 +503,28 @@ export function resolveSessionName(meta: SessionFileMeta): TailSessionInfo {
|
|
|
458
503
|
}
|
|
459
504
|
}
|
|
460
505
|
|
|
506
|
+
// Resolve rename names for a batch of forward-loaded headers, the pure core of
|
|
507
|
+
// the picker's cooperative name-resolution drain. Skips headers whose forward
|
|
508
|
+
// pass reached EOF (their name is already final — no tail read needed) and
|
|
509
|
+
// bounds each remaining tail read below by the forward pass's consumed bytes
|
|
510
|
+
// (never re-reads already-covered bytes). Returns only paths whose tail found
|
|
511
|
+
// a session_info (name may be undefined for an explicit clear), for the caller
|
|
512
|
+
// to apply in-place. Exposed for direct testing and benchmarking.
|
|
513
|
+
export function resolveSessionNamesDeferred(
|
|
514
|
+
headers: SessionHeader[],
|
|
515
|
+
metaByPath: Map<string, SessionFileMeta>,
|
|
516
|
+
): Map<string, string | undefined> {
|
|
517
|
+
const updates = new Map<string, string | undefined>();
|
|
518
|
+
for (const h of headers) {
|
|
519
|
+
if (h._fwdReachedEof) continue; // forward pass saw every session_info
|
|
520
|
+
const meta = metaByPath.get(h.path);
|
|
521
|
+
if (!meta) continue;
|
|
522
|
+
const tail = resolveSessionName(meta, { consumedBytesLowerBound: h._fwdConsumedBytes });
|
|
523
|
+
if (tail.found) updates.set(h.path, tail.name);
|
|
524
|
+
}
|
|
525
|
+
return updates;
|
|
526
|
+
}
|
|
527
|
+
|
|
461
528
|
// Load a session header using a streaming forward read plus a bounded tail read
|
|
462
529
|
// — forward + tail in one shared fd. Equivalent to loadSessionHeaderForward
|
|
463
530
|
// followed by resolveSessionName, but bounds the tail below by the forward stop
|
|
@@ -490,7 +557,8 @@ export function loadSessionHeader(
|
|
|
490
557
|
if (!forwardReachedEof) {
|
|
491
558
|
try {
|
|
492
559
|
const tailReadSize = Math.min(TAIL_READ_SIZE, meta.size - consumedBytes);
|
|
493
|
-
|
|
560
|
+
// #8 — allocUnsafe: readSync overwrites [0, bytesRead) before use.
|
|
561
|
+
const tailBuf = Buffer.allocUnsafe(tailReadSize);
|
|
494
562
|
const tailOffset = meta.size - tailReadSize;
|
|
495
563
|
const tailBytesRead = readSync(fd, tailBuf, 0, tailReadSize, tailOffset);
|
|
496
564
|
tailInfo = scanTailForSessionInfo(tailBuf, tailBytesRead);
|
|
@@ -557,16 +625,37 @@ export function filterByCwd(
|
|
|
557
625
|
});
|
|
558
626
|
}
|
|
559
627
|
|
|
628
|
+
// Process-lifetime memo of realpath results. canonicalizePath is called
|
|
629
|
+
// 2–3× per session per tree build (buildSessionTree) and once per visible row
|
|
630
|
+
// per render (isCurrentSessionPath), and the tree is rebuilt on every keystroke
|
|
631
|
+
// in threaded mode. realpathSync is a syscall (~µs each); memoizing collapses
|
|
632
|
+
// thousands of syscalls per keystroke to Map lookups. This is pure memoization
|
|
633
|
+
// (no persistent file, no staleness to manage) — realpath of a path is stable
|
|
634
|
+
// for the process lifetime unless a symlink target changes, which doesn't
|
|
635
|
+
// happen to session files under ~/.pi/agent/sessions. Call
|
|
636
|
+
// clearCanonicalPathCache() to reset (used by tests/benches).
|
|
637
|
+
const canonicalPathCache = new Map<string, string>();
|
|
638
|
+
|
|
560
639
|
/**
|
|
561
|
-
* Canonicalize a file path by resolving symlinks.
|
|
640
|
+
* Canonicalize a file path by resolving symlinks, memoized per process.
|
|
562
641
|
* Matches pi-core's canonicalizePath behavior (realpathSync with fallback).
|
|
563
642
|
*/
|
|
564
643
|
export function canonicalizePath(path: string): string {
|
|
644
|
+
const cached = canonicalPathCache.get(path);
|
|
645
|
+
if (cached !== undefined) return cached;
|
|
646
|
+
let result: string;
|
|
565
647
|
try {
|
|
566
|
-
|
|
648
|
+
result = realpathSync(path);
|
|
567
649
|
} catch {
|
|
568
|
-
|
|
650
|
+
result = path;
|
|
569
651
|
}
|
|
652
|
+
canonicalPathCache.set(path, result);
|
|
653
|
+
return result;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Clear the canonicalizePath memo. Intended for tests/benches. */
|
|
657
|
+
export function clearCanonicalPathCache(): void {
|
|
658
|
+
canonicalPathCache.clear();
|
|
570
659
|
}
|
|
571
660
|
|
|
572
661
|
export function matchQuery(
|
package/src/search.ts
CHANGED
|
@@ -45,9 +45,22 @@ function normalizeWhitespaceLower(text: string): string {
|
|
|
45
45
|
* matches are unaffected.
|
|
46
46
|
*
|
|
47
47
|
* See README "Known Limitations" section for the user-facing explanation.
|
|
48
|
+
*
|
|
49
|
+
* Memoized on the session (`_searchText`): matchSession is called per session
|
|
50
|
+
* per token per keystroke, so the concatenation is built once on first search
|
|
51
|
+
* and reused thereafter. Invalidate via invalidateSessionSearchText when
|
|
52
|
+
* `name` mutates (the picker's rename-name resolution updates names in place).
|
|
48
53
|
*/
|
|
49
54
|
function getSessionSearchText(session: SessionHeader): string {
|
|
50
|
-
|
|
55
|
+
if (session._searchText !== undefined) return session._searchText;
|
|
56
|
+
const text = `${session.id} ${session.name ?? ""} ${session.firstMessage} ${session.cwd}`;
|
|
57
|
+
session._searchText = text;
|
|
58
|
+
return text;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Drop the cached search blob so the next getSessionSearchText rebuilds it. */
|
|
62
|
+
export function invalidateSessionSearchText(session: SessionHeader): void {
|
|
63
|
+
session._searchText = undefined;
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
export function parseSearchQuery(query: string): ParsedSearch {
|
package/vitest.config.ts
CHANGED
|
@@ -9,7 +9,10 @@ export default defineConfig({
|
|
|
9
9
|
coverage: {
|
|
10
10
|
provider: "v8",
|
|
11
11
|
reporter: ["text", "json", "html"],
|
|
12
|
-
exclude: ["node_modules/", "**/*.d.ts", "**/*.test.ts"],
|
|
12
|
+
exclude: ["node_modules/", "**/*.d.ts", "**/*.test.ts", "**/*.bench.ts"],
|
|
13
13
|
},
|
|
14
14
|
},
|
|
15
|
+
// `vitest bench` discovers bench files via its default glob
|
|
16
|
+
// (**/*.{bench,benchmark}.*); `vitest run` only uses test.include above, so
|
|
17
|
+
// __tests__/perf.bench.ts is benchmark-only and never runs as a test.
|
|
15
18
|
});
|