Skip to content

Commit 54c9699

Browse files
authored
Merge pull request #191 from pajoma/187-performance-input-box-slow-on-first-invocation-cold-start-latency
perf: input box cold-start + cache short-circuit (#187)
2 parents 24d6ea8 + eec2f8c commit 54c9699

5 files changed

Lines changed: 202 additions & 19 deletions

File tree

src/ext/dialogues.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,16 @@ import { sortPickEntries } from '../provider';
3636
*/
3737
export class Dialogues {
3838

39-
private scanner;
39+
private scanner: J.Provider.ScanEntries;
4040

4141
constructor(public ctrl: J.Util.Ctrl) {
4242
this.scanner = new J.Provider.ScanEntries(this.ctrl);
4343
}
4444

45+
public getScanner(): J.Provider.ScanEntries {
46+
return this.scanner;
47+
}
48+
4549

4650
/**
4751
*
@@ -570,13 +574,35 @@ function addItemToPickList(entries: J.Model.FileEntry[], input: J.Model.TimedQui
570574
fileEntry: fe,
571575
description: displayDescription
572576
};
573-
input.items = input.items.concat(item);
577+
items.push(item);
574578

575579
});
576580

577-
578-
/* we have to sort the items list */
579-
input.items = Array.from(input.items).sort((a, b) => sortPickEntries(a.fileEntry!, b.fileEntry!));
581+
/* Sorted insertion into input.items via binary search (#187): avoids the O(n² log n)
582+
cost of re-sorting the growing items array on every directory-level callback. */
583+
if (items.length > 0) {
584+
const merged = Array.from(input.items);
585+
for (const item of items) {
586+
if (!item.fileEntry) {
587+
merged.push(item);
588+
continue;
589+
}
590+
let lo = 0;
591+
let hi = merged.length;
592+
while (lo < hi) {
593+
const mid = (lo + hi) >>> 1;
594+
const midEntry = merged[mid].fileEntry;
595+
// items without fileEntry (the placeholder rows) stay at the front
596+
if (!midEntry || sortPickEntries(midEntry, item.fileEntry) <= 0) {
597+
lo = mid + 1;
598+
} else {
599+
hi = mid;
600+
}
601+
}
602+
merged.splice(lo, 0, item);
603+
}
604+
input.items = merged;
605+
}
580606

581607
/* Some voodoo to stop the spinner. Since it's a mess to find out when the recursive directory walker is finished, we simply finish after 3 seconds. */
582608
if ((input.items.length > 20) || (((new Date().getTime()) - input.start!) > 3000)) {

src/ext/startup.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export class Startup {
4141
.then(() => this.registerCommands(this.ctrl, context))
4242
.then(() => this.registerCodeActions(this.ctrl, context))
4343
.then(() => this.registerSyntaxHighlighting(this.ctrl))
44+
.then(() => this.registerCacheInvalidation(this.ctrl, context))
4445

4546
.then((ctrl) => {
4647
console.timeEnd("startup");
@@ -141,6 +142,15 @@ export class Startup {
141142

142143
}
143144

145+
public async registerCacheInvalidation(ctrl: J.Util.Ctrl, context: vscode.ExtensionContext): Promise<void> {
146+
try {
147+
const scanner = ctrl.ui.getScanner();
148+
context.subscriptions.push(...scanner.registerInvalidationListeners());
149+
} catch (error) {
150+
ctrl.logger.error("Failed to register cache invalidation listeners, reason: ", error);
151+
}
152+
}
153+
144154
public async registerCodeActions(ctrl: J.Util.Ctrl, context: vscode.ExtensionContext): Promise<void> {
145155
try {
146156

src/provider/features/scan-entries.ts

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ export class ScanEntries {
1717
this.cache = new Map();
1818
}
1919

20+
/**
21+
* Discards cached entries so the next scan re-reads the filesystem.
22+
*/
23+
public clearCache(): void {
24+
this.cache.clear();
25+
}
26+
2027

2128

2229
/**
@@ -75,16 +82,18 @@ export class ScanEntries {
7582

7683
this.ctrl.logger.trace("Entering getPreviouslyAccessedFiles() in actions/reader.ts and number of directories to scan: ", directories.size);
7784

78-
// we add everything from the cache
85+
// Cache short-circuit (#187): if we have already scanned, return cached entries and
86+
// skip the filesystem walk entirely. Cache is invalidated explicitly via clearCache()
87+
// — wire workspace file-event listeners through registerInvalidationListeners().
7988
if (this.cache.size > 0) {
8089
let cachedEntries: FileEntry[] = Array.from(this.cache.values()).filter(fe => fe.type === type).sort(sortPickEntries);
8190
callback(cachedEntries, picker, type);
91+
return;
8292
}
8393

84-
8594
// we have to live with duplicates in the set of directories (which also means we have to live with non-deterministic scope resolution)
8695

87-
// we scan the scopes first
96+
// we scan the scopes first
8897
Array.from(directories)
8998
.filter(dir => dir.scope !== SCOPE_DEFAULT)
9099
.forEach(dir => this.scanDirectory(thresholdInMs, callback, picker, type, dir));
@@ -94,6 +103,17 @@ export class ScanEntries {
94103
.forEach(dir => this.scanDirectory(thresholdInMs, callback, picker, type, dir));
95104
}
96105

106+
/**
107+
* Registers workspace file-create/delete listeners that clear the cache so the next
108+
* scan re-reads the filesystem. Caller (Startup) owns the returned disposables.
109+
*/
110+
public registerInvalidationListeners(): vscode.Disposable[] {
111+
const onCreate = vscode.workspace.onDidCreateFiles(() => this.clearCache());
112+
const onDelete = vscode.workspace.onDidDeleteFiles(() => this.clearCache());
113+
const onRename = vscode.workspace.onDidRenameFiles(() => this.clearCache());
114+
return [onCreate, onDelete, onRename];
115+
}
116+
97117

98118
private async scanDirectory(thresholdInMs: number, callback: Function, picker: any, type: J.Model.JournalPageType, directory: J.Model.ScopeDirectory): Promise<void> {
99119
try {
@@ -139,31 +159,43 @@ export class ScanEntries {
139159
return; // ignore errors
140160
}
141161

142-
const foundFiles: FileEntry[] = [];
162+
// Partition into files (need stat) and subdirectories (need recursion). Issue #187:
163+
// stat calls are fanned out per directory level so remote filesystems do not pay
164+
// sequential round-trip latency for every file.
165+
const files: { name: string; childPath: string }[] = [];
166+
const subdirs: string[] = [];
143167

144168
for (const [name, type] of entries) {
145169
if (name.startsWith(".")) { continue; }
146170
const childPath = Path.join(dir, name);
147-
148171
if (type === vscode.FileType.Directory) {
149-
await this.walkDir(childPath, thresholdInMs, callback);
172+
subdirs.push(childPath);
150173
} else {
174+
files.push({ name, childPath });
175+
}
176+
}
177+
178+
const statResults = await Promise.all(
179+
files.map(async ({ name, childPath }) => {
151180
try {
152181
const stat = await vscode.workspace.fs.stat(vscode.Uri.file(childPath));
153-
foundFiles.push({
182+
return {
154183
path: childPath,
155-
name: name,
184+
name,
156185
updateAt: stat.mtime,
157186
accessedAt: stat.mtime, // vscode.FileStat does not expose atime
158187
createdAt: stat.ctime
159-
});
188+
} as FileEntry;
160189
} catch {
161-
// skip files we can't stat
190+
return undefined;
162191
}
163-
}
164-
}
192+
})
193+
);
165194

195+
const foundFiles: FileEntry[] = statResults.filter((f): f is FileEntry => f !== undefined);
166196
callback(foundFiles);
197+
198+
await Promise.all(subdirs.map(d => this.walkDir(d, thresholdInMs, callback)));
167199
}
168200

169201
// deprecated — converted to async vscode.workspace.fs for remote compatibility
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import * as assert from 'assert';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
import * as vscode from 'vscode';
5+
import * as J from '../..';
6+
import { ScanEntries } from '../../provider/features/scan-entries';
7+
import { SCOPE_DEFAULT } from '../../ext';
8+
import { JournalPageType, ScopeDirectory } from '../../model';
9+
import { TestLogger } from '../test-logger';
10+
11+
async function seedEntry(base: string, year: number, month: number, day: number, content = '# Entry\n'): Promise<void> {
12+
const yy = String(year).padStart(4, '0');
13+
const mm = String(month).padStart(2, '0');
14+
const dd = String(day).padStart(2, '0');
15+
const dir = vscode.Uri.file(path.join(base, yy, mm));
16+
await vscode.workspace.fs.createDirectory(dir);
17+
const file = vscode.Uri.file(path.join(base, yy, mm, `${dd}.md`));
18+
await vscode.workspace.fs.writeFile(file, new TextEncoder().encode(content));
19+
}
20+
21+
suite('Issue #187 — ScanEntries cache short-circuit and invalidation', () => {
22+
let originalBase: string | undefined;
23+
let tmpBase: string;
24+
let ctrl: J.Util.Ctrl;
25+
let scanner: ScanEntries;
26+
let walkCount: number;
27+
let originalWalkDir: any;
28+
29+
setup(async () => {
30+
const config = vscode.workspace.getConfiguration('journal');
31+
originalBase = config.get<string>('base');
32+
33+
tmpBase = path.join(os.tmpdir(), `issue187-base-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
34+
await vscode.workspace.fs.createDirectory(vscode.Uri.file(tmpBase));
35+
await config.update('base', tmpBase, vscode.ConfigurationTarget.Workspace);
36+
37+
await seedEntry(tmpBase, 2025, 3, 5);
38+
await seedEntry(tmpBase, 2025, 3, 8);
39+
await seedEntry(tmpBase, 2025, 4, 1);
40+
41+
const refreshed = vscode.workspace.getConfiguration('journal');
42+
ctrl = new J.Util.Ctrl(refreshed);
43+
ctrl.logger = new TestLogger(false);
44+
scanner = new ScanEntries(ctrl);
45+
46+
walkCount = 0;
47+
originalWalkDir = (ScanEntries.prototype as any).walkDir;
48+
(ScanEntries.prototype as any).walkDir = async function (dir: string, threshold: number, callback: Function): Promise<void> {
49+
if (typeof dir === 'string' && dir.startsWith(tmpBase)) {
50+
walkCount++;
51+
}
52+
return originalWalkDir.call(this, dir, threshold, callback);
53+
};
54+
});
55+
56+
teardown(async () => {
57+
(ScanEntries.prototype as any).walkDir = originalWalkDir;
58+
const config = vscode.workspace.getConfiguration('journal');
59+
await config.update('base', originalBase, vscode.ConfigurationTarget.Workspace);
60+
try { await vscode.workspace.fs.delete(vscode.Uri.file(tmpBase), { recursive: true }); } catch { /* ignore */ }
61+
});
62+
63+
async function runScan(): Promise<void> {
64+
const directories = new Set<ScopeDirectory>([{ path: tmpBase, scope: SCOPE_DEFAULT }]);
65+
let resolveDone: () => void = () => { /* set below */ };
66+
const done = new Promise<void>(resolve => { resolveDone = resolve; });
67+
68+
let pendingDirs = 0;
69+
let walkStarted = false;
70+
71+
const callback = (_entries: any[], _picker: any, _type: any) => {
72+
// first callback signals at least one walk pass completed
73+
if (!walkStarted) {
74+
walkStarted = true;
75+
}
76+
};
77+
78+
await scanner.getPreviouslyAccessedFiles(Date.now() - 1000 * 60 * 60 * 24 * 365, callback as any, null, JournalPageType.entry, directories);
79+
// scanDirectory is fire-and-forget inside getPreviouslyAccessedFiles; let the microtask queue drain
80+
await new Promise(resolve => setTimeout(resolve, 100));
81+
// silence unused warnings
82+
void pendingDirs; void resolveDone; void done;
83+
}
84+
85+
test('first scan walks the filesystem', async () => {
86+
await runScan();
87+
assert.ok(walkCount > 0, `expected ScanEntries.walkDir to run on first scan, got ${walkCount}`);
88+
});
89+
90+
test('second scan with populated cache does NOT walk the filesystem', async () => {
91+
await runScan();
92+
const firstCount = walkCount;
93+
assert.ok(firstCount > 0, 'precondition: first scan must have walked the FS');
94+
95+
walkCount = 0;
96+
await runScan();
97+
98+
assert.strictEqual(walkCount, 0, `expected zero walkDir calls on cached scan, got ${walkCount}`);
99+
});
100+
101+
test('clearCache() restores dirty state — next scan walks again', async () => {
102+
await runScan();
103+
assert.ok(walkCount > 0);
104+
105+
scanner.clearCache();
106+
107+
walkCount = 0;
108+
await runScan();
109+
110+
assert.ok(walkCount > 0, `expected fresh walk after clearCache, got ${walkCount}`);
111+
});
112+
});

src/util/logger.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
//
1818

1919

20-
import moment = require('moment');
2120
import * as vscode from 'vscode';
2221
import * as J from '../.';
2322

@@ -131,8 +130,12 @@ export class ConsoleLogger implements Logger {
131130

132131

133132
private appendCurrentTime() : void {
133+
// HH:mm:ss.SSS — native to drop moment from the activation path (#187)
134+
const d = new Date();
135+
const pad = (n: number, w: number = 2) => String(n).padStart(w, '0');
136+
const stamp = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
134137
this.channel.append("[");
135-
this.channel.append(moment(new Date()).format('HH:mm:ss.SSS'));
138+
this.channel.append(stamp);
136139
this.channel.append("]");
137140
}
138141
}

0 commit comments

Comments
 (0)