diff --git a/src/vs/base/browser/ui/list/list.css b/src/vs/base/browser/ui/list/list.css index ccde136e12ff9..3386a50c9d713 100644 --- a/src/vs/base/browser/ui/list/list.css +++ b/src/vs/base/browser/ui/list/list.css @@ -42,6 +42,11 @@ touch-action: none; } +/* Make sure the scrollbar renders above overlays (sticky scroll) */ +.monaco-list .monaco-scrollable-element > .scrollbar { + z-index: 14; +} + /* for OS X ballistic scrolling */ .monaco-list-row.scrolling { display: none !important; diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index af760c78a6d95..9729d8ab2860d 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -227,6 +227,7 @@ export interface IListView extends ISpliceable, IDisposable { readonly renderHeight: number; readonly scrollHeight: number; readonly firstVisibleIndex: number; + readonly firstMostlyVisibleIndex: number; readonly lastVisibleIndex: number; onDidScroll: Event; onWillScroll: Event; @@ -753,16 +754,21 @@ export class ListView implements IListView { get firstVisibleIndex(): number { const range = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight); - const firstElTop = this.rangeMap.positionAt(range.start); - const nextElTop = this.rangeMap.positionAt(range.start + 1); + return range.start; + } + + get firstMostlyVisibleIndex(): number { + const firstVisibleIndex = this.firstVisibleIndex; + const firstElTop = this.rangeMap.positionAt(firstVisibleIndex); + const nextElTop = this.rangeMap.positionAt(firstVisibleIndex + 1); if (nextElTop !== -1) { const firstElMidpoint = (nextElTop - firstElTop) / 2 + firstElTop; if (firstElMidpoint < this.scrollTop) { - return range.start + 1; + return firstVisibleIndex + 1; } } - return range.start; + return firstVisibleIndex; } get lastVisibleIndex(): number { diff --git a/src/vs/base/browser/ui/list/listWidget.ts b/src/vs/base/browser/ui/list/listWidget.ts index e05bf2c9dab5d..02db4869cfe77 100644 --- a/src/vs/base/browser/ui/list/listWidget.ts +++ b/src/vs/base/browser/ui/list/listWidget.ts @@ -256,8 +256,8 @@ export function isInputElement(e: HTMLElement): boolean { return e.tagName === 'INPUT' || e.tagName === 'TEXTAREA'; } -export function isMonacoEditor(e: HTMLElement): boolean { - if (e.classList.contains('monaco-editor')) { +function isListElementDescendantOfClass(e: HTMLElement, className: string): boolean { + if (e.classList.contains(className)) { return true; } @@ -269,7 +269,27 @@ export function isMonacoEditor(e: HTMLElement): boolean { return false; } - return isMonacoEditor(e.parentElement); + return isListElementDescendantOfClass(e.parentElement, className); +} + +export function isMonacoEditor(e: HTMLElement): boolean { + return isListElementDescendantOfClass(e, 'monaco-editor'); +} + +export function isMonacoCustomToggle(e: HTMLElement): boolean { + return isListElementDescendantOfClass(e, 'monaco-custom-toggle'); +} + +export function isActionItem(e: HTMLElement): boolean { + return isListElementDescendantOfClass(e, 'action-item'); +} + +export function isMonacoTwistie(e: HTMLElement): boolean { + return isListElementDescendantOfClass(e, 'monaco-tl-twistie'); +} + +export function isStickyScrollElement(e: HTMLElement): boolean { + return isListElementDescendantOfClass(e, 'monaco-tree-sticky-row'); } export function isButton(e: HTMLElement): boolean { @@ -1598,6 +1618,10 @@ export class List implements ISpliceable, IDisposable { return this.view.firstVisibleIndex; } + get firstMostlyVisibleIndex(): number { + return this.view.firstMostlyVisibleIndex; + } + get lastVisibleIndex(): number { return this.view.lastVisibleIndex; } @@ -1830,7 +1854,7 @@ export class List implements ISpliceable, IDisposable { return this.getFocus().map(i => this.view.element(i)); } - reveal(index: number, relativeTop?: number): void { + reveal(index: number, relativeTop?: number, paddingTop: number = 0): void { if (index < 0 || index >= this.length) { throw new ListError(this.user, `Invalid index ${index}`); } @@ -1841,16 +1865,16 @@ export class List implements ISpliceable, IDisposable { if (isNumber(relativeTop)) { // y = mx + b - const m = elementHeight - this.view.renderHeight; - this.view.setScrollTop(m * clamp(relativeTop, 0, 1) + elementTop); + const m = elementHeight - this.view.renderHeight + paddingTop; + this.view.setScrollTop(m * clamp(relativeTop, 0, 1) + elementTop - paddingTop); } else { const viewItemBottom = elementTop + elementHeight; const scrollBottom = scrollTop + this.view.renderHeight; - if (elementTop < scrollTop && viewItemBottom >= scrollBottom) { + if (elementTop < scrollTop + paddingTop && viewItemBottom >= scrollBottom) { // The element is already overflowing the viewport, no-op - } else if (elementTop < scrollTop || (viewItemBottom >= scrollBottom && elementHeight >= this.view.renderHeight)) { - this.view.setScrollTop(elementTop); + } else if (elementTop < scrollTop + paddingTop || (viewItemBottom >= scrollBottom && elementHeight >= this.view.renderHeight)) { + this.view.setScrollTop(elementTop - paddingTop); } else if (viewItemBottom >= scrollBottom) { this.view.setScrollTop(viewItemBottom - this.view.renderHeight); } @@ -1861,7 +1885,7 @@ export class List implements ISpliceable, IDisposable { * Returns the relative position of an element rendered in the list. * Returns `null` if the element isn't *entirely* in the visible viewport. */ - getRelativeTop(index: number): number | null { + getRelativeTop(index: number, paddingTop: number = 0): number | null { if (index < 0 || index >= this.length) { throw new ListError(this.user, `Invalid index ${index}`); } @@ -1870,13 +1894,13 @@ export class List implements ISpliceable, IDisposable { const elementTop = this.view.elementTop(index); const elementHeight = this.view.elementHeight(index); - if (elementTop < scrollTop || elementTop + elementHeight > scrollTop + this.view.renderHeight) { + if (elementTop < scrollTop + paddingTop || elementTop + elementHeight > scrollTop + this.view.renderHeight) { return null; } // y = mx + b - const m = elementHeight - this.view.renderHeight; - return Math.abs((scrollTop - elementTop) / m); + const m = elementHeight - this.view.renderHeight + paddingTop; + return Math.abs((scrollTop + paddingTop - elementTop) / m); } isDOMFocused(): boolean { @@ -1887,10 +1911,18 @@ export class List implements ISpliceable, IDisposable { return this.view.domNode; } + getScrollableElement(): HTMLElement { + return this.view.scrollableElementDomNode; + } + getElementID(index: number): string { return this.view.getElementDomId(index); } + getElementTop(index: number): number { + return this.view.elementTop(index); + } + style(styles: IListStyles): void { this.styleController.style(styles); } diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 4377e0dcde91a..21e83772b7ec3 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IDragAndDropData } from 'vs/base/browser/dnd'; -import { $, append, clearNode, createStyleSheet, getWindow, h, hasParentWithClass, isActiveElement } from 'vs/base/browser/dom'; +import { $, addDisposableListener, append, clearNode, createStyleSheet, getWindow, h, hasParentWithClass, isActiveElement } from 'vs/base/browser/dom'; import { DomEmitter } from 'vs/base/browser/event'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; @@ -13,7 +13,7 @@ import { FindInput } from 'vs/base/browser/ui/findinput/findInput'; import { IInputBoxStyles, IMessage, MessageType, unthemedInboxStyles } from 'vs/base/browser/ui/inputbox/inputBox'; import { IIdentityProvider, IKeyboardNavigationLabelProvider, IListContextMenuEvent, IListDragAndDrop, IListDragOverReaction, IListMouseEvent, IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; import { ElementsDragAndDropData } from 'vs/base/browser/ui/list/listView'; -import { IListOptions, IListStyles, isButton, isInputElement, isMonacoEditor, List, MouseController, TypeNavigationMode } from 'vs/base/browser/ui/list/listWidget'; +import { IListOptions, IListStyles, isActionItem, isButton, isInputElement, isMonacoCustomToggle, isMonacoEditor, isStickyScrollElement, List, MouseController, TypeNavigationMode } from 'vs/base/browser/ui/list/listWidget'; import { IToggleStyles, Toggle, unthemedToggleStyles } from 'vs/base/browser/ui/toggle/toggle'; import { getVisibleState, isFilterResult } from 'vs/base/browser/ui/tree/indexTreeModel'; import { ICollapseStateChangeEvent, ITreeContextMenuEvent, ITreeDragAndDrop, ITreeEvent, ITreeFilter, ITreeModel, ITreeModelSpliceEvent, ITreeMouseEvent, ITreeNavigator, ITreeNode, ITreeRenderer, TreeDragOverBubble, TreeError, TreeFilterResult, TreeMouseEventTarget, TreeVisibility } from 'vs/base/browser/ui/tree/tree'; @@ -26,7 +26,7 @@ import { SetMap } from 'vs/base/common/map'; import { Emitter, Event, EventBufferer, Relay } from 'vs/base/common/event'; import { fuzzyScore, FuzzyScore } from 'vs/base/common/filters'; import { KeyCode } from 'vs/base/common/keyCodes'; -import { Disposable, DisposableStore, dispose, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore, dispose, IDisposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { clamp } from 'vs/base/common/numbers'; import { ScrollEvent } from 'vs/base/common/scrollable'; import { ISpliceable } from 'vs/base/common/sequence'; @@ -327,7 +327,7 @@ class EventCollection implements Collection, IDisposable { } } -class TreeRenderer implements IListRenderer, ITreeListTemplateData> { +export class TreeRenderer implements IListRenderer, ITreeListTemplateData> { private static readonly DefaultIndent = 8; @@ -1172,6 +1172,448 @@ class FindController implements IDisposable { } } +interface StickyScrollNode { + readonly node: ITreeNode; + readonly startIndex: number; + readonly endIndex: number; + readonly height: number; + readonly position: number; +} + +function stickyScrollNodeEquals(node1: StickyScrollNode, node2: StickyScrollNode) { + return node1.position === node2.position && + node1.node.element === node2.node.element && + node1.startIndex === node2.startIndex && + node1.height === node2.height && + node1.endIndex === node2.endIndex; +} + +class StickyScrollState extends Disposable { + + constructor( + readonly stickyNodes: StickyScrollNode[] = [] + ) { + super(); + } + + get count(): number { return this.stickyNodes.length; } + + equal(state: StickyScrollState): boolean { + return equals(this.stickyNodes, state.stickyNodes, stickyScrollNodeEquals); + } + + addDisposable(disposable: IDisposable): void { + this._register(disposable); + } +} + +class StickyScrollController extends Disposable { + + private stickyScrollMaxItemCount: number; + private readonly maxWidgetViewRatio = 0.4; + + private readonly _widget: StickyScrollWidget; + + private get firstVisibleNode() { + const index = this.view.firstVisibleIndex; + + if (index < 0 || index >= this.view.length) { + return undefined; + } + + return this.view.element(index); + } + + constructor( + private readonly tree: AbstractTree, + private readonly model: ITreeModel, + private readonly view: List>, + renderers: TreeRenderer[], + private readonly treeDelegate: IListVirtualDelegate>, + options: IAbstractTreeOptions = {}, + ) { + super(); + + const stickyScrollOptions = this.validateStickySettings(options); + this.stickyScrollMaxItemCount = stickyScrollOptions.stickyScrollMaxItemCount; + + this._widget = this._register(new StickyScrollWidget(view.getScrollableElement(), view, model, renderers, treeDelegate)); + + this._register(view.onDidScroll(() => this.update())); + this._register(view.onDidChangeContentHeight(() => this.update())); + this._register(tree.onDidChangeCollapseState(() => this.update())); + + this.update(); + } + + get height(): number { + return this._widget.height; + } + + get count(): number { + return this._widget.count; + } + + getNode(node: ITreeNode): StickyScrollNode | undefined { + return this._widget.getNode(node); + } + + private update() { + const firstVisibleNode = this.firstVisibleNode; + + // Don't render anything if there are no elements + if (!firstVisibleNode || this.tree.scrollTop === 0) { + this._widget.setState(undefined); + return; + } + + const stickyState = this.findStickyState(firstVisibleNode); + this._widget.setState(stickyState); + } + + private findStickyState(firstVisibleNode: ITreeNode): StickyScrollState | undefined { + const stickyNodes: StickyScrollNode[] = []; + const maximumStickyWidgetHeight = this.view.renderHeight * this.maxWidgetViewRatio; + let firstVisibleNodeUnderWidget: ITreeNode | undefined = firstVisibleNode; + let stickyNodesHeight = 0; + + let nextStickyNode = this.getNextStickyNode(firstVisibleNodeUnderWidget, undefined, stickyNodesHeight); + while (nextStickyNode && stickyNodesHeight + nextStickyNode.height < maximumStickyWidgetHeight) { + + stickyNodes.push(nextStickyNode); + stickyNodesHeight += nextStickyNode.height; + + if (stickyNodes.length >= this.stickyScrollMaxItemCount) { + break; + } + + firstVisibleNodeUnderWidget = this.getNextVisibleNode(firstVisibleNodeUnderWidget); + if (!firstVisibleNodeUnderWidget) { + break; + } + + nextStickyNode = this.getNextStickyNode(firstVisibleNodeUnderWidget, nextStickyNode.node, stickyNodesHeight); + } + + return stickyNodes.length ? new StickyScrollState(stickyNodes) : undefined; + } + + private getNextVisibleNode(node: ITreeNode): ITreeNode | undefined { + const nodeIndex = this.getNodeIndex(node); + if (nodeIndex === -1 || nodeIndex === this.view.length - 1) { + return undefined; + } + const nextNode = this.view.element(nodeIndex + 1); + return nextNode; + } + + private getNextStickyNode(firstVisibleNodeUnderWidget: ITreeNode, previousStickyNode: ITreeNode | undefined, stickyNodesHeight: number): StickyScrollNode | undefined { + const nextStickyNode = this.getAncestorUnderPrevious(firstVisibleNodeUnderWidget, previousStickyNode); + if (!nextStickyNode) { + return undefined; + } + + if (nextStickyNode === firstVisibleNodeUnderWidget) { + if (!this.nodeIsUncollapsedParent(firstVisibleNodeUnderWidget)) { + return undefined; + } + + if (this.nodeTopAlignsWithStickyNodesBottom(firstVisibleNodeUnderWidget, stickyNodesHeight)) { + return undefined; + } + } + + return this.createStickyScrollNode(nextStickyNode, stickyNodesHeight); + } + + private nodeTopAlignsWithStickyNodesBottom(node: ITreeNode, stickyNodesHeight: number): boolean { + const nodeIndex = this.getNodeIndex(node); + const elementTop = this.view.getElementTop(nodeIndex); + const stickyPosition = stickyNodesHeight; + return this.view.scrollTop === elementTop - stickyPosition; + } + + private createStickyScrollNode(node: ITreeNode, currentStickyNodesHeight: number): StickyScrollNode { + const height = this.treeDelegate.getHeight(node); + const { startIndex, endIndex } = this.getNodeRange(node); + + const position = this.calculateStickyNodePosition(endIndex, currentStickyNodesHeight); + + return { node, position, height, startIndex, endIndex }; + } + + private getAncestorUnderPrevious(node: ITreeNode, previousAncestor: ITreeNode | undefined = undefined): ITreeNode | undefined { + let currentAncestor: ITreeNode = node; + let parentOfcurrentAncestor: ITreeNode | undefined = this.getParentNode(currentAncestor); + + while (parentOfcurrentAncestor) { + if (parentOfcurrentAncestor === previousAncestor) { + return currentAncestor; + } + currentAncestor = parentOfcurrentAncestor; + parentOfcurrentAncestor = this.getParentNode(currentAncestor); + } + + if (previousAncestor === undefined) { + return currentAncestor; + } + + return undefined; + } + + private calculateStickyNodePosition(lastDescendantIndex: number, stickyRowPositionTop: number): number { + let lastChildRelativeTop = this.view.getRelativeTop(lastDescendantIndex); + + // If the last descendant is only partially visible at the top of the view, getRelativeTop() returns null + // In that case, utilize the next node's relative top to calculate the sticky node's position + if (lastChildRelativeTop === null && this.view.firstVisibleIndex === lastDescendantIndex && lastDescendantIndex + 1 < this.view.length) { + const nodeHeight = this.treeDelegate.getHeight(this.view.element(lastDescendantIndex)); + const nextNodeRelativeTop = this.view.getRelativeTop(lastDescendantIndex + 1); + lastChildRelativeTop = nextNodeRelativeTop ? nextNodeRelativeTop - nodeHeight / this.view.renderHeight : null; + } + + if (lastChildRelativeTop === null) { + return stickyRowPositionTop; + } + + const lastChildNode = this.view.element(lastDescendantIndex); + const lastChildHeight = this.treeDelegate.getHeight(lastChildNode); + const topOfLastChild = lastChildRelativeTop * this.view.renderHeight; + const bottomOfLastChild = topOfLastChild + lastChildHeight; + + if (stickyRowPositionTop > topOfLastChild && stickyRowPositionTop <= bottomOfLastChild) { + return topOfLastChild; + } + + return stickyRowPositionTop; + } + + private getParentNode(node: ITreeNode): ITreeNode | undefined { + const nodeLocation = this.model.getNodeLocation(node); + const parentLocation = this.model.getParentNodeLocation(nodeLocation); + return parentLocation ? this.model.getNode(parentLocation) : undefined; + } + + private nodeIsUncollapsedParent(node: ITreeNode): boolean { + const nodeLocation = this.model.getNodeLocation(node); + return this.model.getListRenderCount(nodeLocation) > 1; + } + + private getNodeIndex(node: ITreeNode, nodeLocation?: TRef): number { + if (nodeLocation === undefined) { + nodeLocation = this.model.getNodeLocation(node); + } + const nodeIndex = this.model.getListIndex(nodeLocation); + return nodeIndex; + } + + private getNodeRange(node: ITreeNode): { startIndex: number; endIndex: number } { + const nodeLocation = this.model.getNodeLocation(node); + const startIndex = this.model.getListIndex(nodeLocation); + + if (startIndex < 0) { + throw new Error('Node not found in tree'); + } + + const renderCount = this.model.getListRenderCount(nodeLocation); + const endIndex = startIndex + renderCount - 1; + + return { startIndex, endIndex }; + } + + nodePositionTopBelowWidget(node: ITreeNode): number { + const ancestors = []; + let currentAncestor = this.getParentNode(node); + while (currentAncestor) { + ancestors.push(currentAncestor); + currentAncestor = this.getParentNode(currentAncestor); + } + + let widgetHeight = 0; + for (let i = 0; i < ancestors.length && i < this.stickyScrollMaxItemCount; i++) { + widgetHeight += this.treeDelegate.getHeight(ancestors[i]); + } + return widgetHeight; + } + + updateOptions(optionsUpdate: IAbstractTreeOptionsUpdate = {}): void { + const validatedOptions = this.validateStickySettings(optionsUpdate); + if (this.stickyScrollMaxItemCount !== validatedOptions.stickyScrollMaxItemCount) { + this.stickyScrollMaxItemCount = validatedOptions.stickyScrollMaxItemCount; + this.update(); + } + } + + validateStickySettings(options: IAbstractTreeOptionsUpdate): { stickyScrollMaxItemCount: number } { + let stickyScrollMaxItemCount = 5; + if (typeof options.stickyScrollMaxItemCount === 'number') { + stickyScrollMaxItemCount = Math.max(options.stickyScrollMaxItemCount, 1); + } + return { stickyScrollMaxItemCount }; + } +} + +class StickyScrollWidget implements IDisposable { + + private readonly _rootDomNode: HTMLElement; + private _previousState: StickyScrollState | undefined; + private _mouseUpTimerDisposable = new MutableDisposable(); + + constructor( + container: HTMLElement, + private readonly view: List>, + private readonly model: ITreeModel, + private readonly treeRenderers: TreeRenderer[], + private readonly treeDelegate: IListVirtualDelegate> + ) { + + this._rootDomNode = document.createElement('div'); + this._rootDomNode.classList.add('monaco-tree-sticky-container'); + container.appendChild(this._rootDomNode); + } + + get height(): number { + if (!this._previousState) { + return 0; + } + const lastElement = this._previousState.stickyNodes[this._previousState.count - 1]; + return lastElement.position + lastElement.height; + } + + get count(): number { + return this._previousState?.count ?? 0; + } + + getNode(node: ITreeNode): StickyScrollNode | undefined { + return this._previousState?.stickyNodes.find(stickyNode => stickyNode.node === node); + } + + setState(state: StickyScrollState | undefined): void { + + const wasVisible = !!this._previousState && this._previousState.count > 0; + const isVisible = !!state && state.count > 0; + + // If state has not changed, do nothing + if ((!wasVisible && !isVisible) || (wasVisible && isVisible && this._previousState!.equal(state!))) { + return; + } + + // Update visibility of the widget if changed + if (wasVisible !== isVisible) { + this.setVisible(isVisible); + } + + // Remove previous state + this._previousState?.dispose(); + this._previousState = state; + + if (!isVisible) { + return; + } + + for (let stickyIndex = state.count - 1; stickyIndex >= 0; stickyIndex--) { + const stickyNode = state.stickyNodes[stickyIndex]; + const previousStickyNode = stickyIndex ? state.stickyNodes[stickyIndex - 1] : undefined; + const currentWidgetHieght = previousStickyNode ? previousStickyNode.position + previousStickyNode.height : 0; + + const { element, disposable } = this.createElement(stickyNode, currentWidgetHieght); + + if (stickyIndex === state.count - 1) { + element.classList.add('last-sticky'); + } + + this._rootDomNode.appendChild(element); + state.addDisposable(disposable); + } + + // Add shadow element to the end of the widget + const shadow = $('.monaco-tree-sticky-container-shadow'); + this._rootDomNode.appendChild(shadow); + state.addDisposable(toDisposable(() => shadow.remove())); + + // Set the height of the widget to the bottom of the last sticky node + const lastStickyNode = state.stickyNodes[state.count - 1]; + this._rootDomNode.style.height = `${lastStickyNode.position + lastStickyNode.height}px`; + } + + private createElement(stickyNode: StickyScrollNode, currentWidgetHeight: number): { element: HTMLElement; disposable: IDisposable } { + + const nodeLocation = this.model.getNodeLocation(stickyNode.node); + const nodeIndex = this.model.getListIndex(nodeLocation); + + // Sticky element container + const stickyElement = document.createElement('div'); + stickyElement.style.top = `${stickyNode.position}px`; + + stickyElement.classList.add('monaco-tree-sticky-row'); + stickyElement.classList.add('monaco-list-row'); + + stickyElement.setAttribute('data-index', `${nodeIndex}`); + stickyElement.setAttribute('data-parity', nodeIndex % 2 === 0 ? 'even' : 'odd'); + stickyElement.setAttribute('id', this.view.getElementID(nodeIndex)); + + // Get the renderer for the node + const nodeTemplateId = this.treeDelegate.getTemplateId(stickyNode.node); + const renderer = this.treeRenderers.find((renderer) => renderer.templateId === nodeTemplateId); + if (!renderer) { + throw new Error(`No renderer found for template id ${nodeTemplateId}`); + } + + const nodeCopy = new Proxy(stickyNode.node, {}); + + // Render the element + const templateData = renderer.renderTemplate(stickyElement); + renderer.renderElement(nodeCopy, stickyNode.startIndex, templateData, stickyNode.height); + + const mouseListenerDisposable = this.registerMouseListeners(stickyElement, stickyNode, currentWidgetHeight); + + // Remove the element from the DOM when state is disposed + const disposable = toDisposable(() => { + renderer.disposeElement(nodeCopy, stickyNode.startIndex, templateData, stickyNode.height); + renderer.disposeTemplate(templateData); + mouseListenerDisposable.dispose(); + stickyElement.remove(); + }); + + return { element: stickyElement, disposable }; + } + + private registerMouseListeners(stickyElement: HTMLElement, stickyNode: StickyScrollNode, currentWidgetHeight: number): IDisposable { + + return addDisposableListener(stickyElement, 'mouseup', (e: MouseEvent) => { + const isRightClick = e.button === 2; + if (isRightClick) { + return; + } + + if (isMonacoCustomToggle(e.target as HTMLElement) || isActionItem(e.target as HTMLElement)) { + return; + } + + // Timeout 0 ensures that the tree handles the click event first + this._mouseUpTimerDisposable.value = disposableTimeout(() => { + const elementTop = this.view.getElementTop(stickyNode.startIndex); + // We can't rely on the current sticky node's position + // because the node might be partially scrolled under the widget + const previousStickyNodeBottom = currentWidgetHeight; + this.view.scrollTop = elementTop - previousStickyNodeBottom; + this.view.setFocus([stickyNode.startIndex]); + this.view.setSelection([stickyNode.startIndex]); + }, 0); + }); + } + + private setVisible(visible: boolean): void { + this._rootDomNode.style.display = visible ? 'block' : 'none'; + } + + dispose(): void { + this._mouseUpTimerDisposable.dispose(); + this._previousState?.dispose(); + this._rootDomNode.remove(); + } +} + function asTreeMouseEvent(event: IListMouseEvent>): ITreeMouseEvent { let target: TreeMouseEventTarget = TreeMouseEventTarget.Unknown; @@ -1212,6 +1654,8 @@ export interface IAbstractTreeOptionsUpdate extends ITreeRendererOptions { readonly fastScrollSensitivity?: number; readonly expandOnDoubleClick?: boolean; readonly expandOnlyOnTwistieClick?: boolean | ((e: any) => boolean); // e is T + readonly enableStickyScroll?: boolean; + readonly stickyScrollMaxItemCount?: number; } export interface IAbstractTreeOptions extends IAbstractTreeOptionsUpdate, IListOptions { @@ -1377,24 +1821,30 @@ class TreeNodeListMouseController extends MouseController< const target = e.browserEvent.target as HTMLElement; const onTwistie = target.classList.contains('monaco-tl-twistie') || (target.classList.contains('monaco-icon-label') && target.classList.contains('folder-icon') && e.browserEvent.offsetX < 16); + const isStickyElement = isStickyScrollElement(e.browserEvent.target as HTMLElement); let expandOnlyOnTwistieClick = false; - if (typeof this.tree.expandOnlyOnTwistieClick === 'function') { + if (isStickyElement) { + expandOnlyOnTwistieClick = true; + } + else if (typeof this.tree.expandOnlyOnTwistieClick === 'function') { expandOnlyOnTwistieClick = this.tree.expandOnlyOnTwistieClick(node.element); } else { expandOnlyOnTwistieClick = !!this.tree.expandOnlyOnTwistieClick; } - if (expandOnlyOnTwistieClick && !onTwistie && e.browserEvent.detail !== 2) { - return super.onViewPointer(e); - } + if (!isStickyElement) { + if (expandOnlyOnTwistieClick && !onTwistie && e.browserEvent.detail !== 2) { + return super.onViewPointer(e); + } - if (!this.tree.expandOnDoubleClick && e.browserEvent.detail === 2) { - return super.onViewPointer(e); + if (!this.tree.expandOnDoubleClick && e.browserEvent.detail === 2) { + return super.onViewPointer(e); + } } - if (node.collapsible) { + if (node.collapsible && (!isStickyElement || onTwistie)) { const location = this.tree.getNodeLocation(node); const recursive = e.browserEvent.altKey; this.tree.setFocus([location]); @@ -1407,7 +1857,9 @@ class TreeNodeListMouseController extends MouseController< } } - super.onViewPointer(e); + if (!isStickyElement) { + super.onViewPointer(e); + } } protected override onDoubleClick(e: IListMouseEvent>): void { @@ -1524,6 +1976,7 @@ export abstract class AbstractTree implements IDisposable protected view: TreeNodeList; private renderers: TreeRenderer[]; protected model: ITreeModel; + private treeDelegate: ComposedTreeDelegate>; private focus: Trait; private selection: Trait; private anchor: Trait; @@ -1531,6 +1984,7 @@ export abstract class AbstractTree implements IDisposable private findController?: FindController; readonly onDidChangeFindOpenState: Event = Event.None; private focusNavigationFilter: ((node: ITreeNode) => boolean) | undefined; + private stickyScrollController?: StickyScrollController; private styleElement: HTMLStyleElement; protected readonly disposables = new DisposableStore(); @@ -1584,7 +2038,7 @@ export abstract class AbstractTree implements IDisposable renderers: ITreeRenderer[], private _options: IAbstractTreeOptions = {} ) { - const treeDelegate = new ComposedTreeDelegate>(delegate); + this.treeDelegate = new ComposedTreeDelegate>(delegate); const onDidChangeCollapseStateRelay = new Relay>(); const onDidChangeActiveNodes = new Relay[]>(); @@ -1606,7 +2060,7 @@ export abstract class AbstractTree implements IDisposable this.focus = new Trait(() => this.view.getFocusedElements()[0], _options.identityProvider); this.selection = new Trait(() => this.view.getSelectedElements()[0], _options.identityProvider); this.anchor = new Trait(() => this.view.getAnchorElement(), _options.identityProvider); - this.view = new TreeNodeList(_user, container, treeDelegate, this.renderers, this.focus, this.selection, this.anchor, { ...asListOptions(() => this.model, _options), tree: this }); + this.view = new TreeNodeList(_user, container, this.treeDelegate, this.renderers, this.focus, this.selection, this.anchor, { ...asListOptions(() => this.model, _options), tree: this }); this.model = this.createModel(_user, this.view, _options); onDidChangeCollapseStateRelay.input = this.model.onDidChangeCollapseState; @@ -1668,6 +2122,10 @@ export abstract class AbstractTree implements IDisposable this.onDidChangeFindMatchType = Event.None; } + if (_options.enableStickyScroll) { + this.stickyScrollController = new StickyScrollController(this, this.model, this.view, this.renderers, this.treeDelegate, _options); + } + this.styleElement = createStyleSheet(this.view.getHTMLElement()); this.getHTMLElement().classList.toggle('always', this._options.renderIndentGuides === RenderIndentGuides.Always); } @@ -1681,6 +2139,7 @@ export abstract class AbstractTree implements IDisposable this.view.updateOptions(this._options); this.findController?.updateOptions(optionsUpdate); + this.updateStickyScroll(optionsUpdate); this._onDidUpdateOptions.fire(this._options); @@ -1691,6 +2150,16 @@ export abstract class AbstractTree implements IDisposable return this._options; } + private updateStickyScroll(optionsUpdate: IAbstractTreeOptionsUpdate) { + if (!this.stickyScrollController && this._options.enableStickyScroll) { + this.stickyScrollController = new StickyScrollController(this, this.model, this.view, this.renderers, this.treeDelegate, this._options); + } else if (this.stickyScrollController && !this._options.enableStickyScroll) { + this.stickyScrollController.dispose(); + this.stickyScrollController = undefined; + } + this.stickyScrollController?.updateOptions(optionsUpdate); + } + updateWidth(element: TRef): void { const index = this.model.getListIndex(element); @@ -1748,7 +2217,11 @@ export abstract class AbstractTree implements IDisposable } get firstVisibleElement(): T | undefined { - const index = this.view.firstVisibleIndex; + let index = this.view.firstVisibleIndex; + + if (this.stickyScrollController) { + index += this.stickyScrollController.count; + } if (index < 0 || index >= this.view.length) { return undefined; @@ -1801,6 +2274,11 @@ export abstract class AbstractTree implements IDisposable content.push(`.monaco-list${suffix} .monaco-tl-indent > .indent-guide.active { border-color: ${styles.treeIndentGuidesStroke}; }`); } + if (styles.listBackground) { + content.push(`.monaco-list${suffix} .monaco-scrollable-element .monaco-tree-sticky-container { background-color: ${styles.listBackground}; }`); + content.push(`.monaco-list${suffix} .monaco-scrollable-element .monaco-tree-sticky-container .monaco-tree-sticky-row { background-color: ${styles.listBackground}; }`); + } + this.styleElement.textContent = content.join('\n'); this.view.style(styles); @@ -1957,7 +2435,12 @@ export abstract class AbstractTree implements IDisposable return; } - this.view.reveal(index, relativeTop); + if (!this.stickyScrollController) { + this.view.reveal(index, relativeTop); + } else { + const paddingTop = this.stickyScrollController.nodePositionTopBelowWidget(this.getNode(location)); + this.view.reveal(index, relativeTop, paddingTop); + } } /** @@ -1971,7 +2454,8 @@ export abstract class AbstractTree implements IDisposable return null; } - return this.view.getRelativeTop(index); + const stickyScrollNode = this.stickyScrollController?.getNode(this.getNode(location)); + return this.view.getRelativeTop(index, stickyScrollNode?.position ?? this.stickyScrollController?.height); } getViewState(identityProvider = this.options.identityProvider): AbstractTreeViewState { @@ -2086,6 +2570,7 @@ export abstract class AbstractTree implements IDisposable dispose(): void { dispose(this.disposables); + this.stickyScrollController?.dispose(); this.view.dispose(); } } diff --git a/src/vs/base/browser/ui/tree/media/tree.css b/src/vs/base/browser/ui/tree/media/tree.css index a83a0a84e31e7..fd489bc742fd9 100644 --- a/src/vs/base/browser/ui/tree/media/tree.css +++ b/src/vs/base/browser/ui/tree/media/tree.css @@ -128,3 +128,32 @@ .monaco-tree-type-filter-actionbar .monaco-action-bar .action-label { padding: 2px; } + +.monaco-list .monaco-scrollable-element .monaco-tree-sticky-container{ + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 0; + z-index: 13; /* Settings editor uses z-index: 12 */ +} + +.monaco-list .monaco-scrollable-element .monaco-tree-sticky-container .monaco-tree-sticky-row.monaco-list-row{ + position: absolute; + width: 100%; + opacity: 1 !important; /* Settings editor uses opacity < 1 */ +} + +.monaco-list .monaco-scrollable-element .monaco-tree-sticky-container .monaco-tree-sticky-row:hover{ + background-color: var(--vscode-list-hoverBackground) !important; + cursor: pointer; +} + +.monaco-list .monaco-scrollable-element .monaco-tree-sticky-container .monaco-tree-sticky-container-shadow{ + position: absolute; + bottom: -3px; + left: 0px; + height: 3px; + width: 100%; + box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px inset; +} diff --git a/src/vs/platform/list/browser/listService.ts b/src/vs/platform/list/browser/listService.ts index f2dd5ce5c54df..ab8e21706599d 100644 --- a/src/vs/platform/list/browser/listService.ts +++ b/src/vs/platform/list/browser/listService.ts @@ -189,6 +189,8 @@ const listSmoothScrolling = 'workbench.list.smoothScrolling'; const mouseWheelScrollSensitivityKey = 'workbench.list.mouseWheelScrollSensitivity'; const fastScrollSensitivityKey = 'workbench.list.fastScrollSensitivity'; const treeExpandMode = 'workbench.tree.expandMode'; +const treeStickyScroll = 'workbench.tree.enableStickyScroll'; +const treeStickyScrollMaxElements = 'workbench.tree.stickyScrollMaxItemCount'; function useAltAsMultipleSelectionModifier(configurationService: IConfigurationService): boolean { return configurationService.getValue(multiSelectModifierSettingKey) === 'alt'; @@ -1167,6 +1169,8 @@ function workbenchTreeDataPreamble(treeExpandMode) === 'doubleClick'), contextViewProvider: contextViewService as IContextViewProvider, findWidgetStyles: defaultFindWidgetStyles, + enableStickyScroll: Boolean(configurationService.getValue(treeStickyScroll)), + stickyScrollMaxItemCount: Number(configurationService.getValue(treeStickyScrollMaxElements)), } as TOptions }; } @@ -1313,6 +1317,14 @@ class WorkbenchTreeInternals { if (e.affectsConfiguration(treeExpandMode) && options.expandOnlyOnTwistieClick === undefined) { newOptions = { ...newOptions, expandOnlyOnTwistieClick: configurationService.getValue<'singleClick' | 'doubleClick'>(treeExpandMode) === 'doubleClick' }; } + if (e.affectsConfiguration(treeStickyScroll)) { + const enableStickyScroll = configurationService.getValue(treeStickyScroll); + newOptions = { ...newOptions, enableStickyScroll }; + } + if (e.affectsConfiguration(treeStickyScrollMaxElements)) { + const stickyScrollMaxItemCount = Math.max(1, configurationService.getValue(treeStickyScrollMaxElements)); + newOptions = { ...newOptions, stickyScrollMaxItemCount }; + } if (e.affectsConfiguration(mouseWheelScrollSensitivityKey)) { const mouseWheelScrollSensitivity = configurationService.getValue(mouseWheelScrollSensitivityKey); newOptions = { ...newOptions, mouseWheelScrollSensitivity }; @@ -1465,6 +1477,16 @@ configurationRegistry.registerConfiguration({ default: 'singleClick', description: localize('expand mode', "Controls how tree folders are expanded when clicking the folder names. Note that some trees and lists might choose to ignore this setting if it is not applicable."), }, + [treeStickyScroll]: { + type: 'boolean', + default: 'false', + description: localize('sticky scroll', "Controls whether sticky scrolling is enabled in trees."), + }, + [treeStickyScrollMaxElements]: { + type: 'number', + default: 5, + markdownDescription: localize('sticky scroll maximum items', "Controls the number of sticky elements displayed in the tree when `#workbench.tree.enableStickyScroll#` is enabled."), + }, [typeNavigationModeSettingKey]: { type: 'string', enum: ['automatic', 'trigger'], diff --git a/src/vs/workbench/browser/media/style.css b/src/vs/workbench/browser/media/style.css index 19500496d7e4d..87ee974d535a5 100644 --- a/src/vs/workbench/browser/media/style.css +++ b/src/vs/workbench/browser/media/style.css @@ -230,7 +230,7 @@ body.web { left: 0; width: 100%; height: 100%; - z-index: 5; /* make sure we are on top of the tree items */ + z-index: 15; /* make sure we are on top of the tree sticky scroll widget */ content: ""; pointer-events: none; /* enable click through */ outline: 1px solid; /* we still need to handle the empty tree or no focus item case */ diff --git a/src/vs/workbench/browser/parts/views/treeView.ts b/src/vs/workbench/browser/parts/views/treeView.ts index 09a5a5895eb95..3eb674cae2cbf 100644 --- a/src/vs/workbench/browser/parts/views/treeView.ts +++ b/src/vs/workbench/browser/parts/views/treeView.ts @@ -1093,7 +1093,7 @@ class TreeRenderer extends Disposable implements ITreeRenderer; rendered: ITreeExplorerTemplateData }>(); // tree item handle to template data + private _renderedElements = new Map; rendered: ITreeExplorerTemplateData }[]>(); // tree item handle to template data constructor( private treeViewId: string, @@ -1268,8 +1268,9 @@ class TreeRenderer extends Disposable implements ITreeRenderer { - const renderedItem = this._renderedElements.get(item.handle); - if (renderedItem) { - renderedItem.rendered.checkbox?.render(item); + const renderedItems = this._renderedElements.get(item.handle); + if (renderedItems) { + renderedItems.forEach(renderedItems => renderedItems.rendered.checkbox?.render(item)); } }); this._onDidChangeCheckboxState.fire(items); @@ -1417,7 +1418,19 @@ class TreeRenderer extends Disposable implements ITreeRenderer, index: number, templateData: ITreeExplorerTemplateData): void { templateData.elementDisposable.clear(); - this._renderedElements.delete(resource.element.handle); + const itemRenders = this._renderedElements.get(resource.element.handle) ?? []; + const renderedIndex = itemRenders.findIndex(renderedItem => templateData === renderedItem.rendered); + + if (renderedIndex < 0) { + throw new Error('Disposing unknown element'); + } + + if (itemRenders.length === 1) { + this._renderedElements.delete(resource.element.handle); + } else { + itemRenders.splice(renderedIndex, 1); + } + this.treeViewsService.removeRenderedTreeItemElement(resource.element); templateData.checkbox?.dispose(); diff --git a/src/vs/workbench/contrib/files/browser/views/explorerView.ts b/src/vs/workbench/contrib/files/browser/views/explorerView.ts index fcc5cb9631ef1..ff7b789268041 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerView.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerView.ts @@ -93,7 +93,7 @@ const identityProvider = { }; export function getContext(focus: ExplorerItem[], selection: ExplorerItem[], respectMultiSelection: boolean, - compressedNavigationControllerProvider: { getCompressedNavigationController(stat: ExplorerItem): ICompressedNavigationController | undefined }): ExplorerItem[] { + compressedNavigationControllerProvider: { getCompressedNavigationController(stat: ExplorerItem): ICompressedNavigationController[] | undefined }): ExplorerItem[] { let focusedStat: ExplorerItem | undefined; focusedStat = focus.length ? focus[0] : undefined; @@ -103,13 +103,15 @@ export function getContext(focus: ExplorerItem[], selection: ExplorerItem[], res focusedStat = undefined; } - const compressedNavigationController = focusedStat && compressedNavigationControllerProvider.getCompressedNavigationController(focusedStat); + const compressedNavigationControllers = focusedStat && compressedNavigationControllerProvider.getCompressedNavigationController(focusedStat); + const compressedNavigationController = compressedNavigationControllers && compressedNavigationControllers.length ? compressedNavigationControllers[0] : undefined; focusedStat = compressedNavigationController ? compressedNavigationController.current : focusedStat; const selectedStats: ExplorerItem[] = []; for (const stat of selection) { - const controller = compressedNavigationControllerProvider.getCompressedNavigationController(stat); + const controllers = compressedNavigationControllerProvider.getCompressedNavigationController(stat); + const controller = controllers && controllers.length ? controllers[0] : undefined; if (controller && focusedStat && controller === compressedNavigationController) { if (stat === focusedStat) { selectedStats.push(stat); @@ -524,8 +526,8 @@ export class ExplorerView extends ViewPane implements IExplorerView { this._register(this.tree.onDidChangeCollapseState(e => { const element = e.node.element?.element; if (element) { - const navigationController = this.renderer.getCompressedNavigationController(element instanceof Array ? element[0] : element); - navigationController?.updateCollapsed(e.node.collapsed); + const navigationControllers = this.renderer.getCompressedNavigationController(element instanceof Array ? element[0] : element); + navigationControllers?.forEach(controller => controller.updateCollapsed(e.node.collapsed)); } // Update showing expand / collapse button this.updateAnyCollapsedContext(); @@ -594,13 +596,13 @@ export class ExplorerView extends ViewPane implements IExplorerView { // Adjust for compressed folders (except when mouse is used) if (anchor instanceof HTMLElement) { if (stat) { - const controller = this.renderer.getCompressedNavigationController(stat); + const controllers = this.renderer.getCompressedNavigationController(stat); - if (controller) { + if (controllers && controllers.length > 0) { if (DOM.isKeyboardEvent(e.browserEvent) || isCompressedFolderName(e.browserEvent.target)) { - anchor = controller.labels[controller.index]; + anchor = controllers[0].labels[controllers[0].index]; } else { - controller.last(); + controllers.forEach(controller => controller.last()); } } } @@ -615,8 +617,8 @@ export class ExplorerView extends ViewPane implements IExplorerView { const roots = this.explorerService.roots; // If the click is outside of the elements pass the root resource if there is only one root. If there are multiple roots pass empty object. let arg: URI | {}; if (stat instanceof ExplorerItem) { - const compressedController = this.renderer.getCompressedNavigationController(stat); - arg = compressedController ? compressedController.current.resource : stat.resource; + const compressedControllers = this.renderer.getCompressedNavigationController(stat); + arg = compressedControllers && compressedControllers.length ? compressedControllers[0].current.resource : stat.resource; } else { arg = roots.length === 1 ? roots[0].resource : {}; } @@ -649,15 +651,17 @@ export class ExplorerView extends ViewPane implements IExplorerView { this.resourceMoveableToTrash.reset(); } - const compressedNavigationController = stat && this.renderer.getCompressedNavigationController(stat); + const compressedNavigationControllers = stat && this.renderer.getCompressedNavigationController(stat); - if (!compressedNavigationController) { + if (!compressedNavigationControllers) { this.compressedFocusContext.set(false); return; } this.compressedFocusContext.set(true); - this.updateCompressedNavigationContextKeys(compressedNavigationController); + compressedNavigationControllers.forEach(controller => { + this.updateCompressedNavigationContextKeys(controller); + }); } // General methods @@ -870,9 +874,11 @@ export class ExplorerView extends ViewPane implements IExplorerView { return; } - const compressedNavigationController = this.renderer.getCompressedNavigationController(focused[0])!; - compressedNavigationController.previous(); - this.updateCompressedNavigationContextKeys(compressedNavigationController); + const compressedNavigationControllers = this.renderer.getCompressedNavigationController(focused[0])!; + compressedNavigationControllers.forEach(controller => { + controller.previous(); + this.updateCompressedNavigationContextKeys(controller); + }); } nextCompressedStat(): void { @@ -881,9 +887,11 @@ export class ExplorerView extends ViewPane implements IExplorerView { return; } - const compressedNavigationController = this.renderer.getCompressedNavigationController(focused[0])!; - compressedNavigationController.next(); - this.updateCompressedNavigationContextKeys(compressedNavigationController); + const compressedNavigationControllers = this.renderer.getCompressedNavigationController(focused[0])!; + compressedNavigationControllers.forEach(controller => { + controller.next(); + this.updateCompressedNavigationContextKeys(controller); + }); } firstCompressedStat(): void { @@ -892,9 +900,11 @@ export class ExplorerView extends ViewPane implements IExplorerView { return; } - const compressedNavigationController = this.renderer.getCompressedNavigationController(focused[0])!; - compressedNavigationController.first(); - this.updateCompressedNavigationContextKeys(compressedNavigationController); + const compressedNavigationControllers = this.renderer.getCompressedNavigationController(focused[0])!; + compressedNavigationControllers.forEach(controller => { + controller.first(); + this.updateCompressedNavigationContextKeys(controller); + }); } lastCompressedStat(): void { @@ -903,9 +913,11 @@ export class ExplorerView extends ViewPane implements IExplorerView { return; } - const compressedNavigationController = this.renderer.getCompressedNavigationController(focused[0])!; - compressedNavigationController.last(); - this.updateCompressedNavigationContextKeys(compressedNavigationController); + const compressedNavigationControllers = this.renderer.getCompressedNavigationController(focused[0])!; + compressedNavigationControllers.forEach(controller => { + controller.last(); + this.updateCompressedNavigationContextKeys(controller); + }); } private updateCompressedNavigationContextKeys(controller: ICompressedNavigationController): void { diff --git a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts index d42faed523e91..78cd137d73be7 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts @@ -276,7 +276,7 @@ export class FilesRenderer implements ICompressibleTreeRenderer(); + private compressedNavigationControllers = new Map(); private _onDidChangeActiveDescendant = new EventMultiplexer(); readonly onDidChangeActiveDescendant = this._onDidChangeActiveDescendant.event; @@ -455,7 +455,9 @@ export class FilesRenderer implements ICompressibleTreeRenderer this.compressedNavigationControllers.delete(stat))); + templateData.elementDisposables.add(toDisposable(() => { + const nodeControllers = this.compressedNavigationControllers.get(stat) ?? []; + const renderedIndex = nodeControllers.findIndex(controller => controller === compressedNavigationController); + + if (renderedIndex < 0) { + throw new Error('Disposing unknown navigation controller'); + } + + if (nodeControllers.length === 1) { + this.compressedNavigationControllers.delete(stat); + } else { + nodeControllers.splice(renderedIndex, 1); + } + })); } // Input Box @@ -662,7 +677,7 @@ export class FilesRenderer implements ICompressibleTreeRenderer { - private renderedNodes = new Map, IResourceMarkersTemplateData>(); + private renderedNodes = new Map(); private readonly disposables = new DisposableStore(); constructor( @@ -185,11 +185,23 @@ export class ResourceMarkersRenderer implements ITreeRenderer): void { - this.renderedNodes.delete(node); + disposeElement(node: ITreeNode, index: number, templateData: IResourceMarkersTemplateData): void { + const nodeRenders = this.renderedNodes.get(node.element) ?? []; + const nodeRenderIndex = nodeRenders.findIndex(nodeRender => templateData === nodeRender); + + if (nodeRenderIndex < 0) { + throw new Error('Disposing unknown resource marker'); + } + + if (nodeRenders.length === 1) { + this.renderedNodes.delete(node.element); + } else { + nodeRenders.splice(nodeRenderIndex, 1); + } } disposeTemplate(templateData: IResourceMarkersTemplateData): void { @@ -197,13 +209,13 @@ export class ResourceMarkersRenderer implements ITreeRenderer): void { - const templateData = this.renderedNodes.get(node); + const nodeRenders = this.renderedNodes.get(node.element); - if (!templateData) { + if (!nodeRenders) { return; } - this.updateCount(node, templateData); + nodeRenders.forEach(nodeRender => this.updateCount(node, nodeRender)); } private updateCount(node: ITreeNode, templateData: IResourceMarkersTemplateData): void { diff --git a/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts b/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts index bd6e7b68c7c8d..7abcb85f42287 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/notebookCellList.ts @@ -830,7 +830,7 @@ export class NotebookCellList extends WorkbenchList implements ID } private _revealInViewWithMinimalScrolling(viewIndex: number, firstLine?: boolean) { - const firstIndex = this.view.firstVisibleIndex; + const firstIndex = this.view.firstMostlyVisibleIndex; const elementHeight = this.view.elementHeight(viewIndex); if (viewIndex <= firstIndex || (!firstLine && elementHeight >= this.view.renderHeight)) { diff --git a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts index 8d639ca195596..f61228af222b5 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts @@ -164,7 +164,7 @@ export class ActionButtonRenderer implements ICompressibleTreeRenderer(); + private actionButtons = new Map(); constructor( @ICommandService private commandService: ICommandService, @@ -193,8 +193,24 @@ export class ActionButtonRenderer implements ICompressibleTreeRenderer this.actionButtons.delete(actionButton) }); + const renderedActionButtons = this.actionButtons.get(actionButton) ?? []; + this.actionButtons.set(actionButton, [...renderedActionButtons, templateData.actionButton]); + disposables.add({ + dispose: () => { + const renderedActionButtons = this.actionButtons.get(actionButton) ?? []; + const renderedWidgetIndex = renderedActionButtons.findIndex(renderedActionButton => renderedActionButton === templateData.actionButton); + + if (renderedWidgetIndex < 0) { + throw new Error('Disposing unknown action button'); + } + + if (renderedActionButtons.length === 1) { + this.actionButtons.delete(actionButton); + } else { + renderedActionButtons.splice(renderedWidgetIndex, 1); + } + } + }); templateData.disposable = disposables; } @@ -204,7 +220,7 @@ export class ActionButtonRenderer implements ICompressibleTreeRenderer renderedActionButton.focus()); } disposeElement(node: ITreeNode, index: number, template: ActionButtonTemplate): void { @@ -285,7 +301,7 @@ class InputRenderer implements ICompressibleTreeRenderer(); + private inputWidgets = new Map(); private contentHeights = new WeakMap(); private editorSelections = new WeakMap(); @@ -316,9 +332,23 @@ class InputRenderer implements ICompressibleTreeRenderer this.inputWidgets.delete(input) + dispose: () => { + const renderedWidgets = this.inputWidgets.get(input) ?? []; + const renderedWidgetIndex = renderedWidgets.findIndex(renderedWidget => renderedWidget === templateData.inputWidget); + + if (renderedWidgetIndex < 0) { + throw new Error('Disposing unknown input widget'); + } + + if (renderedWidgets.length === 1) { + this.inputWidgets.delete(input); + } else { + renderedWidgets.splice(renderedWidgetIndex, 1); + } + } }); // Widget cursor selections @@ -379,14 +409,16 @@ class InputRenderer implements ICompressibleTreeRenderer widget.focus()); } this.updateRepositoryCollapseAllContextKeys(); @@ -2942,10 +2978,12 @@ export class SCMViewPane extends ViewPane { if (this.isExpanded()) { if (this.tree.getFocus().length === 0) { for (const repository of this.scmViewService.visibleRepositories) { - const widget = this.inputRenderer.getRenderedInputWidget(repository.input); + const widgets = this.inputRenderer.getRenderedInputWidget(repository.input); - if (widget) { - widget.focus(); + if (widgets) { + for (const widget of widgets) { + widget.focus(); + } return; } }