@eclipse-glsp/client
Version:
A sprotty-based client for GLSP
250 lines (223 loc) • 9.69 kB
text/typescript
/********************************************************************************
* Copyright (c) 2020-2024 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/
import {
Action,
CenterAction,
GIssueSeverity,
GModelElement,
GModelRoot,
IActionDispatcher,
IActionHandler,
IContextMenuItemProvider,
KeyListener,
MenuItem,
Point,
SelectAction,
TYPES,
findParentByFeature,
hasArrayProp,
hasStringProp,
isBoundsAware,
isSelectable,
matchesKeystroke
} from '@eclipse-glsp/sprotty';
import { inject, injectable } from 'inversify';
import { SelectionService } from '../../base/selection-service';
import { BoundsAwareModelElement, SelectableElement, getElements, isSelectableAndBoundsAware } from '../../utils/gmodel-util';
import { MarkerPredicates, collectIssueMarkers } from '../../utils/marker';
import { GIssueMarker } from './issue-marker';
export interface NavigateToMarkerAction extends Action {
kind: typeof NavigateToMarkerAction.KIND;
direction: MarkerNavigationDirection;
selectedElementIds?: string[];
severities: GIssueSeverity[];
}
export type MarkerNavigationDirection = 'next' | 'previous';
export namespace NavigateToMarkerAction {
export const KIND = 'navigateToMarker';
export function is(object: any): object is NavigateToMarkerAction {
return Action.hasKind(object, KIND) && hasStringProp(object, 'direction') && hasArrayProp(object, 'severities');
}
export function create(options: {
direction?: MarkerNavigationDirection;
selectedElementIds?: string[];
severities?: GIssueSeverity[];
}): NavigateToMarkerAction {
return {
kind: KIND,
direction: 'next',
severities: MarkerNavigator.ALL_SEVERITIES,
...options
};
}
}
export class GModelElementComparator {
compare(_one: GModelElement, _other: GModelElement): number {
return 0;
}
}
/** Specifies the order of two selectable and bounds-aware elements left-to-right and top-to-bottom. */
export class LeftToRightTopToBottomComparator {
compare(one: GModelElement, other: GModelElement): number {
const boundsOne = findParentByFeature(one, isSelectableAndBoundsAware);
const boundsOther = findParentByFeature(other, isSelectableAndBoundsAware);
if (boundsOne && boundsOther) {
if (boundsOne.bounds.y !== boundsOther.bounds.y) {
return boundsOne.bounds.y - boundsOther.bounds.y;
}
if (boundsOne.bounds.x !== boundsOther.bounds.x) {
return boundsOne.bounds.x - boundsOther.bounds.x;
}
}
return 0;
}
}
/**
* Specifies the next/previous marker in a graph model.
*
* This navigator uses a `MarkerComparator` to determine the order of markers. It can also return next/previous
*/
export class MarkerNavigator {
static readonly ALL_SEVERITIES: GIssueSeverity[] = ['error', 'warning', 'info'];
protected markerComparator: GModelElementComparator;
next(
root: Readonly<GModelRoot>,
current?: BoundsAwareModelElement,
predicate: (marker: GIssueMarker) => boolean = MarkerPredicates.ALL
): GIssueMarker | undefined {
const markers = this.getMarkers(root, predicate);
if (current === undefined) {
return markers.length > 0 ? markers[0] : undefined;
}
return markers[this.getNextIndex(current, markers) % markers.length];
}
previous(
root: Readonly<GModelRoot>,
current?: BoundsAwareModelElement,
predicate: (marker: GIssueMarker) => boolean = MarkerPredicates.ALL
): GIssueMarker | undefined {
const markers = this.getMarkers(root, predicate);
if (current === undefined) {
return markers.length > 0 ? markers[0] : undefined;
}
return markers[this.getPreviousIndex(current, markers) % markers.length];
}
protected getMarkers(root: Readonly<GModelRoot>, predicate: (marker: GIssueMarker) => boolean): GIssueMarker[] {
const markers = collectIssueMarkers(root);
return markers.filter(predicate).sort(this.markerComparator.compare);
}
protected getNextIndex(current: BoundsAwareModelElement, markers: GIssueMarker[]): number {
for (let index = 0; index < markers.length; index++) {
if (this.markerComparator.compare(markers[index], current) > 0) {
return index;
}
}
return 0;
}
protected getPreviousIndex(current: BoundsAwareModelElement, markers: GIssueMarker[]): number {
for (let index = markers.length - 1; index >= 0; index--) {
if (this.markerComparator.compare(markers[index], current) < 0) {
return index;
}
}
return markers.length - 1;
}
}
export class NavigateToMarkerActionHandler implements IActionHandler {
protected markerComparator: GModelElementComparator;
protected markerNavigator: MarkerNavigator;
protected selectionService: SelectionService;
protected actionDispatcher: IActionDispatcher;
handle(action: NavigateToMarkerAction): void {
const selected = this.getSelectedElements(action);
const target = this.getTarget(action, selected);
const selectableTarget = target ? findParentByFeature(target, isSelectable) : undefined;
if (selectableTarget) {
const deselectedElementsIDs = selected.map(e => e.id).filter(id => id !== selectableTarget.id);
this.actionDispatcher.dispatch(SelectAction.create({ selectedElementsIDs: [selectableTarget.id], deselectedElementsIDs }));
this.actionDispatcher.dispatch(CenterAction.create([selectableTarget.id]));
}
}
protected getSelectedElements(action: NavigateToMarkerAction): SelectableElement[] {
if (action.selectedElementIds && action.selectedElementIds.length > 0) {
return getElements(this.selectionService.getModelRoot().index, action.selectedElementIds, isSelectable);
}
return this.selectionService.getSelectedElements();
}
protected getTarget(action: NavigateToMarkerAction, selected: GModelElement[]): GIssueMarker | undefined {
const root = this.selectionService.getModelRoot();
const target = selected.sort(this.markerComparator.compare).find(isBoundsAware);
if (action.direction === 'previous') {
return this.markerNavigator.previous(root, target, marker => this.matchesSeverities(action, marker));
} else {
return this.markerNavigator.next(root, target, marker => this.matchesSeverities(action, marker));
}
}
protected matchesSeverities(action: NavigateToMarkerAction, marker: GIssueMarker): boolean {
return marker.issues.find(issue => action.severities.includes(issue.severity)) !== undefined;
}
}
export class MarkerNavigatorContextMenuItemProvider implements IContextMenuItemProvider {
protected selectionService: SelectionService;
getItems(root: Readonly<GModelRoot>, lastMousePosition?: Point): Promise<MenuItem[]> {
const selectedElementIds = Array.from(this.selectionService.getSelectedElementIDs());
const hasMarkers = collectIssueMarkers(root).length > 0;
return Promise.resolve([
{
id: 'navigate',
label: 'Go to',
group: 'navigate',
actions: [],
children: [
{
id: 'next-marker',
label: 'Next marker',
group: 'marker',
actions: [NavigateToMarkerAction.create({ direction: 'next', selectedElementIds })],
isEnabled: () => hasMarkers
},
{
id: 'previous-marker',
label: 'Previous marker',
group: 'marker',
actions: [NavigateToMarkerAction.create({ direction: 'previous', selectedElementIds })],
isEnabled: () => hasMarkers
}
]
}
]);
}
}
export class MarkerNavigatorKeyListener extends KeyListener {
override keyDown(_element: GModelElement, event: KeyboardEvent): Action[] {
if (matchesKeystroke(event, 'Period', 'ctrl')) {
return [NavigateToMarkerAction.create({ direction: 'next' })];
} else if (matchesKeystroke(event, 'Comma', 'ctrl')) {
return [NavigateToMarkerAction.create({ direction: 'previous' })];
}
return [];
}
}