UNPKG

@theia/monaco

Version:
405 lines (375 loc) • 18.4 kB
// ***************************************************************************** // Copyright (C) 2017 TypeFox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { injectable, inject } from '@theia/core/shared/inversify'; import { FrontendApplicationContribution, FrontendApplication, TreeNode } from '@theia/core/lib/browser'; import { Range, EditorManager, EditorOpenerOptions } from '@theia/editor/lib/browser'; import { DisposableCollection, Disposable } from '@theia/core'; import { OutlineViewService } from '@theia/outline-view/lib/browser/outline-view-service'; import { OutlineSymbolInformationNode } from '@theia/outline-view/lib/browser/outline-view-widget'; import URI from '@theia/core/lib/common/uri'; import { MonacoEditor } from './monaco-editor'; import debounce = require('@theia/core/shared/lodash.debounce'); import * as monaco from '@theia/monaco-editor-core'; import { ILanguageFeaturesService } from '@theia/monaco-editor-core/esm/vs/editor/common/services/languageFeatures'; import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices'; import { ITextModel } from '@theia/monaco-editor-core/esm/vs/editor/common/model'; import { DocumentSymbol } from '@theia/monaco-editor-core/esm/vs/editor/common/languages'; @injectable() export class MonacoOutlineContribution implements FrontendApplicationContribution { protected readonly toDisposeOnEditor = new DisposableCollection(); protected roots: MonacoOutlineSymbolInformationNode[] | undefined; protected canUpdateOutline: boolean = true; @inject(OutlineViewService) protected readonly outlineViewService: OutlineViewService; @inject(EditorManager) protected readonly editorManager: EditorManager; onStart(app: FrontendApplication): void { // updateOutline and handleCurrentEditorChanged need to be called even when the outline view widget is closed // in order to update breadcrumbs. StandaloneServices.get(ILanguageFeaturesService).documentSymbolProvider.onDidChange( debounce(() => this.updateOutline()) ); this.editorManager.onCurrentEditorChanged( debounce(() => this.handleCurrentEditorChanged(), 50) ); this.handleCurrentEditorChanged(); this.outlineViewService.onDidSelect(async node => { if (MonacoOutlineSymbolInformationNode.is(node) && node.parent) { const options: EditorOpenerOptions = { mode: 'reveal', selection: node.range }; await this.selectInEditor(node, options); } }); this.outlineViewService.onDidOpen(async node => { if (MonacoOutlineSymbolInformationNode.is(node)) { const options: EditorOpenerOptions = { selection: { start: node.range.start } }; await this.selectInEditor(node, options); } }); } protected async selectInEditor(node: MonacoOutlineSymbolInformationNode, options?: EditorOpenerOptions): Promise<void> { // Avoid cyclic updates: Outline -> Editor -> Outline. this.canUpdateOutline = false; try { await this.editorManager.open(node.uri, options); } finally { this.canUpdateOutline = true; } } protected handleCurrentEditorChanged(): void { this.toDisposeOnEditor.dispose(); this.toDisposeOnEditor.push(Disposable.create(() => this.roots = undefined)); const editor = this.editorManager.currentEditor; if (editor) { const model = MonacoEditor.get(editor)!.getControl().getModel(); if (model) { this.toDisposeOnEditor.push(model.onDidChangeContent(() => { this.roots = undefined; // Invalidate the previously resolved roots. this.updateOutline(); })); } this.toDisposeOnEditor.push(editor.editor.onSelectionChanged(selection => this.updateOutline(selection))); } this.updateOutline(); } protected tokenSource = new monaco.CancellationTokenSource(); protected async updateOutline(editorSelection?: Range): Promise<void> { if (!this.canUpdateOutline) { return; } this.tokenSource.cancel(); this.tokenSource = new monaco.CancellationTokenSource(); const token = this.tokenSource.token; const editor = MonacoEditor.get(this.editorManager.currentEditor); const model = editor && editor.getControl().getModel(); const roots = model && await this.createRoots(model, token, editorSelection); if (token.isCancellationRequested) { return; } this.outlineViewService.publish(roots || []); } protected async createRoots( model: monaco.editor.ITextModel | ITextModel, token: monaco.CancellationToken, editorSelection?: Range ): Promise<MonacoOutlineSymbolInformationNode[]> { model = model as ITextModel; if (this.roots && this.roots.length > 0) { // Reset the selection on the tree nodes, so that we can apply the new ones based on the `editorSelection`. const resetSelection = (node: MonacoOutlineSymbolInformationNode) => { node.selected = false; node.children.forEach(resetSelection); }; this.roots.forEach(resetSelection); } else { this.roots = []; const providers = StandaloneServices.get(ILanguageFeaturesService).documentSymbolProvider.all(model); if (token.isCancellationRequested) { return []; } const uri = new URI(model.uri.toString()); for (const provider of providers) { try { const symbols = await provider.provideDocumentSymbols(model, token) ?? []; if (token.isCancellationRequested) { return []; } const nodes = this.createNodes(uri, symbols); if (providers.length > 1 && provider.displayName) { const providerRoot = this.createProviderRootNode(uri, provider.displayName, nodes); this.roots.push(providerRoot); } else { this.roots.push(...nodes); } } catch { /* collect symbols from other providers */ } } } this.applySelection(this.roots, editorSelection); return this.roots; } protected createProviderRootNode(uri: URI, displayName: string, children: MonacoOutlineSymbolInformationNode[]): MonacoOutlineSymbolInformationNode { const node: MonacoOutlineSymbolInformationNode = { uri, id: displayName, name: displayName, iconClass: '', range: this.asRange(new monaco.Range(1, 1, 1, 1)), fullRange: this.asRange(new monaco.Range(1, 1, 1, 1)), children, parent: undefined, selected: false, expanded: true }; return node; } protected createNodes(uri: URI, symbols: monaco.languages.DocumentSymbol[] | DocumentSymbol[]): MonacoOutlineSymbolInformationNode[] { symbols = symbols as monaco.languages.DocumentSymbol[]; let rangeBased = false; const ids = new Map(); const roots: MonacoOutlineSymbolInformationNode[] = []; const nodesByName = symbols.sort(this.orderByPosition).reduce((result, symbol) => { const node = this.createNode(uri, symbol, ids); if (symbol.children) { MonacoOutlineSymbolInformationNode.insert(roots, node); } else { rangeBased = rangeBased || symbol.range.startLineNumber !== symbol.range.endLineNumber; const values = result.get(symbol.name) || []; values.push({ symbol, node }); result.set(symbol.name, values); } return result; }, new Map<string, MonacoOutlineContribution.NodeAndSymbol[]>()); for (const nodes of nodesByName.values()) { for (const { node, symbol } of nodes) { if (!symbol.containerName) { MonacoOutlineSymbolInformationNode.insert(roots, node); } else { const possibleParents = nodesByName.get(symbol.containerName); if (possibleParents) { const parent = possibleParents.find(possibleParent => this.parentContains(symbol, possibleParent.symbol, rangeBased)); if (parent) { node.parent = parent.node; MonacoOutlineSymbolInformationNode.insert(parent.node.children, node); } } } } } if (!roots.length) { const nodes = nodesByName.values().next().value; if (nodes && !nodes[0].node.parent) { return [nodes[0].node]; } return []; } return roots; } /** * Sets the selection on the sub-trees based on the optional editor selection. * Select the narrowest node that is strictly contains the editor selection. */ protected applySelection(roots: MonacoOutlineSymbolInformationNode[], editorSelection?: Range): boolean { if (editorSelection) { for (const root of roots) { if (this.parentContains(editorSelection, root.fullRange, true)) { const { children } = root; root.selected = !root.expanded || !this.applySelection(children, editorSelection); return true; } } } return false; } /** * Returns `true` if `candidate` is strictly contained inside `parent` * * If the argument is a `DocumentSymbol`, then `getFullRange` will be used to retrieve the range of the underlying symbol. */ protected parentContains(candidate: monaco.languages.DocumentSymbol | Range, parent: monaco.languages.DocumentSymbol | Range, rangeBased: boolean): boolean { // TODO: move this code to the `monaco-languageclient`: https://github.com/eclipse-theia/theia/pull/2885#discussion_r217800446 const candidateRange = Range.is(candidate) ? candidate : this.getFullRange(candidate); const parentRange = Range.is(parent) ? parent : this.getFullRange(parent); const sameStartLine = candidateRange.start.line === parentRange.start.line; const startColGreaterOrEqual = candidateRange.start.character >= parentRange.start.character; const startLineGreater = candidateRange.start.line > parentRange.start.line; const sameEndLine = candidateRange.end.line === parentRange.end.line; const endColSmallerOrEqual = candidateRange.end.character <= parentRange.end.character; const endLineSmaller = candidateRange.end.line < parentRange.end.line; return (((sameStartLine && startColGreaterOrEqual || startLineGreater) && (sameEndLine && endColSmallerOrEqual || endLineSmaller)) || !rangeBased); } /** * `monaco` to LSP `Range` converter. Converts the `1-based` location indices into `0-based` ones. */ protected asRange(range: monaco.IRange): Range { const { startLineNumber, startColumn, endLineNumber, endColumn } = range; return { start: { line: startLineNumber - 1, character: startColumn - 1 }, end: { line: endLineNumber - 1, character: endColumn - 1 } }; } /** * Returns with a range enclosing this symbol not including leading/trailing whitespace but everything else like comments. * This information is typically used to determine if the clients cursor is inside the symbol to reveal in the symbol in the UI. * This allows to obtain the range including the associated comments. * * See: [`DocumentSymbol#range`](https://microsoft.github.io/language-server-protocol/specification#textDocument_documentSymbol) for more details. */ protected getFullRange(documentSymbol: monaco.languages.DocumentSymbol): Range { return this.asRange(documentSymbol.range); } /** * The range that should be selected and revealed when this symbol is being picked, e.g the name of a function. Must be contained by the `getSelectionRange`. * * See: [`DocumentSymbol#selectionRange`](https://microsoft.github.io/language-server-protocol/specification#textDocument_documentSymbol) for more details. */ protected getNameRange(documentSymbol: monaco.languages.DocumentSymbol): Range { return this.asRange(documentSymbol.selectionRange); } protected createNode( uri: URI, symbol: monaco.languages.DocumentSymbol, ids: Map<string, number>, parent?: MonacoOutlineSymbolInformationNode ): MonacoOutlineSymbolInformationNode { const id = this.createId(symbol.name, ids); const children: MonacoOutlineSymbolInformationNode[] = []; const node: MonacoOutlineSymbolInformationNode = { children, id, iconClass: monaco.languages.SymbolKind[symbol.kind].toString().toLowerCase(), name: this.getName(symbol), detail: this.getDetail(symbol), parent, uri, range: this.getNameRange(symbol), fullRange: this.getFullRange(symbol), selected: false, expanded: this.shouldExpand(symbol) }; if (symbol.children) { for (const child of symbol.children) { MonacoOutlineSymbolInformationNode.insert(children, this.createNode(uri, child, ids, node)); } } return node; } protected getName(symbol: monaco.languages.DocumentSymbol): string { return symbol.name; } protected getDetail(symbol: monaco.languages.DocumentSymbol): string { return symbol.detail; } protected createId(name: string, ids: Map<string, number>): string { const counter = ids.get(name); const index = typeof counter === 'number' ? counter + 1 : 0; ids.set(name, index); return name + '_' + index; } protected shouldExpand(symbol: monaco.languages.DocumentSymbol): boolean { return [ monaco.languages.SymbolKind.Class, monaco.languages.SymbolKind.Enum, monaco.languages.SymbolKind.File, monaco.languages.SymbolKind.Interface, monaco.languages.SymbolKind.Module, monaco.languages.SymbolKind.Namespace, monaco.languages.SymbolKind.Object, monaco.languages.SymbolKind.Package, monaco.languages.SymbolKind.Struct ].indexOf(symbol.kind) !== -1; } protected orderByPosition(symbol: monaco.languages.DocumentSymbol, symbol2: monaco.languages.DocumentSymbol): number { const startLineComparison = symbol.range.startLineNumber - symbol2.range.startLineNumber; if (startLineComparison !== 0) { return startLineComparison; } const startOffsetComparison = symbol.range.startColumn - symbol2.range.startColumn; if (startOffsetComparison !== 0) { return startOffsetComparison; } const endLineComparison = symbol.range.endLineNumber - symbol2.range.endLineNumber; if (endLineComparison !== 0) { return endLineComparison; } return symbol.range.endColumn - symbol2.range.endColumn; } } export namespace MonacoOutlineContribution { export interface NodeAndSymbol { node: MonacoOutlineSymbolInformationNode; symbol: monaco.languages.DocumentSymbol } } export interface MonacoOutlineSymbolInformationNode extends OutlineSymbolInformationNode { uri: URI; range: Range; fullRange: Range; detail?: string; parent: MonacoOutlineSymbolInformationNode | undefined; children: MonacoOutlineSymbolInformationNode[]; } export namespace MonacoOutlineSymbolInformationNode { export function is(node: TreeNode): node is MonacoOutlineSymbolInformationNode { return OutlineSymbolInformationNode.is(node) && 'uri' in node && 'range' in node; } export function insert(nodes: MonacoOutlineSymbolInformationNode[], node: MonacoOutlineSymbolInformationNode): void { const index = nodes.findIndex(current => compare(node, current) < 0); if (index === -1) { nodes.push(node); } else { nodes.splice(index, 0, node); } } export function compare(node: MonacoOutlineSymbolInformationNode, node2: MonacoOutlineSymbolInformationNode): number { const startLineComparison = node.range.start.line - node2.range.start.line; if (startLineComparison !== 0) { return startLineComparison; } const startColumnComparison = node.range.start.character - node2.range.start.character; if (startColumnComparison !== 0) { return startColumnComparison; } const endLineComparison = node2.range.end.line - node.range.end.line; if (endLineComparison !== 0) { return endLineComparison; } return node2.range.end.character - node.range.end.character; } }