Skip to content

Commit ccba8ca

Browse files
authored
track usage of snippets to support snippet LRU (microsoft#155289)
enforce that all snippets have an identifier, mark a snippet as used after completing with it or after inserting one, store the last 100 snippet usages per (user, profile)
1 parent 6f6e26f commit ccba8ca

File tree

10 files changed

+183
-83
lines changed

10 files changed

+183
-83
lines changed

src/vs/workbench/contrib/snippets/browser/insertSnippet.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ class InsertSnippetAction extends EditorAction {
102102
snippet,
103103
'',
104104
SnippetSource.User,
105+
`random/${Math.random()}`
105106
));
106107
}
107108

@@ -143,6 +144,7 @@ class InsertSnippetAction extends EditorAction {
143144
clipboardText = await clipboardService.readText();
144145
}
145146
SnippetController2.get(editor)?.insert(snippet.codeSnippet, { clipboardText });
147+
snippetService.updateUsageTimestamp(snippet);
146148
}
147149
}
148150

src/vs/workbench/contrib/snippets/browser/snippetCompletionProvider.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { compare, compareSubstring } from 'vs/base/common/strings';
88
import { Position } from 'vs/editor/common/core/position';
99
import { IRange, Range } from 'vs/editor/common/core/range';
1010
import { ITextModel } from 'vs/editor/common/model';
11-
import { CompletionItem, CompletionItemKind, CompletionItemProvider, CompletionList, CompletionItemInsertTextRule, CompletionContext, CompletionTriggerKind, CompletionItemLabel } from 'vs/editor/common/languages';
11+
import { CompletionItem, CompletionItemKind, CompletionItemProvider, CompletionList, CompletionItemInsertTextRule, CompletionContext, CompletionTriggerKind, CompletionItemLabel, Command } from 'vs/editor/common/languages';
1212
import { ILanguageService } from 'vs/editor/common/languages/language';
1313
import { SnippetParser } from 'vs/editor/contrib/snippet/browser/snippetParser';
1414
import { localize } from 'vs/nls';
@@ -19,6 +19,18 @@ import { StopWatch } from 'vs/base/common/stopwatch';
1919
import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry';
2020
import { getWordAtText } from 'vs/editor/common/core/wordHelper';
2121
import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions';
22+
import { CommandsRegistry } from 'vs/platform/commands/common/commands';
23+
24+
25+
const markSnippetAsUsed = '_snippet.markAsUsed';
26+
27+
CommandsRegistry.registerCommand(markSnippetAsUsed, (accessor, ...args) => {
28+
const snippetsService = accessor.get(ISnippetsService);
29+
const [first] = args;
30+
if (first instanceof Snippet) {
31+
snippetsService.updateUsageTimestamp(first);
32+
}
33+
});
2234

