Path: blob/main/src/vs/editor/contrib/quickAccess/browser/gotoSymbolQuickAccess.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { DeferredPromise } from '../../../../base/common/async.js';6import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';7import { Codicon } from '../../../../base/common/codicons.js';8import { ThemeIcon } from '../../../../base/common/themables.js';9import { IMatch } from '../../../../base/common/filters.js';10import { IPreparedQuery, pieceToQuery, prepareQuery, scoreFuzzy2 } from '../../../../base/common/fuzzyScorer.js';11import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';12import { format, trim } from '../../../../base/common/strings.js';13import { IRange, Range } from '../../../common/core/range.js';14import { ScrollType } from '../../../common/editorCommon.js';15import { ITextModel } from '../../../common/model.js';16import { DocumentSymbol, SymbolKind, SymbolKinds, SymbolTag, getAriaLabelForSymbol } from '../../../common/languages.js';17import { IOutlineModelService } from '../../documentSymbols/browser/outlineModel.js';18import { AbstractEditorNavigationQuickAccessProvider, IEditorNavigationQuickAccessOptions, IQuickAccessTextEditorContext } from './editorNavigationQuickAccess.js';19import { localize } from '../../../../nls.js';20import { IQuickInputButton, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js';21import { ILanguageFeaturesService } from '../../../common/services/languageFeatures.js';22import { Position } from '../../../common/core/position.js';23import { findLast } from '../../../../base/common/arraysFind.js';24import { IQuickAccessProviderRunOptions } from '../../../../platform/quickinput/common/quickAccess.js';25import { URI } from '../../../../base/common/uri.js';2627export interface IGotoSymbolQuickPickItem extends IQuickPickItem {28kind: SymbolKind;29index: number;30score?: number;31uri?: URI;32symbolName?: string;33range?: { decoration: IRange; selection: IRange };34}3536export interface IGotoSymbolQuickAccessProviderOptions extends IEditorNavigationQuickAccessOptions {37openSideBySideDirection?: () => undefined | 'right' | 'down';38/**39* A handler to invoke when an item is accepted for40* this particular showing of the quick access.41* @param item The item that was accepted.42*/43readonly handleAccept?: (item: IQuickPickItem) => void;44}4546export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEditorNavigationQuickAccessProvider {4748static PREFIX = '@';49static SCOPE_PREFIX = ':';50static PREFIX_BY_CATEGORY = `${this.PREFIX}${this.SCOPE_PREFIX}`;5152protected override readonly options: IGotoSymbolQuickAccessProviderOptions;5354constructor(55@ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService,56@IOutlineModelService private readonly _outlineModelService: IOutlineModelService,57options: IGotoSymbolQuickAccessProviderOptions = Object.create(null)58) {59super(options);6061this.options = options;62this.options.canAcceptInBackground = true;63}6465protected provideWithoutTextEditor(picker: IQuickPick<IGotoSymbolQuickPickItem, { useSeparators: true }>): IDisposable {66this.provideLabelPick(picker, localize('cannotRunGotoSymbolWithoutEditor', "To go to a symbol, first open a text editor with symbol information."));6768return Disposable.None;69}7071protected provideWithTextEditor(context: IQuickAccessTextEditorContext, picker: IQuickPick<IGotoSymbolQuickPickItem, { useSeparators: true }>, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions): IDisposable {72const editor = context.editor;73const model = this.getModel(editor);74if (!model) {75return Disposable.None;76}7778// Provide symbols from model if available in registry79if (this._languageFeaturesService.documentSymbolProvider.has(model)) {80return this.doProvideWithEditorSymbols(context, model, picker, token, runOptions);81}8283// Otherwise show an entry for a model without registry84// But give a chance to resolve the symbols at a later85// point if possible86return this.doProvideWithoutEditorSymbols(context, model, picker, token);87}8889private doProvideWithoutEditorSymbols(context: IQuickAccessTextEditorContext, model: ITextModel, picker: IQuickPick<IGotoSymbolQuickPickItem, { useSeparators: true }>, token: CancellationToken): IDisposable {90const disposables = new DisposableStore();9192// Generic pick for not having any symbol information93this.provideLabelPick(picker, localize('cannotRunGotoSymbolWithoutSymbolProvider', "The active text editor does not provide symbol information."));9495// Wait for changes to the registry and see if eventually96// we do get symbols. This can happen if the picker is opened97// very early after the model has loaded but before the98// language registry is ready.99// https://github.com/microsoft/vscode/issues/70607100(async () => {101const result = await this.waitForLanguageSymbolRegistry(model, disposables);102if (!result || token.isCancellationRequested) {103return;104}105106disposables.add(this.doProvideWithEditorSymbols(context, model, picker, token));107})();108109return disposables;110}111112private provideLabelPick(picker: IQuickPick<IGotoSymbolQuickPickItem, { useSeparators: true }>, label: string): void {113picker.items = [{ label, index: 0, kind: SymbolKind.String }];114picker.ariaLabel = label;115}116117protected async waitForLanguageSymbolRegistry(model: ITextModel, disposables: DisposableStore): Promise<boolean> {118if (this._languageFeaturesService.documentSymbolProvider.has(model)) {119return true;120}121122const symbolProviderRegistryPromise = new DeferredPromise<boolean>();123124// Resolve promise when registry knows model125const symbolProviderListener = disposables.add(this._languageFeaturesService.documentSymbolProvider.onDidChange(() => {126if (this._languageFeaturesService.documentSymbolProvider.has(model)) {127symbolProviderListener.dispose();128129symbolProviderRegistryPromise.complete(true);130}131}));132133// Resolve promise when we get disposed too134disposables.add(toDisposable(() => symbolProviderRegistryPromise.complete(false)));135136return symbolProviderRegistryPromise.p;137}138139private doProvideWithEditorSymbols(context: IQuickAccessTextEditorContext, model: ITextModel, picker: IQuickPick<IGotoSymbolQuickPickItem, { useSeparators: true }>, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions): IDisposable {140const editor = context.editor;141const disposables = new DisposableStore();142143// Goto symbol once picked144disposables.add(picker.onDidAccept(event => {145const [item] = picker.selectedItems;146if (item && item.range) {147this.gotoLocation(context, { range: item.range.selection, keyMods: picker.keyMods, preserveFocus: event.inBackground });148149runOptions?.handleAccept?.(item, event.inBackground);150151if (!event.inBackground) {152picker.hide();153}154}155}));156157// Goto symbol side by side if enabled158disposables.add(picker.onDidTriggerItemButton(({ item }) => {159if (item && item.range) {160this.gotoLocation(context, { range: item.range.selection, keyMods: picker.keyMods, forceSideBySide: true });161162picker.hide();163}164}));165166// Resolve symbols from document once and reuse this167// request for all filtering and typing then on168const symbolsPromise = this.getDocumentSymbols(model, token);169170// Set initial picks and update on type171const picksCts = disposables.add(new MutableDisposable<CancellationTokenSource>());172const updatePickerItems = async (positionToEnclose: Position | undefined) => {173174// Cancel any previous ask for picks and busy175picksCts?.value?.cancel();176picker.busy = false;177178// Create new cancellation source for this run179picksCts.value = new CancellationTokenSource();180181// Collect symbol picks182picker.busy = true;183try {184const query = prepareQuery(picker.value.substr(AbstractGotoSymbolQuickAccessProvider.PREFIX.length).trim());185const items = await this.doGetSymbolPicks(symbolsPromise, query, undefined, picksCts.value.token, model);186if (token.isCancellationRequested) {187return;188}189190if (items.length > 0) {191picker.items = items;192if (positionToEnclose && query.original.length === 0) {193const candidate = <IGotoSymbolQuickPickItem>findLast(items, item => Boolean(item.type !== 'separator' && item.range && Range.containsPosition(item.range.decoration, positionToEnclose)));194if (candidate) {195picker.activeItems = [candidate];196}197}198199} else {200if (query.original.length > 0) {201this.provideLabelPick(picker, localize('noMatchingSymbolResults', "No matching editor symbols"));202} else {203this.provideLabelPick(picker, localize('noSymbolResults', "No editor symbols"));204}205}206} finally {207if (!token.isCancellationRequested) {208picker.busy = false;209}210}211};212disposables.add(picker.onDidChangeValue(() => updatePickerItems(undefined)));213updatePickerItems(editor.getSelection()?.getPosition());214215216// Reveal and decorate when active item changes217disposables.add(picker.onDidChangeActive(() => {218const [item] = picker.activeItems;219if (item && item.range) {220221// Reveal222editor.revealRangeInCenter(item.range.selection, ScrollType.Smooth);223224// Decorate225this.addDecorations(editor, item.range.decoration);226}227}));228229return disposables;230}231232protected async doGetSymbolPicks(symbolsPromise: Promise<DocumentSymbol[]>, query: IPreparedQuery, options: { extraContainerLabel?: string } | undefined, token: CancellationToken, model: ITextModel): Promise<Array<IGotoSymbolQuickPickItem | IQuickPickSeparator>> {233const symbols = await symbolsPromise;234if (token.isCancellationRequested) {235return [];236}237238const filterBySymbolKind = query.original.indexOf(AbstractGotoSymbolQuickAccessProvider.SCOPE_PREFIX) === 0;239const filterPos = filterBySymbolKind ? 1 : 0;240241// Split between symbol and container query242let symbolQuery: IPreparedQuery;243let containerQuery: IPreparedQuery | undefined;244if (query.values && query.values.length > 1) {245symbolQuery = pieceToQuery(query.values[0]); // symbol: only match on first part246containerQuery = pieceToQuery(query.values.slice(1)); // container: match on all but first parts247} else {248symbolQuery = query;249}250251// Convert to symbol picks and apply filtering252253let buttons: IQuickInputButton[] | undefined;254const openSideBySideDirection = this.options?.openSideBySideDirection?.();255if (openSideBySideDirection) {256buttons = [{257iconClass: openSideBySideDirection === 'right' ? ThemeIcon.asClassName(Codicon.splitHorizontal) : ThemeIcon.asClassName(Codicon.splitVertical),258tooltip: openSideBySideDirection === 'right' ? localize('openToSide', "Open to the Side") : localize('openToBottom', "Open to the Bottom")259}];260}261262const filteredSymbolPicks: IGotoSymbolQuickPickItem[] = [];263for (let index = 0; index < symbols.length; index++) {264const symbol = symbols[index];265266const symbolLabel = trim(symbol.name);267const symbolLabelWithIcon = `$(${SymbolKinds.toIcon(symbol.kind).id}) ${symbolLabel}`;268const symbolLabelIconOffset = symbolLabelWithIcon.length - symbolLabel.length;269270let containerLabel = symbol.containerName;271if (options?.extraContainerLabel) {272if (containerLabel) {273containerLabel = `${options.extraContainerLabel} • ${containerLabel}`;274} else {275containerLabel = options.extraContainerLabel;276}277}278279let symbolScore: number | undefined = undefined;280let symbolMatches: IMatch[] | undefined = undefined;281282let containerScore: number | undefined = undefined;283let containerMatches: IMatch[] | undefined = undefined;284285if (query.original.length > filterPos) {286287// First: try to score on the entire query, it is possible that288// the symbol matches perfectly (e.g. searching for "change log"289// can be a match on a markdown symbol "change log"). In that290// case we want to skip the container query altogether.291let skipContainerQuery = false;292if (symbolQuery !== query) {293[symbolScore, symbolMatches] = scoreFuzzy2(symbolLabelWithIcon, { ...query, values: undefined /* disable multi-query support */ }, filterPos, symbolLabelIconOffset);294if (typeof symbolScore === 'number') {295skipContainerQuery = true; // since we consumed the query, skip any container matching296}297}298299// Otherwise: score on the symbol query and match on the container later300if (typeof symbolScore !== 'number') {301[symbolScore, symbolMatches] = scoreFuzzy2(symbolLabelWithIcon, symbolQuery, filterPos, symbolLabelIconOffset);302if (typeof symbolScore !== 'number') {303continue;304}305}306307// Score by container if specified308if (!skipContainerQuery && containerQuery) {309if (containerLabel && containerQuery.original.length > 0) {310[containerScore, containerMatches] = scoreFuzzy2(containerLabel, containerQuery);311}312313if (typeof containerScore !== 'number') {314continue;315}316317if (typeof symbolScore === 'number') {318symbolScore += containerScore; // boost symbolScore by containerScore319}320}321}322323const deprecated = symbol.tags && symbol.tags.indexOf(SymbolTag.Deprecated) >= 0;324325filteredSymbolPicks.push({326index,327kind: symbol.kind,328score: symbolScore,329label: symbolLabelWithIcon,330ariaLabel: getAriaLabelForSymbol(symbol.name, symbol.kind),331description: containerLabel,332highlights: deprecated ? undefined : {333label: symbolMatches,334description: containerMatches335},336range: {337selection: Range.collapseToStart(symbol.selectionRange),338decoration: symbol.range339},340uri: model.uri,341symbolName: symbolLabel,342strikethrough: deprecated,343buttons344});345}346347// Sort by score348const sortedFilteredSymbolPicks = filteredSymbolPicks.sort((symbolA, symbolB) => filterBySymbolKind ?349this.compareByKindAndScore(symbolA, symbolB) :350this.compareByScore(symbolA, symbolB)351);352353// Add separator for types354// - @ only total number of symbols355// - @: grouped by symbol kind356let symbolPicks: Array<IGotoSymbolQuickPickItem | IQuickPickSeparator> = [];357if (filterBySymbolKind) {358let lastSymbolKind: SymbolKind | undefined = undefined;359let lastSeparator: IQuickPickSeparator | undefined = undefined;360let lastSymbolKindCounter = 0;361362function updateLastSeparatorLabel(): void {363if (lastSeparator && typeof lastSymbolKind === 'number' && lastSymbolKindCounter > 0) {364lastSeparator.label = format(NLS_SYMBOL_KIND_CACHE[lastSymbolKind] || FALLBACK_NLS_SYMBOL_KIND, lastSymbolKindCounter);365}366}367368for (const symbolPick of sortedFilteredSymbolPicks) {369370// Found new kind371if (lastSymbolKind !== symbolPick.kind) {372373// Update last separator with number of symbols we found for kind374updateLastSeparatorLabel();375376lastSymbolKind = symbolPick.kind;377lastSymbolKindCounter = 1;378379// Add new separator for new kind380lastSeparator = { type: 'separator' };381symbolPicks.push(lastSeparator);382}383384// Existing kind, keep counting385else {386lastSymbolKindCounter++;387}388389// Add to final result390symbolPicks.push(symbolPick);391}392393// Update last separator with number of symbols we found for kind394updateLastSeparatorLabel();395} else if (sortedFilteredSymbolPicks.length > 0) {396symbolPicks = [397{ label: localize('symbols', "symbols ({0})", filteredSymbolPicks.length), type: 'separator' },398...sortedFilteredSymbolPicks399];400}401402return symbolPicks;403}404405private compareByScore(symbolA: IGotoSymbolQuickPickItem, symbolB: IGotoSymbolQuickPickItem): number {406if (typeof symbolA.score !== 'number' && typeof symbolB.score === 'number') {407return 1;408} else if (typeof symbolA.score === 'number' && typeof symbolB.score !== 'number') {409return -1;410}411412if (typeof symbolA.score === 'number' && typeof symbolB.score === 'number') {413if (symbolA.score > symbolB.score) {414return -1;415} else if (symbolA.score < symbolB.score) {416return 1;417}418}419420if (symbolA.index < symbolB.index) {421return -1;422} else if (symbolA.index > symbolB.index) {423return 1;424}425426return 0;427}428429private compareByKindAndScore(symbolA: IGotoSymbolQuickPickItem, symbolB: IGotoSymbolQuickPickItem): number {430const kindA = NLS_SYMBOL_KIND_CACHE[symbolA.kind] || FALLBACK_NLS_SYMBOL_KIND;431const kindB = NLS_SYMBOL_KIND_CACHE[symbolB.kind] || FALLBACK_NLS_SYMBOL_KIND;432433// Sort by type first if scoped search434const result = kindA.localeCompare(kindB);435if (result === 0) {436return this.compareByScore(symbolA, symbolB);437}438439return result;440}441442protected async getDocumentSymbols(document: ITextModel, token: CancellationToken): Promise<DocumentSymbol[]> {443const model = await this._outlineModelService.getOrCreate(document, token);444return token.isCancellationRequested ? [] : model.asListOfDocumentSymbols();445}446}447448// #region NLS Helpers449450const FALLBACK_NLS_SYMBOL_KIND = localize('property', "properties ({0})");451const NLS_SYMBOL_KIND_CACHE: { [type: number]: string } = {452[SymbolKind.Method]: localize('method', "methods ({0})"),453[SymbolKind.Function]: localize('function', "functions ({0})"),454[SymbolKind.Constructor]: localize('_constructor', "constructors ({0})"),455[SymbolKind.Variable]: localize('variable', "variables ({0})"),456[SymbolKind.Class]: localize('class', "classes ({0})"),457[SymbolKind.Struct]: localize('struct', "structs ({0})"),458[SymbolKind.Event]: localize('event', "events ({0})"),459[SymbolKind.Operator]: localize('operator', "operators ({0})"),460[SymbolKind.Interface]: localize('interface', "interfaces ({0})"),461[SymbolKind.Namespace]: localize('namespace', "namespaces ({0})"),462[SymbolKind.Package]: localize('package', "packages ({0})"),463[SymbolKind.TypeParameter]: localize('typeParameter', "type parameters ({0})"),464[SymbolKind.Module]: localize('modules', "modules ({0})"),465[SymbolKind.Property]: localize('property', "properties ({0})"),466[SymbolKind.Enum]: localize('enum', "enumerations ({0})"),467[SymbolKind.EnumMember]: localize('enumMember', "enumeration members ({0})"),468[SymbolKind.String]: localize('string', "strings ({0})"),469[SymbolKind.File]: localize('file', "files ({0})"),470[SymbolKind.Array]: localize('array', "arrays ({0})"),471[SymbolKind.Number]: localize('number', "numbers ({0})"),472[SymbolKind.Boolean]: localize('boolean', "booleans ({0})"),473[SymbolKind.Object]: localize('object', "objects ({0})"),474[SymbolKind.Key]: localize('key', "keys ({0})"),475[SymbolKind.Field]: localize('field', "fields ({0})"),476[SymbolKind.Constant]: localize('constant', "constants ({0})")477};478479//#endregion480481482