gotoSymbolQuickAccess.ts 18.2 KB
Newer Older
1 2 3 4 5 6
/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

import { localize } from 'vs/nls';
7
import { IQuickPick, IQuickPickItem, IQuickPickSeparator } from 'vs/platform/quickinput/common/quickInput';
8
import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation';
9
import { DisposableStore, IDisposable, Disposable, toDisposable } from 'vs/base/common/lifecycle';
10
import { ScrollType } from 'vs/editor/common/editorCommon';
11 12
import { ITextModel } from 'vs/editor/common/model';
import { IRange, Range } from 'vs/editor/common/core/range';
13
import { AbstractEditorNavigationQuickAccessProvider, IEditorNavigationQuickAccessOptions, IQuickAccessTextEditorContext } from 'vs/editor/contrib/quickAccess/editorNavigationQuickAccess';
14 15 16
import { DocumentSymbol, SymbolKinds, SymbolTag, DocumentSymbolProviderRegistry, SymbolKind } from 'vs/editor/common/modes';
import { OutlineModel, OutlineElement } from 'vs/editor/contrib/documentSymbols/outlineModel';
import { trim, format } from 'vs/base/common/strings';
17 18
import { prepareQuery, IPreparedQuery, pieceToQuery, scoreFuzzy2 } from 'vs/base/common/fuzzyScorer';
import { IMatch } from 'vs/base/common/filters';
19
import { Iterable } from 'vs/base/common/iterator';
M
Martin Aeschlimann 已提交
20
import { Codicon } from 'vs/base/common/codicons';
21

22
export interface IGotoSymbolQuickPickItem extends IQuickPickItem {
23 24
	kind: SymbolKind,
	index: number,
25
	score?: number;
26
	range?: { decoration: IRange, selection: IRange }
27 28
}

29
export interface IGotoSymbolQuickAccessProviderOptions extends IEditorNavigationQuickAccessOptions {
A
Alex Dima 已提交
30
	openSideBySideDirection?: () => undefined | 'right' | 'down'
31 32
}

