Merge pull request #1568 from asgerf/asgerf/navigate-alerts

Add commands for navigation of alerts
This commit is contained in:
Andrew Eisenberg
2022-10-25 08:51:38 -07:00
committed by GitHub
12 changed files with 391 additions and 125 deletions

View File

@@ -2,6 +2,8 @@
## [UNRELEASED]
- Add commands for navigating up, down, left, or right in the result viewer. Previously there were only commands for moving up and down the currently-selected path. We suggest binding keyboard shortcuts to these commands, for navigating the result viewer using the keyboard. [#1568](https://github.com/github/vscode-codeql/pull/1568)
## 1.7.2 - 14 October 2022
- Fix a bug where results created in older versions were thought to be unsuccessful. [#1605](https://github.com/github/vscode-codeql/pull/1605)

View File

@@ -99,6 +99,10 @@ When the results are ready, they're displayed in the CodeQL Query Results view.
If there are any problems running a query, a notification is displayed in the bottom right corner of the application. In addition to the error message, the notification includes details of how to fix the problem.
### Keyboad navigation
If you wish to navigate the query results from your keyboard, you can bind shortcuts to the **CodeQL: Navigate Up/Down/Left/Right in Result Viewer** commands.
## What next?
For more information about the CodeQL extension, [see the documentation](https://codeql.github.com/docs/codeql-for-visual-studio-code/). Otherwise, you could:

View File

@@ -602,12 +602,20 @@
"title": "Copy Repository List"
},
{
"command": "codeQLQueryResults.nextPathStep",
"title": "CodeQL: Show Next Step on Path"
"command": "codeQLQueryResults.down",
"title": "CodeQL: Navigate Down in Local Result Viewer"
},
{
"command": "codeQLQueryResults.previousPathStep",
"title": "CodeQL: Show Previous Step on Path"
"command": "codeQLQueryResults.up",
"title": "CodeQL: Navigate Up in Local Result Viewer"
},
{
"command": "codeQLQueryResults.right",
"title": "CodeQL: Navigate Right in Local Result Viewer"
},
{
"command": "codeQLQueryResults.left",
"title": "CodeQL: Navigate Left in Local Result Viewer"
},
{
"command": "codeQL.restartQueryServer",

View File

@@ -27,6 +27,7 @@ import {
ALERTS_TABLE_NAME,
GRAPH_TABLE_NAME,
RawResultsSortState,
NavigationDirection,
} from './pure/interface-types';
import { Logger } from './logging';
import { commandRunner } from './commandRunner';
@@ -141,19 +142,24 @@ export class ResultsView extends AbstractWebview<IntoResultsViewMsg, FromResults
this.handleSelectionChange.bind(this)
)
);
void logger.log('Registering path-step navigation commands.');
this.push(
commandRunner(
'codeQLQueryResults.nextPathStep',
this.navigatePathStep.bind(this, 1)
)
);
this.push(
commandRunner(
'codeQLQueryResults.previousPathStep',
this.navigatePathStep.bind(this, -1)
)
);
const navigationCommands = {
'codeQLQueryResults.up': NavigationDirection.up,
'codeQLQueryResults.down': NavigationDirection.down,
'codeQLQueryResults.left': NavigationDirection.left,
'codeQLQueryResults.right': NavigationDirection.right,
// For backwards compatibility with keybindings set using an earlier version of the extension.
'codeQLQueryResults.nextPathStep': NavigationDirection.down,
'codeQLQueryResults.previousPathStep': NavigationDirection.up,
};
void logger.log('Registering result view navigation commands.');
for (const [commandId, direction] of Object.entries(navigationCommands)) {
this.push(
commandRunner(
commandId,
this.navigateResultView.bind(this, direction)
)
);
}
this.push(
this.databaseManager.onDidChangeDatabaseItem(({ kind }) => {
@@ -169,8 +175,13 @@ export class ResultsView extends AbstractWebview<IntoResultsViewMsg, FromResults
);
}
async navigatePathStep(direction: number): Promise<void> {
await this.postMessage({ t: 'navigatePath', direction });
async navigateResultView(direction: NavigationDirection): Promise<void> {
if (!this.panel?.visible) {
return;
}
// Reveal the panel now as the subsequent call to 'Window.showTextEditor' in 'showLocation' may destroy the webview otherwise.
this.panel.reveal();
await this.postMessage({ t: 'navigate', direction });
}
protected getPanelConfig(): WebviewPanelConfig {

View File

@@ -145,12 +145,17 @@ export interface ShowInterpretedPageMsg {
queryPath: string;
}
/** Advance to the next or previous path no in the path viewer */
export interface NavigatePathMsg {
t: 'navigatePath';
export const enum NavigationDirection {
up = 'up',
down = 'down',
left = 'left',
right = 'right',
}
/** 1 for next, -1 for previous */
direction: number;
/** Move up, down, left, or right in the result viewer. */
export interface NavigateMsg {
t: 'navigate';
direction: NavigationDirection;
}
/**
@@ -168,7 +173,7 @@ export type IntoResultsViewMsg =
| ResultsUpdatingMsg
| SetStateMsg
| ShowInterpretedPageMsg
| NavigatePathMsg
| NavigateMsg
| UntoggleShowProblemsMsg;
/**

View File

@@ -1,43 +1,52 @@
import * as sarif from 'sarif';
/**
* Identifies a result, a path, or one of the nodes on a path.
*/
interface ResultKeyBase {
resultIndex: number;
pathIndex?: number;
pathNodeIndex?: number;
}
/**
* Identifies one of the results in a result set by its index in the result list.
*/
export interface Result {
export interface Result extends ResultKeyBase {
resultIndex: number;
pathIndex?: undefined;
pathNodeIndex?: undefined;
}
/**
* Identifies one of the paths associated with a result.
*/
export interface Path extends Result {
export interface Path extends ResultKeyBase {
pathIndex: number;
pathNodeIndex?: undefined;
}
/**
* Identifies one of the nodes in a path.
*/
export interface PathNode extends Path {
export interface PathNode extends ResultKeyBase {
pathIndex: number;
pathNodeIndex: number;
}
/** Alias for `undefined` but more readable in some cases */
export const none: PathNode | undefined = undefined;
export type ResultKey = Result | Path | PathNode;
/**
* Looks up a specific result in a result set.
*/
export function getResult(sarif: sarif.Log, key: Result): sarif.Result | undefined {
if (sarif.runs.length === 0) return undefined;
if (sarif.runs[0].results === undefined) return undefined;
const results = sarif.runs[0].results;
return results[key.resultIndex];
export function getResult(sarif: sarif.Log, key: Result | Path | PathNode): sarif.Result | undefined {
return sarif.runs[0]?.results?.[key.resultIndex];
}
/**
* Looks up a specific path in a result set.
*/
export function getPath(sarif: sarif.Log, key: Path): sarif.ThreadFlow | undefined {
export function getPath(sarif: sarif.Log, key: Path | PathNode): sarif.ThreadFlow | undefined {
const result = getResult(sarif, key);
if (result === undefined) return undefined;
let index = -1;
@@ -58,22 +67,13 @@ export function getPath(sarif: sarif.Log, key: Path): sarif.ThreadFlow | undefin
export function getPathNode(sarif: sarif.Log, key: PathNode): sarif.Location | undefined {
const path = getPath(sarif, key);
if (path === undefined) return undefined;
return path.locations[key.pathNodeIndex];
}
/**
* Returns true if the two keys are both `undefined` or contain the same set of indices.
*/
export function equals(key1: PathNode | undefined, key2: PathNode | undefined): boolean {
if (key1 === key2) return true;
if (key1 === undefined || key2 === undefined) return false;
return key1.resultIndex === key2.resultIndex && key1.pathIndex === key2.pathIndex && key1.pathNodeIndex === key2.pathNodeIndex;
return path.locations[key.pathNodeIndex]?.location;
}
/**
* Returns true if the two keys contain the same set of indices and neither are `undefined`.
*/
export function equalsNotUndefined(key1: PathNode | undefined, key2: PathNode | undefined): boolean {
export function equalsNotUndefined(key1: Partial<PathNode> | undefined, key2: Partial<PathNode> | undefined): boolean {
if (key1 === undefined || key2 === undefined) return false;
return key1.resultIndex === key2.resultIndex && key1.pathIndex === key2.pathIndex && key1.pathNodeIndex === key2.pathNodeIndex;
}
@@ -93,3 +93,11 @@ export function getAllPaths(result: sarif.Result): sarif.ThreadFlow[] {
}
return paths;
}
/**
* Creates a unique string representation of the given key, suitable for use
* as the key in a map or set.
*/
export function keyToString(key: ResultKey) {
return key.resultIndex + '-' + (key.pathIndex ?? '') + '-' + (key.pathNodeIndex ?? '');
}

View File

@@ -1,13 +1,17 @@
import * as React from 'react';
import { ResultRow } from '../../pure/bqrs-cli-types';
import { zebraStripe } from './result-table-utils';
import { selectedRowClassName, zebraStripe } from './result-table-utils';
import RawTableValue from './RawTableValue';
import { ScrollIntoViewHelper } from './scroll-into-view-helper';
interface Props {
rowIndex: number;
row: ResultRow;
databaseUri: string;
className?: string;
selectedColumn?: number;
onSelected?: (row: number, column: number) => void;
scroller?: ScrollIntoViewHelper;
}
export default function RawTableRow(props: Props) {
@@ -15,14 +19,18 @@ export default function RawTableRow(props: Props) {
<tr key={props.rowIndex} {...zebraStripe(props.rowIndex, props.className || '')}>
<td key={-1}>{props.rowIndex + 1}</td>
{props.row.map((value, columnIndex) => (
<td key={columnIndex}>
<RawTableValue
value={value}
databaseUri={props.databaseUri}
/>
</td>
))}
{props.row.map((value, columnIndex) => {
const isSelected = props.selectedColumn === columnIndex;
return (
<td ref={props.scroller?.ref(isSelected)} key={columnIndex} {...isSelected ? { className: selectedRowClassName } : {}}>
<RawTableValue
value={value}
databaseUri={props.databaseUri}
onSelected={() => props.onSelected?.(props.rowIndex, columnIndex)}
/>
</td>
);
})}
</tr>
);
}

View File

@@ -6,6 +6,7 @@ import { CellValue } from '../../pure/bqrs-cli-types';
interface Props {
value: CellValue;
databaseUri: string;
onSelected?: () => void;
}
export default function RawTableValue(props: Props): JSX.Element {
@@ -18,5 +19,5 @@ export default function RawTableValue(props: Props): JSX.Element {
return <span>{renderLocation(undefined, rawValue.toString())}</span>;
}
return renderLocation(rawValue.url, rawValue.label, props.databaseUri);
return renderLocation(rawValue.url, rawValue.label, props.databaseUri, undefined, props.onSelected);
}

View File

@@ -3,9 +3,9 @@ import * as React from 'react';
import * as Sarif from 'sarif';
import * as Keys from '../../pure/result-keys';
import * as octicons from './octicons';
import { className, renderLocation, ResultTableProps, zebraStripe, selectableZebraStripe, jumpToLocation, nextSortDirection, emptyQueryResultsMessage } from './result-table-utils';
import { onNavigation, NavigationEvent } from './results';
import { InterpretedResultSet, SarifInterpretationData } from '../../pure/interface-types';
import { className, renderLocation, ResultTableProps, selectableZebraStripe, jumpToLocation, nextSortDirection, emptyQueryResultsMessage } from './result-table-utils';
import { onNavigation } from './results';
import { InterpretedResultSet, NavigateMsg, NavigationDirection, SarifInterpretationData } from '../../pure/interface-types';
import {
parseSarifPlainTextMessage,
parseSarifLocation,
@@ -14,37 +14,40 @@ 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<SarifInterpretationData> };
export interface PathTableState {
expanded: { [k: string]: boolean };
selectedPathNode: undefined | Keys.PathNode;
expanded: Set<string>;
selectedItem: undefined | Keys.ResultKey;
}
export class PathTable extends React.Component<PathTableProps, PathTableState> {
private scroller = new ScrollIntoViewHelper();
constructor(props: PathTableProps) {
super(props);
this.state = { expanded: {}, selectedPathNode: undefined };
this.state = { expanded: new Set<string>(), selectedItem: undefined };
this.handleNavigationEvent = this.handleNavigationEvent.bind(this);
}
/**
* Given a list of `indices`, toggle the first, and if we 'open' the
* Given a list of `keys`, toggle the first, and if we 'open' the
* first item, open all the rest as well. This mimics vscode's file
* explorer tree view behavior.
*/
toggle(e: React.MouseEvent, indices: number[]) {
toggle(e: React.MouseEvent, keys: Keys.ResultKey[]) {
const keyStrings = keys.map(Keys.keyToString);
this.setState(previousState => {
if (previousState.expanded[indices[0]]) {
return { expanded: { ...previousState.expanded, [indices[0]]: false } };
}
else {
const expanded = { ...previousState.expanded };
for (const index of indices) {
expanded[index] = true;
const expanded = new Set(previousState.expanded);
if (previousState.expanded.has(keyStrings[0])) {
expanded.delete(keyStrings[0]);
} else {
for (const str of keyStrings) {
expanded.add(str);
}
return { expanded };
}
return { expanded };
});
e.stopPropagation();
e.preventDefault();
@@ -96,7 +99,7 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
const rows: JSX.Element[] = [];
const { numTruncatedResults, sourceLocationPrefix } = resultSet.interpretation;
function renderRelatedLocations(msg: string, relatedLocations: Sarif.Location[]): JSX.Element[] {
function renderRelatedLocations(msg: string, relatedLocations: Sarif.Location[], resultKey: Keys.PathNode | Keys.Result | undefined): JSX.Element[] {
const relatedLocationsById: { [k: string]: Sarif.Location } = {};
for (const loc of relatedLocations) {
relatedLocationsById[loc.id!] = loc;
@@ -110,7 +113,7 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
return <span key={i}>{part}</span>;
} else {
const renderedLocation = renderSarifLocationWithText(part.text, relatedLocationsById[part.dest],
undefined);
resultKey);
return <span key={i}>{renderedLocation}</span>;
}
});
@@ -122,16 +125,16 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
return <span title={locationHint}>{msg}</span>;
}
const updateSelectionCallback = (pathNodeKey: Keys.PathNode | undefined) => {
const updateSelectionCallback = (resultKey: Keys.PathNode | Keys.Result | undefined) => {
return () => {
this.setState(previousState => ({
...previousState,
selectedPathNode: pathNodeKey
selectedItem: resultKey
}));
};
};
function renderSarifLocationWithText(text: string | undefined, loc: Sarif.Location, pathNodeKey: Keys.PathNode | undefined): JSX.Element | undefined {
function renderSarifLocationWithText(text: string | undefined, loc: Sarif.Location, resultKey: Keys.PathNode | Keys.Result | undefined): JSX.Element | undefined {
const parsedLoc = parseSarifLocation(loc, sourceLocationPrefix);
if ('hint' in parsedLoc) {
return renderNonLocation(text, parsedLoc.hint);
@@ -141,7 +144,7 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
text,
databaseUri,
undefined,
updateSelectionCallback(pathNodeKey)
updateSelectionCallback(resultKey)
);
} else {
return undefined;
@@ -154,7 +157,7 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
*/
function renderSarifLocation(
loc: Sarif.Location,
pathNodeKey: Keys.PathNode | undefined
pathNodeKey: Keys.PathNode | Keys.Result | undefined
): JSX.Element | undefined {
const parsedLoc = parseSarifLocation(loc, sourceLocationPrefix);
if ('hint' in parsedLoc) {
@@ -184,7 +187,7 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
}
}
const toggler: (indices: number[]) => (e: React.MouseEvent) => void = (indices) => {
const toggler: (keys: Keys.ResultKey[]) => (e: React.MouseEvent) => void = (indices) => {
return (e) => this.toggle(e, indices);
};
@@ -192,24 +195,26 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
return this.renderNoResults();
}
let expansionIndex = 0;
resultSet.interpretation.data.runs[0].results.forEach((result, resultIndex) => {
const resultKey: Keys.Result = { resultIndex };
const text = result.message.text || '[no text]';
const msg: JSX.Element[] =
result.relatedLocations === undefined ?
[<span key="0">{text}</span>] :
renderRelatedLocations(text, result.relatedLocations);
renderRelatedLocations(text, result.relatedLocations, resultKey);
const currentResultExpanded = this.state.expanded[expansionIndex];
const currentResultExpanded = this.state.expanded.has(Keys.keyToString(resultKey));
const indicator = currentResultExpanded ? octicons.chevronDown : octicons.chevronRight;
const location = result.locations !== undefined && result.locations.length > 0 &&
renderSarifLocation(result.locations[0], Keys.none);
renderSarifLocation(result.locations[0], resultKey);
const locationCells = <td className="vscode-codeql__location-cell">{location}</td>;
const selectedItem = this.state.selectedItem;
const resultRowIsSelected = selectedItem?.resultIndex === resultIndex && selectedItem.pathIndex === undefined;
if (result.codeFlows === undefined) {
rows.push(
<tr key={resultIndex} {...zebraStripe(resultIndex)}>
<tr ref={this.scroller.ref(resultRowIsSelected)} key={resultIndex} {...selectableZebraStripe(resultRowIsSelected, resultIndex)}>
<td className="vscode-codeql__icon-cell">{octicons.info}</td>
<td colSpan={3}>{msg}</td>
{locationCells}
@@ -220,12 +225,12 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
const paths: Sarif.ThreadFlow[] = Keys.getAllPaths(result);
const indices = paths.length == 1 ?
[expansionIndex, expansionIndex + 1] : /* if there's exactly one path, auto-expand
* the path when expanding the result */
[expansionIndex];
[resultKey, { ...resultKey, pathIndex: 0 }] : /* if there's exactly one path, auto-expand
* the path when expanding the result */
[resultKey];
rows.push(
<tr {...zebraStripe(resultIndex)} key={resultIndex}>
<tr ref={this.scroller.ref(resultRowIsSelected)} {...selectableZebraStripe(resultRowIsSelected, resultIndex)} key={resultIndex}>
<td className="vscode-codeql__icon-cell vscode-codeql__dropdown-cell" onMouseDown={toggler(indices)}>
{indicator}
</td>
@@ -238,24 +243,23 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
{locationCells}
</tr >
);
expansionIndex++;
paths.forEach((path, pathIndex) => {
const pathKey = { resultIndex, pathIndex };
const currentPathExpanded = this.state.expanded[expansionIndex];
const currentPathExpanded = this.state.expanded.has(Keys.keyToString(pathKey));
if (currentResultExpanded) {
const indicator = currentPathExpanded ? octicons.chevronDown : octicons.chevronRight;
const isPathSpecificallySelected = Keys.equalsNotUndefined(pathKey, selectedItem);
rows.push(
<tr {...zebraStripe(resultIndex)} key={`${resultIndex}-${pathIndex}`}>
<tr ref={this.scroller.ref(isPathSpecificallySelected)} {...selectableZebraStripe(isPathSpecificallySelected, resultIndex)} key={`${resultIndex}-${pathIndex}`}>
<td className="vscode-codeql__icon-cell"><span className="vscode-codeql__vertical-rule"></span></td>
<td className="vscode-codeql__icon-cell vscode-codeql__dropdown-cell" onMouseDown={toggler([expansionIndex])}>{indicator}</td>
<td className="vscode-codeql__icon-cell vscode-codeql__dropdown-cell" onMouseDown={toggler([pathKey])}>{indicator}</td>
<td className="vscode-codeql__text-center" colSpan={3}>
Path
</td>
</tr>
);
}
expansionIndex++;
if (currentResultExpanded && currentPathExpanded) {
const pathNodes = path.locations;
@@ -268,11 +272,11 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
const additionalMsg = step.location !== undefined ?
renderSarifLocation(step.location, pathNodeKey) :
'';
const isSelected = Keys.equalsNotUndefined(this.state.selectedPathNode, pathNodeKey);
const isSelected = Keys.equalsNotUndefined(this.state.selectedItem, pathNodeKey);
const stepIndex = pathNodeIndex + 1; // Convert to 1-based
const zebraIndex = resultIndex + stepIndex;
rows.push(
<tr className={isSelected ? 'vscode-codeql__selected-path-node' : undefined} key={`${resultIndex}-${pathIndex}-${pathNodeIndex}`}>
<tr ref={this.scroller.ref(isSelected)} className={isSelected ? 'vscode-codeql__selected-path-node' : undefined} key={`${resultIndex}-${pathIndex}-${pathNodeIndex}`}>
<td className="vscode-codeql__icon-cell"><span className="vscode-codeql__vertical-rule"></span></td>
<td className="vscode-codeql__icon-cell"><span className="vscode-codeql__vertical-rule"></span></td>
<td {...selectableZebraStripe(isSelected, zebraIndex, 'vscode-codeql__path-index-cell')}>{stepIndex}</td>
@@ -302,34 +306,103 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
</table>;
}
private handleNavigationEvent(event: NavigationEvent) {
private handleNavigationEvent(event: NavigateMsg) {
this.setState(prevState => {
const { selectedPathNode } = prevState;
if (selectedPathNode === undefined) return prevState;
const key = this.getNewSelection(prevState.selectedItem, event.direction);
const data = this.props.resultSet.interpretation.data;
const path = Keys.getPath(this.props.resultSet.interpretation.data, selectedPathNode);
if (path === undefined) return prevState;
const nextIndex = selectedPathNode.pathNodeIndex + event.direction;
if (nextIndex < 0 || nextIndex >= path.locations.length) return prevState;
const sarifLoc = path.locations[nextIndex].location;
if (sarifLoc === undefined) {
return prevState;
// Check if the selected node actually exists (bounds check) and get its location if relevant
let jumpLocation: Sarif.Location | undefined;
if (key.pathNodeIndex !== undefined) {
jumpLocation = Keys.getPathNode(data, key);
if (jumpLocation === undefined) {
return prevState; // Result does not exist
}
} else if (key.pathIndex !== undefined) {
if (Keys.getPath(data, key) === undefined) {
return prevState; // Path does not exist
}
jumpLocation = undefined; // When selecting a 'path', don't jump anywhere.
} else {
jumpLocation = Keys.getResult(data, key)?.locations?.[0];
if (jumpLocation === undefined) {
return prevState; // Path step does not exist.
}
}
if (jumpLocation !== undefined) {
const parsedLocation = parseSarifLocation(jumpLocation, this.props.resultSet.interpretation.sourceLocationPrefix);
if (!isNoLocation(parsedLocation)) {
jumpToLocation(parsedLocation, this.props.databaseUri);
}
}
const loc = parseSarifLocation(sarifLoc, this.props.resultSet.interpretation.sourceLocationPrefix);
if (isNoLocation(loc)) {
return prevState;
const expanded = new Set(prevState.expanded);
if (event.direction === NavigationDirection.right) {
// When stepping right, expand to ensure the selected node is visible
expanded.add(Keys.keyToString({ resultIndex: key.resultIndex }));
if (key.pathIndex !== undefined) {
expanded.add(Keys.keyToString({ resultIndex: key.resultIndex, pathIndex: key.pathIndex }));
}
} else if (event.direction === NavigationDirection.left) {
// When stepping left, collapse immediately
expanded.delete(Keys.keyToString(key));
} else {
// When stepping up or down, collapse the previous node
if (prevState.selectedItem !== undefined) {
expanded.delete(Keys.keyToString(prevState.selectedItem));
}
}
jumpToLocation(loc, this.props.databaseUri);
const newSelection = { ...selectedPathNode, pathNodeIndex: nextIndex };
return { ...prevState, selectedPathNode: newSelection };
this.scroller.scrollIntoViewOnNextUpdate();
return {
...prevState,
expanded,
selectedItem: key
};
});
}
private getNewSelection(key: Keys.ResultKey | undefined, direction: NavigationDirection): Keys.ResultKey {
if (key === undefined) {
return { resultIndex: 0 };
}
const { resultIndex, pathIndex, pathNodeIndex } = key;
switch (direction) {
case NavigationDirection.up:
case NavigationDirection.down: {
const delta = direction === NavigationDirection.up ? -1 : 1;
if (key.pathNodeIndex !== undefined) {
return { resultIndex, pathIndex: key.pathIndex, pathNodeIndex: key.pathNodeIndex + delta };
} else if (pathIndex !== undefined) {
return { resultIndex, pathIndex: pathIndex + delta };
} else {
return { resultIndex: resultIndex + delta };
}
}
case NavigationDirection.left:
if (key.pathNodeIndex !== undefined) {
return { resultIndex, pathIndex: key.pathIndex };
} else if (pathIndex !== undefined) {
return { resultIndex };
} else {
return key;
}
case NavigationDirection.right:
if (pathIndex === undefined) {
return { resultIndex, pathIndex: 0 };
} else if (pathNodeIndex === undefined) {
return { resultIndex, pathIndex, pathNodeIndex: 0 };
} else {
return key;
}
}
}
componentDidUpdate() {
this.scroller.update();
}
componentDidMount() {
this.scroller.update();
onNavigation.addListener(this.handleNavigationEvent);
}

View File

@@ -1,10 +1,13 @@
import * as React from 'react';
import { ResultTableProps, className, emptyQueryResultsMessage } from './result-table-utils';
import { RAW_RESULTS_LIMIT, RawResultsSortState } from '../../pure/interface-types';
import { ResultTableProps, className, emptyQueryResultsMessage, jumpToLocation } from './result-table-utils';
import { RAW_RESULTS_LIMIT, RawResultsSortState, NavigateMsg, NavigationDirection } from '../../pure/interface-types';
import { RawTableResultSet } from '../../pure/interface-types';
import RawTableHeader from './RawTableHeader';
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;
@@ -12,9 +15,25 @@ export type RawTableProps = ResultTableProps & {
offset: number;
};
export class RawTable extends React.Component<RawTableProps, Record<string, never>> {
interface RawTableState {
selectedItem?: { row: number, column: number };
}
export class RawTable extends React.Component<RawTableProps, RawTableState> {
private scroller = new ScrollIntoViewHelper();
constructor(props: RawTableProps) {
super(props);
this.setSelection = this.setSelection.bind(this);
this.handleNavigationEvent = this.handleNavigationEvent.bind(this);
this.state = {};
}
private setSelection(row: number, column: number) {
this.setState(prev => ({
...prev,
selectedItem: { row, column }
}));
}
render(): React.ReactNode {
@@ -37,6 +56,9 @@ export class RawTable extends React.Component<RawTableProps, Record<string, neve
rowIndex={rowIndex + this.props.offset}
row={row}
databaseUri={databaseUri}
selectedColumn={this.state.selectedItem?.row === rowIndex ? this.state.selectedItem?.column : undefined}
onSelected={this.setSelection}
scroller={this.scroller}
/>
);
@@ -58,4 +80,75 @@ export class RawTable extends React.Component<RawTableProps, Record<string, neve
</tbody>
</table>;
}
private handleNavigationEvent(event: NavigateMsg) {
switch (event.direction) {
case NavigationDirection.up: {
this.navigateWithDelta(-1, 0);
break;
}
case NavigationDirection.down: {
this.navigateWithDelta(1, 0);
break;
}
case NavigationDirection.left: {
this.navigateWithDelta(0, -1);
break;
}
case NavigationDirection.right: {
this.navigateWithDelta(0, 1);
break;
}
}
}
private navigateWithDelta(rowDelta: number, columnDelta: number) {
this.setState(prevState => {
const numberOfAlerts = this.props.resultSet.rows.length;
if (numberOfAlerts === 0) {
return prevState;
}
const currentRow = prevState.selectedItem?.row;
const nextRow = currentRow === undefined
? 0
: (currentRow + rowDelta);
if (nextRow < 0 || nextRow >= numberOfAlerts) {
return prevState;
}
const currentColumn = prevState.selectedItem?.column;
const nextColumn = currentColumn === undefined
? 0
: (currentColumn + columnDelta);
// Jump to the location of the new cell
const rowData = this.props.resultSet.rows[nextRow];
if (nextColumn < 0 || nextColumn >= rowData.length) {
return prevState;
}
const cellData = rowData[nextColumn];
if (cellData != null && typeof cellData === 'object') {
const location = tryGetResolvableLocation(cellData.url);
if (location !== undefined) {
jumpToLocation(location, this.props.databaseUri);
}
}
this.scroller.scrollIntoViewOnNextUpdate();
return {
...prevState,
selectedItem: { row: nextRow, column: nextColumn }
};
});
}
componentDidUpdate() {
this.scroller.update();
}
componentDidMount() {
this.scroller.update();
onNavigation.addListener(this.handleNavigationEvent);
}
componentWillUnmount() {
onNavigation.removeListener(this.handleNavigationEvent);
}
}

View File

@@ -6,12 +6,12 @@ import {
IntoResultsViewMsg,
SortedResultSetInfo,
RawResultsSortState,
NavigatePathMsg,
QueryMetadata,
ResultsPaths,
ALERTS_TABLE_NAME,
GRAPH_TABLE_NAME,
ParsedResultSets,
NavigateMsg,
} from '../../pure/interface-types';
import { EventHandlers as EventHandlerList } from './event-handler-list';
import { ResultTables } from './result-tables';
@@ -62,12 +62,10 @@ interface ResultsViewState {
isExpectingResultsUpdate: boolean;
}
export type NavigationEvent = NavigatePathMsg;
/**
* Event handlers to be notified of navigation events coming from outside the webview.
*/
export const onNavigation = new EventHandlerList<NavigationEvent>();
export const onNavigation = new EventHandlerList<NavigateMsg>();
/**
* A minimal state container for displaying results.
@@ -145,7 +143,7 @@ export class ResultsApp extends React.Component<Record<string, never>, ResultsVi
isExpectingResultsUpdate: true,
});
break;
case 'navigatePath':
case 'navigate':
onNavigation.fire(msg);
break;

View File

@@ -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<HTMLElement | any>(); // 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
});
}
}
}