@finos/legend-application-pure-ide
Version:
Legend Pure IDE application core
561 lines (522 loc) • 17.8 kB
text/typescript
/**
* Copyright (c) 2020-present, Goldman Sachs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type CommandRegistrar,
ActionAlertActionType,
ActionAlertType,
} from '@finos/legend-application';
import {
clearMarkers,
setErrorMarkers,
type CodeEditorPosition,
CODE_EDITOR_LANGUAGE,
} from '@finos/legend-code-editor';
import { DIRECTORY_PATH_DELIMITER } from '@finos/legend-graph';
import { at, assertErrorThrown, type GeneratorFn } from '@finos/legend-shared';
import {
action,
computed,
flow,
flowResult,
makeObservable,
observable,
} from 'mobx';
import {
editor as monacoEditorAPI,
Uri,
type Position,
type Selection,
} from 'monaco-editor';
import { ConceptType } from '../server/models/ConceptTree.js';
import {
FileCoordinate,
type File,
trimPathLeadingSlash,
type FileErrorCoordinate,
} from '../server/models/File.js';
import {
type ConceptInfo,
FIND_USAGE_FUNCTION_PATH,
} from '../server/models/Usage.js';
import type { PureIDEStore } from './PureIDEStore.js';
import { PureIDETabState } from './PureIDETabManagerState.js';
import { LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY } from '../__lib__/LegendPureIDECommand.js';
import type { TabState } from '@finos/legend-lego/application';
const getFileEditorLanguage = (filePath: string): string => {
const extension = filePath.split('.').at(-1);
switch (extension) {
case 'pure':
return CODE_EDITOR_LANGUAGE.PURE;
case 'java':
return CODE_EDITOR_LANGUAGE.JAVA;
case 'md':
return CODE_EDITOR_LANGUAGE.MARKDOWN;
case 'sql':
return CODE_EDITOR_LANGUAGE.SQL;
case 'json':
return CODE_EDITOR_LANGUAGE.JSON;
case 'xml':
return CODE_EDITOR_LANGUAGE.XML;
case 'yml':
case 'yaml':
return CODE_EDITOR_LANGUAGE.YAML;
case 'graphql':
return CODE_EDITOR_LANGUAGE.GRAPHQL;
default:
return CODE_EDITOR_LANGUAGE.TEXT;
}
};
class FileTextEditorState {
readonly model: monacoEditorAPI.ITextModel;
editor?: monacoEditorAPI.IStandaloneCodeEditor | undefined;
private _dummyCursorObservable = {};
language!: string;
viewState?: monacoEditorAPI.ICodeEditorViewState | undefined;
forcedCursorPosition: CodeEditorPosition | undefined;
wrapText = false;
constructor(fileEditorState: FileEditorState) {
makeObservable<FileTextEditorState, '_dummyCursorObservable'>(this, {
viewState: observable.ref,
editor: observable.ref,
_dummyCursorObservable: observable.ref,
forcedCursorPosition: observable.ref,
wrapText: observable,
cursorObserver: computed,
notifyCursorObserver: action,
setViewState: action,
setEditor: action,
setForcedCursorPosition: action,
setWrapText: action,
});
this.language = getFileEditorLanguage(fileEditorState.filePath);
this.model = monacoEditorAPI.createModel(
'',
this.language,
Uri.file(`/${fileEditorState.uuid}.pure`),
);
this.model.setValue(fileEditorState.file.content);
}
// trigger for the manual observer of editor cursor
notifyCursorObserver(): void {
this._dummyCursorObservable = {};
}
// subscriber for the manual observer of editor cursor
get cursorObserver():
| {
position: Position | undefined;
selection: Selection | undefined;
}
| undefined {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
this._dummyCursorObservable; // manually trigger cursor observer
return this.editor
? {
position: this.editor.getPosition() ?? undefined,
selection: this.editor.getSelection() ?? undefined,
}
: undefined;
}
setViewState(val: monacoEditorAPI.ICodeEditorViewState | undefined): void {
this.viewState = val;
}
setEditor(val: monacoEditorAPI.IStandaloneCodeEditor | undefined): void {
this.editor = val;
}
setForcedCursorPosition(val: CodeEditorPosition | undefined): void {
this.forcedCursorPosition = val;
}
setWrapText(val: boolean): void {
const oldVal = this.wrapText;
this.wrapText = val;
if (oldVal !== val && this.editor) {
this.editor.updateOptions({
wordWrap: val ? 'on' : 'off',
});
this.editor.focus();
}
}
}
export class FileEditorRenameConceptState {
readonly fileEditorState: FileEditorState;
readonly concept: ConceptInfo;
readonly coordinate: FileCoordinate;
constructor(
fileEditorState: FileEditorState,
concept: ConceptInfo,
coordiate: FileCoordinate,
) {
this.fileEditorState = fileEditorState;
this.concept = concept;
this.coordinate = coordiate;
}
}
export class FileEditorState
extends PureIDETabState
implements CommandRegistrar
{
readonly filePath: string;
readonly textEditorState!: FileTextEditorState;
private _currentHashCode: string;
file: File;
renameConceptState: FileEditorRenameConceptState | undefined;
showGoToLinePrompt = false;
constructor(ideStore: PureIDEStore, file: File, filePath: string) {
super(ideStore);
makeObservable<FileEditorState, '_currentHashCode'>(this, {
_currentHashCode: observable,
file: observable,
renameConceptState: observable,
showGoToLinePrompt: observable,
hasChanged: computed,
resetChangeDetection: action,
setFile: action,
setShowGoToLinePrompt: action,
setConceptToRenameState: flow,
runTest: flow,
});
this.file = file;
this._currentHashCode = file.hashCode;
this.filePath = filePath;
this.textEditorState = new FileTextEditorState(this);
}
get label(): string {
return trimPathLeadingSlash(this.filePath);
}
override get description(): string | undefined {
return `File: ${trimPathLeadingSlash(this.filePath)}${
this.file.RO ? ' (readonly)' : ''
}`;
}
get fileName(): string {
return at(this.filePath.split(DIRECTORY_PATH_DELIMITER), -1);
}
override match(tab: TabState): boolean {
return tab instanceof FileEditorState && this.filePath === tab.filePath;
}
override onClose(): void {
// dispose text model to avoid memory leak
this.textEditorState.model.dispose();
}
get hasChanged(): boolean {
return this._currentHashCode !== this.file.hashCode;
}
resetChangeDetection(): void {
this._currentHashCode = this.file.hashCode;
}
setFile(val: File): void {
this.file = val;
this.textEditorState.model.setValue(val.content);
this.resetChangeDetection();
}
setShowGoToLinePrompt(val: boolean): void {
this.showGoToLinePrompt = val;
}
*setConceptToRenameState(
coordinate: FileCoordinate | undefined,
): GeneratorFn<void> {
if (!coordinate) {
this.renameConceptState = undefined;
return;
}
if (this.hasChanged) {
this.ideStore.applicationStore.notificationService.notifyWarning(
`Can't rename concept: source is not compiled`,
);
return;
}
const concept = (yield this.ideStore.getConceptInfo(coordinate)) as
| ConceptInfo
| undefined;
this.renameConceptState = concept
? new FileEditorRenameConceptState(this, concept, coordinate)
: undefined;
}
*runTest(coordinate: FileCoordinate | undefined): GeneratorFn<void> {
if (!coordinate) {
return;
}
if (this.hasChanged) {
this.ideStore.applicationStore.notificationService.notifyWarning(
`Can't run test: source is not compiled`,
);
return;
}
const concept = (yield this.ideStore.getConceptInfo(coordinate)) as
| ConceptInfo
| undefined;
if (concept?.pureType === ConceptType.FUNCTION) {
if (concept.pct) {
this.ideStore.setPCTRunPath(concept.path);
} else if (concept.test) {
flowResult(this.ideStore.executeTests(concept.path, false)).catch(
this.ideStore.applicationStore.alertUnhandledError,
);
} else {
this.ideStore.applicationStore.notificationService.notifyWarning(
`Can't run test: function not marked as test`,
);
}
} else {
this.ideStore.applicationStore.notificationService.notifyWarning(
`Can't run test: not a function`,
);
}
}
showError(coordinate: FileErrorCoordinate): void {
setErrorMarkers(
this.textEditorState.model,
[
{
message: coordinate.error.message,
startLineNumber: coordinate.line,
startColumn: coordinate.column,
endLineNumber: coordinate.line,
endColumn: coordinate.column,
},
],
this.uuid,
);
}
clearError(): void {
clearMarkers(this.uuid);
}
registerCommands(): void {
if (this.textEditorState.language === CODE_EDITOR_LANGUAGE.PURE) {
this.ideStore.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.GO_TO_DEFINITION,
trigger: () =>
this.ideStore.tabManagerState.currentTab === this &&
Boolean(this.textEditorState.editor?.hasTextFocus()),
action: () => {
const currentPosition = this.textEditorState.editor?.getPosition();
if (currentPosition) {
flowResult(
this.ideStore.executeNavigation(
new FileCoordinate(
this.filePath,
currentPosition.lineNumber,
currentPosition.column,
),
),
).catch(this.ideStore.applicationStore.alertUnhandledError);
}
},
});
this.ideStore.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.GO_BACK,
action: () => {
flowResult(this.ideStore.navigateBack()).catch(
this.ideStore.applicationStore.alertUnhandledError,
);
},
});
this.ideStore.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.REVEAL_CONCEPT_IN_TREE,
trigger: () =>
this.ideStore.tabManagerState.currentTab === this &&
Boolean(this.textEditorState.editor?.hasTextFocus()),
action: () => {
const currentPosition = this.textEditorState.editor?.getPosition();
if (currentPosition) {
this.ideStore
.revealConceptInTree(
new FileCoordinate(
this.filePath,
currentPosition.lineNumber,
currentPosition.column,
),
)
.catch(this.ideStore.applicationStore.alertUnhandledError);
}
},
});
this.ideStore.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.FIND_USAGES,
trigger: () =>
this.ideStore.tabManagerState.currentTab === this &&
Boolean(this.textEditorState.editor?.hasTextFocus()),
action: () => {
const currentPosition = this.textEditorState.editor?.getPosition();
if (currentPosition) {
const coordinate = new FileCoordinate(
this.filePath,
currentPosition.lineNumber,
currentPosition.column,
);
this.findConceptUsages(coordinate);
}
},
});
this.ideStore.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.RENAME_CONCEPT,
trigger: () =>
this.ideStore.tabManagerState.currentTab === this &&
Boolean(this.textEditorState.editor?.hasTextFocus()),
action: () => {
const currentPosition = this.textEditorState.editor?.getPosition();
if (currentPosition) {
const currentWord =
this.textEditorState.model.getWordAtPosition(currentPosition);
if (!currentWord) {
return;
}
const coordinate = new FileCoordinate(
this.filePath,
currentPosition.lineNumber,
currentPosition.column,
);
flowResult(this.setConceptToRenameState(coordinate)).catch(
this.ideStore.applicationStore.alertUnhandledError,
);
}
},
});
}
this.ideStore.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.DELETE_LINE,
trigger: () =>
this.ideStore.tabManagerState.currentTab === this &&
Boolean(this.textEditorState.editor?.hasTextFocus()),
action: () => {
const currentPosition = this.textEditorState.editor?.getPosition();
if (currentPosition) {
this.textEditorState.model.pushEditOperations(
[],
[
{
range: {
startLineNumber: currentPosition.lineNumber,
startColumn: 1,
endLineNumber: currentPosition.lineNumber + 1,
endColumn: 1,
},
text: '',
forceMoveMarkers: true,
},
],
() => null,
);
}
},
});
this.ideStore.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.GO_TO_LINE,
trigger: () => this.ideStore.tabManagerState.currentTab === this,
action: () => {
this.setShowGoToLinePrompt(true);
},
});
this.ideStore.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.TOGGLE_TEXT_WRAP,
trigger: () => this.ideStore.tabManagerState.currentTab === this,
action: () => {
this.textEditorState.setWrapText(!this.textEditorState.wrapText);
},
});
}
findConceptUsages(coordinate: FileCoordinate): void {
const proceed = (): void => {
flowResult(this.ideStore.findUsagesFromCoordinate(coordinate)).catch(
this.ideStore.applicationStore.alertUnhandledError,
);
};
if (this.hasChanged) {
this.ideStore.applicationStore.alertService.setActionAlertInfo({
message:
'Source is not compiled, finding concept usages might be inaccurate. Do you want compile to proceed?',
type: ActionAlertType.CAUTION,
actions: [
{
label: 'Compile and Proceed',
type: ActionAlertActionType.PROCEED_WITH_CAUTION,
handler: (): void => {
flowResult(this.ideStore.executeGo())
.then(proceed)
.catch(this.ideStore.applicationStore.alertUnhandledError);
},
},
{
label: 'Abort',
type: ActionAlertActionType.PROCEED,
default: true,
},
],
});
} else {
proceed();
}
}
async renameConcept(newName: string): Promise<void> {
if (!this.renameConceptState) {
return;
}
const concept = this.renameConceptState.concept;
try {
this.ideStore.applicationStore.alertService.setBlockingAlert({
message: 'Finding concept usages...',
showLoading: true,
});
const usages = await this.ideStore.findConceptUsages(
concept.pureType === ConceptType.ENUM_VALUE
? FIND_USAGE_FUNCTION_PATH.ENUM
: concept.pureType === ConceptType.PROPERTY ||
concept.pureType === ConceptType.QUALIFIED_PROPERTY
? FIND_USAGE_FUNCTION_PATH.PROPERTY
: FIND_USAGE_FUNCTION_PATH.ELEMENT,
(concept.owner ? [`'${concept.owner}'`] : []).concat(
`'${concept.path}'`,
),
);
await flowResult(
this.ideStore.renameConcept(
concept.pureName,
newName,
concept.pureType,
usages,
),
);
this.textEditorState.setForcedCursorPosition({
lineNumber: this.renameConceptState.coordinate.line,
column: this.renameConceptState.coordinate.column,
});
} catch (error) {
assertErrorThrown(error);
this.ideStore.applicationStore.notificationService.notifyError(error);
} finally {
this.ideStore.applicationStore.alertService.setBlockingAlert(undefined);
}
}
deregisterCommands(): void {
if (this.textEditorState.language === CODE_EDITOR_LANGUAGE.PURE) {
[
LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.GO_TO_DEFINITION,
LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.GO_BACK,
LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.REVEAL_CONCEPT_IN_TREE,
LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.FIND_USAGES,
LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.RENAME_CONCEPT,
].forEach((key) =>
this.ideStore.applicationStore.commandService.deregisterCommand(key),
);
}
[
LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.GO_TO_LINE,
LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.DELETE_LINE,
LEGEND_PURE_IDE_PURE_FILE_EDITOR_COMMAND_KEY.TOGGLE_TEXT_WRAP,
].forEach((key) =>
this.ideStore.applicationStore.commandService.deregisterCommand(key),
);
}
}