Skip to content

Commit

Permalink
feat(training): file navigation inside code editor
Browse files Browse the repository at this point in the history
  • Loading branch information
fpaul-1A committed Dec 5, 2024
1 parent dc7c3eb commit d35b1e1
Show file tree
Hide file tree
Showing 7 changed files with 282 additions and 97 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<as-split-area [size]="25">
<monaco-tree (clickFile)="onClickFile($event)"
[tree]="(cwdTree$ | async) || []"
[currentFile]="project?.startingFile"
[currentFile]="form.controls.file.value || project?.startingFile"
[width]="'auto'"
[height]="'auto'"
class="w-100 editor-view"></monaco-tree>
Expand All @@ -16,6 +16,8 @@
@if (editorOptions) {
<ngx-monaco-editor class="h-100 position-relative"
[options]="editorOptions"
[model]="model()"
(onInit)="monacoReady.next()"
formControlName="code">
</ngx-monaco-editor>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {
AsyncPipe,
JsonPipe,
} from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
inject,
Input,
Expand All @@ -16,6 +16,8 @@ import {
} from '@angular/core';
import {
takeUntilDestroyed,
toObservable,
toSignal,
} from '@angular/core/rxjs-interop';
import {
FormBuilder,
Expand All @@ -33,6 +35,7 @@ import {
import {
AngularSplitModule,
} from 'angular-split';
import type * as Monaco from 'monaco-editor';
import {
MonacoEditorModule,
} from 'ngx-monaco-editor-v2';
Expand All @@ -46,25 +49,35 @@ import {
debounceTime,
distinctUntilChanged,
filter,
firstValueFrom,
from,
map,
Observable,
of,
pairwise,
share,
skip,
startWith,
Subject,
switchMap,
} from 'rxjs';
import {
checkIfPathInMonacoTree,
} from '../../../helpers/monaco-tree.helper';
import {
flattenTree,
WebContainerService,
} from '../../../services';
import {
CodeEditorControlComponent,
} from '../code-editor-control';

declare global {
interface Window {
monaco: typeof Monaco;
}
}

/** ngx-monaco-editor options language - determined based on file extension */
const editorOptionsLanguage: Record<string, string> = {
html: 'xml',
Expand Down Expand Up @@ -96,7 +109,6 @@ export interface TrainingProject {
AsyncPipe,
CodeEditorControlComponent,
FormsModule,
JsonPipe,
MonacoEditorModule,
NgxMonacoTreeComponent,
ReactiveFormsModule,
Expand Down Expand Up @@ -140,6 +152,8 @@ export class CodeEditorViewComponent implements OnDestroy, OnChanges {
* Service to load files and run commands in the application instance of the webcontainer.
*/
public readonly webContainerService = inject(WebContainerService);

private readonly progressChanged$ = toObservable(this.webContainerService.runner.progress);
/**
* File tree loaded in the project folder within the web container instance.
*/
Expand All @@ -162,9 +176,21 @@ export class CodeEditorViewComponent implements OnDestroy, OnChanges {
code: FormControl<string | null>;
file: FormControl<string | null>;
}> = this.formBuilder.group({
code: '',
file: ''
});
code: '',
file: ''
});

/**
* Subject used to notify when monaco editor has been initialized
*/
public readonly monacoReady = new Subject<void>();

/**
* Promise resolved with the global monaco instance
*/
private readonly monacoPromise = firstValueFrom(this.monacoReady.pipe(
map(() => window.monaco)
));

/**
* Configuration for the Monaco Editor
Expand All @@ -178,10 +204,36 @@ export class CodeEditorViewComponent implements OnDestroy, OnChanges {
readOnly: (this.editorMode === 'readonly'),
automaticLayout: true,
scrollBeyondLastLine: false,
overflowWidgetsDomNode: this.monacoOverflowWidgets.nativeElement
overflowWidgetsDomNode: this.monacoOverflowWidgets.nativeElement,
model: this.model()
}))
);

private readonly fileContentLoaded$ = this.form.controls.file.valueChanges.pipe(
takeUntilDestroyed(),
combineLatestWith(this.cwdTree$),
filter(([path, monacoTree]) => !!path && checkIfPathInMonacoTree(monacoTree, path.split('/'))),
switchMap(([path]) => from(this.webContainerService.readFile(`${this.project!.cwd}/${path}`).catch(() => ''))),
share()
);

private readonly fileContent = toSignal(this.fileContentLoaded$);

/**
* Model used for monaco editor for the currently selected file.
* We need that to associate the opened file to a URI which is necessary to resolve relative paths on imports.
*/
public model = computed(() => {
const value = this.fileContent();
const fileName = this.form.controls.file.value!;
const fileExtension = fileName.split('.').at(-1);
return {
value,
language: editorOptionsLanguage[fileExtension || ''] || '',
uri: `file:///${fileName}`
};
});

constructor() {
this.form.controls.code.valueChanges.pipe(
distinctUntilChanged(),
Expand All @@ -198,12 +250,108 @@ export class CodeEditorViewComponent implements OnDestroy, OnChanges {
this.loggerService.log('Writing file', path);
void this.webContainerService.writeFile(path, text);
});
this.form.controls.file.valueChanges.pipe(
combineLatestWith(this.cwdTree$),
filter(([path, monacoTree]) => !!path && checkIfPathInMonacoTree(monacoTree, path.split('/'))),
switchMap(([path]) => from(this.webContainerService.readFile(`${this.project!.cwd}/${path}`).catch(() => ''))),
takeUntilDestroyed()
).subscribe((content) => this.form.controls.code.setValue(content));
this.fileContentLoaded$.subscribe((content) => this.form.controls.code.setValue(content));

// Reload definition types when finishing install
this.progressChanged$.pipe(
takeUntilDestroyed(),
pairwise(),
filter(([prev, curr]) =>
prev.totalSteps === curr.totalSteps
&& curr.currentStep > prev.currentStep
&& curr.currentStep > 2
&& prev.currentStep <= 2
)
).subscribe(async () => {
await this.reloadDeclarationTypes();
});
void this.monacoPromise.then((monaco) => {
monaco.editor.registerEditorOpener({
openCodeEditor: (_source: Monaco.editor.ICodeEditor, resource: Monaco.Uri, selectionOrPosition?: Monaco.IRange | Monaco.IPosition) => {
if (resource && this.project?.files) {
const filePath = resource.path.slice(1);
// TODO write a proper function to search in the tree
const flatFiles = flattenTree(this.project.files);
if (flatFiles.some((projectFile) => projectFile.filePath === resource.path)) {
this.form.controls.file.setValue(filePath);
if (selectionOrPosition) {
// TODO find a way to execute that after the new file is loaded
if (monaco.Position.isIPosition(selectionOrPosition)) {
monaco.editor.getEditors()[0].revealPosition(selectionOrPosition);
} else {
monaco.editor.getEditors()[0].revealRange(selectionOrPosition);
}
return true;
}
}
}
return false;
}
});
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
allowNonTsExtensions: true,
target: monaco.languages.typescript.ScriptTarget.Latest,
module: monaco.languages.typescript.ModuleKind.ESNext,
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
paths: {
sdk: [
'file:///libs/sdk/src/index'
],
'sdk/*': [
'file:///libs/sdk/src/*'
]
}
});
});
}

/**
* Unload ahh the files from the global monaco editor
*/
private async cleanAllModelsFromMonaco() {
const monaco = await this.monacoPromise;
monaco.editor.getModels().forEach((m) => m.dispose());
}

/**
* Load all the files from `this.project` as Models in the global monaco editor.
*/
private async loadAllProjectFilesToMonaco() {
const monaco = await this.monacoPromise;
const flatFiles = flattenTree(this.project?.files!);
flatFiles.forEach(({ filePath, content }) => {
const language = editorOptionsLanguage[filePath.split('.').at(-1) || ''] || '';
monaco.editor.createModel(content, language, monaco.Uri.from({ scheme: 'file', path: filePath }));
});
}

/**
* Load a new project in global monaco editor and update local form accordingly
*/
private async loadNewProject() {
await this.cleanAllModelsFromMonaco();
await this.loadAllProjectFilesToMonaco();
if (this.project?.startingFile) {
this.form.controls.file.setValue(this.project.startingFile);
} else {
this.form.controls.file.setValue('');
this.form.controls.code.setValue('');
}
}

/**
* Reload declaration types from web-container
*/
public async reloadDeclarationTypes() {
if (this.project?.cwd) {
const declarationTypes = [
...await this.webContainerService.getDeclarationTypes(this.project.cwd),
{ filePath: 'file:///node_modules/@ama-sdk/core/index.d.ts', content: 'export * from "./src/public_api.d.ts";' },
{ filePath: 'file:///node_modules/@ama-sdk/client-fetch/index.d.ts', content: 'export * from "./src/public_api.d.ts";' }
];
const monaco = await this.monacoPromise;
monaco.languages.typescript.typescriptDefaults.setExtraLibs(declarationTypes);
}
}

/**
Expand All @@ -228,13 +376,7 @@ export class CodeEditorViewComponent implements OnDestroy, OnChanges {
// Remove link between launch project and terminals
void this.webContainerService.loadProject(this.project.files, this.project.commands, this.project.cwd);
}

if (this.project?.startingFile) {
this.form.controls.file.setValue(this.project.startingFile);
} else {
this.form.controls.file.setValue('');
this.form.controls.code.setValue('');
}
void this.loadNewProject();
this.cwd$.next(this.project?.cwd || '');
}
}
Expand Down
Loading

0 comments on commit d35b1e1

Please sign in to comment.