Skip to content

Commit 0e3679d

Browse files
committed
Scroll selected item into view
1 parent 45b6288 commit 0e3679d

File tree

4 files changed

+92
-13
lines changed

4 files changed

+92
-13
lines changed

extensions/ql-vscode/src/view/results/RawTableRow.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from 'react';
22
import { ResultRow } from '../../pure/bqrs-cli-types';
33
import { selectedRowClassName, zebraStripe } from './result-table-utils';
44
import RawTableValue from './RawTableValue';
5+
import { ScrollIntoViewHelper } from './scroll-into-view-helper';
56

67
interface Props {
78
rowIndex: number;
@@ -10,22 +11,26 @@ interface Props {
1011
className?: string;
1112
selectedColumn?: number;
1213
onSelected?: (row: number, column: number) => void;
14+
scroller?: ScrollIntoViewHelper;
1315
}
1416

1517
export default function RawTableRow(props: Props) {
1618
return (
1719
<tr key={props.rowIndex} {...zebraStripe(props.rowIndex, props.className || '')}>
1820
<td key={-1}>{props.rowIndex + 1}</td>
1921

20-
{props.row.map((value, columnIndex) => (
21-
<td key={columnIndex} {...props.selectedColumn === columnIndex ? { className: selectedRowClassName } : {}}>
22-
<RawTableValue
23-
value={value}
24-
databaseUri={props.databaseUri}
25-
onSelected={() => props.onSelected?.(props.rowIndex, columnIndex)}
26-
/>
27-
</td>
28-
))}
22+
{props.row.map((value, columnIndex) => {
23+
const isSelected = props.selectedColumn === columnIndex;
24+
return (
25+
<td ref={props.scroller?.ref(isSelected)} key={columnIndex} {...isSelected ? { className: selectedRowClassName } : {}}>
26+
<RawTableValue
27+
value={value}
28+
databaseUri={props.databaseUri}
29+
onSelected={() => props.onSelected?.(props.rowIndex, columnIndex)}
30+
/>
31+
</td>
32+
);
33+
})}
2934
</tr>
3035
);
3136
}

extensions/ql-vscode/src/view/results/alert-table.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { InterpretedResultsSortColumn, SortDirection, InterpretedResultsSortState } from '../../pure/interface-types';
1515
import { vscode } from '../vscode-api';
1616
import { isWholeFileLoc, isLineColumnLoc } from '../../pure/bqrs-utils';
17+
import { ScrollIntoViewHelper } from './scroll-into-view-helper';
1718

1819
export type PathTableProps = ResultTableProps & { resultSet: InterpretedResultSet<SarifInterpretationData> };
1920
export interface PathTableState {
@@ -22,6 +23,8 @@ export interface PathTableState {
2223
}
2324

2425
export class PathTable extends React.Component<PathTableProps, PathTableState> {
26+
private scroller = new ScrollIntoViewHelper();
27+
2528
constructor(props: PathTableProps) {
2629
super(props);
2730
this.state = { expanded: new Set<string>(), selectedItem: undefined };
@@ -211,7 +214,7 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
211214

212215
if (result.codeFlows === undefined) {
213216
rows.push(
214-
<tr key={resultIndex} {...selectableZebraStripe(resultRowIsSelected, resultIndex)}>
217+
<tr ref={this.scroller.ref(resultRowIsSelected)} key={resultIndex} {...selectableZebraStripe(resultRowIsSelected, resultIndex)}>
215218
<td className="vscode-codeql__icon-cell">{octicons.info}</td>
216219
<td colSpan={3}>{msg}</td>
217220
{locationCells}
@@ -227,7 +230,7 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
227230
[resultKey];
228231

229232
rows.push(
230-
<tr {...selectableZebraStripe(resultRowIsSelected, resultIndex)} key={resultIndex}>
233+
<tr ref={this.scroller.ref(resultRowIsSelected)} {...selectableZebraStripe(resultRowIsSelected, resultIndex)} key={resultIndex}>
231234
<td className="vscode-codeql__icon-cell vscode-codeql__dropdown-cell" onMouseDown={toggler(indices)}>
232235
{indicator}
233236
</td>
@@ -248,7 +251,7 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
248251
const indicator = currentPathExpanded ? octicons.chevronDown : octicons.chevronRight;
249252
const isPathSpecificallySelected = Keys.equalsNotUndefined(pathKey, selectedItem);
250253
rows.push(
251-
<tr {...selectableZebraStripe(isPathSpecificallySelected, resultIndex)} key={`${resultIndex}-${pathIndex}`}>
254+
<tr ref={this.scroller.ref(isPathSpecificallySelected)} {...selectableZebraStripe(isPathSpecificallySelected, resultIndex)} key={`${resultIndex}-${pathIndex}`}>
252255
<td className="vscode-codeql__icon-cell"><span className="vscode-codeql__vertical-rule"></span></td>
253256
<td className="vscode-codeql__icon-cell vscode-codeql__dropdown-cell" onMouseDown={toggler([pathKey])}>{indicator}</td>
254257
<td className="vscode-codeql__text-center" colSpan={3}>
@@ -273,7 +276,7 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
273276
const stepIndex = pathNodeIndex + 1; // Convert to 1-based
274277
const zebraIndex = resultIndex + stepIndex;
275278
rows.push(
276-
<tr className={isSelected ? 'vscode-codeql__selected-path-node' : undefined} key={`${resultIndex}-${pathIndex}-${pathNodeIndex}`}>
279+
<tr ref={this.scroller.ref(isSelected)} className={isSelected ? 'vscode-codeql__selected-path-node' : undefined} key={`${resultIndex}-${pathIndex}-${pathNodeIndex}`}>
277280
<td className="vscode-codeql__icon-cell"><span className="vscode-codeql__vertical-rule"></span></td>
278281
<td className="vscode-codeql__icon-cell"><span className="vscode-codeql__vertical-rule"></span></td>
279282
<td {...selectableZebraStripe(isSelected, zebraIndex, 'vscode-codeql__path-index-cell')}>{stepIndex}</td>
@@ -349,6 +352,7 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
349352
expanded.delete(Keys.keyToString(prevState.selectedItem));
350353
}
351354
}
355+
this.scroller.scrollIntoViewOnNextUpdate();
352356
return {
353357
...prevState,
354358
expanded,
@@ -393,7 +397,12 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
393397
}
394398
}
395399

400+
componentDidUpdate() {
401+
this.scroller.update();
402+
}
403+
396404
componentDidMount() {
405+
this.scroller.update();
397406
onNavigation.addListener(this.handleNavigationEvent);
398407
}
399408

extensions/ql-vscode/src/view/results/raw-results-table.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import RawTableRow from './RawTableRow';
77
import { ResultRow } from '../../pure/bqrs-cli-types';
88
import { onNavigation } from './results';
99
import { tryGetResolvableLocation } from '../../pure/bqrs-utils';
10+
import { ScrollIntoViewHelper } from './scroll-into-view-helper';
1011

1112
export type RawTableProps = ResultTableProps & {
1213
resultSet: RawTableResultSet;
@@ -19,6 +20,8 @@ interface RawTableState {
1920
}
2021

2122
export class RawTable extends React.Component<RawTableProps, RawTableState> {
23+
private scroller = new ScrollIntoViewHelper();
24+
2225
constructor(props: RawTableProps) {
2326
super(props);
2427
this.setSelection = this.setSelection.bind(this);
@@ -55,6 +58,7 @@ export class RawTable extends React.Component<RawTableProps, RawTableState> {
5558
databaseUri={databaseUri}
5659
selectedColumn={this.state.selectedItem?.row === rowIndex ? this.state.selectedItem?.column : undefined}
5760
onSelected={this.setSelection}
61+
scroller={this.scroller}
5862
/>
5963
);
6064

@@ -127,14 +131,20 @@ export class RawTable extends React.Component<RawTableProps, RawTableState> {
127131
jumpToLocation(location, this.props.databaseUri);
128132
}
129133
}
134+
this.scroller.scrollIntoViewOnNextUpdate();
130135
return {
131136
...prevState,
132137
selectedItem: { row: nextRow, column: nextColumn }
133138
};
134139
});
135140
}
136141

142+
componentDidUpdate() {
143+
this.scroller.update();
144+
}
145+
137146
componentDidMount() {
147+
this.scroller.update();
138148
onNavigation.addListener(this.handleNavigationEvent);
139149
}
140150

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import * as React from 'react';
2+
3+
/**
4+
* Some book-keeping needed to scroll a specific HTML element into view in a React component.
5+
*/
6+
export class ScrollIntoViewHelper {
7+
private selectedElementRef = React.createRef<HTMLElement | any>(); // need 'any' to work around typing bug in React
8+
private shouldScrollIntoView = true;
9+
10+
/**
11+
* If `isSelected` is true, gets the `ref={}` attribute to use for an element that we might want to scroll into view.
12+
*/
13+
public ref(isSelected: boolean) {
14+
return isSelected ? this.selectedElementRef : undefined;
15+
}
16+
17+
/**
18+
* Causes the element whose `ref={}` was set to be scrolled into view after the next render.
19+
*/
20+
public scrollIntoViewOnNextUpdate() {
21+
this.shouldScrollIntoView = true;
22+
}
23+
24+
/**
25+
* Should be called from `componentDidUpdate` and `componentDidMount`.
26+
*
27+
* Scrolls the component into view if requested.
28+
*/
29+
public update() {
30+
if (!this.shouldScrollIntoView) {
31+
return;
32+
}
33+
this.shouldScrollIntoView = false;
34+
const element = this.selectedElementRef.current as HTMLElement | null;
35+
if (element == null) {
36+
return;
37+
}
38+
const rect = element.getBoundingClientRect();
39+
// The selected item's bounding box might be on screen, but hidden underneath the sticky header
40+
// which overlaps the table view. As a workaround we hardcode a fixed distance from the top which
41+
// we consider to be obscured. It does not have to exact, as it's just a threshold for when to scroll.
42+
const heightOfStickyHeader = 30;
43+
if (rect.top < heightOfStickyHeader || rect.bottom > window.innerHeight) {
44+
element.scrollIntoView({
45+
block: 'center', // vertically align to center
46+
});
47+
}
48+
if (rect.left < 0 || rect.right > window.innerWidth) {
49+
element.scrollIntoView({
50+
block: 'nearest',
51+
inline: 'nearest', // horizontally align as little as possible
52+
});
53+
}
54+
}
55+
}

0 commit comments

Comments
 (0)