33
export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEditorNavigationQuickAccessProvider {
34 35 36 37 38

	static PREFIX = '@';
	static SCOPE_PREFIX = ':';
	static PREFIX_BY_CATEGORY = `${AbstractGotoSymbolQuickAccessProvider.PREFIX}${AbstractGotoSymbolQuickAccessProvider.SCOPE_PREFIX}`;

39 40 41 42
	constructor(protected options: IGotoSymbolQuickAccessProviderOptions = Object.create(null)) {
		super(options);

		options.canAcceptInBackground = true;
43 44
	}

45
	protected provideWithoutTextEditor(picker: IQuickPick<IGotoSymbolQuickPickItem>): IDisposable {
46
		this.provideLabelPick(picker, localize('cannotRunGotoSymbolWithoutEditor', "To go to a symbol, first open a text editor with symbol information."));
47 48 49 50

		return Disposable.None;
	}

51 52
	protected provideWithTextEditor(context: IQuickAccessTextEditorContext, picker: IQuickPick<IGotoSymbolQuickPickItem>, token: CancellationToken): IDisposable {
		const editor = context.editor;
53 54 55 56
		const model = this.getModel(editor);
		if (!model) {
			return Disposable.None;
		}
57

58 59
		// Provide symbols from model if available in registry
		if (DocumentSymbolProviderRegistry.has(model)) {
60
			return this.doProvideWithEditorSymbols(context, model, picker, token);
61 62 63 64 65
		}

		// Otherwise show an entry for a model without registry
		// But give a chance to resolve the symbols at a later
		// point if possible
66
		return this.doProvideWithoutEditorSymbols(context, model, picker, token);
67 68
	}

69
	private doProvideWithoutEditorSymbols(context: IQuickAccessTextEditorContext, model: ITextModel, picker: IQuickPick<IGotoSymbolQuickPickItem>, token: CancellationToken): IDisposable {
70 71 72
		const disposables = new DisposableStore();

		// Generic pick for not having any symbol information
73
		this.provideLabelPick(picker, localize('cannotRunGotoSymbolWithoutSymbolProvider', "The active text editor does not provide symbol information."));
74

75
		// Wait for changes to the registry and see if eventually
76 77 78 79
		// we do get symbols. This can happen if the picker is opened
		// very early after the model has loaded but before the
		// language registry is ready.
		// https://github.com/microsoft/vscode/issues/70607
80 81 82 83 84 85
		(async () => {
			const result = await this.waitForLanguageSymbolRegistry(model, disposables);
			if (!result || token.isCancellationRequested) {
				return;
			}

86
			disposables.add(this.doProvideWithEditorSymbols(context, model, picker, token));
87 88 89 90 91
		})();

		return disposables;
	}

92 93 94 95 96
	private provideLabelPick(picker: IQuickPick<IGotoSymbolQuickPickItem>, label: string): void {
		picker.items = [{ label, index: 0, kind: SymbolKind.String }];
		picker.ariaLabel = label;
	}

97
	protected async waitForLanguageSymbolRegistry(model: ITextModel, disposables: DisposableStore): Promise<boolean> {
98 99 100 101 102 103 104 105
		if (DocumentSymbolProviderRegistry.has(model)) {
			return true;
		}

		let symbolProviderRegistryPromiseResolve: (res: boolean) => void;
		const symbolProviderRegistryPromise = new Promise<boolean>(resolve => symbolProviderRegistryPromiseResolve = resolve);

		// Resolve promise when registry knows model
106 107 108 109
		const symbolProviderListener = disposables.add(DocumentSymbolProviderRegistry.onDidChange(() => {
			if (DocumentSymbolProviderRegistry.has(model)) {
				symbolProviderListener.dispose();

110
				symbolProviderRegistryPromiseResolve(true);
111 112 113
			}
		}));

114 115 116 117
		// Resolve promise when we get disposed too
		disposables.add(toDisposable(() => symbolProviderRegistryPromiseResolve(false)));

		return symbolProviderRegistryPromise;
118 119
	}

120 121
	private doProvideWithEditorSymbols(context: IQuickAccessTextEditorContext, model: ITextModel, picker: IQuickPick<IGotoSymbolQuickPickItem>, token: CancellationToken): IDisposable {
		const editor = context.editor;
122
		const disposables = new DisposableStore();
123 124

		// Goto symbol once picked
125
		disposables.add(picker.onDidAccept(event => {
126 127
			const [item] = picker.selectedItems;
			if (item && item.range) {
128
				this.gotoLocation(context, { range: item.range.selection, keyMods: picker.keyMods, preserveFocus: event.inBackground });
129

130 131 132
				if (!event.inBackground) {
					picker.hide();
				}
133 134 135
			}
		}));

136 137 138
		// Goto symbol side by side if enabled
		disposables.add(picker.onDidTriggerItemButton(({ item }) => {
			if (item && item.range) {
139
				this.gotoLocation(context, { range: item.range.selection, keyMods: picker.keyMods, forceSideBySide: true });
140 141 142 143 144

				picker.hide();
			}
		}));

145 146 147 148
		// Resolve symbols from document once and reuse this
		// request for all filtering and typing then on
		const symbolsPromise = this.getDocumentSymbols(model, true, token);

149 150 151 152 153 154 155 156 157 158 159 160 161 162
		// Set initial picks and update on type
		let picksCts: CancellationTokenSource | undefined = undefined;
		const updatePickerItems = async () => {

			// Cancel any previous ask for picks and busy
			picksCts?.dispose(true);
			picker.busy = false;

			// Create new cancellation source for this run
			picksCts = new CancellationTokenSource(token);

			// Collect symbol picks
			picker.busy = true;
			try {
163 164
				const query = prepareQuery(picker.value.substr(AbstractGotoSymbolQuickAccessProvider.PREFIX.length).trim());
				const items = await this.doGetSymbolPicks(symbolsPromise, query, undefined, picksCts.token);
165 166 167 168
				if (token.isCancellationRequested) {
					return;
				}

169 170 171 172 173 174 175 176 177
				if (items.length > 0) {
					picker.items = items;
				} else {
					if (query.original.length > 0) {
						this.provideLabelPick(picker, localize('noMatchingSymbolResults', "No matching editor symbols"));
					} else {
						this.provideLabelPick(picker, localize('noSymbolResults', "No editor symbols"));
					}
				}
178 179 180 181 182 183 184 185 186 187
			} finally {
				if (!token.isCancellationRequested) {
					picker.busy = false;
				}
			}
		};
		disposables.add(picker.onDidChangeValue(() => updatePickerItems()));
		updatePickerItems();

		// Reveal and decorate when active item changes
188 189 190 191
		// However, ignore the very first event so that
		// opening the picker is not immediately revealing
		// and decorating the first entry.
		let ignoreFirstActiveEvent = true;
192 193 194
		disposables.add(picker.onDidChangeActive(() => {
			const [item] = picker.activeItems;
			if (item && item.range) {
195 196 197 198
				if (ignoreFirstActiveEvent) {
					ignoreFirstActiveEvent = false;
					return;
				}
199 200 201 202 203 204 205 206 207 208 209 210

				// Reveal
				editor.revealRangeInCenter(item.range.selection, ScrollType.Smooth);

				// Decorate
				this.addDecorations(editor, item.range.decoration);
			}
		}));

		return disposables;
	}

211
	protected async doGetSymbolPicks(symbolsPromise: Promise<DocumentSymbol[]>, query: IPreparedQuery, options: { extraContainerLabel?: string } | undefined, token: CancellationToken): Promise<Array<IGotoSymbolQuickPickItem | IQuickPickSeparator>> {
212
		const symbols = await symbolsPromise;
213 214 215 216
		if (token.isCancellationRequested) {
			return [];
		}

217
		const filterBySymbolKind = query.original.indexOf(AbstractGotoSymbolQuickAccessProvider.SCOPE_PREFIX) === 0;
218
		const filterPos = filterBySymbolKind ? 1 : 0;
219

220
		// Split between symbol and container query
221 222 223
		let symbolQuery: IPreparedQuery;
		let containerQuery: IPreparedQuery | undefined;
		if (query.values && query.values.length > 1) {
224 225
			symbolQuery = pieceToQuery(query.values[0]); 		  // symbol: only match on first part
			containerQuery = pieceToQuery(query.values.slice(1)); // container: match on all but first parts
226 227 228
		} else {
			symbolQuery = query;
		}
229 230 231 232 233 234

		// Convert to symbol picks and apply filtering
		const filteredSymbolPicks: IGotoSymbolQuickPickItem[] = [];
		for (let index = 0; index < symbols.length; index++) {
			const symbol = symbols[index];

235
			const symbolLabel = trim(symbol.name);
236
			const symbolLabelWithIcon = `$(symbol-${SymbolKinds.toString(symbol.kind) || 'property'}) ${symbolLabel}`;
237
			const symbolLabelIconOffset = symbolLabelWithIcon.length - symbolLabel.length;
238 239

			let containerLabel = symbol.containerName;
B
Benjamin Pasero 已提交
240 241 242 243 244 245
			if (options?.extraContainerLabel) {
				if (containerLabel) {
					containerLabel = `${options.extraContainerLabel}${containerLabel}`;
				} else {
					containerLabel = options.extraContainerLabel;
				}
246
			}
247

248 249 250 251 252
			let symbolScore: number | undefined = undefined;
			let symbolMatches: IMatch[] | undefined = undefined;

			let containerScore: number | undefined = undefined;
			let containerMatches: IMatch[] | undefined = undefined;
253

254
			if (query.original.length > filterPos) {
255

256 257 258 259 260 261
				// First: try to score on the entire query, it is possible that
				// the symbol matches perfectly (e.g. searching for "change log"
				// can be a match on a markdown symbol "change log"). In that
				// case we want to skip the container query altogether.
				let skipContainerQuery = false;
				if (symbolQuery !== query) {
B
Benjamin Pasero 已提交
262
					[symbolScore, symbolMatches] = scoreFuzzy2(symbolLabelWithIcon, { ...query, values: undefined /* disable multi-query support */ }, filterPos, symbolLabelIconOffset);
263
					if (typeof symbolScore === 'number') {
264 265 266 267 268
						skipContainerQuery = true; // since we consumed the query, skip any container matching
					}
				}

				// Otherwise: score on the symbol query and match on the container later
269
				if (typeof symbolScore !== 'number') {
B
Benjamin Pasero 已提交
270
					[symbolScore, symbolMatches] = scoreFuzzy2(symbolLabelWithIcon, symbolQuery, filterPos, symbolLabelIconOffset);
271
					if (typeof symbolScore !== 'number') {
272 273
						continue;
					}
274
				}
275 276

				// Score by container if specified
277
				if (!skipContainerQuery && containerQuery) {
B
Benjamin Pasero 已提交
278
					if (containerLabel && containerQuery.original.length > 0) {
279
						[containerScore, containerMatches] = scoreFuzzy2(containerLabel, containerQuery);
280 281
					}

282
					if (typeof containerScore !== 'number') {
283
						continue;
284 285
					}

286
					if (typeof symbolScore === 'number') {
287
						symbolScore += containerScore; // boost symbolScore by containerScore
288
					}
289
				}
290 291
			}

292 293 294 295 296
			const deprecated = symbol.tags && symbol.tags.indexOf(SymbolTag.Deprecated) >= 0;

			filteredSymbolPicks.push({
				index,
				kind: symbol.kind,
297
				score: symbolScore,
298 299 300 301
				label: symbolLabelWithIcon,
				ariaLabel: symbolLabel,
				description: containerLabel,
				highlights: deprecated ? undefined : {
302 303
					label: symbolMatches,
					description: containerMatches
304 305 306 307 308 309 310
				},
				range: {
					selection: Range.collapseToStart(symbol.selectionRange),
					decoration: symbol.range
				},
				strikethrough: deprecated,
				buttons: (() => {
A
Alex Dima 已提交
311
					const openSideBySideDirection = this.options?.openSideBySideDirection ? this.options?.openSideBySideDirection() : undefined;
312 313 314
					if (!openSideBySideDirection) {
						return undefined;
					}
315

316 317
					return [
						{
318
							iconClass: openSideBySideDirection === 'right' ? Codicon.splitHorizontal.classNames : Codicon.splitVertical.classNames,
319 320 321 322 323
							tooltip: openSideBySideDirection === 'right' ? localize('openToSide', "Open to the Side") : localize('openToBottom', "Open to the Bottom")
						}
					];
				})()
			});
324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373
		}

		// Sort by score
		const sortedFilteredSymbolPicks = filteredSymbolPicks.sort((symbolA, symbolB) => filterBySymbolKind ?
			this.compareByKindAndScore(symbolA, symbolB) :
			this.compareByScore(symbolA, symbolB)
		);

		// Add separator for types
		// - @  only total number of symbols
		// - @: grouped by symbol kind
		let symbolPicks: Array<IGotoSymbolQuickPickItem | IQuickPickSeparator> = [];
		if (filterBySymbolKind) {
			let lastSymbolKind: SymbolKind | undefined = undefined;
			let lastSeparator: IQuickPickSeparator | undefined = undefined;
			let lastSymbolKindCounter = 0;

			function updateLastSeparatorLabel(): void {
				if (lastSeparator && typeof lastSymbolKind === 'number' && lastSymbolKindCounter > 0) {
					lastSeparator.label = format(NLS_SYMBOL_KIND_CACHE[lastSymbolKind] || FALLBACK_NLS_SYMBOL_KIND, lastSymbolKindCounter);
				}
			}

			for (const symbolPick of sortedFilteredSymbolPicks) {

				// Found new kind
				if (lastSymbolKind !== symbolPick.kind) {

					// Update last separator with number of symbols we found for kind
					updateLastSeparatorLabel();

					lastSymbolKind = symbolPick.kind;
					lastSymbolKindCounter = 1;

					// Add new separator for new kind
					lastSeparator = { type: 'separator' };
					symbolPicks.push(lastSeparator);
				}

				// Existing kind, keep counting
				else {
					lastSymbolKindCounter++;
				}

				// Add to final result
				symbolPicks.push(symbolPick);
			}

			// Update last separator with number of symbols we found for kind
			updateLastSeparatorLabel();
374
		} else if (sortedFilteredSymbolPicks.length > 0) {
375 376 377 378 379 380 381 382 383 384
			symbolPicks = [
				{ label: localize('symbols', "symbols ({0})", filteredSymbolPicks.length), type: 'separator' },
				...sortedFilteredSymbolPicks
			];
		}

		return symbolPicks;
	}

	private compareByScore(symbolA: IGotoSymbolQuickPickItem, symbolB: IGotoSymbolQuickPickItem): number {
385
		if (typeof symbolA.score !== 'number' && typeof symbolB.score === 'number') {
386
			return 1;
387
		} else if (typeof symbolA.score === 'number' && typeof symbolB.score !== 'number') {
388 389 390
			return -1;
		}

391
		if (typeof symbolA.score === 'number' && typeof symbolB.score === 'number') {
392
			if (symbolA.score > symbolB.score) {
393
				return -1;
394
			} else if (symbolA.score < symbolB.score) {
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420
				return 1;
			}
		}

		if (symbolA.index < symbolB.index) {
			return -1;
		} else if (symbolA.index > symbolB.index) {
			return 1;
		}

		return 0;
	}

	private compareByKindAndScore(symbolA: IGotoSymbolQuickPickItem, symbolB: IGotoSymbolQuickPickItem): number {
		const kindA = NLS_SYMBOL_KIND_CACHE[symbolA.kind] || FALLBACK_NLS_SYMBOL_KIND;
		const kindB = NLS_SYMBOL_KIND_CACHE[symbolB.kind] || FALLBACK_NLS_SYMBOL_KIND;

		// Sort by type first if scoped search
		const result = kindA.localeCompare(kindB);
		if (result === 0) {
			return this.compareByScore(symbolA, symbolB);
		}

		return result;
	}

421
	protected async getDocumentSymbols(document: ITextModel, flatten: boolean, token: CancellationToken): Promise<DocumentSymbol[]> {
422 423 424 425 426 427
		const model = await OutlineModel.create(document, token);
		if (token.isCancellationRequested) {
			return [];
		}

		const roots: DocumentSymbol[] = [];
428
		for (const child of model.children.values()) {
429 430 431
			if (child instanceof OutlineElement) {
				roots.push(child.symbol);
			} else {
432
				roots.push(...Iterable.map(child.children.values(), child => child.symbol));
433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498
			}
		}

		let flatEntries: DocumentSymbol[] = [];
		if (flatten) {
			this.flattenDocumentSymbols(flatEntries, roots, '');
		} else {
			flatEntries = roots;
		}

		return flatEntries.sort((symbolA, symbolB) => Range.compareRangesUsingStarts(symbolA.range, symbolB.range));
	}

	private flattenDocumentSymbols(bucket: DocumentSymbol[], entries: DocumentSymbol[], overrideContainerLabel: string): void {
		for (const entry of entries) {
			bucket.push({
				kind: entry.kind,
				tags: entry.tags,
				name: entry.name,
				detail: entry.detail,
				containerName: entry.containerName || overrideContainerLabel,
				range: entry.range,
				selectionRange: entry.selectionRange,
				children: undefined, // we flatten it...
			});

			// Recurse over children
			if (entry.children) {
				this.flattenDocumentSymbols(bucket, entry.children, entry.name);
			}
		}
	}
}

