@finos/legend-application-pure-ide
Version:
Legend Pure IDE application core
1,518 lines (1,448 loc) • 52.5 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 {
action,
flow,
flowResult,
makeObservable,
observable,
runInAction,
} from 'mobx';
import {
ACTIVITY_MODE,
PANEL_MODE,
ROOT_PACKAGE_PATH,
WELCOME_FILE_PATH,
} from './PureIDEConfig.js';
import { FileEditorState } from './FileEditorState.js';
import { serialize, deserialize } from 'serializr';
import {
FileCoordinate,
FileErrorCoordinate,
File,
trimPathLeadingSlash,
} from '../server/models/File.js';
import { DirectoryTreeState } from './DirectoryTreeState.js';
import { ConceptTreeState } from './ConceptTreeState.js';
import {
type InitializationActivity,
type InitializationResult,
InitializationFailureWithSourceResult,
InitializationFailureResult,
deserializeInitializationnResult,
} from '../server/models/Initialization.js';
import {
type CandidateWithPackageNotImported,
type ExecutionActivity,
type ExecutionResult,
TestExecutionResult,
UnmatchedFunctionResult,
UnknownSymbolResult,
GetConceptResult,
deserializeExecutionResult,
ExecutionFailureResult,
ExecutionSuccessResult,
} from '../server/models/Execution.js';
import { SearchResultCoordinate } from '../server/models/SearchEntry.js';
import { TestRunnerState } from './TestRunnerState.js';
import {
type ConceptInfo,
getConceptInfoLabel,
Usage,
FIND_USAGE_FUNCTION_PATH,
} from '../server/models/Usage.js';
import {
type CommandResult,
CommandFailureResult,
deserializeCommandResult,
} from '../server/models/Command.js';
import {
ActionAlertActionType,
ActionAlertType,
type CommandRegistrar,
} from '@finos/legend-application';
import {
type GeneratorFn,
type PlainObject,
isNonNullable,
NetworkClient,
ActionState,
assertErrorThrown,
guaranteeNonNullable,
uniq,
filterByType,
} from '@finos/legend-shared';
import { PureServerClient as PureServerClient } from '../server/PureServerClient.js';
import { PanelDisplayState } from '@finos/legend-art';
import { DiagramEditorState } from './DiagramEditorState.js';
import { DiagramInfo, serializeDiagram } from '../server/models/DiagramInfo.js';
import type { LegendPureIDEApplicationStore } from './LegendPureIDEBaseStore.js';
import { FileSearchCommandState } from './FileSearchCommandState.js';
import { PureIDETabManagerState } from './PureIDETabManagerState.js';
import {
LEGEND_PURE_IDE_COMMAND_KEY,
LEGEND_PURE_IDE_TERMINAL_COMMAND,
} from '../__lib__/LegendPureIDECommand.js';
import { ExecutionError } from '../server/models/ExecutionError.js';
import { ELEMENT_PATH_DELIMITER } from '@finos/legend-graph';
import type { SourceModificationResult } from '../server/models/Source.js';
import { ConceptType } from '../server/models/ConceptTree.js';
import { setupTerminal } from './LegendPureIDETerminal.js';
import {
type CodeFixSuggestion,
UnknownSymbolCodeFixSuggestion,
UnmatchedFunctionCodeFixSuggestion,
} from './CodeFixSuggestion.js';
import { ReferenceUsageResult } from './ReferenceUsageResult.js';
import { TextSearchState } from './TextSearchState.js';
import type { TabState } from '@finos/legend-lego/application';
import { PCTAdapter } from '../server/models/Test.js';
export class PureIDEStore implements CommandRegistrar {
readonly applicationStore: LegendPureIDEApplicationStore;
readonly initState = ActionState.create();
readonly directoryTreeState: DirectoryTreeState;
readonly conceptTreeState: ConceptTreeState;
readonly client: PureServerClient;
// Layout
activePanelMode = PANEL_MODE.TERMINAL;
readonly panelGroupDisplayState = new PanelDisplayState({
initial: 0,
default: 300,
snap: 100,
});
activeActivity?: string = ACTIVITY_MODE.CONCEPT_EXPLORER;
readonly sideBarDisplayState = new PanelDisplayState({
initial: 300,
default: 300,
snap: 150,
});
readonly tabManagerState = new PureIDETabManagerState(this);
readonly executionState = ActionState.create();
navigationStack: FileCoordinate[] = []; // TODO?: we might want to limit the number of items in this stack
// File Search Command
readonly fileSearchCommandLoadState = ActionState.create();
readonly fileSearchCommandState = new FileSearchCommandState();
openFileSearchCommand = false;
fileSearchCommandResults: string[] = [];
// Code-fix Suggestions Panel
codeFixSuggestion?: CodeFixSuggestion | undefined;
// Reference Usage Panel
readonly referenceUsageLoadState = ActionState.create();
referenceUsageResult?: ReferenceUsageResult | undefined;
// Text Search Panel
readonly textSearchState: TextSearchState;
// Test Runner Panel
readonly testRunState = ActionState.create();
testRunnerState?: TestRunnerState | undefined;
PCTAdapters: PCTAdapter[] = [];
selectedPCTAdapter?: PCTAdapter | undefined;
PCTRunPath?: string | undefined;
constructor(applicationStore: LegendPureIDEApplicationStore) {
makeObservable(this, {
activePanelMode: observable,
activeActivity: observable,
navigationStack: observable,
openFileSearchCommand: observable,
fileSearchCommandResults: observable,
fileSearchCommandState: observable,
codeFixSuggestion: observable,
referenceUsageResult: observable,
testRunnerState: observable,
PCTAdapters: observable.struct,
selectedPCTAdapter: observable,
setSelectedPCTAdapter: action,
PCTRunPath: observable,
setPCTRunPath: action,
setCodeFixSuggestion: action,
setReferenceUsageResult: action,
setOpenFileSearchCommand: action,
setActivePanelMode: action,
setActiveActivity: action,
setTestRunnerState: action,
pullInitializationActivity: action,
pullExecutionStatus: action,
initialize: flow,
checkIfSessionWakingUp: flow,
loadDiagram: flow,
loadFile: flow,
execute: flow,
executeGo: flow,
runDebugger: flow,
manageExecuteGoResult: flow,
executeTests: flow,
executeFullTestSuite: flow,
executeNavigation: flow,
navigateBack: flow,
fullReCompile: flow,
command: flow,
findUsagesFromCoordinate: flow,
findUsages: flow,
renameConcept: flow,
movePackageableElements: flow,
updateFileUsingSuggestionCandidate: flow,
updateFile: flow,
searchFile: flow,
createNewDirectory: flow,
createNewFile: flow,
renameFile: flow,
deleteDirectoryOrFile: flow,
});
this.applicationStore = applicationStore;
this.textSearchState = new TextSearchState(this);
this.directoryTreeState = new DirectoryTreeState(this);
this.conceptTreeState = new ConceptTreeState(this);
this.client = new PureServerClient(
new NetworkClient({
baseUrl: this.applicationStore.config.useDynamicPureServer
? window.location.origin
: this.applicationStore.config.pureUrl,
}),
);
setupTerminal(this);
}
setOpenFileSearchCommand(val: boolean): void {
this.openFileSearchCommand = val;
}
setSelectedPCTAdapter(val: PCTAdapter | undefined): void {
this.selectedPCTAdapter = val;
}
setPCTRunPath(val: string | undefined): void {
this.PCTRunPath = val;
}
setActivePanelMode(val: PANEL_MODE): void {
this.activePanelMode = val;
}
setCodeFixSuggestion(val: CodeFixSuggestion | undefined): void {
this.codeFixSuggestion = val;
}
setReferenceUsageResult(val: ReferenceUsageResult | undefined): void {
this.referenceUsageResult = val;
}
setTestRunnerState(val: TestRunnerState | undefined): void {
this.testRunnerState = val;
}
cleanUp(): void {
// dismiss all the alerts as these are parts of application, if we don't do this, we might
// end up blocking other parts of the app
// e.g. trying going to an unknown workspace, we will be redirected to the home page
// but the blocking alert for not-found workspace will still block the app
this.applicationStore.alertService.setBlockingAlert(undefined);
this.applicationStore.alertService.setActionAlertInfo(undefined);
// dispose the terminal
this.applicationStore.terminalService.terminal.dispose();
}
/**
* This is the entry of the app logic where initialization of editor states happens
* Here, we ensure the order of calls after checking existence of current project and workspace
* If either of them does not exist, we cannot proceed.
*/
*initialize(
fullInit: boolean,
func: (() => Promise<void>) | undefined,
mode: string | undefined,
fastCompile: string | undefined,
): GeneratorFn<void> {
if (!this.initState.isInInitialState) {
this.applicationStore.notificationService.notifyIllegalState(
'Editor store is re-initialized',
);
return;
}
// set PURE IDE mode
this.client.mode = mode;
this.client.compilerMode = fastCompile;
// initialize editor
this.initState.inProgress();
try {
this.applicationStore.alertService.setBlockingAlert({
message: 'Loading Pure IDE...',
prompt:
'Please be patient as we are building the initial application state',
showLoading: true,
});
const initializationPromise = this.client
.initialize(!fullInit)
.catch((error) => {
assertErrorThrown(error);
this.applicationStore.notificationService.notifyError(error);
this.initState.fail();
this.applicationStore.alertService.setBlockingAlert({
message: `Failed to initialize IDE`,
prompt: `Before debugging, make sure the server is running then restart the application`,
});
});
yield this.pullInitializationActivity();
this.applicationStore.alertService.setBlockingAlert(undefined);
const openWelcomeFilePromise = flowResult(
this.loadFile(WELCOME_FILE_PATH),
).then(() => {
const welcomeFileTab = this.tabManagerState.tabs.find(
(tab) =>
tab instanceof FileEditorState &&
tab.filePath === WELCOME_FILE_PATH,
);
if (welcomeFileTab) {
this.tabManagerState.pinTab(welcomeFileTab);
}
});
const directoryTreeInitPromise = this.directoryTreeState.initialize();
const conceptTreeInitPromise = this.conceptTreeState.initialize();
const getPCTAdaptersPromise = this.client
.getPCTAdapters()
.then((result) => {
runInAction(() => {
this.PCTAdapters = (
result as { first: string; second: string }[]
).map((adapter) => new PCTAdapter(adapter.first, adapter.second));
this.selectedPCTAdapter =
this.PCTAdapters.find(
(adapter) => adapter.name === 'In-Memory',
) ?? (this.PCTAdapters.length ? this.PCTAdapters[0] : undefined);
});
});
const result = deserializeInitializationnResult(
(yield initializationPromise) as PlainObject<InitializationResult>,
);
if (result.text) {
this.applicationStore.terminalService.terminal.output(result.text, {
systemCommand: 'initialize application',
});
}
this.setActivePanelMode(PANEL_MODE.TERMINAL);
this.panelGroupDisplayState.open();
if (result instanceof InitializationFailureResult) {
if (result.sessionError) {
this.applicationStore.alertService.setBlockingAlert({
message: 'Session corrupted',
prompt: result.sessionError,
});
} else if (result instanceof InitializationFailureWithSourceResult) {
yield flowResult(
this.loadFile(
result.source,
new FileErrorCoordinate(
result.source,
result.line,
result.column,
new ExecutionError(
(result.text ?? '').split('\n').filter(Boolean)[0],
),
),
),
);
}
} else {
if (func) {
yield func();
}
yield Promise.all([
openWelcomeFilePromise,
directoryTreeInitPromise,
conceptTreeInitPromise,
getPCTAdaptersPromise,
]);
}
this.initState.pass();
} catch (error) {
assertErrorThrown(error);
this.applicationStore.notificationService.notifyError(error);
this.initState.fail();
this.applicationStore.alertService.setActionAlertInfo({
message: `Failed to initialize IDE`,
prompt: `This can either due to an internal server error, which you would need to manually resolve; or a compilation, which you can proceed to debug`,
type: ActionAlertType.CAUTION,
actions: [
{
label: 'Compile to debug',
type: ActionAlertActionType.PROCEED_WITH_CAUTION,
default: true,
handler: () => {
flowResult(this.executeGo()).catch(
this.applicationStore.alertUnhandledError,
);
},
},
],
});
} finally {
// initialize the terminal
this.applicationStore.terminalService.terminal.clear();
}
}
*checkIfSessionWakingUp(message?: string): GeneratorFn<void> {
this.applicationStore.alertService.setBlockingAlert({
message: message ?? 'Checking IDE session...',
showLoading: true,
});
yield this.pullInitializationActivity(
(activity: InitializationActivity) => {
if (activity.text) {
this.applicationStore.alertService.setBlockingAlert({
message: message ?? 'Checking IDE session...',
prompt: activity.text,
showLoading: true,
});
}
},
);
this.applicationStore.alertService.setBlockingAlert(undefined);
}
async pullInitializationActivity(
fn?: (activity: InitializationActivity) => void,
): Promise<void> {
const result =
(await this.client.getInitializationActivity()) as unknown as InitializationActivity;
if (result.initializing) {
return new Promise((resolve, reject) =>
setTimeout(() => {
try {
resolve(this.pullInitializationActivity());
} catch (error) {
reject(error);
}
}, 1000),
);
}
return Promise.resolve();
}
registerCommands(): void {
this.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_COMMAND_KEY.SEARCH_FILE,
action: () => this.setOpenFileSearchCommand(true),
});
this.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_COMMAND_KEY.SEARCH_TEXT,
action: () => {
this.setActivePanelMode(PANEL_MODE.SEARCH);
this.panelGroupDisplayState.open();
this.textSearchState.focus();
this.textSearchState.select();
},
});
this.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_COMMAND_KEY.GO_TO_FILE,
action: () => {
if (this.tabManagerState.currentTab instanceof FileEditorState) {
this.directoryTreeState.revealPath(
this.tabManagerState.currentTab.filePath,
{
forceOpenExplorerPanel: true,
},
);
}
},
});
this.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_COMMAND_KEY.TOGGLE_TERMINAL_PANEL,
action: () => {
// toggle the panel and activate terminal tab if needs be
// if the terminal is already open, and not yet focused, focus on it
// else, close it
if (this.panelGroupDisplayState.isOpen) {
if (this.activePanelMode !== PANEL_MODE.TERMINAL) {
this.setActivePanelMode(PANEL_MODE.TERMINAL);
this.applicationStore.terminalService.terminal.focus();
} else {
if (!this.applicationStore.terminalService.terminal.isFocused()) {
this.applicationStore.terminalService.terminal.focus();
} else {
this.panelGroupDisplayState.close();
}
}
} else {
this.setActivePanelMode(PANEL_MODE.TERMINAL);
this.panelGroupDisplayState.open();
}
},
});
this.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_COMMAND_KEY.EXECUTE,
action: () => {
flowResult(this.executeGo()).catch(
this.applicationStore.alertUnhandledError,
);
},
});
this.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_COMMAND_KEY.FULL_RECOMPILE,
action: () => {
flowResult(this.fullReCompile(false)).catch(
this.applicationStore.alertUnhandledError,
);
},
});
this.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_COMMAND_KEY.FULL_RECOMPILE_WITH_FULL_INIT,
action: () => {
flowResult(this.fullReCompile(true)).catch(
this.applicationStore.alertUnhandledError,
);
},
});
this.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_COMMAND_KEY.RUN_ALL_TESTS,
action: () => {
flowResult(this.executeFullTestSuite(false)).catch(
this.applicationStore.alertUnhandledError,
);
},
});
this.applicationStore.commandService.registerCommand({
key: LEGEND_PURE_IDE_COMMAND_KEY.RUN_RELAVANT_TESTS,
action: () => {
flowResult(this.executeFullTestSuite(true)).catch(
this.applicationStore.alertUnhandledError,
);
},
});
}
deregisterCommands(): void {
[
LEGEND_PURE_IDE_COMMAND_KEY.SEARCH_FILE,
LEGEND_PURE_IDE_COMMAND_KEY.SEARCH_TEXT,
LEGEND_PURE_IDE_COMMAND_KEY.GO_TO_FILE,
LEGEND_PURE_IDE_COMMAND_KEY.TOGGLE_TERMINAL_PANEL,
LEGEND_PURE_IDE_COMMAND_KEY.EXECUTE,
LEGEND_PURE_IDE_COMMAND_KEY.FULL_RECOMPILE,
LEGEND_PURE_IDE_COMMAND_KEY.FULL_RECOMPILE_WITH_FULL_INIT,
LEGEND_PURE_IDE_COMMAND_KEY.RUN_ALL_TESTS,
LEGEND_PURE_IDE_COMMAND_KEY.RUN_RELAVANT_TESTS,
].forEach((key) =>
this.applicationStore.commandService.deregisterCommand(key),
);
}
setActiveActivity(
activity: string,
options?: { keepShowingIfMatchedCurrent?: boolean },
): void {
if (!this.sideBarDisplayState.isOpen) {
this.sideBarDisplayState.open();
} else if (
activity === this.activeActivity &&
!options?.keepShowingIfMatchedCurrent
) {
this.sideBarDisplayState.close();
}
this.activeActivity = activity;
}
*loadDiagram(
filePath: string,
diagramPath: string,
line: number,
column: number,
): GeneratorFn<void> {
let editorState = this.tabManagerState.tabs.find(
(tab): tab is DiagramEditorState =>
tab instanceof DiagramEditorState && tab.diagramPath === diagramPath,
);
if (!editorState) {
yield flowResult(this.checkIfSessionWakingUp());
editorState = new DiagramEditorState(
this,
deserialize(DiagramInfo, yield this.client.getDiagramInfo(diagramPath)),
diagramPath,
filePath,
line,
column,
);
}
this.tabManagerState.openTab(editorState);
}
*loadFile(filePath: string, coordinate?: FileCoordinate): GeneratorFn<void> {
try {
let editorState = this.tabManagerState.tabs.find(
(tab): tab is FileEditorState =>
tab instanceof FileEditorState && tab.filePath === filePath,
);
if (!editorState) {
yield flowResult(this.checkIfSessionWakingUp());
editorState = new FileEditorState(
this,
deserialize(File, yield this.client.getFile(filePath)),
filePath,
);
}
this.tabManagerState.openTab(editorState);
if (coordinate) {
editorState.textEditorState.setForcedCursorPosition({
lineNumber: coordinate.line,
column: coordinate.column,
});
if (coordinate instanceof FileErrorCoordinate) {
editorState.showError(coordinate);
}
}
} catch (error) {
assertErrorThrown(error);
this.applicationStore.terminalService.terminal.fail(error.message, {
systemCommand: `load file ${filePath}`,
});
}
}
async reloadFile(filePath: string): Promise<void> {
const tabsToClose: TabState[] = [];
await Promise.all(
this.tabManagerState.tabs.map(async (tab) => {
if (tab instanceof FileEditorState && tab.filePath === filePath) {
tab.setFile(deserialize(File, await this.client.getFile(filePath)));
} else if (
tab instanceof DiagramEditorState &&
tab.filePath === filePath
) {
try {
tab.rebuild(
deserialize(
DiagramInfo,
await this.client.getDiagramInfo(tab.diagramPath),
),
);
} catch {
// something happened, most likely the diagram has been removed or renamed,
// we should close the tab then
tabsToClose.push(tab);
}
}
}),
);
tabsToClose.forEach((tab) => this.tabManagerState.closeTab(tab));
}
*execute(
url: string,
extraParams: Record<PropertyKey, unknown>,
checkExecutionStatus: boolean,
manageResult: (
result: ExecutionResult,
potentiallyAffectedFiles: string[],
) => Promise<void>,
command: string | undefined,
options?: {
/**
* Some execution, such as find concept produces no output
* so we should not reset the console text in that case
*/
silentTerminalOnSuccess?: boolean;
clearTerminal?: boolean;
},
): GeneratorFn<void> {
if (!this.initState.hasCompleted) {
this.applicationStore.notificationService.notifyWarning(
`Can't execute while initializing application`,
);
return;
}
if (this.executionState.isInProgress) {
this.applicationStore.notificationService.notifyWarning(
'Another execution is already in progress!',
);
return;
}
// reset suggestions before execution
this.setCodeFixSuggestion(undefined);
this.executionState.inProgress();
const potentiallyAffectedFiles = this.tabManagerState.tabs
.filter(filterByType(FileEditorState))
.map((tab) => tab.filePath);
try {
const openedFiles = this.tabManagerState.tabs
.map((tab) => {
if (tab instanceof FileEditorState) {
return {
path: tab.filePath,
code: tab.file.content,
};
} else if (tab instanceof DiagramEditorState) {
return {
diagram: tab.diagramPath,
code: serializeDiagram(tab.diagram),
};
}
return undefined;
})
.filter(isNonNullable);
const executionPromise = this.client.execute(
openedFiles,
url,
extraParams,
);
// NOTE: when we execute, it could take a while, and by default, we run a status check which potentially
// blocks the screen, as such, to be less disruptive to the UX and to avoid creating the illusion of slowness
// we will have a wait time, if execution is below this threshold, we will not conduct the check.
// The current threshold we choose is 1000ms, i.e. the execution should be sub-second
const WAIT_TIME_TO_TRIGGER_STATUS_CHECK = 1000;
let executionPromiseFinished = false;
let executionPromiseResult: PlainObject<ExecutionResult> | undefined;
yield Promise.all<void>([
executionPromise.then((value) => {
executionPromiseFinished = true;
executionPromiseResult = value;
}),
new Promise((resolve, reject) =>
setTimeout(
() => {
if (!executionPromiseFinished && checkExecutionStatus) {
this.applicationStore.alertService.setBlockingAlert({
message: 'Executing...',
prompt: 'Please do not refresh the application',
showLoading: true,
});
resolve(
this.pullExecutionStatus().finally(() => {
this.applicationStore.alertService.setBlockingAlert(
undefined,
);
}),
);
}
resolve();
},
WAIT_TIME_TO_TRIGGER_STATUS_CHECK,
true,
),
),
]);
const result = deserializeExecutionResult(
guaranteeNonNullable(executionPromiseResult),
);
this.applicationStore.alertService.setBlockingAlert(undefined);
if (result instanceof ExecutionFailureResult) {
this.applicationStore.notificationService.notifyError(
`Execution failed${result.text ? `: ${result.text}` : ''}`,
);
this.applicationStore.terminalService.terminal.fail(result.text, {
systemCommand: command ?? 'execute',
});
if (result.sessionError) {
this.applicationStore.alertService.setBlockingAlert({
message: 'Session corrupted',
prompt: result.sessionError,
});
} else {
yield flowResult(manageResult(result, potentiallyAffectedFiles));
}
} else {
if (!options?.silentTerminalOnSuccess) {
this.applicationStore.terminalService.terminal.output(
result.text ?? '',
{
clear: options?.clearTerminal,
systemCommand: command ?? 'execute',
},
);
}
if (result instanceof ExecutionSuccessResult) {
this.applicationStore.notificationService.notifySuccess(
'Execution succeeded!',
);
if (result.reinit) {
this.applicationStore.alertService.setBlockingAlert({
message: 'Reinitializing...',
prompt: 'Please do not refresh the application',
showLoading: true,
});
this.initState.reset();
yield flowResult(
this.initialize(
false,
() =>
flowResult(
this.execute(
url,
extraParams,
checkExecutionStatus,
manageResult,
command,
),
),
this.client.mode,
this.client.compilerMode,
),
);
} else {
yield flowResult(manageResult(result, potentiallyAffectedFiles));
}
} else {
yield flowResult(manageResult(result, potentiallyAffectedFiles));
}
}
} catch (error) {
assertErrorThrown(error);
this.applicationStore.notificationService.notifyError(error);
this.applicationStore.terminalService.terminal.fail(error.message, {
systemCommand: command ?? 'execute',
});
} finally {
this.applicationStore.alertService.setBlockingAlert(undefined);
this.executionState.reset();
}
}
// NOTE: currently backend do not suppor this operation, so we temporarily disable it, but
// in theory, this will pull up a blocking modal to show the execution status to user
async pullExecutionStatus(): Promise<void> {
const result =
(await this.client.getExecutionActivity()) as unknown as ExecutionActivity;
this.applicationStore.alertService.setBlockingAlert({
message: 'Executing...',
prompt: result.text
? result.text
: 'Please do not refresh the application',
showLoading: true,
});
if (result.executing) {
return new Promise((resolve, reject) =>
setTimeout(() => {
try {
resolve(this.pullExecutionStatus());
} catch (error) {
reject(error);
}
// NOTE: tune this slightly lower for better experience, also for sub-second execution, setting a high number
// might create the illusion that the system is slow
}, 500),
);
}
this.applicationStore.alertService.setBlockingAlert({
message: 'Executing...',
prompt: 'Please do not refresh the application',
showLoading: true,
});
return Promise.resolve();
}
*executeGo(): GeneratorFn<void> {
yield flowResult(
this.execute(
'executeGo',
{},
true,
(result: ExecutionResult, potentiallyAffectedFiles: string[]) =>
flowResult(
this.manageExecuteGoResult(result, potentiallyAffectedFiles),
),
LEGEND_PURE_IDE_TERMINAL_COMMAND.GO,
{
clearTerminal: true,
},
),
);
}
*runDebugger(command: { args: string[] }): GeneratorFn<void> {
yield flowResult(
this.client
.execute([], 'debugging', command)
.then((r) => {
const execResult = deserializeExecutionResult(
guaranteeNonNullable(r),
);
if (execResult.text) {
this.applicationStore.terminalService.terminal.output(
execResult.text,
);
}
})
.catch((er) => {
this.applicationStore.terminalService.terminal.fail(er.message);
}),
);
}
*manageExecuteGoResult(
result: ExecutionResult,
potentiallyAffectedFiles: string[],
): GeneratorFn<void> {
const refreshTreesPromise = this.refreshTrees();
// reset errors on all tabs before potentially show the latest error
this.tabManagerState.tabs
.filter(filterByType(FileEditorState))
.filter((tab) => potentiallyAffectedFiles.includes(tab.filePath))
.forEach((tab) => tab.clearError());
if (result instanceof ExecutionFailureResult) {
if (result.source) {
yield flowResult(
this.loadFile(
result.source,
new FileErrorCoordinate(
result.source,
result.line,
result.column,
new ExecutionError(result.text.split('\n').filter(Boolean)[0]),
),
),
);
}
if (result instanceof UnmatchedFunctionResult) {
this.setCodeFixSuggestion(
new UnmatchedFunctionCodeFixSuggestion(this, result),
);
this.setActivePanelMode(PANEL_MODE.CODE_FIX_SUGGESTION);
this.panelGroupDisplayState.open();
} else if (result instanceof UnknownSymbolResult) {
this.setCodeFixSuggestion(
new UnknownSymbolCodeFixSuggestion(this, result),
);
this.setActivePanelMode(PANEL_MODE.CODE_FIX_SUGGESTION);
this.panelGroupDisplayState.open();
}
this.resetChangeDetection(potentiallyAffectedFiles);
} else if (result instanceof ExecutionSuccessResult) {
if (result.modifiedFiles.length) {
for (const path of result.modifiedFiles) {
yield this.reloadFile(path);
}
}
this.resetChangeDetection(
potentiallyAffectedFiles.concat(result.modifiedFiles),
);
// NOTE: this is for the case where compilation failed during IDE initialization
// this is when we fix the compilation and execute for the first time, which in turn
// will properly `initialize` the application
// therefore, we will need to re-initialize the concept tree which was not initialized
// before
if (this.initState.hasFailed || !this.conceptTreeState.treeData) {
yield flowResult(this.conceptTreeState.initialize());
this.initState.pass();
}
}
yield refreshTreesPromise;
}
*executeTests(
path: string,
relevantTestsOnly?: boolean | undefined,
pctAdapter?: string | undefined,
): GeneratorFn<void> {
if (relevantTestsOnly) {
this.applicationStore.notificationService.notifyUnsupportedFeature(
`Run relevant tests! (reason: VCS required)`,
);
return;
}
if (this.testRunState.isInProgress) {
this.applicationStore.notificationService.notifyWarning(
'Test runner is working. Please try again later',
);
return;
}
this.testRunState.inProgress();
yield flowResult(
this.execute(
'executeTests',
{
path,
pctAdapter,
relevantTestsOnly,
},
false,
async (result: ExecutionResult, potentiallyAffectedFiles: string[]) => {
const refreshTreesPromise = this.refreshTrees();
if (result instanceof ExecutionFailureResult) {
if (result.source) {
await flowResult(
this.loadFile(
result.source,
new FileErrorCoordinate(
result.source,
result.line,
result.column,
new ExecutionError(
result.text.split('\n').filter(Boolean)[0],
),
),
),
);
}
this.setActivePanelMode(PANEL_MODE.TERMINAL);
this.panelGroupDisplayState.open();
this.testRunState.fail();
} else if (result instanceof TestExecutionResult) {
this.setActivePanelMode(PANEL_MODE.TEST_RUNNER);
this.panelGroupDisplayState.open();
const testRunnerState = new TestRunnerState(this, result);
this.setTestRunnerState(testRunnerState);
await flowResult(testRunnerState.buildTestTreeData());
if (testRunnerState.testExecutionResult.count <= 100) {
testRunnerState.expandTree();
}
// make sure we refresh tree so it is shown in the explorer panel
// NOTE: we could potentially expand the tree here, but this operation is expensive since we have all nodes observable
// so it will lag the UI if we have too many nodes open
testRunnerState.refreshTree();
await flowResult(testRunnerState.pollTestRunnerResult());
this.testRunState.pass();
}
this.resetChangeDetection(potentiallyAffectedFiles);
// do nothing?
await refreshTreesPromise;
},
`${LEGEND_PURE_IDE_TERMINAL_COMMAND.TEST} ${path}`,
),
);
}
*executeFullTestSuite(relevantTestsOnly?: boolean): GeneratorFn<void> {
yield flowResult(this.executeTests(ROOT_PACKAGE_PATH, relevantTestsOnly));
}
*executeNavigation(coordinate: FileCoordinate): GeneratorFn<void> {
this.navigationStack.push(coordinate);
yield flowResult(
this.execute(
'getConcept',
{
file: coordinate.file,
line: coordinate.line,
column: coordinate.column,
},
false,
async (result: ExecutionResult, potentiallyAffectedFiles: string[]) => {
if (result instanceof GetConceptResult) {
await flowResult(
this.loadFile(
result.jumpTo.source,
new FileCoordinate(
result.jumpTo.source,
result.jumpTo.line,
result.jumpTo.column,
),
),
);
}
this.resetChangeDetection(potentiallyAffectedFiles);
},
`navigate`,
{ silentTerminalOnSuccess: true },
),
);
}
*navigateBack(): GeneratorFn<void> {
if (this.navigationStack.length === 0) {
this.applicationStore.notificationService.notifyWarning(
`Can't navigate back any further - navigation stack is empty`,
);
return;
}
if (this.navigationStack.length > 0) {
const coordinate = this.navigationStack.pop();
if (coordinate) {
yield flowResult(this.loadFile(coordinate.file, coordinate));
}
}
}
*fullReCompile(fullInit: boolean): GeneratorFn<void> {
this.applicationStore.alertService.setActionAlertInfo({
message: 'Are you sure you want to perform a full re-compile?',
prompt: 'This may take a long time to complete',
type: ActionAlertType.CAUTION,
actions: [
{
label: 'Perform full re-compile',
type: ActionAlertActionType.PROCEED_WITH_CAUTION,
handler: () => {
flowResult(
this.execute(
'executeSaveAndReset',
{},
true,
async (
result: ExecutionResult,
potentiallyAffectedFiles: string[],
) => {
this.initState.reset();
await flowResult(
this.initialize(
fullInit,
undefined,
this.client.mode,
this.client.compilerMode,
),
);
this.resetChangeDetection(potentiallyAffectedFiles);
this.setActiveActivity(ACTIVITY_MODE.CONCEPT_EXPLORER, {
keepShowingIfMatchedCurrent: true,
});
},
`recompile`,
),
).catch(this.applicationStore.alertUnhandledError);
},
},
{
label: 'Abort',
type: ActionAlertActionType.PROCEED,
default: true,
},
],
});
}
resetChangeDetection(files: string[]): void {
this.tabManagerState.tabs
.filter(filterByType(FileEditorState))
.filter((tab) => files.includes(tab.filePath))
.forEach((tab) => tab.resetChangeDetection());
}
async refreshTrees(): Promise<void> {
await Promise.all([
this.directoryTreeState.refreshTreeData(),
this.conceptTreeState.refreshTreeData(),
]);
if (this.directoryTreeState.selectedNode) {
document
.getElementById(this.directoryTreeState.selectedNode.id)
?.scrollIntoView({
behavior: 'instant',
block: 'center',
});
}
if (this.conceptTreeState.selectedNode) {
document
.getElementById(this.conceptTreeState.selectedNode.id)
?.scrollIntoView({
behavior: 'instant',
block: 'center',
});
}
}
async revealConceptInTree(coordinate: FileCoordinate): Promise<void> {
const errorMessage =
'Error revealing concept. Please make sure that the code compiles and that you are looking for a valid concept';
let concept: ConceptInfo;
try {
concept = await this.client.getConceptInfo(
coordinate.file,
coordinate.line,
coordinate.column,
);
} catch {
this.applicationStore.notificationService.notifyWarning(
`Can't find concept info. Please make sure that the code compiles and that you are looking for references of non primitive types!`,
);
return;
}
if (!concept.path) {
return;
}
this.applicationStore.alertService.setBlockingAlert({
message: 'Revealing concept in tree...',
showLoading: true,
});
try {
if (this.activeActivity !== ACTIVITY_MODE.CONCEPT_EXPLORER) {
this.setActiveActivity(ACTIVITY_MODE.CONCEPT_EXPLORER);
}
const parts = concept.path.split(ELEMENT_PATH_DELIMITER);
let currentPath = guaranteeNonNullable(parts[0]);
let currentNode = guaranteeNonNullable(
this.conceptTreeState.getTreeData().nodes.get(currentPath),
);
for (let i = 1; i < parts.length; ++i) {
currentPath = `${currentPath}${ELEMENT_PATH_DELIMITER}${parts[i]}`;
if (!this.conceptTreeState.getTreeData().nodes.get(currentPath)) {
await flowResult(this.conceptTreeState.expandNode(currentNode));
}
currentNode = guaranteeNonNullable(
this.conceptTreeState.getTreeData().nodes.get(currentPath),
);
}
this.conceptTreeState.setSelectedNode(currentNode);
document.getElementById(currentNode.id)?.scrollIntoView({
behavior: 'instant',
block: 'center',
});
} catch {
this.applicationStore.notificationService.notifyWarning(errorMessage);
} finally {
this.applicationStore.alertService.setBlockingAlert(undefined);
}
}
*command(
fn: () => Promise<PlainObject<CommandResult>>,
command: string,
): GeneratorFn<boolean> {
try {
const result = deserializeCommandResult(
(yield fn()) as PlainObject<CommandResult>,
);
if (result instanceof CommandFailureResult) {
if (result.errorDialog) {
this.applicationStore.notificationService.notifyWarning(
`Can't run command '${command}': ${result.text}`,
);
} else {
this.applicationStore.terminalService.terminal.output(result.text, {
systemCommand: command,
});
}
return false;
}
return true;
} catch (error) {
assertErrorThrown(error);
this.applicationStore.notificationService.notifyError(error);
this.applicationStore.terminalService.terminal.fail(error.message, {
systemCommand: command,
});
return false;
}
}
async getConceptInfo(
coordinate: FileCoordinate,
options?: {
silent?: boolean | undefined;
},
): Promise<ConceptInfo | undefined> {
try {
const concept = await this.client.getConceptInfo(
coordinate.file,
coordinate.line,
coordinate.column,
);
return concept;
} catch {
if (!options?.silent) {
this.applicationStore.notificationService.notifyWarning(
`Can't find concept info. Please make sure that the code compiles and that you are looking for references of non primitive types!`,
);
}
return undefined;
}
}
async findConceptUsages(func: string, param: string[]): Promise<Usage[]> {
return (await this.client.getUsages(func, param)).map((usage) =>
deserialize(Usage, usage),
);
}
*findUsagesFromCoordinate(coordinate: FileCoordinate): GeneratorFn<void> {
const concept = (yield this.getConceptInfo(coordinate)) as
| ConceptInfo
| undefined;
if (!concept) {
return;
}
yield flowResult(this.findUsages(concept));
}
*findUsages(concept: ConceptInfo): GeneratorFn<void> {
try {
this.referenceUsageLoadState.inProgress();
this.applicationStore.alertService.setBlockingAlert({
message: 'Finding concept usages...',
prompt: `Finding references of ${getConceptInfoLabel(concept)}`,
showLoading: true,
});
const usages = (yield this.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}'`,
),
)) as Usage[];
const searchResultCoordinates = (
(yield this.client.getTextSearchPreview(
usages.map((usage) =>
serialize(
SearchResultCoordinate,
new SearchResultCoordinate(
usage.source,
usage.startLine,
usage.startColumn,
usage.endLine,
usage.endColumn,
),
),
),
)) as PlainObject<SearchResultCoordinate>[]
).map((preview) => deserialize(SearchResultCoordinate, preview));
this.setReferenceUsageResult(
new ReferenceUsageResult(
this,
concept,
usages,
searchResultCoordinates,
),
);
this.setActivePanelMode(PANEL_MODE.REFERENCES);
this.panelGroupDisplayState.open();
} catch (error) {
assertErrorThrown(error);
this.applicationStore.notificationService.notifyError(error);
} finally {
this.applicationStore.alertService.setBlockingAlert(undefined);
this.referenceUsageLoadState.complete();
}
}
*renameConcept(
oldName: string,
newName: string,
pureType: string,
usages: Usage[],
): GeneratorFn<void> {
try {
yield this.client.renameConcept({
oldName,
newName,
pureType,
sourceInformations: usages.map((usage) => ({
sourceId: usage.source,
line: usage.line,
column: usage.column,
})),
});
const potentiallyModifiedFiles = usages.map((usage) => usage.source);
for (const file of potentiallyModifiedFiles) {
yield this.reloadFile(file);
}
yield this.refreshTrees();
this.applicationStore.notificationService.notifyWarning(
`Please re-compile the code after refacting`,
);
} catch (error) {
assertErrorThrown(error);
this.applicationStore.notificationService.notifyError(
`Can't rename concept '${oldName}'`,
);
}
}
*movePackageableElements(
inputs: {
pureName: string;
pureType: string;
sourcePackage: string;
destinationPackage: string;
usages: Usage[];
}[],
): GeneratorFn<void> {
try {
yield this.client.movePackageableElements(
inputs.map((input) => ({
pureName: input.pureName,
pureType: input.pureType,
sourcePackage: input.sourcePackage,
destinationPackage: input.destinationPackage,
sourceInformations: input.usages.map((usage) => ({
sourceId: usage.source,
line: usage.line,
column: usage.column,
})),
})),
);
const potentiallyModifiedFiles = uniq(
inputs.flatMap((input) => input.usages.map((usage) => usage.source)),
);
for (const file of potentiallyModifiedFiles) {
yield this.reloadFile(file);
}
yield this.refreshTrees();
this.applicationStore.notificationService.notifyWarning(
`Please re-compile the code after refacting`,
);
} catch (error) {
assertErrorThrown(error);
this.applicationStore.notificationService.notifyError(
`Can't move packageable elements:\n${error.message}`,
);
}
}
*updateFileUsingSuggestionCandidate(
candidate: CandidateWithPackageNotImported,
): GeneratorFn<void> {
this.setCodeFixSuggestion(undefined);
yield flowResult(
this.updateFile(
candidate.fileToBeModified,
candidate.lineToBeModified,
candidate.columnToBeModified,
candidate.add,
candidate.messageToBeModified,
),
);
this.setActivePanelMode(PANEL_MODE.TERMINAL);
this.panelGroupDisplayState.open();
}
*updateFile(
path: string,
line: number,
column: number,
add: boolean,
message: string,
): GeneratorFn<void> {
try {
const result = (yield this.client.updateSource([
{
path,
line,
column,
message,
add,
},
])) as SourceModificationResult;
if (result.modifiedFiles.length) {
for (const file of result.modifiedFiles) {
yield this.reloadFile(file);
}
}
this.applicationStore.notificationService.notifyWarning(
`Please re-compile the code after refacting`,
);
} catch (error) {
assertErrorThrown(error);
this.applicationStore.notificationService.notifyError(
`Can't update file '${path}'`,
);
}
}
*searchFile(): GeneratorFn<void> {
if (
this.fileSearchCommandLoadState.isInProgress ||
this.fileSearchCommandState.text.length <= 3
) {
return;
}
this.fileSearchCommandLoadState.inProgress();
this.fileSearchCommandResults = (yield this.client.findFiles(
this.fileSearchCommandState.text,
this.fileSearchCommandState.isRegExp,
)) as string[];
this.fileSearchCommandLoadState.pass();
}
*createNewDirectory(path: string): GeneratorFn<void> {
try {
yield flowResult(
this.command(
() => this.client.createFolder(trimPathLeadingSlash(path)),
LEGEND_PURE_IDE_TERMINAL_COMMAND.NEW_DIRECTORY,
),
);
yield flowResult(this.directoryTreeState.refreshTreeData());
} catch (error) {
assertErrorThrown(error);
this.applicationStore.notificationService.notifyError(error);
}
}
*createNewFile(path: string): GeneratorFn<void> {
try {
const result = (yield flowResult(
this.command(
() => this.client.createFile(trimPathLeadingSlash(path)),
LEGEND_PURE_IDE_TERMINAL_COMMAND.NEW_FILE,
),
)) as boolean;
yield flowResult(this.directoryTreeState.refreshTreeData());
if (result) {
yield flowResult(this.loadFile(path));
}
} catch (error) {
assertErrorThrown(error);
this.applicationStore.notificationService.notifyError(error);
}
}
*renameFile(oldPath: string, newPath: string): Gen