Skip to content

Commit 34a41e0

Browse files
justschenCopilot
andauthored
chat sticky scroll: more fluid, less vertical space (#332095)
* chat sticky scroll: more fluid, less vertical space * cleanup * fix tests --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 8ed3e10 commit 34a41e0

17 files changed

Lines changed: 1259 additions & 107 deletions

File tree

src/vs/base/browser/ui/tree/abstractTree.ts

Lines changed: 116 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1275,6 +1275,16 @@ export interface StickyScrollNode<T, TFilterData> {
12751275
readonly endIndex: number;
12761276
readonly height: number;
12771277
readonly position: number;
1278+
readonly sourceNodeEnd: number;
1279+
readonly sourceNodePartiallyVisible: boolean;
1280+
readonly hasExplicitHeight: boolean;
1281+
}
1282+
1283+
export interface IStickyScrollNodeSourceRange {
1284+
readonly start: number;
1285+
readonly end: number;
1286+
readonly stickyNodeHeight?: number;
1287+
readonly estimated?: boolean;
12781288
}
12791289

12801290
function stickyScrollNodeStateEquals<T, TFilterData>(node1: StickyScrollNode<T, TFilterData>, node2: StickyScrollNode<T, TFilterData>) {
@@ -1285,7 +1295,10 @@ function stickyScrollNodeEquals<T, TFilterData>(node1: StickyScrollNode<T, TFilt
12851295
return node1.node.element === node2.node.element &&
12861296
node1.startIndex === node2.startIndex &&
12871297
node1.height === node2.height &&
1288-
node1.endIndex === node2.endIndex;
1298+
node1.endIndex === node2.endIndex &&
1299+
node1.sourceNodeEnd === node2.sourceNodeEnd &&
1300+
node1.sourceNodePartiallyVisible === node2.sourceNodePartiallyVisible &&
1301+
node1.hasExplicitHeight === node2.hasExplicitHeight;
12891302
}
12901303

12911304
class StickyScrollState<T, TFilterData, TRef> {
@@ -1499,28 +1512,43 @@ class StickyScrollController<T, TFilterData, TRef> extends Disposable {
14991512
return undefined;
15001513
}
15011514

1502-
if (nextStickyNode === firstVisibleNodeUnderWidget) {
1503-
if (!this.nodeIsUncollapsedParent(firstVisibleNodeUnderWidget)) {
1504-
return undefined;
1505-
}
1515+
if (nextStickyNode === firstVisibleNodeUnderWidget && !this.nodeIsUncollapsedParent(firstVisibleNodeUnderWidget)) {
1516+
return undefined;
1517+
}
15061518

1507-
if (this.tree.options.stickyScrollShowOnlyWhenNodeFullyHidden) {
1508-
return undefined;
1509-
}
1519+
const sourceRange = this.getStickyScrollNodeSourceRange(nextStickyNode);
1520+
if (!sourceRange || !Number.isFinite(sourceRange.start) || !Number.isFinite(sourceRange.end) || sourceRange.end <= sourceRange.start) {
1521+
return undefined;
1522+
}
1523+
const sourceNodeTop = this.view.getElementTop(this.getNodeIndex(nextStickyNode));
1524+
const stickyViewportBottom = this.view.scrollTop + stickyNodesHeight;
1525+
if (stickyViewportBottom <= sourceNodeTop + sourceRange.start) {
1526+
return undefined;
1527+
}
1528+
const height = this.getStickyScrollNodeHeight(sourceRange, stickyNodesHeight);
1529+
const sourceNodePartiallyVisible = stickyViewportBottom + height < sourceNodeTop + sourceRange.end;
15101530

1511-
if (this.nodeTopAlignsWithStickyNodesBottom(firstVisibleNodeUnderWidget, stickyNodesHeight)) {
1512-
return undefined;
1513-
}
1531+
return this.createStickyScrollNode(nextStickyNode, stickyNodesHeight, height, sourceRange.end, sourceNodePartiallyVisible, sourceRange.stickyNodeHeight !== undefined);
1532+
}
1533+
1534+
private getStickyScrollNodeSourceRange(node: ITreeNode<T, TFilterData>): IStickyScrollNodeSourceRange | undefined {
1535+
const defaultRange = { start: 0, end: this.getNodeHeight(node) };
1536+
const sourceRangeProvider = this.tree.options.stickyScrollNodeSourceRangeProvider;
1537+
if (sourceRangeProvider) {
1538+
return sourceRangeProvider(node.element, defaultRange);
15141539
}
15151540

1516-
return this.createStickyScrollNode(nextStickyNode, stickyNodesHeight);
1541+
return defaultRange;
15171542
}
15181543

1519-
private nodeTopAlignsWithStickyNodesBottom(node: ITreeNode<T, TFilterData>, stickyNodesHeight: number): boolean {
1520-
const nodeIndex = this.getNodeIndex(node);
1521-
const elementTop = this.view.getElementTop(nodeIndex);
1522-
const stickyPosition = stickyNodesHeight;
1523-
return this.view.scrollTop === elementTop - stickyPosition;
1544+
private getStickyScrollNodeHeight(sourceRange: IStickyScrollNodeSourceRange, currentStickyNodesHeight: number): number {
1545+
const height = this.clampNodeHeight(sourceRange.stickyNodeHeight ?? sourceRange.end - sourceRange.start);
1546+
if (!sourceRange.estimated) {
1547+
return height;
1548+
}
1549+
1550+
const availableHeight = Math.max(1, this.view.renderHeight * this.maxWidgetViewRatio - currentStickyNodesHeight);
1551+
return Math.min(height, availableHeight);
15241552
}
15251553

15261554
private getNodeHeight(node: ITreeNode<T, TFilterData>): number {
@@ -1537,13 +1565,12 @@ class StickyScrollController<T, TFilterData, TRef> extends Disposable {
15371565
return max !== undefined ? Math.min(height, max) : height;
15381566
}
15391567

1540-
private createStickyScrollNode(node: ITreeNode<T, TFilterData>, currentStickyNodesHeight: number): StickyScrollNode<T, TFilterData> {
1541-
const height = this.clampNodeHeight(this.getNodeHeight(node));
1568+
private createStickyScrollNode(node: ITreeNode<T, TFilterData>, currentStickyNodesHeight: number, height: number, sourceNodeEnd: number, sourceNodePartiallyVisible: boolean, hasExplicitHeight: boolean): StickyScrollNode<T, TFilterData> {
15421569
const { startIndex, endIndex } = this.getNodeRange(node);
15431570

15441571
const position = this.calculateStickyNodePosition(endIndex, currentStickyNodesHeight, height);
15451572

1546-
return { node, position, height, startIndex, endIndex };
1573+
return { node, position, height, startIndex, endIndex, sourceNodeEnd, sourceNodePartiallyVisible, hasExplicitHeight };
15471574
}
15481575

15491576
private getAncestorUnderPrevious(node: ITreeNode<T, TFilterData>, previousAncestor: ITreeNode<T, TFilterData> | undefined = undefined): ITreeNode<T, TFilterData> | undefined {
@@ -1566,23 +1593,8 @@ class StickyScrollController<T, TFilterData, TRef> extends Disposable {
15661593
}
15671594

15681595
private calculateStickyNodePosition(lastDescendantIndex: number, stickyRowPositionTop: number, stickyNodeHeight: number): number {
1569-
let lastChildRelativeTop = this.view.getRelativeTop(lastDescendantIndex);
1570-
1571-
// If the last descendant is only partially visible at the top of the view, getRelativeTop() returns null
1572-
// In that case, utilize the next node's relative top to calculate the sticky node's position
1573-
if (lastChildRelativeTop === null && this.view.firstVisibleIndex === lastDescendantIndex && lastDescendantIndex + 1 < this.view.length) {
1574-
const nodeHeight = this.view.getElementHeight(lastDescendantIndex);
1575-
const nextNodeRelativeTop = this.view.getRelativeTop(lastDescendantIndex + 1);
1576-
lastChildRelativeTop = nextNodeRelativeTop ? nextNodeRelativeTop - nodeHeight / this.view.renderHeight : null;
1577-
}
1578-
1579-
if (lastChildRelativeTop === null) {
1580-
return stickyRowPositionTop;
1581-
}
1582-
15831596
const lastChildHeight = this.view.getElementHeight(lastDescendantIndex);
1584-
const topOfLastChild = lastChildRelativeTop * this.view.renderHeight;
1585-
const bottomOfLastChild = topOfLastChild + lastChildHeight;
1597+
const bottomOfLastChild = this.view.getElementTop(lastDescendantIndex) + lastChildHeight - this.view.scrollTop;
15861598

15871599
if (stickyRowPositionTop + stickyNodeHeight > bottomOfLastChild && stickyRowPositionTop <= bottomOfLastChild) {
15881600
return bottomOfLastChild - stickyNodeHeight;
@@ -1660,7 +1672,10 @@ class StickyScrollController<T, TFilterData, TRef> extends Disposable {
16601672

16611673
let widgetHeight = 0;
16621674
for (let i = 0; i < ancestors.length && i < this.stickyScrollMaxItemCount; i++) {
1663-
widgetHeight += this.clampNodeHeight(this.getNodeHeight(ancestors[i]));
1675+
const sourceRange = this.getStickyScrollNodeSourceRange(ancestors[i]);
1676+
if (sourceRange) {
1677+
widgetHeight += this.getStickyScrollNodeHeight(sourceRange, widgetHeight);
1678+
}
16641679
}
16651680
return widgetHeight;
16661681
}
@@ -1692,6 +1707,15 @@ class StickyScrollController<T, TFilterData, TRef> extends Disposable {
16921707
}
16931708
}
16941709

1710+
refresh(): void {
1711+
this.update();
1712+
}
1713+
1714+
rerender(): void {
1715+
this._widget.rerender();
1716+
this.update();
1717+
}
1718+
16951719
validateStickySettings(options: IAbstractTreeOptionsUpdate<T>): { stickyScrollMaxItemCount: number } {
16961720
let stickyScrollMaxItemCount = 7;
16971721
if (typeof options.stickyScrollMaxItemCount === 'number') {
@@ -1706,6 +1730,7 @@ class StickyScrollWidget<T, TFilterData, TRef> implements IDisposable {
17061730
private readonly _rootDomNode: HTMLElement;
17071731
private _previousState: StickyScrollState<T, TFilterData, TRef> | undefined;
17081732
private _previousElements: HTMLElement[] = [];
1733+
private _previousElementHeights: number[] = [];
17091734
private readonly _previousStateDisposables: DisposableStore = new DisposableStore();
17101735
get state(): StickyScrollState<T, TFilterData, TRef> | undefined { return this._previousState; }
17111736

@@ -1758,6 +1783,7 @@ class StickyScrollWidget<T, TFilterData, TRef> implements IDisposable {
17581783

17591784
// If state has not changed, do nothing
17601785
if ((!wasVisible && !isVisible) || (wasVisible && isVisible && this._previousState!.equal(state))) {
1786+
this.updateSourceNodeVisibility(state);
17611787
return;
17621788
}
17631789

@@ -1767,8 +1793,10 @@ class StickyScrollWidget<T, TFilterData, TRef> implements IDisposable {
17671793
}
17681794

17691795
if (!isVisible) {
1796+
this.updateSourceNodeVisibility(undefined);
17701797
this._previousState = undefined;
17711798
this._previousElements = [];
1799+
this._previousElementHeights = [];
17721800
this._previousStateDisposables.clear();
17731801
return;
17741802
}
@@ -1786,9 +1814,28 @@ class StickyScrollWidget<T, TFilterData, TRef> implements IDisposable {
17861814

17871815
this._previousState = state;
17881816

1817+
this.updateSourceNodeVisibility(state);
17891818
this.updateRootHeight(state);
17901819
}
17911820

1821+
private updateSourceNodeVisibility(state: StickyScrollState<T, TFilterData, TRef> | undefined): void {
1822+
let sourceNodePartiallyVisible = false;
1823+
for (let i = 0; state && i < state.count; i++) {
1824+
const stickyNode = state.stickyNodes[i];
1825+
const stickyElement = this._previousElements[i];
1826+
let nodePartiallyVisible = stickyNode.sourceNodePartiallyVisible;
1827+
if (stickyElement) {
1828+
const sourceNodeBottom = this.view.getElementTop(stickyNode.startIndex) + stickyNode.sourceNodeEnd;
1829+
const stickyElementHeight = this.getRenderedNodeHeight(stickyNode, i);
1830+
const stickyNodeBottom = this.view.scrollTop + stickyNode.position + stickyElementHeight;
1831+
nodePartiallyVisible = stickyNodeBottom < sourceNodeBottom;
1832+
}
1833+
stickyElement?.classList.toggle('source-node-partially-visible', nodePartiallyVisible);
1834+
sourceNodePartiallyVisible ||= nodePartiallyVisible;
1835+
}
1836+
this._rootDomNode.classList.toggle('source-node-partially-visible', sourceNodePartiallyVisible);
1837+
}
1838+
17921839
private renderState(state: StickyScrollState<T, TFilterData, TRef>): void {
17931840
this._previousStateDisposables.clear();
17941841

@@ -1807,13 +1854,13 @@ class StickyScrollWidget<T, TFilterData, TRef> implements IDisposable {
18071854

18081855
this._previousElements = elements;
18091856

1810-
// Probe dynamic heights after rendering into DOM
1811-
this.probeDynamicHeights(state, elements);
1857+
this._previousElementHeights = this.probeDynamicHeights(state, elements);
18121858
}
18131859

18141860
rerender(): void {
18151861
if (this._previousState) {
18161862
this.renderState(this._previousState);
1863+
this.updateSourceNodeVisibility(this._previousState);
18171864
this.updateRootHeight(this._previousState);
18181865
}
18191866
}
@@ -1824,17 +1871,25 @@ class StickyScrollWidget<T, TFilterData, TRef> implements IDisposable {
18241871

18251872
private getRootHeight(state: StickyScrollState<T, TFilterData, TRef>): number {
18261873
const lastStickyNode = state.stickyNodes[state.count - 1];
1827-
const lastStickyElement = this._previousElements[state.count - 1];
1828-
const lastStickyElementHeight = lastStickyElement?.offsetHeight ?? lastStickyNode.height;
1829-
return lastStickyNode.position + lastStickyElementHeight;
1874+
const lastStickyElementHeight = this.getRenderedNodeHeight(lastStickyNode, state.count - 1);
1875+
return Math.max(0, lastStickyNode.position + lastStickyElementHeight);
18301876
}
18311877

1832-
private probeDynamicHeights(state: StickyScrollState<T, TFilterData, TRef>, elements: HTMLElement[]): void {
1878+
private getRenderedNodeHeight(stickyNode: StickyScrollNode<T, TFilterData>, index: number): number {
1879+
return Math.min(this._previousElementHeights[index] ?? stickyNode.height, stickyNode.height);
1880+
}
1881+
1882+
private probeDynamicHeights(state: StickyScrollState<T, TFilterData, TRef>, elements: HTMLElement[]): number[] {
18331883
const heightChanges: { index: number; height: number }[] = [];
1884+
const elementHeights = state.stickyNodes.map(node => node.height);
18341885

18351886
for (let i = 0; i < state.count; i++) {
18361887
const stickyNode = state.stickyNodes[i];
18371888
if (!this.treeDelegate.hasDynamicHeight || !this.treeDelegate.hasDynamicHeight(stickyNode.node)) {
1889+
const measuredHeight = elements[i].offsetHeight;
1890+
if (measuredHeight > 0) {
1891+
elementHeights[i] = measuredHeight;
1892+
}
18381893
continue;
18391894
}
18401895

@@ -1850,25 +1905,28 @@ class StickyScrollWidget<T, TFilterData, TRef> implements IDisposable {
18501905
}
18511906
const maxNodeHeight = this.tree.options.stickyScrollMaxNodeHeight;
18521907
const clampedMeasuredHeight = maxNodeHeight !== undefined ? Math.min(measuredHeight, maxNodeHeight) : measuredHeight;
1908+
const renderedHeight = stickyNode.hasExplicitHeight ? stickyNode.height : clampedMeasuredHeight;
1909+
elementHeights[i] = renderedHeight;
18531910

18541911
// Always update the sticky element's visual height to match the measured content
18551912
if (this.tree.options.setRowHeight !== false) {
1856-
element.style.height = `${clampedMeasuredHeight}px`;
1913+
element.style.height = `${renderedHeight}px`;
18571914
}
18581915
if (this.tree.options.setRowLineHeight !== false) {
1859-
element.style.lineHeight = `${clampedMeasuredHeight}px`;
1916+
element.style.lineHeight = `${renderedHeight}px`;
18601917
}
18611918

1862-
// Only propagate height increases to the real row — never shrink it,
1863-
// since sticky elements may have CSS truncation (e.g. line-clamp).
1864-
if (clampedMeasuredHeight > stickyNode.height) {
1919+
// A sticky row may represent only part of its source, so never shrink the source row.
1920+
if (!stickyNode.hasExplicitHeight && clampedMeasuredHeight > this.view.getElementHeight(stickyNode.startIndex)) {
18651921
heightChanges.push({ index: stickyNode.startIndex, height: clampedMeasuredHeight });
18661922
}
18671923
}
18681924

18691925
if (heightChanges.length > 0) {
18701926
this._onDidChangeHeight.fire(heightChanges);
18711927
}
1928+
1929+
return elementHeights;
18721930
}
18731931

18741932
private createElement(stickyNode: StickyScrollNode<T, TFilterData>, stickyIndex: number, stickyNodesTotal: number): { element: HTMLElement; disposable: IDisposable } {
@@ -1894,6 +1952,7 @@ class StickyScrollWidget<T, TFilterData, TRef> implements IDisposable {
18941952

18951953
stickyElement.classList.add('monaco-tree-sticky-row');
18961954
stickyElement.classList.add('monaco-list-row');
1955+
stickyElement.classList.toggle('source-node-partially-visible', stickyNode.sourceNodePartiallyVisible);
18971956

18981957
stickyElement.setAttribute('data-index', `${nodeIndex}`);
18991958
stickyElement.setAttribute('data-parity', nodeIndex % 2 === 0 ? 'even' : 'odd');
@@ -2311,7 +2370,6 @@ export interface IAbstractTreeOptionsUpdate<T> extends ITreeRendererOptions<T> {
23112370
readonly enableStickyScroll?: boolean;
23122371
readonly stickyScrollMaxItemCount?: number;
23132372
readonly stickyScrollMaxNodeHeight?: number;
2314-
readonly stickyScrollShowOnlyWhenNodeFullyHidden?: boolean;
23152373
readonly paddingTop?: number;
23162374
}
23172375

@@ -2327,6 +2385,7 @@ export interface IAbstractTreeOptions<T, TFilterData = void> extends IAbstractTr
23272385
readonly findWidgetContainer?: HTMLElement;
23282386
readonly defaultFindVisibility?: TreeVisibility | ((e: T) => TreeVisibility);
23292387
readonly stickyScrollDelegate?: IStickyScrollDelegate<T, TFilterData>;
2388+
readonly stickyScrollNodeSourceRangeProvider?: (element: T, defaultRange: IStickyScrollNodeSourceRange) => IStickyScrollNodeSourceRange | undefined;
23302389
readonly disableExpandOnSpacebar?: boolean; // defaults to false
23312390
}
23322391

@@ -2909,6 +2968,14 @@ export abstract class AbstractTree<T, TFilterData, TRef> implements IDisposable
29092968
return this.view.renderHeight;
29102969
}
29112970

2971+
refreshStickyScroll(): void {
2972+
this.stickyScrollController?.refresh();
2973+
}
2974+
2975+
rerenderStickyScroll(): void {
2976+
this.stickyScrollController?.rerender();
2977+
}
2978+
29122979
get firstVisibleElement(): T | undefined {
29132980
let index = this.view.firstVisibleIndex;
29142981

src/vs/base/browser/ui/tree/asyncDataTree.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,7 @@ function asObjectTreeOptions<TInput, T, TFilterData>(options?: IAsyncDataTreeOpt
475475
return options.keyboardNavigationLabelProvider!.getKeyboardNavigationLabel(e.element as T);
476476
}
477477
},
478+
stickyScrollNodeSourceRangeProvider: options.stickyScrollNodeSourceRangeProvider && ((e, defaultRange) => options.stickyScrollNodeSourceRangeProvider!(e.element as T, defaultRange)),
478479
sorter: undefined,
479480
expandOnlyOnTwistieClick: typeof options.expandOnlyOnTwistieClick === 'undefined' ? undefined : (
480481
typeof options.expandOnlyOnTwistieClick !== 'function' ? options.expandOnlyOnTwistieClick : (

src/vs/base/browser/ui/tree/objectTree.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,9 @@ class CompressibleStickyScrollDelegate<T, TFilterData> implements IStickyScrollD
234234
endIndex: lastStickyNode.endIndex,
235235
position: stickyNodes[0].position,
236236
height: stickyNodes[0].height,
237+
sourceNodeEnd: stickyNodes[0].sourceNodeEnd,
238+
sourceNodePartiallyVisible: stickyNodes.some(node => node.sourceNodePartiallyVisible),
239+
hasExplicitHeight: stickyNodes[0].hasExplicitHeight,
237240
};
238241

239242
this.compressedStickyNodes.set(stickyTreeNode, compressedNode);

0 commit comments

Comments
 (0)