diff --git a/extensions/ql-vscode/src/view/results/RawTableRow.tsx b/extensions/ql-vscode/src/view/results/RawTableRow.tsx index 74f22c54b..c52388c97 100644 --- a/extensions/ql-vscode/src/view/results/RawTableRow.tsx +++ b/extensions/ql-vscode/src/view/results/RawTableRow.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { ResultRow } from '../../pure/bqrs-cli-types'; import { selectedRowClassName, zebraStripe } from './result-table-utils'; import RawTableValue from './RawTableValue'; +import { ScrollIntoViewHelper } from './scroll-into-view-helper'; interface Props { rowIndex: number; @@ -10,6 +11,7 @@ interface Props { className?: string; selectedColumn?: number; onSelected?: (row: number, column: number) => void; + scroller?: ScrollIntoViewHelper; } export default function RawTableRow(props: Props) { @@ -17,15 +19,18 @@ export default function RawTableRow(props: Props) { {props.rowIndex + 1} - {props.row.map((value, columnIndex) => ( - - props.onSelected?.(props.rowIndex, columnIndex)} - /> - - ))} + {props.row.map((value, columnIndex) => { + const isSelected = props.selectedColumn === columnIndex; + return ( + + props.onSelected?.(props.rowIndex, columnIndex)} + /> + + ); + })} ); } diff --git a/extensions/ql-vscode/src/view/results/alert-table.tsx b/extensions/ql-vscode/src/view/results/alert-table.tsx index 213ccfc3b..e2b211d72 100644 --- a/extensions/ql-vscode/src/view/results/alert-table.tsx +++ b/extensions/ql-vscode/src/view/results/alert-table.tsx @@ -14,6 +14,7 @@ import { import { InterpretedResultsSortColumn, SortDirection, InterpretedResultsSortState } from '../../pure/interface-types'; import { vscode } from '../vscode-api'; import { isWholeFileLoc, isLineColumnLoc } from '../../pure/bqrs-utils'; +import { ScrollIntoViewHelper } from './scroll-into-view-helper'; export type PathTableProps = ResultTableProps & { resultSet: InterpretedResultSet }; export interface PathTableState { @@ -22,6 +23,8 @@ export interface PathTableState { } export class PathTable extends React.Component { + private scroller = new ScrollIntoViewHelper(); + constructor(props: PathTableProps) { super(props); this.state = { expanded: new Set(), selectedItem: undefined }; @@ -211,7 +214,7 @@ export class PathTable extends React.Component { if (result.codeFlows === undefined) { rows.push( - + {octicons.info} {msg} {locationCells} @@ -227,7 +230,7 @@ export class PathTable extends React.Component { [resultKey]; rows.push( - + {indicator} @@ -248,7 +251,7 @@ export class PathTable extends React.Component { const indicator = currentPathExpanded ? octicons.chevronDown : octicons.chevronRight; const isPathSpecificallySelected = Keys.equalsNotUndefined(pathKey, selectedItem); rows.push( - + {indicator} @@ -273,7 +276,7 @@ export class PathTable extends React.Component { const stepIndex = pathNodeIndex + 1; // Convert to 1-based const zebraIndex = resultIndex + stepIndex; rows.push( - + {stepIndex} @@ -349,6 +352,7 @@ export class PathTable extends React.Component { expanded.delete(Keys.keyToString(prevState.selectedItem)); } } + this.scroller.scrollIntoViewOnNextUpdate(); return { ...prevState, expanded, @@ -393,7 +397,12 @@ export class PathTable extends React.Component { } } + componentDidUpdate() { + this.scroller.update(); + } + componentDidMount() { + this.scroller.update(); onNavigation.addListener(this.handleNavigationEvent); } diff --git a/extensions/ql-vscode/src/view/results/raw-results-table.tsx b/extensions/ql-vscode/src/view/results/raw-results-table.tsx index b90e8e18b..ab655d1a0 100644 --- a/extensions/ql-vscode/src/view/results/raw-results-table.tsx +++ b/extensions/ql-vscode/src/view/results/raw-results-table.tsx @@ -7,6 +7,7 @@ import RawTableRow from './RawTableRow'; import { ResultRow } from '../../pure/bqrs-cli-types'; import { onNavigation } from './results'; import { tryGetResolvableLocation } from '../../pure/bqrs-utils'; +import { ScrollIntoViewHelper } from './scroll-into-view-helper'; export type RawTableProps = ResultTableProps & { resultSet: RawTableResultSet; @@ -19,6 +20,8 @@ interface RawTableState { } export class RawTable extends React.Component { + private scroller = new ScrollIntoViewHelper(); + constructor(props: RawTableProps) { super(props); this.setSelection = this.setSelection.bind(this); @@ -55,6 +58,7 @@ export class RawTable extends React.Component { databaseUri={databaseUri} selectedColumn={this.state.selectedItem?.row === rowIndex ? this.state.selectedItem?.column : undefined} onSelected={this.setSelection} + scroller={this.scroller} /> ); @@ -127,6 +131,7 @@ export class RawTable extends React.Component { jumpToLocation(location, this.props.databaseUri); } } + this.scroller.scrollIntoViewOnNextUpdate(); return { ...prevState, selectedItem: { row: nextRow, column: nextColumn } @@ -134,7 +139,12 @@ export class RawTable extends React.Component { }); } + componentDidUpdate() { + this.scroller.update(); + } + componentDidMount() { + this.scroller.update(); onNavigation.addListener(this.handleNavigationEvent); } diff --git a/extensions/ql-vscode/src/view/results/scroll-into-view-helper.ts b/extensions/ql-vscode/src/view/results/scroll-into-view-helper.ts new file mode 100644 index 000000000..17b859918 --- /dev/null +++ b/extensions/ql-vscode/src/view/results/scroll-into-view-helper.ts @@ -0,0 +1,55 @@ +import * as React from 'react'; + +/** + * Some book-keeping needed to scroll a specific HTML element into view in a React component. + */ +export class ScrollIntoViewHelper { + private selectedElementRef = React.createRef(); // need 'any' to work around typing bug in React + private shouldScrollIntoView = true; + + /** + * If `isSelected` is true, gets the `ref={}` attribute to use for an element that we might want to scroll into view. + */ + public ref(isSelected: boolean) { + return isSelected ? this.selectedElementRef : undefined; + } + + /** + * Causes the element whose `ref={}` was set to be scrolled into view after the next render. + */ + public scrollIntoViewOnNextUpdate() { + this.shouldScrollIntoView = true; + } + + /** + * Should be called from `componentDidUpdate` and `componentDidMount`. + * + * Scrolls the component into view if requested. + */ + public update() { + if (!this.shouldScrollIntoView) { + return; + } + this.shouldScrollIntoView = false; + const element = this.selectedElementRef.current as HTMLElement | null; + if (element == null) { + return; + } + const rect = element.getBoundingClientRect(); + // The selected item's bounding box might be on screen, but hidden underneath the sticky header + // which overlaps the table view. As a workaround we hardcode a fixed distance from the top which + // we consider to be obscured. It does not have to exact, as it's just a threshold for when to scroll. + const heightOfStickyHeader = 30; + if (rect.top < heightOfStickyHeader || rect.bottom > window.innerHeight) { + element.scrollIntoView({ + block: 'center', // vertically align to center + }); + } + if (rect.left < 0 || rect.right > window.innerWidth) { + element.scrollIntoView({ + block: 'nearest', + inline: 'nearest', // horizontally align as little as possible + }); + } + } +}