2335
export class SnippetCompletion implements CompletionItem {
2436

@@ -31,10 +43,11 @@ export class SnippetCompletion implements CompletionItem {
3143
kind: CompletionItemKind;
3244
insertTextRules: CompletionItemInsertTextRule;
3345
extensionId?: ExtensionIdentifier;
46+
command?: Command;
3447

3548
constructor(
3649
readonly snippet: Snippet,
37-
range: IRange | { insert: IRange; replace: IRange }
50+
range: IRange | { insert: IRange; replace: IRange },
3851
) {
3952
this.label = { label: snippet.prefix, description: snippet.name };
4053
this.detail = localize('detail.snippet', "{0} ({1})", snippet.description || snippet.name, snippet.source);
@@ -44,6 +57,7 @@ export class SnippetCompletion implements CompletionItem {
4457
this.sortText = `${snippet.snippetSource === SnippetSource.Extension ? 'z' : 'a'}-${snippet.prefix}`;
4558
this.kind = CompletionItemKind.Snippet;
4659
this.insertTextRules = CompletionItemInsertTextRule.InsertAsSnippet;
60+
this.command = { id: markSnippetAsUsed, title: '', arguments: [snippet] };
4761
}
4862

4963
resolve(): this {

src/vs/workbench/contrib/snippets/browser/snippetPicker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export async function pickSnippet(accessor: ServicesAccessor, languageIdOrSnippe
2727
snippets = (await snippetService.getSnippets(languageIdOrSnippets, { includeDisabledSnippets: true, includeNoPrefixSnippets: true }));
2828
}
2929

30-
snippets.sort(Snippet.compare);
30+
snippets.sort((a, b) => a.snippetSource - b.snippetSource);
3131

3232
const makeSnippetPicks = () => {
3333
const result: QuickPickInput<ISnippetPick>[] = [];

src/vs/workbench/contrib/snippets/browser/snippets.contribution.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const ISnippetsService = createDecorator<ISnippetsService>('snippetServic
1515
export interface ISnippetGetOptions {
1616
includeDisabledSnippets?: boolean;
1717
includeNoPrefixSnippets?: boolean;
18+
noRecencySort?: boolean;
1819
}
1920

2021
export interface ISnippetsService {
@@ -27,6 +28,8 @@ export interface ISnippetsService {
2728

2829
updateEnablement(snippet: Snippet, enabled: boolean): void;
2930

31+
updateUsageTimestamp(snippet: Snippet): void;
32+
3033
getSnippets(languageId: string, opt?: ISnippetGetOptions): Promise<Snippet[]>;
3134

3235
getSnippetsSync(languageId: string, opt?: ISnippetGetOptions): Snippet[];

src/vs/workbench/contrib/snippets/browser/snippetsFile.ts

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export class Snippet {
113113
readonly body: string,
114114
readonly source: string,
115115
readonly snippetSource: SnippetSource,
116-
readonly snippetIdentifier?: string,
116+
readonly snippetIdentifier: string,
117117
readonly extensionId?: ExtensionIdentifier,
118118
) {
119119
this.prefixLow = prefix.toLowerCase();
@@ -139,24 +139,6 @@ export class Snippet {
139139
get usesSelection(): boolean {
140140
return this._bodyInsights.value.usesSelectionVariable;
141141
}
142-
143-
static compare(a: Snippet, b: Snippet): number {
144-
if (a.snippetSource < b.snippetSource) {
145-
return -1;
146-
} else if (a.snippetSource > b.snippetSource) {
147-
return 1;
148-
} else if (a.source < b.source) {
149-
return -1;
150-
} else if (a.source > b.source) {
151-
return 1;
152-
} else if (a.name > b.name) {
153-
return 1;
154-
} else if (a.name < b.name) {
155-
return -1;
156-
} else {
157-
return 0;
158-
}
159-
}
160142
}
161143

162144

@@ -195,7 +177,7 @@ export class SnippetFile {
195177
public defaultScopes: string[] | undefined,
196178
private readonly _extension: IExtensionDescription | undefined,
197179
private readonly _fileService: IFileService,
198-
private readonly _extensionResourceLoaderService: IExtensionResourceLoaderService
180+
private readonly _extensionResourceLoaderService: IExtensionResourceLoaderService,
199181
) {
200182
this.isGlobalSnippets = extname(location.path) === '.code-snippets';
201183
this.isUserSnippets = !this._extension;
@@ -330,7 +312,7 @@ export class SnippetFile {
330312
body,
331313
source,
332314
this.source,
333-
this._extension && `${relativePath(this._extension.extensionLocation, this.location)}/${name}`,
315+
this._extension ? `${relativePath(this._extension.extensionLocation, this.location)}/${name}` : `${basename(this.location.path)}/${name}`,
334316
this._extension?.identifier,
335317
));
336318
}

src/vs/workbench/contrib/snippets/browser/snippetsService.ts

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,42 @@ class SnippetEnablement {
167167
}
168168
}
169169

170+
class SnippetUsageTimestamps {
171+
172+
private static _key = 'snippets.usageTimestamps';
173+
174+
private readonly _usages: Map<string, number>;
175+
176+
constructor(
177+
@IStorageService private readonly _storageService: IStorageService,
178+
) {
179+
180+
const raw = _storageService.get(SnippetUsageTimestamps._key, StorageScope.PROFILE, '');
181+
let data: [string, number][] | undefined;
182+
try {
183+
data = JSON.parse(raw);
184+
} catch {
185+
data = [];
186+
}
187+
188+
this._usages = Array.isArray(data) ? new Map(data) : new Map();
189+
}
190+
191+
getUsageTimestamp(id: string): number | undefined {
192+
return this._usages.get(id);
193+
}
194+
195+
updateUsageTimestamp(id: string): void {
196+
// map uses insertion order, we want most recent at the end
197+
this._usages.delete(id);
198+
this._usages.set(id, Date.now());
199+
200+
// persist last 100 item
201+
const all = [...this._usages].slice(-100);
202+
this._storageService.store(SnippetUsageTimestamps._key, JSON.stringify(all), StorageScope.PROFILE, StorageTarget.USER);
203+
}
204+
}
205+
170206
class SnippetsService implements ISnippetsService {
171207

172208
declare readonly _serviceBrand: undefined;
@@ -175,6 +211,7 @@ class SnippetsService implements ISnippetsService {
175211
private readonly _pendingWork: Promise<any>[] = [];
176212
private readonly _files = new ResourceMap<SnippetFile>();
177213
private readonly _enablement: SnippetEnablement;
214+
private readonly _usageTimestamps: SnippetUsageTimestamps;
178215

179216
constructor(
180217
@IEnvironmentService private readonly _environmentService: IEnvironmentService,
@@ -198,20 +235,23 @@ class SnippetsService implements ISnippetsService {
198235
setSnippetSuggestSupport(new SnippetCompletionProvider(this._languageService, this, languageConfigurationService));
199236

200237
this._enablement = instantiationService.createInstance(SnippetEnablement);
238+
this._usageTimestamps = instantiationService.createInstance(SnippetUsageTimestamps);
201239
}
202240

203241
dispose(): void {
204242
this._disposables.dispose();
205243
}
206244

207245
isEnabled(snippet: Snippet): boolean {
208-
return !snippet.snippetIdentifier || !this._enablement.isIgnored(snippet.snippetIdentifier);
246+
return !this._enablement.isIgnored(snippet.snippetIdentifier);
209247
}
210248

211249
updateEnablement(snippet: Snippet, enabled: boolean): void {
212-
if (snippet.snippetIdentifier) {
213-
this._enablement.updateIgnored(snippet.snippetIdentifier, !enabled);
214-
}
250+
this._enablement.updateIgnored(snippet.snippetIdentifier, !enabled);
251+
}
252+
253+
updateUsageTimestamp(snippet: Snippet): void {
254+
this._usageTimestamps.updateUsageTimestamp(snippet.snippetIdentifier);
215255
}
216256

217257
private _joinSnippets(): Promise<any> {
@@ -240,7 +280,7 @@ class SnippetsService implements ISnippetsService {
240280
}
241281
}
242282
await Promise.all(promises);
243-
return this._filterSnippets(result, opts);
283+
return this._filterAndSortSnippets(result, opts);
244284
}
245285

246286
getSnippetsSync(languageId: string, opts?: ISnippetGetOptions): Snippet[] {
@@ -253,14 +293,45 @@ class SnippetsService implements ISnippetsService {
253293
file.select(languageId, result);
254294
}
255295
}
256-
return this._filterSnippets(result, opts);
296+
return this._filterAndSortSnippets(result, opts);
257297
}
258298

259-
private _filterSnippets(snippets: Snippet[], opts?: ISnippetGetOptions): Snippet[] {
260-
return snippets.filter(snippet => {
299+
private _filterAndSortSnippets(snippets: Snippet[], opts?: ISnippetGetOptions): Snippet[] {
300+
const result = snippets.filter(snippet => {
261301
return (snippet.prefix || opts?.includeNoPrefixSnippets) // prefix or no-prefix wanted
262302
&& (this.isEnabled(snippet) || opts?.includeDisabledSnippets); // enabled or disabled wanted
263303
});
304+
305+
return result.sort((a, b) => {
306+
let result = 0;
307+
if (!opts?.noRecencySort) {
308+
const val1 = this._usageTimestamps.getUsageTimestamp(a.snippetIdentifier) ?? -1;
309+
const val2 = this._usageTimestamps.getUsageTimestamp(b.snippetIdentifier) ?? -1;
310+
result = val2 - val1;
311+
}
312+
if (result === 0) {
313+
result = this._compareSnippet(a, b);
314+
}
315+
return result;
316+
});
317+
}
318+
319+
private _compareSnippet(a: Snippet, b: Snippet): number {
320+
if (a.snippetSource < b.snippetSource) {
321+
return -1;
322+
} else if (a.snippetSource > b.snippetSource) {
323+
return 1;
324+
} else if (a.source < b.source) {
325+
return -1;
326+
} else if (a.source > b.source) {
327+
return 1;
328+
} else if (a.name > b.name) {
329+
return 1;
330+
} else if (a.name < b.name) {
331+
return -1;
332+
} else {
333+
return 0;
334+
}
264335
}
265336

266337
// --- loading, watching

src/vs/workbench/contrib/snippets/browser/surroundWithSnippet.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ class SurroundWithSnippetEditorAction extends EditorAction2 {
8383
}
8484

8585
SnippetController2.get(editor)?.insert(snippet.codeSnippet, { clipboardText });
86+
snippetService.updateUsageTimestamp(snippet);
8687
}
8788
}
8889

src/vs/workbench/contrib/snippets/test/browser/snippetFile.test.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as assert from 'assert';
77
import { SnippetFile, Snippet, SnippetSource } from 'vs/workbench/contrib/snippets/browser/snippetsFile';
88
import { URI } from 'vs/base/common/uri';
99
import { SnippetParser } from 'vs/editor/contrib/snippet/browser/snippetParser';
10+
import { generateUuid } from 'vs/base/common/uuid';
1011

1112
suite('Snippets', function () {
1213

@@ -24,12 +25,12 @@ suite('Snippets', function () {
2425
assert.strictEqual(bucket.length, 0);
2526

2627
file = new TestSnippetFile(URI.file('somepath/foo.code-snippets'), [
27-
new Snippet(['foo'], 'FooSnippet1', 'foo', '', 'snippet', 'test', SnippetSource.User),
28-
new Snippet(['foo'], 'FooSnippet2', 'foo', '', 'snippet', 'test', SnippetSource.User),
29-
new Snippet(['bar'], 'BarSnippet1', 'foo', '', 'snippet', 'test', SnippetSource.User),
30-
new Snippet(['bar.comment'], 'BarSnippet2', 'foo', '', 'snippet', 'test', SnippetSource.User),
31-
new Snippet(['bar.strings'], 'BarSnippet2', 'foo', '', 'snippet', 'test', SnippetSource.User),
32-
new Snippet(['bazz', 'bazz'], 'BazzSnippet1', 'foo', '', 'snippet', 'test', SnippetSource.User),
28+
new Snippet(['foo'], 'FooSnippet1', 'foo', '', 'snippet', 'test', SnippetSource.User, generateUuid()),
29+
new Snippet(['foo'], 'FooSnippet2', 'foo', '', 'snippet', 'test', SnippetSource.User, generateUuid()),
30+
new Snippet(['bar'], 'BarSnippet1', 'foo', '', 'snippet', 'test', SnippetSource.User, generateUuid()),
31+
new Snippet(['bar.comment'], 'BarSnippet2', 'foo', '', 'snippet', 'test', SnippetSource.User, generateUuid()),
32+
new Snippet(['bar.strings'], 'BarSnippet2', 'foo', '', 'snippet', 'test', SnippetSource.User, generateUuid()),
33+
new Snippet(['bazz', 'bazz'], 'BazzSnippet1', 'foo', '', 'snippet', 'test', SnippetSource.User, generateUuid()),
3334
]);
3435

3536
bucket = [];
@@ -56,8 +57,8 @@ suite('Snippets', function () {
5657
test('SnippetFile#select - any scope', function () {
5758

5859
const file = new TestSnippetFile(URI.file('somepath/foo.code-snippets'), [
59-
new Snippet([], 'AnySnippet1', 'foo', '', 'snippet', 'test', SnippetSource.User),
60-
new Snippet(['foo'], 'FooSnippet1', 'foo', '', 'snippet', 'test', SnippetSource.User),
60+
new Snippet([], 'AnySnippet1', 'foo', '', 'snippet', 'test', SnippetSource.User, generateUuid()),
61+
new Snippet(['foo'], 'FooSnippet1', 'foo', '', 'snippet', 'test', SnippetSource.User, generateUuid()),
6162
]);
6263

6364
const bucket: Snippet[] = [];
@@ -69,7 +70,7 @@ suite('Snippets', function () {
6970
test('Snippet#needsClipboard', function () {
7071

7172
function assertNeedsClipboard(body: string, expected: boolean): void {
72-
const snippet = new Snippet(['foo'], 'FooSnippet1', 'foo', '', body, 'test', SnippetSource.User);
73+
const snippet = new Snippet(['foo'], 'FooSnippet1', 'foo', '', body, 'test', SnippetSource.User, generateUuid());
7374
assert.strictEqual(snippet.needsClipboard, expected);
7475

7576
assert.strictEqual(SnippetParser.guessNeedsClipboard(body), expected);
@@ -86,7 +87,7 @@ suite('Snippets', function () {
8687
test('Snippet#isTrivial', function () {
8788

8889
function assertIsTrivial(body: string, expected: boolean): void {
89-
const snippet = new Snippet(['foo'], 'FooSnippet1', 'foo', '', body, 'test', SnippetSource.User);
90+
const snippet = new Snippet(['foo'], 'FooSnippet1', 'foo', '', body, 'test', SnippetSource.User, generateUuid());
9091
assert.strictEqual(snippet.isTrivial, expected);
9192
}
9293

src/vs/workbench/contrib/snippets/test/browser/snippetsRewrite.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import * as assert from 'assert';
7+
import { generateUuid } from 'vs/base/common/uuid';
78
import { Snippet, SnippetSource } from 'vs/workbench/contrib/snippets/browser/snippetsFile';
89

910
suite('SnippetRewrite', function () {
1011

1112
function assertRewrite(input: string, expected: string | boolean): void {
12-
const actual = new Snippet(['foo'], 'foo', 'foo', 'foo', input, 'foo', SnippetSource.User);
13+
const actual = new Snippet(['foo'], 'foo', 'foo', 'foo', input, 'foo', SnippetSource.User, generateUuid());
1314
if (typeof expected === 'boolean') {
1415
assert.strictEqual(actual.codeSnippet, input);
1516
} else {
@@ -47,7 +48,7 @@ suite('SnippetRewrite', function () {
4748
});
4849

4950
test('lazy bogous variable rewrite', function () {
50-
const snippet = new Snippet(['fooLang'], 'foo', 'prefix', 'desc', 'This is ${bogous} because it is a ${var}', 'source', SnippetSource.Extension);
51+
const snippet = new Snippet(['fooLang'], 'foo', 'prefix', 'desc', 'This is ${bogous} because it is a ${var}', 'source', SnippetSource.Extension, generateUuid());
5152
assert.strictEqual(snippet.body, 'This is ${bogous} because it is a ${var}');
5253
assert.strictEqual(snippet.codeSnippet, 'This is ${1:bogous} because it is a ${2:var}');
5354
assert.strictEqual(snippet.isBogous, true);

0 commit comments

Comments
 (0)