Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/editor/contrib/quickAccess/browser/gotoSymbolQuickAccess.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { DeferredPromise } from '../../../../base/common/async.js';
7
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
8
import { Codicon } from '../../../../base/common/codicons.js';
9
import { ThemeIcon } from '../../../../base/common/themables.js';
10
import { IMatch } from '../../../../base/common/filters.js';
11
import { IPreparedQuery, pieceToQuery, prepareQuery, scoreFuzzy2 } from '../../../../base/common/fuzzyScorer.js';
12
import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
13
import { format, trim } from '../../../../base/common/strings.js';
14
import { IRange, Range } from '../../../common/core/range.js';
15
import { ScrollType } from '../../../common/editorCommon.js';
16
import { ITextModel } from '../../../common/model.js';
17
import { DocumentSymbol, SymbolKind, SymbolKinds, SymbolTag, getAriaLabelForSymbol } from '../../../common/languages.js';
18
import { IOutlineModelService } from '../../documentSymbols/browser/outlineModel.js';
19
import { AbstractEditorNavigationQuickAccessProvider, IEditorNavigationQuickAccessOptions, IQuickAccessTextEditorContext } from './editorNavigationQuickAccess.js';
20
import { localize } from '../../../../nls.js';
21
import { IQuickInputButton, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js';
22
import { ILanguageFeaturesService } from '../../../common/services/languageFeatures.js';
23
import { Position } from '../../../common/core/position.js';
24
import { findLast } from '../../../../base/common/arraysFind.js';
25
import { IQuickAccessProviderRunOptions } from '../../../../platform/quickinput/common/quickAccess.js';
26
import { URI } from '../../../../base/common/uri.js';
27
28
export interface IGotoSymbolQuickPickItem extends IQuickPickItem {
29
kind: SymbolKind;
30
index: number;
31
score?: number;
32
uri?: URI;
33
symbolName?: string;
34
range?: { decoration: IRange; selection: IRange };
35
}
36
37
export interface IGotoSymbolQuickAccessProviderOptions extends IEditorNavigationQuickAccessOptions {
38
openSideBySideDirection?: () => undefined | 'right' | 'down';
39
/**
40
* A handler to invoke when an item is accepted for
41
* this particular showing of the quick access.
42
* @param item The item that was accepted.
43
*/
44
readonly handleAccept?: (item: IQuickPickItem) => void;
45
}
46
47
export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEditorNavigationQuickAccessProvider {
48
49
static PREFIX = '@';
50
static SCOPE_PREFIX = ':';
51
static PREFIX_BY_CATEGORY = `${this.PREFIX}${this.SCOPE_PREFIX}`;
52
53
protected override readonly options: IGotoSymbolQuickAccessProviderOptions;
54
55
constructor(
56
@ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService,
57
@IOutlineModelService private readonly _outlineModelService: IOutlineModelService,
58
options: IGotoSymbolQuickAccessProviderOptions = Object.create(null)
59
) {
60
super(options);
61
62
this.options = options;
63
this.options.canAcceptInBackground = true;
64
}
65
66
protected provideWithoutTextEditor(picker: IQuickPick<IGotoSymbolQuickPickItem, { useSeparators: true }>): IDisposable {
67
this.provideLabelPick(picker, localize('cannotRunGotoSymbolWithoutEditor', "To go to a symbol, first open a text editor with symbol information."));
68
69
return Disposable.None;
70
}
71
72
protected provideWithTextEditor(context: IQuickAccessTextEditorContext, picker: IQuickPick<IGotoSymbolQuickPickItem, { useSeparators: true }>, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions): IDisposable {
73
const editor = context.editor;
74
const model = this.getModel(editor);
75
if (!model) {
76
return Disposable.None;
77
}
78
79
// Provide symbols from model if available in registry
80
if (this._languageFeaturesService.documentSymbolProvider.has(model)) {
81
return this.doProvideWithEditorSymbols(context, model, picker, token, runOptions);
82
}
83
84
// Otherwise show an entry for a model without registry
85
// But give a chance to resolve the symbols at a later
86
// point if possible
87
return this.doProvideWithoutEditorSymbols(context, model, picker, token);
88
}
89
90
private doProvideWithoutEditorSymbols(context: IQuickAccessTextEditorContext, model: ITextModel, picker: IQuickPick<IGotoSymbolQuickPickItem, { useSeparators: true }>, token: CancellationToken): IDisposable {
91
const disposables = new DisposableStore();
92
93
// Generic pick for not having any symbol information
94
this.provideLabelPick(picker, localize('cannotRunGotoSymbolWithoutSymbolProvider', "The active text editor does not provide symbol information."));
95
96
// Wait for changes to the registry and see if eventually
97
// we do get symbols. This can happen if the picker is opened
98
// very early after the model has loaded but before the
99
// language registry is ready.
100
// https://github.com/microsoft/vscode/issues/70607
101
(async () => {
102
const result = await this.waitForLanguageSymbolRegistry(model, disposables);
103
if (!result || token.isCancellationRequested) {
104
return;
105
}
106
107
disposables.add(this.doProvideWithEditorSymbols(context, model, picker, token));
108
})();
109
110
return disposables;
111
}
112
113
private provideLabelPick(picker: IQuickPick<IGotoSymbolQuickPickItem, { useSeparators: true }>, label: string): void {
114
picker.items = [{ label, index: 0, kind: SymbolKind.String }];
115
picker.ariaLabel = label;
116
}
117
118
protected async waitForLanguageSymbolRegistry(model: ITextModel, disposables: DisposableStore): Promise<boolean> {
119
if (this._languageFeaturesService.documentSymbolProvider.has(model)) {
120
return true;
121
}
122
123
const symbolProviderRegistryPromise = new DeferredPromise<boolean>();
124
125
// Resolve promise when registry knows model
126
const symbolProviderListener = disposables.add(this._languageFeaturesService.documentSymbolProvider.onDidChange(() => {
127
if (this._languageFeaturesService.documentSymbolProvider.has(model)) {
128
symbolProviderListener.dispose();
129
130
symbolProviderRegistryPromise.complete(true);
131
}
132
}));
133
134
// Resolve promise when we get disposed too
135
disposables.add(toDisposable(() => symbolProviderRegistryPromise.complete(false)));
136
137
return symbolProviderRegistryPromise.p;
138
}
139
140
private doProvideWithEditorSymbols(context: IQuickAccessTextEditorContext, model: ITextModel, picker: IQuickPick<IGotoSymbolQuickPickItem, { useSeparators: true }>, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions): IDisposable {
141
const editor = context.editor;
142
const disposables = new DisposableStore();
143
144
// Goto symbol once picked
145
disposables.add(picker.onDidAccept(event => {
146
const [item] = picker.selectedItems;
147
if (item && item.range) {
148
this.gotoLocation(context, { range: item.range.selection, keyMods: picker.keyMods, preserveFocus: event.inBackground });
149
150
runOptions?.handleAccept?.(item, event.inBackground);
151
152
if (!event.inBackground) {
153
picker.hide();
154
}
155
}
156
}));
157
158
// Goto symbol side by side if enabled
159
disposables.add(picker.onDidTriggerItemButton(({ item }) => {
160
if (item && item.range) {
161
this.gotoLocation(context, { range: item.range.selection, keyMods: picker.keyMods, forceSideBySide: true });
162
163
picker.hide();
164
}
165
}));
166
167
// Resolve symbols from document once and reuse this
168
// request for all filtering and typing then on
169
const symbolsPromise = this.getDocumentSymbols(model, token);
170
171
// Set initial picks and update on type
172
const picksCts = disposables.add(new MutableDisposable<CancellationTokenSource>());
173
const updatePickerItems = async (positionToEnclose: Position | undefined) => {
174
175
// Cancel any previous ask for picks and busy
176
picksCts?.value?.cancel();
177
picker.busy = false;
178
179
// Create new cancellation source for this run
180
picksCts.value = new CancellationTokenSource();
181
182
// Collect symbol picks
183
picker.busy = true;
184
try {
185
const query = prepareQuery(picker.value.substr(AbstractGotoSymbolQuickAccessProvider.PREFIX.length).trim());
186
const items = await this.doGetSymbolPicks(symbolsPromise, query, undefined, picksCts.value.token, model);
187
if (token.isCancellationRequested) {
188
return;
189
}
190
191
if (items.length > 0) {
192
picker.items = items;
193
if (positionToEnclose && query.original.length === 0) {
194
const candidate = <IGotoSymbolQuickPickItem>findLast(items, item => Boolean(item.type !== 'separator' && item.range && Range.containsPosition(item.range.decoration, positionToEnclose)));
195
if (candidate) {
196
picker.activeItems = [candidate];
197
}
198
}
199
200
} else {
201
if (query.original.length > 0) {
202
this.provideLabelPick(picker, localize('noMatchingSymbolResults', "No matching editor symbols"));
203
} else {
204
this.provideLabelPick(picker, localize('noSymbolResults', "No editor symbols"));
205
}
206
}
207
} finally {
208
if (!token.isCancellationRequested) {
209
picker.busy = false;
210
}
211
}
212
};
213
disposables.add(picker.onDidChangeValue(() => updatePickerItems(undefined)));
214
updatePickerItems(editor.getSelection()?.getPosition());
215
216
217
// Reveal and decorate when active item changes
218
disposables.add(picker.onDidChangeActive(() => {
219
const [item] = picker.activeItems;
220
if (item && item.range) {
221
222
// Reveal
223
editor.revealRangeInCenter(item.range.selection, ScrollType.Smooth);
224
225
// Decorate
226
this.addDecorations(editor, item.range.decoration);
227
}
228
}));
229
230
return disposables;
231
}
232
233
protected async doGetSymbolPicks(symbolsPromise: Promise<DocumentSymbol[]>, query: IPreparedQuery, options: { extraContainerLabel?: string } | undefined, token: CancellationToken, model: ITextModel): Promise<Array<IGotoSymbolQuickPickItem | IQuickPickSeparator>> {
234
const symbols = await symbolsPromise;
235
if (token.isCancellationRequested) {
236
return [];
237
}
238
239
const filterBySymbolKind = query.original.indexOf(AbstractGotoSymbolQuickAccessProvider.SCOPE_PREFIX) === 0;
240
const filterPos = filterBySymbolKind ? 1 : 0;
241
242
// Split between symbol and container query
243
let symbolQuery: IPreparedQuery;
244
let containerQuery: IPreparedQuery | undefined;
245
if (query.values && query.values.length > 1) {
246
symbolQuery = pieceToQuery(query.values[0]); // symbol: only match on first part
247
containerQuery = pieceToQuery(query.values.slice(1)); // container: match on all but first parts
248
} else {
249
symbolQuery = query;
250
}
251
252
// Convert to symbol picks and apply filtering
253
254
let buttons: IQuickInputButton[] | undefined;
255
const openSideBySideDirection = this.options?.openSideBySideDirection?.();
256
if (openSideBySideDirection) {
257
buttons = [{
258
iconClass: openSideBySideDirection === 'right' ? ThemeIcon.asClassName(Codicon.splitHorizontal) : ThemeIcon.asClassName(Codicon.splitVertical),
259
tooltip: openSideBySideDirection === 'right' ? localize('openToSide', "Open to the Side") : localize('openToBottom', "Open to the Bottom")
260
}];
261
}
262
263
const filteredSymbolPicks: IGotoSymbolQuickPickItem[] = [];
264
for (let index = 0; index < symbols.length; index++) {
265
const symbol = symbols[index];
266
267
const symbolLabel = trim(symbol.name);
268
const symbolLabelWithIcon = `$(${SymbolKinds.toIcon(symbol.kind).id}) ${symbolLabel}`;
269
const symbolLabelIconOffset = symbolLabelWithIcon.length - symbolLabel.length;
270
271
let containerLabel = symbol.containerName;
272
if (options?.extraContainerLabel) {
273
if (containerLabel) {
274
containerLabel = `${options.extraContainerLabel} • ${containerLabel}`;
275
} else {
276
containerLabel = options.extraContainerLabel;
277
}
278
}
279
280
let symbolScore: number | undefined = undefined;
281
let symbolMatches: IMatch[] | undefined = undefined;
282
283
let containerScore: number | undefined = undefined;
284
let containerMatches: IMatch[] | undefined = undefined;
285
286
if (query.original.length > filterPos) {
287
288
// First: try to score on the entire query, it is possible that
289
// the symbol matches perfectly (e.g. searching for "change log"
290
// can be a match on a markdown symbol "change log"). In that
291
// case we want to skip the container query altogether.
292
let skipContainerQuery = false;
293
if (symbolQuery !== query) {
294
[symbolScore, symbolMatches] = scoreFuzzy2(symbolLabelWithIcon, { ...query, values: undefined /* disable multi-query support */ }, filterPos, symbolLabelIconOffset);
295
if (typeof symbolScore === 'number') {
296
skipContainerQuery = true; // since we consumed the query, skip any container matching
297
}
298
}
299
300
// Otherwise: score on the symbol query and match on the container later
301
if (typeof symbolScore !== 'number') {
302
[symbolScore, symbolMatches] = scoreFuzzy2(symbolLabelWithIcon, symbolQuery, filterPos, symbolLabelIconOffset);
303
if (typeof symbolScore !== 'number') {
304
continue;
305
}
306
}
307
308
// Score by container if specified
309
if (!skipContainerQuery && containerQuery) {
310
if (containerLabel && containerQuery.original.length > 0) {
311
[containerScore, containerMatches] = scoreFuzzy2(containerLabel, containerQuery);
312
}
313
314
if (typeof containerScore !== 'number') {
315
continue;
316
}
317
318
if (typeof symbolScore === 'number') {
319
symbolScore += containerScore; // boost symbolScore by containerScore
320
}
321
}
322
}
323
324
const deprecated = symbol.tags && symbol.tags.indexOf(SymbolTag.Deprecated) >= 0;
325
326
filteredSymbolPicks.push({
327
index,
328
kind: symbol.kind,
329
score: symbolScore,
330
label: symbolLabelWithIcon,
331
ariaLabel: getAriaLabelForSymbol(symbol.name, symbol.kind),
332
description: containerLabel,
333
highlights: deprecated ? undefined : {
334
label: symbolMatches,
335
description: containerMatches
336
},
337
range: {
338
selection: Range.collapseToStart(symbol.selectionRange),
339
decoration: symbol.range
340
},
341
uri: model.uri,
342
symbolName: symbolLabel,
343
strikethrough: deprecated,
344
buttons
345
});
346
}
347
348
// Sort by score
349
const sortedFilteredSymbolPicks = filteredSymbolPicks.sort((symbolA, symbolB) => filterBySymbolKind ?
350
this.compareByKindAndScore(symbolA, symbolB) :
351
this.compareByScore(symbolA, symbolB)
352
);
353
354
// Add separator for types
355
// - @ only total number of symbols
356
// - @: grouped by symbol kind
357
let symbolPicks: Array<IGotoSymbolQuickPickItem | IQuickPickSeparator> = [];
358
if (filterBySymbolKind) {
359
let lastSymbolKind: SymbolKind | undefined = undefined;
360
let lastSeparator: IQuickPickSeparator | undefined = undefined;
361
let lastSymbolKindCounter = 0;
362
363
function updateLastSeparatorLabel(): void {
364
if (lastSeparator && typeof lastSymbolKind === 'number' && lastSymbolKindCounter > 0) {
365
lastSeparator.label = format(NLS_SYMBOL_KIND_CACHE[lastSymbolKind] || FALLBACK_NLS_SYMBOL_KIND, lastSymbolKindCounter);
366
}
367
}
368
369
for (const symbolPick of sortedFilteredSymbolPicks) {
370
371
// Found new kind
372
if (lastSymbolKind !== symbolPick.kind) {
373
374
// Update last separator with number of symbols we found for kind
375
updateLastSeparatorLabel();
376
377
lastSymbolKind = symbolPick.kind;
378
lastSymbolKindCounter = 1;
379
380
// Add new separator for new kind
381
lastSeparator = { type: 'separator' };
382
symbolPicks.push(lastSeparator);
383
}
384
385
// Existing kind, keep counting
386
else {
387
lastSymbolKindCounter++;
388
}
389
390
// Add to final result
391
symbolPicks.push(symbolPick);
392
}
393
394
// Update last separator with number of symbols we found for kind
395
updateLastSeparatorLabel();
396
} else if (sortedFilteredSymbolPicks.length > 0) {
397
symbolPicks = [
398
{ label: localize('symbols', "symbols ({0})", filteredSymbolPicks.length), type: 'separator' },
399
...sortedFilteredSymbolPicks
400
];
401
}
402
403
return symbolPicks;
404
}
405
406
private compareByScore(symbolA: IGotoSymbolQuickPickItem, symbolB: IGotoSymbolQuickPickItem): number {
407
if (typeof symbolA.score !== 'number' && typeof symbolB.score === 'number') {
408
return 1;
409
} else if (typeof symbolA.score === 'number' && typeof symbolB.score !== 'number') {
410
return -1;
411
}
412
413
if (typeof symbolA.score === 'number' && typeof symbolB.score === 'number') {
414
if (symbolA.score > symbolB.score) {
415
return -1;
416
} else if (symbolA.score < symbolB.score) {
417
return 1;
418
}
419
}
420
421
if (symbolA.index < symbolB.index) {
422
return -1;
423
} else if (symbolA.index > symbolB.index) {
424
return 1;
425
}
426
427
return 0;
428
}
429
430
private compareByKindAndScore(symbolA: IGotoSymbolQuickPickItem, symbolB: IGotoSymbolQuickPickItem): number {
431
const kindA = NLS_SYMBOL_KIND_CACHE[symbolA.kind] || FALLBACK_NLS_SYMBOL_KIND;
432
const kindB = NLS_SYMBOL_KIND_CACHE[symbolB.kind] || FALLBACK_NLS_SYMBOL_KIND;
433
434
// Sort by type first if scoped search
435
const result = kindA.localeCompare(kindB);
436
if (result === 0) {
437
return this.compareByScore(symbolA, symbolB);
438
}
439
440
return result;
441
}
442
443
protected async getDocumentSymbols(document: ITextModel, token: CancellationToken): Promise<DocumentSymbol[]> {
444
const model = await this._outlineModelService.getOrCreate(document, token);
445
return token.isCancellationRequested ? [] : model.asListOfDocumentSymbols();
446
}
447
}
448
449
// #region NLS Helpers
450
451
const FALLBACK_NLS_SYMBOL_KIND = localize('property', "properties ({0})");
452
const NLS_SYMBOL_KIND_CACHE: { [type: number]: string } = {
453
[SymbolKind.Method]: localize('method', "methods ({0})"),
454
[SymbolKind.Function]: localize('function', "functions ({0})"),
455
[SymbolKind.Constructor]: localize('_constructor', "constructors ({0})"),
456
[SymbolKind.Variable]: localize('variable', "variables ({0})"),
457
[SymbolKind.Class]: localize('class', "classes ({0})"),
458
[SymbolKind.Struct]: localize('struct', "structs ({0})"),
459
[SymbolKind.Event]: localize('event', "events ({0})"),
460
[SymbolKind.Operator]: localize('operator', "operators ({0})"),
461
[SymbolKind.Interface]: localize('interface', "interfaces ({0})"),
462
[SymbolKind.Namespace]: localize('namespace', "namespaces ({0})"),
463
[SymbolKind.Package]: localize('package', "packages ({0})"),
464
[SymbolKind.TypeParameter]: localize('typeParameter', "type parameters ({0})"),
465
[SymbolKind.Module]: localize('modules', "modules ({0})"),
466
[SymbolKind.Property]: localize('property', "properties ({0})"),
467
[SymbolKind.Enum]: localize('enum', "enumerations ({0})"),
468
[SymbolKind.EnumMember]: localize('enumMember', "enumeration members ({0})"),
469
[SymbolKind.String]: localize('string', "strings ({0})"),
470
[SymbolKind.File]: localize('file', "files ({0})"),
471
[SymbolKind.Array]: localize('array', "arrays ({0})"),
472
[SymbolKind.Number]: localize('number', "numbers ({0})"),
473
[SymbolKind.Boolean]: localize('boolean', "booleans ({0})"),
474
[SymbolKind.Object]: localize('object', "objects ({0})"),
475
[SymbolKind.Key]: localize('key', "keys ({0})"),
476
[SymbolKind.Field]: localize('field', "fields ({0})"),
477
[SymbolKind.Constant]: localize('constant', "constants ({0})")
478
};
479
480
//#endregion
481
482