Skip to content

Commit fa68b0e

Browse files
committed
feat(training): loading progress indicator
1 parent f19fa65 commit fa68b0e

File tree

6 files changed

+125
-46
lines changed

6 files changed

+125
-46
lines changed

apps/showcase/src/components/training/code-editor-control/code-editor-control.component.html

+7-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22
<li ngbNavItem class="nav-item" role="presentation" [destroyOnHide]="false">
33
<a ngbNavLink class="nav-link" [class.active]="activeTab === 'preview'" (click)="activeTab = 'preview'">Preview</a>
44
<ng-template ngbNavContent>
5-
<iframe class="w-100 h-100" #iframe allow="cross-origin-isolated" [srcdoc]="'Loading...'"></iframe>
5+
@let isLoading = percentProgress() < 100;
6+
@if (isLoading) {
7+
<div class="w-100 h-100 d-flex align-items-center justify-content-center p-2">
8+
<df-progressbar [value]="percentProgress()" [text]="progressLabel()"></df-progressbar>
9+
</div>
10+
}
11+
<iframe class="w-100 h-100" #iframe allow="cross-origin-isolated" [srcdoc]="'Loading...'" [class.invisible]="isLoading"></iframe>
612
</ng-template>
713
</li>
814
<li ngbNavItem class="nav-item" role="presentation" [destroyOnHide]="false">

apps/showcase/src/components/training/code-editor-control/code-editor-control.component.scss

+4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ code-editor-control {
99
height: 100%;
1010
}
1111

