From d35b1e11ea92f5b9a3416edd00766e2048100a4d Mon Sep 17 00:00:00 2001 From: Florian PAUL Date: Mon, 2 Dec 2024 16:51:47 +0100 Subject: [PATCH] feat(training): file navigation inside code editor --- .../code-editor-view.component.html | 4 +- .../code-editor-view.component.ts | 180 ++++++++++++++++-- .../components/training/training.component.ts | 75 +------- .../src/helpers/monaco-tree.helper.ts | 8 +- .../webcontainer/webcontainer.helpers.ts | 80 ++++++++ .../webcontainer/webcontainer.service.ts | 28 +++ apps/showcase/src/styles.scss | 4 +- 7 files changed, 282 insertions(+), 97 deletions(-) diff --git a/apps/showcase/src/components/training/code-editor-view/code-editor-view.component.html b/apps/showcase/src/components/training/code-editor-view/code-editor-view.component.html index 374ef493d0..f87ddd9bff 100644 --- a/apps/showcase/src/components/training/code-editor-view/code-editor-view.component.html +++ b/apps/showcase/src/components/training/code-editor-view/code-editor-view.component.html @@ -5,7 +5,7 @@ @@ -16,6 +16,8 @@ @if (editorOptions) { } diff --git a/apps/showcase/src/components/training/code-editor-view/code-editor-view.component.ts b/apps/showcase/src/components/training/code-editor-view/code-editor-view.component.ts index bc3b34b0da..9a9ee32d65 100644 --- a/apps/showcase/src/components/training/code-editor-view/code-editor-view.component.ts +++ b/apps/showcase/src/components/training/code-editor-view/code-editor-view.component.ts @@ -1,10 +1,10 @@ import { AsyncPipe, - JsonPipe, } from '@angular/common'; import { ChangeDetectionStrategy, Component, + computed, ElementRef, inject, Input, @@ -16,6 +16,8 @@ import { } from '@angular/core'; import { takeUntilDestroyed, + toObservable, + toSignal, } from '@angular/core/rxjs-interop'; import { FormBuilder, @@ -33,6 +35,7 @@ import { import { AngularSplitModule, } from 'angular-split'; +import type * as Monaco from 'monaco-editor'; import { MonacoEditorModule, } from 'ngx-monaco-editor-v2'; @@ -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 = { html: 'xml', @@ -96,7 +109,6 @@ export interface TrainingProject { AsyncPipe, CodeEditorControlComponent, FormsModule, - JsonPipe, MonacoEditorModule, NgxMonacoTreeComponent, ReactiveFormsModule, @@ -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. */ @@ -162,9 +176,21 @@ export class CodeEditorViewComponent implements OnDestroy, OnChanges { code: FormControl; file: FormControl; }> = this.formBuilder.group({ - code: '', - file: '' - }); + code: '', + file: '' + }); + + /** + * Subject used to notify when monaco editor has been initialized + */ + public readonly monacoReady = new Subject(); + + /** + * Promise resolved with the global monaco instance + */ + private readonly monacoPromise = firstValueFrom(this.monacoReady.pipe( + map(() => window.monaco) + )); /** * Configuration for the Monaco Editor @@ -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(), @@ -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); + } } /** @@ -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 || ''); } } diff --git a/apps/showcase/src/components/training/training.component.ts b/apps/showcase/src/components/training/training.component.ts index 5d463bad6c..703d590a00 100644 --- a/apps/showcase/src/components/training/training.component.ts +++ b/apps/showcase/src/components/training/training.component.ts @@ -1,8 +1,3 @@ -import { - AsyncPipe, - JsonPipe, - NgComponentOutlet, -} from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -32,13 +27,14 @@ import { } from '@o3r/logger'; import type { DirectoryNode, - FileNode, FileSystemTree, - SymlinkNode, } from '@webcontainer/api'; import { firstValueFrom, } from 'rxjs'; +import { + overrideFileSystemTree, +} from '../../services'; import { EditorMode, TrainingProject, @@ -110,68 +106,6 @@ type Resource = { /** RegExp of current step index at the end of the location URL (example: http://url/#/fragment#3) */ const currentStepLocationRegExp = new RegExp(/#([0-9]+)$/); -/** - * Node is of type FileNode - * @param node Node from FileSystemTree - */ -const isFileNode = (node: DirectoryNode | FileNode | SymlinkNode): node is FileNode | SymlinkNode => !!(node as FileNode).file; - -/** - * Deep merge of directories - * @param dirBase Base directory - * @param dirOverride Directory to override base - */ -const mergeDirectories = (dirBase: DirectoryNode, dirOverride: DirectoryNode): DirectoryNode => { - const merge = structuredClone(dirBase); - Object.entries(dirOverride.directory).forEach(([path, node]) => { - const baseNode = merge.directory[path]; - if (!baseNode || (isFileNode(node) && isFileNode(baseNode))) { - // Not present in base directory - // Or present in both as file - merge.directory[path] = node; - } else if (!isFileNode(node) && !isFileNode(baseNode)) { - // Present in both as directory - merge.directory[path] = mergeDirectories(baseNode, node); - } else { - throw new Error('Cannot merge file and directory together'); - } - }); - return merge; -}; - -/** - * Predicate to check if fileSystem can be typed as a `DirectoryNode` - * @param fileSystem - */ -const isDirectory = (fileSystem: DirectoryNode | FileNode | SymlinkNode): fileSystem is DirectoryNode => { - return 'directory' in fileSystem; -}; - -/** - * Merge a sub file system into another - * @param fileSystemTree Original file system. - * @param fileSystemOverride File system that should be merged with the original. Its files take precedence over the original one. - * @param path Location in mergeFolder where fileSystemOverride should be merged. - */ -function overrideFileSystemTree(fileSystemTree: FileSystemTree, fileSystemOverride: FileSystemTree, path: string[]): FileSystemTree { - const key = path.shift() as string; - const target = fileSystemTree[key] || { directory: {} }; - if (path.length === 0 && isDirectory(target)) { - // Exploration of file system is done, we can merge the directories - fileSystemTree[key] = mergeDirectories(target, { directory: fileSystemOverride }); - } else if (isDirectory(target)) { - fileSystemTree[key] = { - directory: { - ...target.directory, - ...overrideFileSystemTree(target.directory, fileSystemOverride, path) - } - }; - } else { - throw new Error(`Cannot override the file ${key} with a folder`); - } - return fileSystemTree; -} - /** * Generate a file system tree composed of the deep merge of all the resources passed in parameters * @param resources Sorted list of path and content to load. If a file is defined several time, the last occurrence @@ -190,12 +124,9 @@ function getFilesContent(resources: Resource[]) { selector: 'o3r-training', standalone: true, imports: [ - AsyncPipe, DynamicContentModule, FormsModule, - JsonPipe, NgbAccordionModule, - NgComponentOutlet, TrainingStepPresComponent, NgbDropdown, NgbDropdownToggle, diff --git a/apps/showcase/src/helpers/monaco-tree.helper.ts b/apps/showcase/src/helpers/monaco-tree.helper.ts index f4a05a9c5d..2296d767bc 100644 --- a/apps/showcase/src/helpers/monaco-tree.helper.ts +++ b/apps/showcase/src/helpers/monaco-tree.helper.ts @@ -6,6 +6,9 @@ import type { import type { MonacoTreeElement, } from 'ngx-monaco-tree'; +import { + isDirectoryNode, +} from '../services/webcontainer/webcontainer.helpers'; /** * Check if the monaco tree contains the path in parameters @@ -31,9 +34,8 @@ export function checkIfPathInMonacoTree(tree: MonacoTreeElement[], path: string[ export function convertTreeRec(path: string, node: DirectoryNode | FileNode | SymlinkNode): MonacoTreeElement { return { name: path, - content: (node as DirectoryNode).directory - ? Object.entries((node as DirectoryNode).directory) - .map(([p, n]) => convertTreeRec(p, n)) + content: isDirectoryNode(node) + ? Object.entries(node.directory).map(([p, n]) => convertTreeRec(p, n)) : undefined }; } diff --git a/apps/showcase/src/services/webcontainer/webcontainer.helpers.ts b/apps/showcase/src/services/webcontainer/webcontainer.helpers.ts index 575a18d250..f9443f9239 100644 --- a/apps/showcase/src/services/webcontainer/webcontainer.helpers.ts +++ b/apps/showcase/src/services/webcontainer/webcontainer.helpers.ts @@ -3,6 +3,10 @@ import { getFilesTree, } from '@o3r-training/training-tools'; import { + type DirectoryNode, + type FileNode, + type FileSystemTree, + type SymlinkNode, WebContainer, WebContainerProcess, } from '@webcontainer/api'; @@ -61,3 +65,79 @@ export const makeProcessWritable = (process: WebContainerProcess, terminal: Term terminal.onData((data) => input.write(data)); return input; }; + +/** + * Asser that node input node is a DirectoryNode + * @param node + */ +export const isDirectoryNode = (node: DirectoryNode | FileNode | SymlinkNode): node is DirectoryNode => !!(node as DirectoryNode).directory; + +/** + * Asser that node input node is a FileNode + * @param node + */ +export const isFileNode = (node: DirectoryNode | FileNode | SymlinkNode): node is FileNode => !!(node as FileNode).file; + +/** + * Deep merge of directories + * @param dirBase Base directory + * @param dirOverride Directory to override base + */ +export const mergeDirectories = (dirBase: DirectoryNode, dirOverride: DirectoryNode): DirectoryNode => { + const merge = structuredClone(dirBase); + Object.entries(dirOverride.directory).forEach(([path, node]) => { + const baseNode = merge.directory[path]; + if (!baseNode || (isFileNode(node) && isFileNode(baseNode))) { + // Not present in base directory + // Or present in both as file + merge.directory[path] = node; + } else if (isDirectoryNode(node) && isDirectoryNode(baseNode)) { + // Present in both as directory + merge.directory[path] = mergeDirectories(baseNode, node); + } else { + throw new Error('Cannot merge file and directory together'); + } + }); + return merge; +}; + +/** + * Merge a sub file system into another + * @param fileSystemTree Original file system. + * @param fileSystemOverride File system that should be merged with the original. Its files take precedence over the original one. + * @param path Location in mergeFolder where fileSystemOverride should be merged. + */ +export function overrideFileSystemTree(fileSystemTree: FileSystemTree, fileSystemOverride: FileSystemTree, path: string[]): FileSystemTree { + const key = path.shift() as string; + const target = fileSystemTree[key] || { directory: {} }; + if (path.length === 0 && isDirectoryNode(target)) { + // Exploration of file system is done, we can merge the directories + fileSystemTree[key] = mergeDirectories(target, { directory: fileSystemOverride }); + } else if (isDirectoryNode(target)) { + fileSystemTree[key] = { + directory: { + ...target.directory, + ...overrideFileSystemTree(target.directory, fileSystemOverride, path) + } + }; + } else { + throw new Error(`Cannot override the file ${key} with a folder`); + } + return fileSystemTree; +} + +/** + * Flatten a tree to an array of objects with filePath and content + * @param tree + * @param basePath + */ +export const flattenTree = (tree: FileSystemTree, basePath = ''): { filePath: string; content: string }[] => { + return Object.entries(tree).reduce((out, [path, node]) => { + if (isDirectoryNode(node)) { + out.push(...flattenTree(node.directory, `${basePath}/${path}`)); + } else if (isFileNode(node)) { + out.push({ filePath: `${basePath}/${path}`, content: (node.file as any).contents }); + } + return out; + }, [] as { filePath: string; content: string }[]); +}; diff --git a/apps/showcase/src/services/webcontainer/webcontainer.service.ts b/apps/showcase/src/services/webcontainer/webcontainer.service.ts index 308ea8fc8f..c8e6953398 100644 --- a/apps/showcase/src/services/webcontainer/webcontainer.service.ts +++ b/apps/showcase/src/services/webcontainer/webcontainer.service.ts @@ -126,4 +126,32 @@ export class WebContainerService { return getFilesTreeFromContainer(instance, EXCLUDED_FILES_OR_DIRECTORY); } + + /** + * Retrieve all typescript declaration files from web-container + * @param project + * @param maxDepth + * @param path + */ + public async getDeclarationTypes(project: string, maxDepth = 20, path = 'node_modules'): Promise<{ filePath: string; content: string }[]> { + // TODO read that from project devDependencies? + const dependenciesWhiteList = /@ama-sdk|@angular|@o3r|rxjs/; + const dependenciesBlackList = /^(node_modules|docs?|locales?|bin|dist|templates|@angular-devkit|compiler(-cli)?|schematics?|.*eslint.*|cli)$/; + const instance = await this.runner.instancePromise; + const basePath = `${project}/${path}`; + const dependencies = await instance.fs.readdir(basePath, { encoding: 'utf8', withFileTypes: true }); + return (await Promise.all(dependencies.map(async (dirEntry) => { + if (dirEntry.isDirectory() && !dependenciesBlackList.test(dirEntry.name) && (dependenciesWhiteList.test(dirEntry.name) || dependenciesWhiteList.test(path))) { + const files = await instance.fs.readdir(`${basePath}/${dirEntry.name}`, { encoding: 'utf8', withFileTypes: true }); + const indexFiles = await Promise.all(files.filter((entry) => entry.isFile() && entry.name.endsWith('.d.ts')).map(async (indexFile) => ({ + filePath: `file:///${path}/${dirEntry.name}/${indexFile.name}`, + content: await instance.fs.readFile(`${basePath}/${dirEntry.name}/${indexFile.name}`, 'utf8') + }))); + return [ + ...indexFiles, + ...(maxDepth > 1 ? await this.getDeclarationTypes(project, maxDepth - 1, `${path}/${dirEntry.name}`) : []) + ]; + } + }))).flat().filter((entry) => !!entry); + } } diff --git a/apps/showcase/src/styles.scss b/apps/showcase/src/styles.scss index 9fc2e1930d..76e3a525e7 100644 --- a/apps/showcase/src/styles.scss +++ b/apps/showcase/src/styles.scss @@ -41,8 +41,8 @@ h1, h2 { color: #1f7e36; } -.markdown-clipboard-toolbar button { - --df-btn-icononly-size: 1.85em; +[clipboard] pre[class*=language-]>code[class*=language-] { + padding-right: 2.5em; } // Start overrides of design factory