Skip to content

Commit

Permalink
feat(training): persistence of code modifications
Browse files Browse the repository at this point in the history
  • Loading branch information
matthieu-crouzet authored and fpaul-1A committed Dec 16, 2024
1 parent 2e1374e commit e5f498a
Show file tree
Hide file tree
Showing 8 changed files with 160 additions and 42 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<as-split direction="vertical">
<as-split-area [size]="editorMode() === 'interactive' ? 50 : 100">
<form [formGroup]="form" class="editor h-100">
<form [formGroup]="form" class="editor h-100 overflow-hidden position-relative">
<as-split direction="horizontal">
<as-split-area [size]="25">
<monaco-tree (clickFile)="onClickFile($event)"
Expand All @@ -11,7 +11,6 @@
class="w-100 editor-view"></monaco-tree>
</as-split-area>
<as-split-area [size]="75" class="editor-view">
<div class="monaco-editor monaco-overflow-widgets position-absolute" #monacoOverflowWidgets></div>
@let editorOptions = editorOptions$ | async;
@if (editorOptions) {
<ngx-monaco-editor class="h-100 position-relative"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ ngx-monaco-editor {
background: #1d1d1d;
}

.monaco-overflow-widgets {
z-index: var(--o3r-header-zindex);
.save-code-dialog-backdrop {
position: absolute;
}

.save-code-dialog-window {
position: absolute;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@ import {
Component,
computed,
effect,
ElementRef,
inject,
input,
OnDestroy,
untracked,
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import {
Expand All @@ -25,6 +23,9 @@ import {
FormsModule,
ReactiveFormsModule,
} from '@angular/forms';
import {
DfModalService,
} from '@design-factory/design-factory';
import {
LoggerService,
} from '@o3r/logger';
Expand All @@ -44,16 +45,18 @@ import {
} from 'ngx-monaco-tree';
import {
BehaviorSubject,
combineLatest,
combineLatestWith,
debounceTime,
distinctUntilChanged,
filter,
firstValueFrom,
from,
map,
merge,
Observable,
of,
share,
shareReplay,
skip,
startWith,
Subject,
Expand All @@ -69,6 +72,9 @@ import {
import {
CodeEditorControlComponent,
} from '../code-editor-control';
import {
SaveCodeDialogComponent,
} from '../save-code-dialog';

declare global {
interface Window {
Expand Down Expand Up @@ -134,9 +140,6 @@ export class CodeEditorViewComponent implements OnDestroy {
*/
private readonly loggerService = inject(LoggerService);

@ViewChild('monacoOverflowWidgets')
private readonly monacoOverflowWidgets!: ElementRef;

/**
* Allow to edit the code in the monaco editor
*/
Expand All @@ -163,7 +166,7 @@ export class CodeEditorViewComponent implements OnDestroy {
: of([])
),
filter((tree) => tree.length > 0),
share()
shareReplay({ bufferSize: 1, refCount: true })
);

/**
Expand Down Expand Up @@ -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<void>();
private readonly forceSave = new Subject<void>();

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$);
Expand All @@ -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(
Expand Down Expand Up @@ -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<string>(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;
Expand All @@ -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();
}

/**
Expand All @@ -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();
}
Expand All @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './save-code-dialog.component';
Original file line number Diff line number Diff line change
@@ -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<SaveCodeDialogComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SaveCodeDialogComponent]
}).compileComponents();

fixture = TestBed.createComponent(SaveCodeDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -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);

Check failure on line 18 in apps/showcase/src/components/training/save-code-dialog/save-code-dialog.component.ts

View workflow job for this annotation

GitHub Actions / UT Tests report-ubuntu-latest

ViewComponent ► apps/showcase/src/components/training/save-code-dialog/save-code-dialog.component.spec.ts ► ViewComponent should create

Failed test found in: apps/showcase/dist-test/junit.xml Error: NullInjectorError: R3InjectorError(DynamicTestModule)[NgbActiveModal -> NgbActiveModal]:
Raw output
NullInjectorError: R3InjectorError(DynamicTestModule)[NgbActiveModal -> NgbActiveModal]: 
  NullInjectorError: No provider for NgbActiveModal!
    at NullInjector.get (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:1663:27)
    at R3Injector.get (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:3109:33)
    at R3Injector.get (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:3109:33)
    at ChainedInjector.get (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:5454:36)
    at lookupTokenUsingModuleInjector (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:5807:39)
    at getOrCreateInjectable (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:5855:12)
    at ɵɵdirectiveInject (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:11933:19)
    at ɵɵinject (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:1113:42)
    at inject (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:1199:12)
    at new SaveCodeDialogComponent (/home/runner/work/otter/otter/apps/showcase/src/components/training/save-code-dialog/save-code-dialog.component.ts:18:39)
    at NodeInjectorFactory.SaveCodeDialogComponent_Factory [as factory] (ng:///SaveCodeDialogComponent/ɵfac.js:5:10)
    at getNodeInjectable (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:6067:44)
    at createRootComponent (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:16916:35)
    at ComponentFactory.create (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:16767:29)
    at initComponent (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/testing.mjs:2039:51)
    at _ZoneDelegate.invoke (/home/runner/work/otter/otter/.yarn/cache/zone.js-npm-0.14.10-f84b9a2b62-a7bed2f9a7.zip/node_modules/zone.js/bundles/zone.umd.js:416:32)
    at Object.onInvoke (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:7251:33)
    at _ZoneDelegate.invoke (/home/runner/work/otter/otter/.yarn/cache/zone.js-npm-0.14.10-f84b9a2b62-a7bed2f9a7.zip/node_modules/zone.js/bundles/zone.umd.js:415:38)
    at ZoneImpl.run (/home/runner/work/otter/otter/.yarn/cache/zone.js-npm-0.14.10-f84b9a2b62-a7bed2f9a7.zip/node_modules/zone.js/bundles/zone.umd.js:147:47)
    at NgZone.run (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/core.mjs:7097:28)
    at _TestBedImpl.createComponent (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/testing.mjs:2051:41)
    at Function.createComponent (/home/runner/work/otter/otter/.yarn/__virtual__/@angular-core-virtual-f54d3f4cff/0/cache/@angular-core-npm-18.2.13-cddd3f8e6c-81b7656622.zip/node_modules/@angular/core/fesm2022/testing.mjs:1842:37)
    at /home/runner/work/otter/otter/apps/showcase/src/components/training/save-code-dialog/save-code-dialog.component.spec.ts:18:23
    at processTicksAndRejections (node:internal/process/task_queues:95:5)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<div class="modal-header">
<h2 class="modal-title">Code modifications detected</h2>
</div>
<div class="modal-body">
<p>Do you want to resume your training or discard your changes?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-danger me-5" (click)="activeModal.close(false)">Discard</button>
<button type="button" class="btn btn-primary" ngbAutofocus (click)="activeModal.close(true)">Resume</button>
</div>
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
<div class="h-100">
<as-split direction="horizontal">
@if (instructions) {
<as-split-area [size]="50">
<as-split-area [size]="project ? 50 : 100">
<div class="instructions h-100 overflow-auto pe-6">
<h2>{{title}}</h2>
<markdown clipboard [data]="instructions"></markdown>
</div>
</as-split-area>
}
@if (project) {
<as-split-area [size]="50">
<as-split-area [size]="instructions ? 50 : 100">
<div class="h-100 overflow-hidden">
<code-editor-view
[project]="project"
Expand Down

0 comments on commit e5f498a

Please sign in to comment.