From e5f498aeb8ff43c076622ea00a81ff7242640b4d Mon Sep 17 00:00:00 2001 From: matthieu-crouzet Date: Wed, 11 Dec 2024 08:04:20 +0100 Subject: [PATCH] feat(training): persistence of code modifications --- .../code-editor-view.component.html | 3 +- .../code-editor-view.component.scss | 8 +- .../code-editor-view.component.ts | 131 +++++++++++++----- .../training/save-code-dialog/index.ts | 1 + .../save-code-dialog.component.spec.ts | 26 ++++ .../save-code-dialog.component.ts | 19 +++ .../save-code-dialog.template.html | 10 ++ .../training-step-pres.component.html | 4 +- 8 files changed, 160 insertions(+), 42 deletions(-) create mode 100644 apps/showcase/src/components/training/save-code-dialog/index.ts create mode 100644 apps/showcase/src/components/training/save-code-dialog/save-code-dialog.component.spec.ts create mode 100644 apps/showcase/src/components/training/save-code-dialog/save-code-dialog.component.ts create mode 100644 apps/showcase/src/components/training/save-code-dialog/save-code-dialog.template.html 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 fa6bec3c5b..80c6481c4e 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 @@ -1,6 +1,6 @@ -
+ -
@let editorOptions = editorOptions$ | async; @if (editorOptions) { tree.length > 0), - share() + shareReplay({ bufferSize: 1, refCount: true }) ); /** @@ -205,17 +208,24 @@ export class CodeEditorViewComponent implements OnDestroy { readOnly: (this.editorMode() === 'readonly'), automaticLayout: true, scrollBeyondLastLine: false, - overflowWidgetsDomNode: this.monacoOverflowWidgets.nativeElement, + fixedOverflowWidgets: true, model: this.model() })) ); - private readonly fileContentLoaded$ = this.form.controls.file.valueChanges.pipe( + private readonly modalService = inject(DfModalService); + private readonly forceReload = new Subject(); + private readonly forceSave = new Subject(); + + private readonly fileContentLoaded$ = combineLatest([ + this.form.controls.file.valueChanges, + this.forceReload.pipe(startWith(undefined)) + ]).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() + filter(([[path], monacoTree]) => !!path && checkIfPathInMonacoTree(monacoTree, path.split('/'))), + switchMap(([[path]]) => from(this.webContainerService.readFile(`${this.project().cwd}/${path}`).catch(() => ''))), + shareReplay({ refCount: true, bufferSize: 1 }) ); private readonly fileContent = toSignal(this.fileContentLoaded$); @@ -240,33 +250,46 @@ export class CodeEditorViewComponent implements OnDestroy { const project = this.project(); await untracked(async () => { if (project.files) { - await Promise.all([ - this.webContainerService.loadProject(project.files, project.commands, project.cwd).then( - () => { - this.cwd$.next(project?.cwd || ''); - } - ), - this.loadNewProject() - ]); + await this.webContainerService.loadProject(project.files, project.commands, project.cwd); + this.loadNewProject(); + this.cwd$.next(project?.cwd || ''); } }); }); - this.form.controls.code.valueChanges.pipe( - distinctUntilChanged(), - skip(1), - debounceTime(300), + this.forceReload.subscribe(async () => { + await this.cleanAllModelsFromMonaco(); + await this.loadAllProjectFilesToMonaco(); + }); + merge( + this.forceSave.pipe(map(() => this.form.value.code)), + this.form.controls.code.valueChanges.pipe( + distinctUntilChanged(), + skip(1), + debounceTime(1000) + ) + ).pipe( filter((text): text is string => !!text), takeUntilDestroyed() ).subscribe((text: string) => { - if (!this.project) { + const project = this.project(); + if (!project) { this.loggerService.error('No project found'); return; } + const { cwd } = project; + if (text !== this.fileContent() || localStorage.getItem(cwd)) { + localStorage.setItem(cwd, JSON.stringify({ + ...JSON.parse(localStorage.getItem(cwd) || '{}'), + [this.form.controls.file.value!]: text + })); + } const path = `${this.project().cwd}/${this.form.controls.file.value}`; this.loggerService.log('Writing file', path); void this.webContainerService.writeFile(path, text); }); - this.fileContentLoaded$.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.webContainerService.runner.dependenciesLoaded$.pipe( @@ -316,10 +339,48 @@ export class CodeEditorViewComponent implements OnDestroy { } }); }); + void this.retrieveSaveChanges(); + } + + private async retrieveSaveChanges() { + await firstValueFrom(this.cwdTree$); + const { cwd } = this.project(); + const savedState = localStorage.getItem(cwd); + if (!savedState) { + return; + } + const state = JSON.parse(savedState); + const hasDiscrepancies = (await Promise.all( + Object.entries(state).map(async ([path, text]) => + text !== (await this.webContainerService.readFile(`${this.project().cwd}/${path}`).catch(() => undefined)) + )) + ).some((hasDiscrepancy) => !!hasDiscrepancy); + if (!hasDiscrepancies) { + return; + } + const modal = this.modalService.open( + SaveCodeDialogComponent, + { + backdrop: 'static', + container: '.editor', + backdropClass: 'save-code-dialog-backdrop', + windowClass: 'save-code-dialog-window' + } + ); + void modal.result.then(async (positiveReply) => { + if (positiveReply) { + await Promise.all( + Object.entries(state).map(([path, text]) => this.webContainerService.writeFile(`${cwd}/${path}`, text)) + ); + this.forceReload.next(); + } else { + localStorage.removeItem(cwd); + } + }); } /** - * Unload ahh the files from the global monaco editor + * Unload all the files from the global monaco editor */ private async cleanAllModelsFromMonaco() { const monaco = await this.monacoPromise; @@ -345,15 +406,9 @@ export class CodeEditorViewComponent implements OnDestroy { /** * Load a new project in global monaco editor and update local form accordingly */ - private async loadNewProject() { - if (this.project()?.startingFile) { - this.form.controls.file.setValue(this.project().startingFile); - } else { - this.form.controls.file.setValue(''); - this.form.controls.code.setValue(''); - } - await this.cleanAllModelsFromMonaco(); - await this.loadAllProjectFilesToMonaco(); + private loadNewProject() { + this.form.controls.file.setValue(this.project().startingFile); + this.forceReload.next(); } /** @@ -374,6 +429,7 @@ export class CodeEditorViewComponent implements OnDestroy { public onEditorKeyDown(event: KeyboardEvent) { const ctrlKey = /mac/i.test(navigator.userAgent) ? event.metaKey : event.ctrlKey; if (ctrlKey && event.key.toLowerCase() === 's') { + this.forceSave.next(); event.stopPropagation(); event.preventDefault(); } @@ -393,6 +449,9 @@ export class CodeEditorViewComponent implements OnDestroy { * @inheritDoc */ public ngOnDestroy() { + this.forceReload.complete(); + this.forceSave.complete(); + this.newMonacoEditorCreated.complete(); this.webContainerService.runner.killContainer(); } } diff --git a/apps/showcase/src/components/training/save-code-dialog/index.ts b/apps/showcase/src/components/training/save-code-dialog/index.ts new file mode 100644 index 0000000000..aeaabc8013 --- /dev/null +++ b/apps/showcase/src/components/training/save-code-dialog/index.ts @@ -0,0 +1 @@ +export * from './save-code-dialog.component'; diff --git a/apps/showcase/src/components/training/save-code-dialog/save-code-dialog.component.spec.ts b/apps/showcase/src/components/training/save-code-dialog/save-code-dialog.component.spec.ts new file mode 100644 index 0000000000..33558e6bfc --- /dev/null +++ b/apps/showcase/src/components/training/save-code-dialog/save-code-dialog.component.spec.ts @@ -0,0 +1,26 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { + SaveCodeDialogComponent, +} from './save-code-dialog.component'; + +describe('ViewComponent', () => { + let component: SaveCodeDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SaveCodeDialogComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(SaveCodeDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/showcase/src/components/training/save-code-dialog/save-code-dialog.component.ts b/apps/showcase/src/components/training/save-code-dialog/save-code-dialog.component.ts new file mode 100644 index 0000000000..9fc894a659 --- /dev/null +++ b/apps/showcase/src/components/training/save-code-dialog/save-code-dialog.component.ts @@ -0,0 +1,19 @@ +import { + ChangeDetectionStrategy, + Component, + inject, +} from '@angular/core'; +import { + NgbActiveModal, +} from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'code-editor-terminal', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [], + templateUrl: './save-code-dialog.template.html' +}) +export class SaveCodeDialogComponent { + public readonly activeModal = inject(NgbActiveModal); +} diff --git a/apps/showcase/src/components/training/save-code-dialog/save-code-dialog.template.html b/apps/showcase/src/components/training/save-code-dialog/save-code-dialog.template.html new file mode 100644 index 0000000000..8474e24f3b --- /dev/null +++ b/apps/showcase/src/components/training/save-code-dialog/save-code-dialog.template.html @@ -0,0 +1,10 @@ + + + diff --git a/apps/showcase/src/components/training/training-step/training-step-pres.component.html b/apps/showcase/src/components/training/training-step/training-step-pres.component.html index a40fa641e6..ef586643ad 100644 --- a/apps/showcase/src/components/training/training-step/training-step-pres.component.html +++ b/apps/showcase/src/components/training/training-step/training-step-pres.component.html @@ -1,7 +1,7 @@
@if (instructions) { - +

{{title}}

@@ -9,7 +9,7 @@

{{title}}

} @if (project) { - +