diff --git a/src/vs/editor/browser/widget/diffEditorWidget2/diffEditorWidget2.ts b/src/vs/editor/browser/widget/diffEditorWidget2/diffEditorWidget2.ts index 280c2bb7b9ae1..b8c42ae717f70 100644 --- a/src/vs/editor/browser/widget/diffEditorWidget2/diffEditorWidget2.ts +++ b/src/vs/editor/browser/widget/diffEditorWidget2/diffEditorWidget2.ts @@ -157,7 +157,9 @@ export class DiffEditorWidget2 extends DelegatingEditor implements IDiffEditor { this._register(autorunWithStore((reader, store) => { /** @description UnchangedRangesFeature */ - this.unchangedRangesFeature = store.add(new (readHotReloadableExport(UnchangedRangesFeature, reader))(this._editors, this._diffModel, this._options)); + this.unchangedRangesFeature = store.add( + this._instantiationService.createInstance(readHotReloadableExport(UnchangedRangesFeature, reader), this._editors, this._diffModel, this._options) + ); })); this._register(autorunWithStore((reader, store) => { @@ -178,7 +180,9 @@ export class DiffEditorWidget2 extends DelegatingEditor implements IDiffEditor { this._register(autorunWithStore((reader, store) => { /** @description OverviewRulerPart */ - store.add(this._instantiationService.createInstance(readHotReloadableExport(OverviewRulerPart, reader), this._editors, + store.add(this._instantiationService.createInstance( + readHotReloadableExport(OverviewRulerPart, reader), + this._editors, this.elements.root, this._diffModel, this._rootSizeObserver.width, diff --git a/src/vs/editor/browser/widget/diffEditorWidget2/outlineModel.ts b/src/vs/editor/browser/widget/diffEditorWidget2/outlineModel.ts new file mode 100644 index 0000000000000..cd12277b82cf9 --- /dev/null +++ b/src/vs/editor/browser/widget/diffEditorWidget2/outlineModel.ts @@ -0,0 +1,384 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { binarySearch, coalesceInPlace, equals } from 'vs/base/common/arrays'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { onUnexpectedExternalError } from 'vs/base/common/errors'; +import { Iterable } from 'vs/base/common/iterator'; +import { commonPrefixLength } from 'vs/base/common/strings'; +import { URI } from 'vs/base/common/uri'; +import { IPosition, Position } from 'vs/editor/common/core/position'; +import { IRange, Range } from 'vs/editor/common/core/range'; +import { LanguageFeatureRegistry } from 'vs/editor/common/languageFeatureRegistry'; +import { DocumentSymbol, DocumentSymbolProvider } from 'vs/editor/common/languages'; +import { ITextModel } from 'vs/editor/common/model'; +import { MarkerSeverity } from 'vs/platform/markers/common/markers'; + +// TODO@hediet: These classes are copied from outlineModel.ts because of layering issues. +// Because these classes just depend on the DocumentSymbolProvider (which is in the core editor), +// they should be moved to the core editor as well. + +export abstract class TreeElement { + + abstract id: string; + abstract children: Map; + abstract parent: TreeElement | undefined; + + remove(): void { + this.parent?.children.delete(this.id); + } + + static findId(candidate: DocumentSymbol | string, container: TreeElement): string { + // complex id-computation which contains the origin/extension, + // the parent path, and some dedupe logic when names collide + let candidateId: string; + if (typeof candidate === 'string') { + candidateId = `${container.id}/${candidate}`; + } else { + candidateId = `${container.id}/${candidate.name}`; + if (container.children.get(candidateId) !== undefined) { + candidateId = `${container.id}/${candidate.name}_${candidate.range.startLineNumber}_${candidate.range.startColumn}`; + } + } + + let id = candidateId; + for (let i = 0; container.children.get(id) !== undefined; i++) { + id = `${candidateId}_${i}`; + } + + return id; + } + + static getElementById(id: string, element: TreeElement): TreeElement | undefined { + if (!id) { + return undefined; + } + const len = commonPrefixLength(id, element.id); + if (len === id.length) { + return element; + } + if (len < element.id.length) { + return undefined; + } + for (const [, child] of element.children) { + const candidate = TreeElement.getElementById(id, child); + if (candidate) { + return candidate; + } + } + return undefined; + } + + static size(element: TreeElement): number { + let res = 1; + for (const [, child] of element.children) { + res += TreeElement.size(child); + } + return res; + } + + static empty(element: TreeElement): boolean { + return element.children.size === 0; + } +} + +export interface IOutlineMarker { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + severity: MarkerSeverity; +} + +export class OutlineElement extends TreeElement { + + children = new Map(); + marker: { count: number; topSev: MarkerSeverity } | undefined; + + constructor( + readonly id: string, + public parent: TreeElement | undefined, + readonly symbol: DocumentSymbol + ) { + super(); + } +} + +export class OutlineGroup extends TreeElement { + + children = new Map(); + + constructor( + readonly id: string, + public parent: TreeElement | undefined, + readonly label: string, + readonly order: number, + ) { + super(); + } + + getItemEnclosingPosition(position: IPosition): OutlineElement | undefined { + return position ? this._getItemEnclosingPosition(position, this.children) : undefined; + } + + private _getItemEnclosingPosition(position: IPosition, children: Map): OutlineElement | undefined { + for (const [, item] of children) { + if (!item.symbol.range || !Range.containsPosition(item.symbol.range, position)) { + continue; + } + return this._getItemEnclosingPosition(position, item.children) || item; + } + return undefined; + } + + updateMarker(marker: IOutlineMarker[]): void { + for (const [, child] of this.children) { + this._updateMarker(marker, child); + } + } + + private _updateMarker(markers: IOutlineMarker[], item: OutlineElement): void { + item.marker = undefined; + + // find the proper start index to check for item/marker overlap. + const idx = binarySearch(markers, item.symbol.range, Range.compareRangesUsingStarts); + let start: number; + if (idx < 0) { + start = ~idx; + if (start > 0 && Range.areIntersecting(markers[start - 1], item.symbol.range)) { + start -= 1; + } + } else { + start = idx; + } + + const myMarkers: IOutlineMarker[] = []; + let myTopSev: MarkerSeverity | undefined; + + for (; start < markers.length && Range.areIntersecting(item.symbol.range, markers[start]); start++) { + // remove markers intersecting with this outline element + // and store them in a 'private' array. + const marker = markers[start]; + myMarkers.push(marker); + (markers as Array)[start] = undefined; + if (!myTopSev || marker.severity > myTopSev) { + myTopSev = marker.severity; + } + } + + // Recurse into children and let them match markers that have matched + // this outline element. This might remove markers from this element and + // therefore we remember that we have had markers. That allows us to render + // the dot, saying 'this element has children with markers' + for (const [, child] of item.children) { + this._updateMarker(myMarkers, child); + } + + if (myTopSev) { + item.marker = { + count: myMarkers.length, + topSev: myTopSev + }; + } + + coalesceInPlace(markers); + } +} + +export class OutlineModel extends TreeElement { + + static create(registry: LanguageFeatureRegistry, textModel: ITextModel, token: CancellationToken): Promise { + + const cts = new CancellationTokenSource(token); + const result = new OutlineModel(textModel.uri); + const provider = registry.ordered(textModel); + const promises = provider.map((provider, index) => { + + const id = TreeElement.findId(`provider_${index}`, result); + const group = new OutlineGroup(id, result, provider.displayName ?? 'Unknown Outline Provider', index); + + + return Promise.resolve(provider.provideDocumentSymbols(textModel, cts.token)).then(result => { + for (const info of result || []) { + OutlineModel._makeOutlineElement(info, group); + } + return group; + }, err => { + onUnexpectedExternalError(err); + return group; + }).then(group => { + if (!TreeElement.empty(group)) { + result._groups.set(id, group); + } else { + group.remove(); + } + }); + }); + + const listener = registry.onDidChange(() => { + const newProvider = registry.ordered(textModel); + if (!equals(newProvider, provider)) { + cts.cancel(); + } + }); + + return Promise.all(promises).then(() => { + if (cts.token.isCancellationRequested && !token.isCancellationRequested) { + return OutlineModel.create(registry, textModel, token); + } else { + return result._compact(); + } + }).finally(() => { + listener.dispose(); + }); + } + + private static _makeOutlineElement(info: DocumentSymbol, container: OutlineGroup | OutlineElement): void { + const id = TreeElement.findId(info, container); + const res = new OutlineElement(id, container, info); + if (info.children) { + for (const childInfo of info.children) { + OutlineModel._makeOutlineElement(childInfo, res); + } + } + container.children.set(res.id, res); + } + + static get(element: TreeElement | undefined): OutlineModel | undefined { + while (element) { + if (element instanceof OutlineModel) { + return element; + } + element = element.parent; + } + return undefined; + } + + readonly id = 'root'; + readonly parent = undefined; + + protected _groups = new Map(); + children = new Map(); + + protected constructor(readonly uri: URI) { + super(); + + this.id = 'root'; + this.parent = undefined; + } + + private _compact(): this { + let count = 0; + for (const [key, group] of this._groups) { + if (group.children.size === 0) { // empty + this._groups.delete(key); + } else { + count += 1; + } + } + if (count !== 1) { + // + this.children = this._groups; + } else { + // adopt all elements of the first group + const group = Iterable.first(this._groups.values())!; + for (const [, child] of group.children) { + child.parent = this; + this.children.set(child.id, child); + } + } + return this; + } + + merge(other: OutlineModel): boolean { + if (this.uri.toString() !== other.uri.toString()) { + return false; + } + if (this._groups.size !== other._groups.size) { + return false; + } + this._groups = other._groups; + this.children = other.children; + return true; + } + + getItemEnclosingPosition(position: IPosition, context?: OutlineElement): OutlineElement | undefined { + + let preferredGroup: OutlineGroup | undefined; + if (context) { + let candidate = context.parent; + while (candidate && !preferredGroup) { + if (candidate instanceof OutlineGroup) { + preferredGroup = candidate; + } + candidate = candidate.parent; + } + } + + let result: OutlineElement | undefined = undefined; + for (const [, group] of this._groups) { + result = group.getItemEnclosingPosition(position); + if (result && (!preferredGroup || preferredGroup === group)) { + break; + } + } + return result; + } + + getItemById(id: string): TreeElement | undefined { + return TreeElement.getElementById(id, this); + } + + updateMarker(marker: IOutlineMarker[]): void { + // sort markers by start range so that we can use + // outline element starts for quicker look up + marker.sort(Range.compareRangesUsingStarts); + + for (const [, group] of this._groups) { + group.updateMarker(marker.slice(0)); + } + } + + getTopLevelSymbols(): DocumentSymbol[] { + const roots: DocumentSymbol[] = []; + for (const child of this.children.values()) { + if (child instanceof OutlineElement) { + roots.push(child.symbol); + } else { + roots.push(...Iterable.map(child.children.values(), child => child.symbol)); + } + } + return roots.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range)); + } + + asListOfDocumentSymbols(): DocumentSymbol[] { + const roots = this.getTopLevelSymbols(); + const bucket: DocumentSymbol[] = []; + OutlineModel._flattenDocumentSymbols(bucket, roots, ''); + return bucket.sort((a, b) => + Position.compare(Range.getStartPosition(a.range), Range.getStartPosition(b.range)) || Position.compare(Range.getEndPosition(b.range), Range.getEndPosition(a.range)) + ); + } + + private static _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) { + OutlineModel._flattenDocumentSymbols(bucket, entry.children, entry.name); + } + } + } +} diff --git a/src/vs/editor/browser/widget/diffEditorWidget2/unchangedRanges.ts b/src/vs/editor/browser/widget/diffEditorWidget2/unchangedRanges.ts index 7272f56b11fdd..b3ac905331d52 100644 --- a/src/vs/editor/browser/widget/diffEditorWidget2/unchangedRanges.ts +++ b/src/vs/editor/browser/widget/diffEditorWidget2/unchangedRanges.ts @@ -4,34 +4,49 @@ *--------------------------------------------------------------------------------------------*/ import { $, addDisposableListener, h, reset } from 'vs/base/browser/dom'; -import { renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels'; +import { renderIcon, renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels'; +import { compareBy, numberComparator, reverseOrder } from 'vs/base/common/arrays'; +import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { Codicon } from 'vs/base/common/codicons'; +import { Event } from 'vs/base/common/event'; import { MarkdownString } from 'vs/base/common/htmlContent'; import { Disposable } from 'vs/base/common/lifecycle'; -import { IObservable, autorun, derived, derivedWithStore, observableFromEvent, transaction } from 'vs/base/common/observable'; +import { IObservable, IReader, autorun, autorunWithStore, derived, derivedWithStore, observableFromEvent, observableSignalFromEvent, observableValue, transaction } from 'vs/base/common/observable'; import { ThemeIcon } from 'vs/base/common/themables'; import { isDefined } from 'vs/base/common/types'; import { ICodeEditor, IViewZone } from 'vs/editor/browser/editorBrowser'; import { DiffEditorEditors } from 'vs/editor/browser/widget/diffEditorWidget2/diffEditorEditors'; import { DiffEditorOptions } from 'vs/editor/browser/widget/diffEditorWidget2/diffEditorOptions'; import { DiffEditorViewModel, UnchangedRegion } from 'vs/editor/browser/widget/diffEditorWidget2/diffEditorViewModel'; +import { OutlineModel } from 'vs/editor/browser/widget/diffEditorWidget2/outlineModel'; import { PlaceholderViewZone, ViewZoneOverlayWidget, applyObservableDecorations, applyStyle, applyViewZones } from 'vs/editor/browser/widget/diffEditorWidget2/utils'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; import { LineRange } from 'vs/editor/common/core/lineRange'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { CursorChangeReason } from 'vs/editor/common/cursorEvents'; -import { IModelDecorationOptions, IModelDeltaDecoration } from 'vs/editor/common/model'; +import { SymbolKind, SymbolKinds } from 'vs/editor/common/languages'; +import { IModelDecorationOptions, IModelDeltaDecoration, ITextModel } from 'vs/editor/common/model'; +import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { localize } from 'vs/nls'; export class UnchangedRangesFeature extends Disposable { private _isUpdatingViewZones = false; public get isUpdatingViewZones(): boolean { return this._isUpdatingViewZones; } + private readonly _modifiedModel = observableFromEvent(this._editors.modified.onDidChangeModel, () => this._editors.modified.getModel()); + + private readonly _modifiedOutlineSource = derivedWithStore('modified outline source', (reader, store) => { + const m = this._modifiedModel.read(reader); + if (!m) { return undefined; } + return store.add(new OutlineSource(this._languageFeaturesService, m)); + }); + constructor( private readonly _editors: DiffEditorEditors, private readonly _diffModel: IObservable, private readonly _options: DiffEditorOptions, + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, ) { super(); @@ -66,6 +81,9 @@ export class UnchangedRangesFeature extends Disposable { const modViewZones: IViewZone[] = []; const sideBySide = this._options.renderSideBySide.read(reader); + const modifiedOutlineSource = this._modifiedOutlineSource.read(reader); + if (!modifiedOutlineSource) { return { origViewZones, modViewZones }; } + const curUnchangedRegions = unchangedRegions.read(reader); for (const r of curUnchangedRegions) { if (r.shouldHideControls(reader)) { @@ -76,13 +94,13 @@ export class UnchangedRangesFeature extends Disposable { const d = derived(reader => /** @description hiddenOriginalRangeStart */ r.getHiddenOriginalRange(reader).startLineNumber - 1); const origVz = new PlaceholderViewZone(d, 24); origViewZones.push(origVz); - store.add(new CollapsedCodeOverlayWidget(this._editors.original, origVz, r, !sideBySide)); + store.add(new CollapsedCodeOverlayWidget(this._editors.original, origVz, r, !sideBySide, modifiedOutlineSource)); } { const d = derived(reader => /** @description hiddenModifiedRangeStart */ r.getHiddenModifiedRange(reader).startLineNumber - 1); const modViewZone = new PlaceholderViewZone(d, 24); modViewZones.push(modViewZone); - store.add(new CollapsedCodeOverlayWidget(this._editors.modified, modViewZone, r, false)); + store.add(new CollapsedCodeOverlayWidget(this._editors.modified, modViewZone, r, false, modifiedOutlineSource)); } } @@ -177,6 +195,57 @@ export class UnchangedRangesFeature extends Disposable { } } +class DisposableCancellationTokenSource extends CancellationTokenSource { + public override dispose() { + super.dispose(true); + } +} + +class OutlineSource extends Disposable { + private readonly _currentModel = observableValue('current model', undefined); + + constructor( + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + private readonly _textModel: ITextModel, + ) { + super(); + + const documentSymbolProviderChanged = observableSignalFromEvent( + 'documentSymbolProvider.onDidChange', + this._languageFeaturesService.documentSymbolProvider.onDidChange + ); + + const textModelChanged = observableSignalFromEvent( + '_textModel.onDidChangeContent', + Event.debounce(e => this._textModel.onDidChangeContent(e), () => undefined, 100) + ); + + this._register(autorunWithStore(async (reader, store) => { + documentSymbolProviderChanged.read(reader); + textModelChanged.read(reader); + + const src = store.add(new DisposableCancellationTokenSource()); + const model = await OutlineModel.create( + this._languageFeaturesService.documentSymbolProvider, + this._textModel, + src.token, + ); + if (store.isDisposed) { return; } + + this._currentModel.set(model, undefined); + })); + } + + public getBreadcrumbItems(startRange: LineRange, reader: IReader): { name: string; kind: SymbolKind }[] { + const m = this._currentModel.read(reader); + if (!m) { return []; } + const symbols = m.asListOfDocumentSymbols() + .filter(s => startRange.contains(s.range.startLineNumber) && !startRange.contains(s.range.endLineNumber)); + symbols.sort(reverseOrder(compareBy(s => s.range.endLineNumber - s.range.startLineNumber, numberComparator))); + return symbols.map(s => ({ name: s.name, kind: s.kind })); + } +} + class CollapsedCodeOverlayWidget extends ViewZoneOverlayWidget { private readonly _nodes = h('div.diff-hidden-lines', [ h('div.top@top', { title: localize('diff.hiddenLines.top', 'Click or drag to show more above') }), @@ -194,6 +263,7 @@ class CollapsedCodeOverlayWidget extends ViewZoneOverlayWidget { _viewZone: PlaceholderViewZone, private readonly _unchangedRegion: UnchangedRegion, private readonly hide: boolean, + private readonly _modifiedOutlineSource: OutlineSource, ) { const root = h('div.diff-hidden-lines-widget'); super(_editor, _viewZone, root.root); @@ -296,19 +366,25 @@ class CollapsedCodeOverlayWidget extends ViewZoneOverlayWidget { children.push($('span', { title: linesHiddenText }, linesHiddenText)); } - // TODO@hediet implement breadcrumbs for collapsed regions - /* - if (_unchangedRegion.originalLineNumber === 48) { - children.push($('span', undefined, '\u00a0|\u00a0')); - children.push($('span', { title: 'test' }, ...renderLabelWithIcons('$(symbol-class) DiffEditorWidget2'))); - } else if (_unchangedRegion.originalLineNumber === 88) { + const range = this._unchangedRegion.getHiddenModifiedRange(reader); + const items = this._modifiedOutlineSource.getBreadcrumbItems(range, reader); + + if (items.length > 0) { children.push($('span', undefined, '\u00a0|\u00a0')); - children.push($('span', { title: 'test' }, ...renderLabelWithIcons('$(symbol-constructor) constructor'))); + + let isFirst = true; + for (const item of items) { + if (!isFirst) { + children.push($('span', {}, ' ', renderIcon(Codicon.chevronRight), ' ')); + } + + const icon = SymbolKinds.toIcon(item.kind); + children.push($('span', {}, renderIcon(icon), ' ', item.name)); + isFirst = false; + } } - */ reset(this._nodes.others, ...children); - })); } }