Skip to content

Commit

Permalink
feat(training): loading progress indicator
Browse files Browse the repository at this point in the history
  • Loading branch information
fpaul-1A committed Nov 22, 2024
1 parent f19fa65 commit fa68b0e
Show file tree
Hide file tree
Showing 6 changed files with 125 additions and 46 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
<li ngbNavItem class="nav-item" role="presentation" [destroyOnHide]="false">
<a ngbNavLink class="nav-link" [class.active]="activeTab === 'preview'" (click)="activeTab = 'preview'">Preview</a>
<ng-template ngbNavContent>
<iframe class="w-100 h-100" #iframe allow="cross-origin-isolated" [srcdoc]="'Loading...'"></iframe>
@let isLoading = percentProgress() < 100;
@if (isLoading) {
<div class="w-100 h-100 d-flex align-items-center justify-content-center p-2">
<df-progressbar [value]="percentProgress()" [text]="progressLabel()"></df-progressbar>
</div>
}
<iframe class="w-100 h-100" #iframe allow="cross-origin-isolated" [srcdoc]="'Loading...'" [class.invisible]="isLoading"></iframe>
</ng-template>
</li>
<li ngbNavItem class="nav-item" role="presentation" [destroyOnHide]="false">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ code-editor-control {
height: 100%;
}

df-progressbar {
min-width: 15em;
}