12+
df-progressbar {
13+
min-width: 15em;
14+
}
15+
1216
.terminal-active-indicator {
1317
display: inline-block;
1418
font-weight: bold;

apps/showcase/src/components/training/code-editor-control/code-editor-control.component.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
AfterViewInit,
33
ChangeDetectionStrategy,
44
Component,
5+
computed,
56
ElementRef,
67
inject,
78
Input,
@@ -10,6 +11,7 @@ import {
1011
ViewEncapsulation
1112
} from '@angular/core';
1213
import {toSignal} from '@angular/core/rxjs-interop';
14+
import {DfProgressbarModule} from '@design-factory/design-factory';
1315
import {NgbNavModule} from '@ng-bootstrap/ng-bootstrap';
1416
import {distinctUntilChanged, map, of, repeat, Subject, throttleTime, timeout} from 'rxjs';
1517
import {WebContainerService} from '../../../services';
@@ -20,7 +22,8 @@ import {CodeEditorTerminalComponent} from '../code-editor-terminal';
2022
standalone: true,
2123
imports: [
2224
CodeEditorTerminalComponent,
23-
NgbNavModule
25+
NgbNavModule,
26+
DfProgressbarModule
2427
],
2528
changeDetection: ChangeDetectionStrategy.OnPush,
2629
encapsulation: ViewEncapsulation.None,
@@ -54,6 +57,19 @@ export class CodeEditorControlComponent implements OnDestroy, AfterViewInit {
5457
*/
5558
public readonly terminalActivity = new Subject<void>();
5659

60+
/**
61+
* Loading progression (between 0 and 100)
62+
*/
63+
public readonly percentProgress = computed(() => {
64+
const { currentStep, totalSteps } = this.webContainerService.runner.progress();
65+
return Math.round(100 * currentStep / totalSteps);
66+
});
67+
68+
/**
69+
* Label to use on the load indicator
70+
*/
71+
public readonly progressLabel = computed(() => this.webContainerService.runner.progress().label);
72+
5773
/**
5874
* Signal with value `true` if the terminal is active, `false` if idle
5975
* The terminal is considered idle if no activity is received for 2 seconds.
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,30 @@
1-
@if (cwdTree$ | async; as tree) {
2-
<as-split direction="vertical">
3-
<as-split-area [size]="50">
4-
<form [formGroup]="form" class="editor overflow-hidden h-100">
5-
<as-split direction="horizontal">
6-
<as-split-area [size]="25">
7-
<monaco-tree (clickFile)="onClickFile($event)"
8-
[tree]="tree"
9-
[currentFile]="project?.startingFile"
10-
[width]="'auto'"
11-
[height]="'auto'"
12-
class="w-100"></monaco-tree>
13-
</as-split-area>
14-
<as-split-area [size]="75">
15-
<ngx-monaco-editor class="h-100 position-relative"
16-
[options]="editorOptions$ | async"
17-
formControlName="code">
18-
</ngx-monaco-editor>
19-
</as-split-area>
20-
</as-split>
21-
</form>
22-
</as-split-area>
23-
@if (editorMode === 'interactive') {
24-
<as-split-area [size]="50">
25-
<code-editor-control class="d-flex flex-column h-100" [showOutput]="project?.commands.length > 0 && tree.length > 0"></code-editor-control>
1+
<as-split direction="vertical">
2+
<as-split-area [size]="50">
3+
<form [formGroup]="form" class="editor overflow-hidden h-100">
4+
<as-split direction="horizontal">
5+
<as-split-area [size]="25">
6+
<monaco-tree (clickFile)="onClickFile($event)"
7+
[tree]="(cwdTree$ | async) || []"
8+
[currentFile]="project?.startingFile"
9+
[width]="'auto'"
10+
[height]="'auto'"
11+
class="w-100 editor-view"></monaco-tree>
2612
</as-split-area>
27-
}
28-
</as-split>
29-
}
30-
@else {
31-
<div class="spinner-border" role="status"></div>
32-
}
13+
<as-split-area [size]="75" class="editor-view">
14+
@let editorOptions = editorOptions$ | async;
15+
@if (editorOptions) {
16+
<ngx-monaco-editor class="h-100 position-relative"
17+
[options]="editorOptions"
18+
formControlName="code">
19+
</ngx-monaco-editor>
20+
}
21+
</as-split-area>
22+
</as-split>
23+
</form>
24+
</as-split-area>
25+
@if (editorMode === 'interactive') {
26+
<as-split-area [size]="50">
27+
<code-editor-control class="d-flex flex-column h-100"></code-editor-control>
28+
</as-split-area>
29+
}
30+
</as-split>

apps/showcase/src/components/training/code-editor-view/code-editor-view.component.scss

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
monaco-tree {
2-
background: #1d1d1d;
3-
42
.monaco-tree {
53
display: flex;
64
flex-direction: column;
@@ -24,3 +22,7 @@ ngx-monaco-editor {
2422
max-height: 100% !important;
2523
}
2624
}
25+
26+
.editor-view {
27+
background: #1d1d1d;
28+
}

apps/showcase/src/services/webcontainer/webcontainer-runner.ts

+64-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DestroyRef, inject, Injectable } from '@angular/core';
1+
import { DestroyRef, inject, Injectable, signal } from '@angular/core';
22
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
33
import {
44
type FileSystemTree,
@@ -9,13 +9,19 @@ import {
99
import { Terminal } from '@xterm/xterm';
1010
import {
1111
BehaviorSubject,
12+
combineLatest,
1213
combineLatestWith,
1314
distinctUntilChanged,
1415
filter,
1516
from,
17+
fromEvent,
1618
map,
1719
Observable,
18-
switchMap
20+
of,
21+
skip,
22+
switchMap,
23+
take,
24+
timeout
1925
} from 'rxjs';
2026
import { withLatestFrom } from 'rxjs/operators';
2127
import { createTerminalStream, killTerminal, makeProcessWritable } from './webcontainer.helpers';
@@ -48,9 +54,17 @@ export class WebContainerRunner {
4854
};
4955
private watcher: IFSWatcher | null = null;
5056

57+
private readonly progressWritable = signal({currentStep: 0, totalSteps: 3, label: 'Initializing web-container'});
58+
59+
/**
60+
* Progress indicator of the loading of a project.
61+
*/
62+
public readonly progress = this.progressWritable.asReadonly();
63+
5164
constructor() {
5265
const destroyRef = inject(DestroyRef);
5366
this.instancePromise = WebContainer.boot().then((instance) => {
67+
this.progressWritable.update(({totalSteps}) => ({currentStep: 1, totalSteps, label: 'Loading project'}));
5468
// eslint-disable-next-line no-console
5569
const unsubscribe = instance.on('error', console.error);
5670
destroyRef.onDestroy(() => unsubscribe());
@@ -65,20 +79,33 @@ export class WebContainerRunner {
6579
void this.runCommand(commandElements[0], commandElements.slice(1), cwd);
6680
});
6781

68-
this.iframe.pipe(
69-
filter((iframe): iframe is HTMLIFrameElement => !!iframe),
70-
distinctUntilChanged(),
71-
withLatestFrom(this.instancePromise),
72-
switchMap(([iframe, instance]) => new Observable((subscriber) => {
82+
combineLatest([
83+
this.iframe.pipe(
84+
filter((iframe): iframe is HTMLIFrameElement => !!iframe),
85+
distinctUntilChanged()
86+
),
87+
this.instancePromise
88+
]).pipe(
89+
switchMap(([iframe, instance]) => new Observable<[typeof iframe, typeof instance, boolean]>((subscriber) => {
90+
let shouldSkipFirstLoadEvent = true;
7391
const serverReadyUnsubscribe = instance.on('server-ready', (_port: number, url: string) => {
92+
this.progressWritable.update(({totalSteps}) => ({currentStep: totalSteps - 1, totalSteps, label: 'Bootstrapping application...'}));
7493
iframe.removeAttribute('srcdoc');
7594
iframe.src = url;
76-
subscriber.next(url);
95+
subscriber.next([iframe, instance, shouldSkipFirstLoadEvent]);
96+
shouldSkipFirstLoadEvent = false;
7797
});
7898
return () => serverReadyUnsubscribe();
7999
})),
100+
switchMap(([iframe, _instance, shouldSkipFirstLoadEvent]) => fromEvent(iframe, 'load').pipe(
101+
skip(shouldSkipFirstLoadEvent ? 1 : 0),
102+
timeout({each: 20000, with: () => of([])}),
103+
take(1)
104+
)),
80105
takeUntilDestroyed()
81-
).subscribe();
106+
).subscribe(() => {
107+
this.progressWritable.update(({totalSteps}) => ({currentStep: totalSteps, totalSteps, label: 'Ready!'}));
108+
});
82109

83110
this.commandOutput.process.pipe(
84111
filter((process): process is WebContainerProcess => !!process && !process.output.locked),
@@ -152,10 +179,35 @@ export class WebContainerRunner {
152179
const process = await instance.spawn(command, args, {cwd: cwd});
153180
this.commandOutput.process.next(process);
154181
const exitCode = await process.exit;
155-
if (exitCode !== 0) {
182+
if (exitCode === 0) {
183+
// The process has ended successfully
184+
this.progressWritable.update(({currentStep, totalSteps}) => ({
185+
currentStep: currentStep + 1,
186+
totalSteps,
187+
label: this.getCommandLabel(this.commands.value.queue[1])
188+
}));
189+
this.commands.next({queue: this.commands.value.queue.slice(1), cwd});
190+
} else if (exitCode === 143) {
191+
// The process was killed by switching to a new project
192+
return;
193+
} else {
194+
// The process has ended with an error
156195
throw new Error(`Command ${[command, ...args].join(' ')} failed with ${exitCode}!`);
157196
}
158-
this.commands.next({queue: this.commands.value.queue.slice(1), cwd});
197+
}
198+
199+
private getCommandLabel(command: string) {
200+
if (!command) {
201+
return 'Waiting for server to start...';
202+
} else {
203+
if (/(npm|yarn) install/.test(command)) {
204+
return 'Installing dependencies...';
205+
} else if (/^(npm|yarn) (run .*:(build|serve)|(run )?build)/.test(command)) {
206+
return 'Building application...';
207+
} else {
208+
return `Executing \`${command}\``;
209+
}
210+
}
159211
}
160212

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

0 commit comments

Comments
 (0)