diff --git a/packages/editor/src/browser/editor.ts b/packages/editor/src/browser/editor.ts index 07c29993b2935..7266a9faee555 100644 --- a/packages/editor/src/browser/editor.ts +++ b/packages/editor/src/browser/editor.ts @@ -36,6 +36,11 @@ export interface TextEditorDocument extends lsp.TextDocument, Saveable, Disposab * @since 1.8.0 */ findMatches?(options: FindMatchesOptions): FindMatch[]; + + /** + * @since 1.14.0 + */ + waitForLanguageSymbolRegistry(): Promise; } // Refactoring diff --git a/packages/file-search/src/browser/quick-file-open.ts b/packages/file-search/src/browser/quick-file-open.ts index 19b3c811a7439..9becb371104ee 100644 --- a/packages/file-search/src/browser/quick-file-open.ts +++ b/packages/file-search/src/browser/quick-file-open.ts @@ -23,13 +23,15 @@ import { import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import URI from '@theia/core/lib/common/uri'; import { FileSearchService, WHITESPACE_QUERY_SEPARATOR } from '../common/file-search-service'; -import { CancellationTokenSource } from '@theia/core/lib/common'; +import { CancellationTokenSource, CommandService } from '@theia/core/lib/common'; import { LabelProvider } from '@theia/core/lib/browser/label-provider'; import { Command } from '@theia/core/lib/common'; import { NavigationLocationService } from '@theia/editor/lib/browser/navigation/navigation-location-service'; import * as fuzzy from '@theia/core/shared/fuzzy'; import { MessageService } from '@theia/core/lib/common/message-service'; import { FileSystemPreferences } from '@theia/filesystem/lib/browser'; +import { EditorOpenerOptions, EditorWidget, Position, Range } from '@theia/editor/lib/browser'; +import { timeout } from '@theia/core/lib/common/promise-util'; export const quickFileOpen: Command = { id: 'file-search.openFile', @@ -37,6 +39,15 @@ export const quickFileOpen: Command = { label: 'Open File...' }; +export interface FilterQuery { + filter: string; + range: Range; + isBySymbol: boolean; +} + +// Supports patterns of <#|:><#|:|,> +const LINE_COLON_PATTERN = /\s?[#:\(](?:line )?(\d*)(?:[#:,](\d*))?\)?\s*$/; + @injectable() export class QuickFileOpenService implements QuickOpenModel, QuickOpenHandler { @@ -58,6 +69,8 @@ export class QuickFileOpenService implements QuickOpenModel, QuickOpenHandler { protected readonly messageService: MessageService; @inject(FileSystemPreferences) protected readonly fsPreferences: FileSystemPreferences; + @inject(CommandService) + protected readonly commandService: CommandService; /** * Whether to hide .gitignored (and other ignored) files. @@ -69,10 +82,19 @@ export class QuickFileOpenService implements QuickOpenModel, QuickOpenHandler { */ protected isOpen: boolean = false; + protected filterAndRangeDefault = { + filter: '', + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 } + }, + isBySymbol: false + }; + /** - * The current lookFor string input by the user. + * Tracks the user file search filter and location range e.g. fileFilter:line:column or fileFilter:line,column */ - protected currentLookFor: string = ''; + protected filterAndRange: FilterQuery = this.filterAndRangeDefault; /** * The score constants when comparing file search results. @@ -94,7 +116,7 @@ export class QuickFileOpenService implements QuickOpenModel, QuickOpenHandler { } getOptions(): QuickOpenOptions { - let placeholder = 'File name to search.'; + let placeholder = 'File name to search (append : to go to line).'; const keybinding = this.getKeyCommand(); if (keybinding) { placeholder += ` (Press ${keybinding} to show/hide ignored files)`; @@ -126,11 +148,11 @@ export class QuickFileOpenService implements QuickOpenModel, QuickOpenHandler { this.hideIgnoredFiles = !this.hideIgnoredFiles; } else { this.hideIgnoredFiles = true; - this.currentLookFor = ''; + this.filterAndRange = this.filterAndRangeDefault; this.isOpen = true; } - this.quickOpenService.open(this.currentLookFor); + this.quickOpenService.open(this.filterAndRange.filter); } /** @@ -157,20 +179,23 @@ export class QuickFileOpenService implements QuickOpenModel, QuickOpenHandler { const roots = this.workspaceService.tryGetRoots(); - this.currentLookFor = lookFor; + this.filterAndRange = this.extractFilterQuery(lookFor); + const fileFilter = this.filterAndRange.filter; + const alreadyCollected = new Set(); const recentlyUsedItems: QuickOpenItem[] = []; const locations = [...this.navigationLocationService.locations()].reverse(); for (const location of locations) { const uriString = location.uri.toString(); - if (location.uri.scheme === 'file' && !alreadyCollected.has(uriString) && fuzzy.test(lookFor, uriString)) { + if (location.uri.scheme === 'file' && !alreadyCollected.has(uriString) && fuzzy.test(fileFilter, uriString)) { const item = this.toItem(location.uri, { groupLabel: recentlyUsedItems.length === 0 ? 'recently opened' : undefined, showBorder: false }); recentlyUsedItems.push(item); alreadyCollected.add(uriString); } } - if (lookFor.length > 0) { + if (fileFilter.length > 0) { + let topItem: QuickOpenItem | undefined = undefined; const handler = async (results: string[]) => { if (token.isCancellationRequested) { return; @@ -202,10 +227,14 @@ export class QuickFileOpenService implements QuickOpenModel, QuickOpenHandler { sortedResults.unshift(item); } // Return the recently used items, followed by the search results. - acceptor([...recentlyUsedItems, ...sortedResults]); + const allItemsSorted = ([...recentlyUsedItems, ...sortedResults]); + if (allItemsSorted.length > 0) { + topItem = allItemsSorted[0]; + } + acceptor(allItemsSorted); }; - this.fileSearchService.find(lookFor, { + this.fileSearchService.find(fileFilter, { rootUris: roots.map(r => r.resource.toString()), fuzzyMatch: true, limit: 200, @@ -213,7 +242,31 @@ export class QuickFileOpenService implements QuickOpenModel, QuickOpenHandler { excludePatterns: this.hideIgnoredFiles ? Object.keys(this.fsPreferences['files.exclude']) : undefined, - }, token).then(handler); + }, token).then(handler).then(async () => { + if (topItem && this.filterAndRange.isBySymbol) { + const uri = topItem.getUri(); + if (uri) { + acceptor([]); + const editor = await this.openFileAsync(uri); + if (editor) { + editor.activate(); + const timeoutValue = 8000; + const result = await Promise.race([ + this.waitForLanguageSymbolRegistry(editor), + timeout(timeoutValue) + ]); + if (result) { + console.log('Opening quick outline'); + this.commandService.executeCommand('editor.action.quickOutline'); + } else { + console.log(`Language Symbol Registry not ready after ${timeoutValue} ms`); + } + } else { + console.log('no editor returned'); + } + } + } + }); } else { if (roots.length !== 0) { acceptor(recentlyUsedItems); @@ -254,7 +307,7 @@ export class QuickFileOpenService implements QuickOpenModel, QuickOpenHandler { } // Normalize the user query. - const query: string = normalize(this.currentLookFor); + const query: string = normalize(this.filterAndRange.filter); /** * Score a given string. @@ -347,13 +400,36 @@ export class QuickFileOpenService implements QuickOpenModel, QuickOpenHandler { } openFile(uri: URI): void { - this.openerService.getOpener(uri) - .then(opener => opener.open(uri)) + const options = this.buildOpenerOptions(); + const resolvedOpener = this.openerService.getOpener(uri, options); + resolvedOpener + .then(opener => opener.open(uri, options)) .catch(error => this.messageService.error(error)); } + async openFileAsync(uri: URI): Promise { + const options = this.buildOpenerOptions(); + const opener = await this.openerService.getOpener(uri, options); + try { + console.log(`opening editor: ${uri.toString()}`); + return await opener.open(uri, options) as EditorWidget; + } catch (e) { + this.messageService.error(e); + } + } + + protected buildOpenerOptions(): EditorOpenerOptions { + return { selection: this.filterAndRange.range }; + } + + protected async waitForLanguageSymbolRegistry(editor: EditorWidget): Promise { + const document = editor?.editor?.document; + return await document.waitForLanguageSymbolRegistry(); + } + private toItem(uriOrString: URI | string, group?: QuickOpenGroupItemOptions): QuickOpenItem { const uri = uriOrString instanceof URI ? uriOrString : new URI(uriOrString); + console.log('=====> toItem uri: ' + uri.toString()); let description = this.labelProvider.getLongName(uri.parent); if (this.workspaceService.isMultiRootWorkspaceOpened) { const rootUri = this.workspaceService.getWorkspaceRootUri(uri); @@ -386,4 +462,40 @@ export class QuickFileOpenService implements QuickOpenModel, QuickOpenHandler { }; return new QuickOpenItem(options); } + + /** + * Extracts the given expression into a structure of search-file-filter, + * location-range and symbol-filter. + * + * @param fileExpression patterns of <#|:><#|:|,> + */ + protected extractFilterQuery(filterExpression: string): FilterQuery { + let lineNumber = 0; + let startColumn = 0; + const fileAndSymbolExpressions = filterExpression.split('@', 2); + const isBySymbol = (fileAndSymbolExpressions.length > 1); + + const fileExpression = fileAndSymbolExpressions[0]; + // Find line and column number from the expression using RegExp. + const patternMatch = LINE_COLON_PATTERN.exec(fileExpression); + + if (patternMatch) { + const line = parseInt(patternMatch[1] ?? '', 10); + if (Number.isFinite(line)) { + lineNumber = line > 0 ? line - 1 : 0; + + const column = parseInt(patternMatch[2] ?? '', 10); + startColumn = Number.isFinite(column) && column > 0 ? column - 1 : 0; + } + } + + const position = Position.create(lineNumber, startColumn); + const range = { start: position, end: position }; + const fileFilter = patternMatch ? fileExpression.substr(0, patternMatch.index) : fileExpression; + return { + filter: fileFilter, + range, + isBySymbol + }; + } } diff --git a/packages/monaco/src/browser/monaco-editor-model.ts b/packages/monaco/src/browser/monaco-editor-model.ts index 2ff794943e8dc..ed7fc4e5899cd 100644 --- a/packages/monaco/src/browser/monaco-editor-model.ts +++ b/packages/monaco/src/browser/monaco-editor-model.ts @@ -313,6 +313,33 @@ export class MonacoEditorModel implements ITextEditorModel, TextEditorDocument { return extractedMatches; } + async waitForLanguageSymbolRegistry(): Promise { + + let symbolProviderRegistryPromiseResolve: (res: boolean) => void; + const symbolProviderRegistryPromise = new Promise(resolve => symbolProviderRegistryPromiseResolve = resolve); + + if (this.textEditorModel) { + const model = this.textEditorModel; + if (monaco.modes.DocumentSymbolProviderRegistry.has(model)) { + return true; + } + + // Resolve promise when registry knows model + const symbolProviderListener = monaco.modes.DocumentSymbolProviderRegistry.onDidChange(() => { + if (monaco.modes.DocumentSymbolProviderRegistry.has(model)) { + symbolProviderListener.dispose(); + + symbolProviderRegistryPromiseResolve(true); + } + }); + } + + // // Resolve promise when we get disposed too + // disposables.add(toDisposable(() => symbolProviderRegistryPromiseResolve(false))); + + return symbolProviderRegistryPromise; + } + async load(): Promise { await this.resolveModel; return this;