.terminal-active-indicator {
display: inline-block;
font-weight: bold;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
inject,
Input,
Expand All @@ -10,6 +11,7 @@ import {
ViewEncapsulation
} from '@angular/core';
import {toSignal} from '@angular/core/rxjs-interop';
import {DfProgressbarModule} from '@design-factory/design-factory';
import {NgbNavModule} from '@ng-bootstrap/ng-bootstrap';
import {distinctUntilChanged, map, of, repeat, Subject, throttleTime, timeout} from 'rxjs';
import {WebContainerService} from '../../../services';
Expand All @@ -20,7 +22,8 @@ import {CodeEditorTerminalComponent} from '../code-editor-terminal';
standalone: true,
imports: [
CodeEditorTerminalComponent,
NgbNavModule
NgbNavModule,
DfProgressbarModule
],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
Expand Down Expand Up @@ -54,6 +57,19 @@ export class CodeEditorControlComponent implements OnDestroy, AfterViewInit {
*/
public readonly terminalActivity = new Subject<void>();

/**
* Loading progression (between 0 and 100)
*/
public readonly percentProgress = computed(() => {
const { currentStep, totalSteps } = this.webContainerService.runner.progress();
return Math.round(100 * currentStep / totalSteps);
});

/**
* Label to use on the load indicator
*/
public readonly progressLabel = computed(() => this.webContainerService.runner.progress().label);

/**
* Signal with value `true` if the terminal is active, `false` if idle
* The terminal is considered idle if no activity is received for 2 seconds.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,30 @@
@if (cwdTree$ | async; as tree) {
<as-split direction="vertical">
<as-split-area [size]="50">
<form [formGroup]="form" class="editor overflow-hidden h-100">
<as-split direction="horizontal">
<as-split-area [size]="25">
<monaco-tree (clickFile)="onClickFile($event)"
[tree]="tree"
[currentFile]="project?.startingFile"
[width]="'auto'"
[height]="'auto'"
class="w-100"></monaco-tree>
</as-split-area>
<as-split-area [size]="75">
<ngx-monaco-editor class="h-100 position-relative"
[options]="editorOptions$ | async"
formControlName="code">
</ngx-monaco-editor>
</as-split-area>
</as-split>
</form>
</as-split-area>
@if (editorMode === 'interactive') {
<as-split-area [size]="50">
<code-editor-control class="d-flex flex-column h-100" [showOutput]="project?.commands.length > 0 && tree.length > 0"></code-editor-control>
<as-split direction="vertical">
<as-split-area [size]="50">
<form [formGroup]="form" class="editor overflow-hidden h-100">
<as-split direction="horizontal">
<as-split-area [size]="25">
<monaco-tree (clickFile)="onClickFile($event)"
[tree]="(cwdTree$ | async) || []"
[currentFile]="project?.startingFile"
[width]="'auto'"
[height]="'auto'"
class="w-100 editor-view"></monaco-tree>
</as-split-area>
}
</as-split>
}
@else {
<div class="spinner-border" role="status"></div>
}
<as-split-area [size]="75" class="editor-view">
@let editorOptions = editorOptions$ | async;
@if (editorOptions) {
<ngx-monaco-editor class="h-100 position-relative"
[options]="editorOptions"
formControlName="code">
</ngx-monaco-editor>
}
</as-split-area>
</as-split>
</form>
</as-split-area>
@if (editorMode === 'interactive') {
<as-split-area [size]="50">
<code-editor-control class="d-flex flex-column h-100"></code-editor-control>
</as-split-area>
}
</as-split>
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
monaco-tree {
background: #1d1d1d;

.monaco-tree {
display: flex;
flex-direction: column;
Expand All @@ -24,3 +22,7 @@ ngx-monaco-editor {
max-height: 100% !important;
}
}

.editor-view {
background: #1d1d1d;
}
75 changes: 64 additions & 11 deletions apps/showcase/src/services/webcontainer/webcontainer-runner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DestroyRef, inject, Injectable } from '@angular/core';
import { DestroyRef, inject, Injectable, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
type FileSystemTree,
Expand All @@ -9,13 +9,19 @@ import {
import { Terminal } from '@xterm/xterm';
import {
BehaviorSubject,
combineLatest,
combineLatestWith,
distinctUntilChanged,
filter,
from,
fromEvent,
map,
Observable,
switchMap
of,
skip,
switchMap,
take,
timeout
} from 'rxjs';
import { withLatestFrom } from 'rxjs/operators';
import { createTerminalStream, killTerminal, makeProcessWritable } from './webcontainer.helpers';
Expand Down Expand Up @@ -48,9 +54,17 @@ export class WebContainerRunner {
};
private watcher: IFSWatcher | null = null;

private readonly progressWritable = signal({currentStep: 0, totalSteps: 3, label: 'Initializing web-container'});

/**
* Progress indicator of the loading of a project.
*/
public readonly progress = this.progressWritable.asReadonly();

constructor() {
const destroyRef = inject(DestroyRef);
this.instancePromise = WebContainer.boot().then((instance) => {
this.progressWritable.update(({totalSteps}) => ({currentStep: 1, totalSteps, label: 'Loading project'}));
// eslint-disable-next-line no-console
const unsubscribe = instance.on('error', console.error);
destroyRef.onDestroy(() => unsubscribe());
Expand All @@ -65,20 +79,33 @@ export class WebContainerRunner {
void this.runCommand(commandElements[0], commandElements.slice(1), cwd);
});

this.iframe.pipe(
filter((iframe): iframe is HTMLIFrameElement => !!iframe),
distinctUntilChanged(),
withLatestFrom(this.instancePromise),
switchMap(([iframe, instance]) => new Observable((subscriber) => {
combineLatest([
this.iframe.pipe(
filter((iframe): iframe is HTMLIFrameElement => !!iframe),
distinctUntilChanged()
),
this.instancePromise
]).pipe(
switchMap(([iframe, instance]) => new Observable<[typeof iframe, typeof instance, boolean]>((subscriber) => {
let shouldSkipFirstLoadEvent = true;
const serverReadyUnsubscribe = instance.on('server-ready', (_port: number, url: string) => {
this.progressWritable.update(({totalSteps}) => ({currentStep: totalSteps - 1, totalSteps, label: 'Bootstrapping application...'}));
iframe.removeAttribute('srcdoc');
iframe.src = url;
subscriber.next(url);
subscriber.next([iframe, instance, shouldSkipFirstLoadEvent]);
shouldSkipFirstLoadEvent = false;
});
return () => serverReadyUnsubscribe();
})),
switchMap(([iframe, _instance, shouldSkipFirstLoadEvent]) => fromEvent(iframe, 'load').pipe(
skip(shouldSkipFirstLoadEvent ? 1 : 0),
timeout({each: 20000, with: () => of([])}),
take(1)
)),
takeUntilDestroyed()
).subscribe();
).subscribe(() => {
this.progressWritable.update(({totalSteps}) => ({currentStep: totalSteps, totalSteps, label: 'Ready!'}));
});

this.commandOutput.process.pipe(
filter((process): process is WebContainerProcess => !!process && !process.output.locked),
Expand Down Expand Up @@ -152,10 +179,35 @@ export class WebContainerRunner {
const process = await instance.spawn(command, args, {cwd: cwd});
this.commandOutput.process.next(process);
const exitCode = await process.exit;
if (exitCode !== 0) {
if (exitCode === 0) {
// The process has ended successfully
this.progressWritable.update(({currentStep, totalSteps}) => ({
currentStep: currentStep + 1,
totalSteps,
label: this.getCommandLabel(this.commands.value.queue[1])
}));
this.commands.next({queue: this.commands.value.queue.slice(1), cwd});
} else if (exitCode === 143) {
// The process was killed by switching to a new project
return;
} else {
// The process has ended with an error
throw new Error(`Command ${[command, ...args].join(' ')} failed with ${exitCode}!`);
}
this.commands.next({queue: this.commands.value.queue.slice(1), cwd});
}

private getCommandLabel(command: string) {
if (!command) {
return 'Waiting for server to start...';
} else {
if (/(npm|yarn) install/.test(command)) {
return 'Installing dependencies...';
} else if (/^(npm|yarn) (run .*:(build|serve)|(run )?build)/.test(command)) {
return 'Building application...';
} else {
return `Executing \`${command}\``;
}
}
}

/**
Expand All @@ -182,6 +234,7 @@ export class WebContainerRunner {
await instance.mount({[projectFolder]: {directory: files}});
}
this.treeUpdateCallback();
this.progressWritable.set({currentStep: 2, totalSteps: 3 + commands.length, label: this.getCommandLabel(commands[0])});
this.commands.next({queue: commands, cwd: projectFolder});
this.watcher = instance.fs.watch(`/${projectFolder}`, {encoding: 'utf8'}, this.treeUpdateCallback);
}
Expand Down

0 comments on commit fa68b0e

Please sign in to comment.