// #region NLS Helpers

const FALLBACK_NLS_SYMBOL_KIND = localize('property', "properties ({0})");
const NLS_SYMBOL_KIND_CACHE: { [type: number]: string } = {
	[SymbolKind.Method]: localize('method', "methods ({0})"),
	[SymbolKind.Function]: localize('function', "functions ({0})"),
	[SymbolKind.Constructor]: localize('_constructor', "constructors ({0})"),
	[SymbolKind.Variable]: localize('variable', "variables ({0})"),
	[SymbolKind.Class]: localize('class', "classes ({0})"),
	[SymbolKind.Struct]: localize('struct', "structs ({0})"),
	[SymbolKind.Event]: localize('event', "events ({0})"),
	[SymbolKind.Operator]: localize('operator', "operators ({0})"),
	[SymbolKind.Interface]: localize('interface', "interfaces ({0})"),
	[SymbolKind.Namespace]: localize('namespace', "namespaces ({0})"),
	[SymbolKind.Package]: localize('package', "packages ({0})"),
	[SymbolKind.TypeParameter]: localize('typeParameter', "type parameters ({0})"),
	[SymbolKind.Module]: localize('modules', "modules ({0})"),
	[SymbolKind.Property]: localize('property', "properties ({0})"),
	[SymbolKind.Enum]: localize('enum', "enumerations ({0})"),
	[SymbolKind.EnumMember]: localize('enumMember', "enumeration members ({0})"),
	[SymbolKind.String]: localize('string', "strings ({0})"),
	[SymbolKind.File]: localize('file', "files ({0})"),
	[SymbolKind.Array]: localize('array', "arrays ({0})"),
	[SymbolKind.Number]: localize('number', "numbers ({0})"),
	[SymbolKind.Boolean]: localize('boolean', "booleans ({0})"),
	[SymbolKind.Object]: localize('object', "objects ({0})"),
	[SymbolKind.Key]: localize('key', "keys ({0})"),
	[SymbolKind.Field]: localize('field', "fields ({0})"),
	[SymbolKind.Constant]: localize('constant', "constants ({0})")
};

//#endregion