Merge pull request #1568 from asgerf/asgerf/navigate-alerts
Add commands for navigation of alerts
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.');
|
||||
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(
|
||||
'codeQLQueryResults.nextPathStep',
|
||||
this.navigatePathStep.bind(this, 1)
|
||||
)
|
||||
);
|
||||
this.push(
|
||||
commandRunner(
|
||||
'codeQLQueryResults.previousPathStep',
|
||||
this.navigatePathStep.bind(this, -1)
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ?? '');
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 } };
|
||||
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);
|
||||
}
|
||||
else {
|
||||
const expanded = { ...previousState.expanded };
|
||||
for (const index of indices) {
|
||||
expanded[index] = true;
|
||||
}
|
||||
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
|
||||
[resultKey, { ...resultKey, pathIndex: 0 }] : /* if there's exactly one path, auto-expand
|
||||
* the path when expanding the result */
|
||||
[expansionIndex];
|
||||
[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 }));
|
||||
}
|
||||
|
||||
jumpToLocation(loc, this.props.databaseUri);
|
||||
const newSelection = { ...selectedPathNode, pathNodeIndex: nextIndex };
|
||||
return { ...prevState, selectedPathNode: newSelection };
|
||||
} 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));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user