diff --git a/adev/src/app/editor/embedded-editor.component.html b/adev/src/app/editor/embedded-editor.component.html index 3445748862c..8d9ff642d94 100644 --- a/adev/src/app/editor/embedded-editor.component.html +++ b/adev/src/app/editor/embedded-editor.component.html @@ -1,81 +1,84 @@ -
- - - - @if (!displayOnlyTerminal()) { - - } - - - @if (displayOnlyTerminal()) { +@if (this.editorUiState.tutorialType() !== undefined) { +
+ @if (displayOnlyTerminal()) { - } - - - - - @if (!displayOnlyTerminal()) { - - - @if (!displayPreviewInMatTabGroup()) { - - -
-
- Preview -
- @if (!displayPreviewInMatTabGroup()) { - - } -
+ } @else { + + + - } - - - - @if (displayPreviewInMatTabGroup()) { - - - + + + + + @if (!displayPreviewInMatTabGroup()) { + + +
+
+ Preview +
+ @if (!displayPreviewInMatTabGroup()) { + + } +
+
} - - - Console @if (errorsCount()) { - error - - {{ errorsCount() }} - - } - - - - - - -
- + + + + @if (displayPreviewInMatTabGroup()) { + + + + } + + + Console + @if (errorsCount()) { + error + + {{ errorsCount() }} + + } + + + + + + + + + + +
- } -
- -
+ } +
+} diff --git a/adev/src/app/editor/node-runtime-sandbox.service.spec.ts b/adev/src/app/editor/node-runtime-sandbox.service.spec.ts index 9e22e908eab..be0b3442284 100644 --- a/adev/src/app/editor/node-runtime-sandbox.service.spec.ts +++ b/adev/src/app/editor/node-runtime-sandbox.service.spec.ts @@ -30,14 +30,14 @@ describe('NodeRuntimeSandbox', () => { let service: NodeRuntimeSandbox; const fakeTerminalHandler = { - interactiveTerminalInstance: { + interactiveTerminalInstance: signal({ write: (data: string) => {}, onData: (data: string) => {}, breakProcess$: observableOf(), - }, - readonlyTerminalInstance: { + }), + readonlyTerminalInstance: signal({ write: (data: string) => {}, - }, + }), clearTerminals: () => {}, }; @@ -194,16 +194,6 @@ describe('NodeRuntimeSandbox', () => { expect(renameFileSpy).toHaveBeenCalledOnceWith(oldPath, newPath); }); - it('should initialize the Angular CLI based on the tutorial config', async () => { - setValuesToInitializeAngularCLI(); - - const initAngularCliSpy = spyOn(service, 'initAngularCli' as any); - - await service.init(); - - expect(initAngularCliSpy).toHaveBeenCalled(); - }); - it('should initialize a project based on the tutorial config', async () => { service['webContainerPromise'] = Promise.resolve( new FakeWebContainer() as unknown as WebContainer, @@ -217,34 +207,6 @@ describe('NodeRuntimeSandbox', () => { expect(initProjectSpy).toHaveBeenCalled(); }); - it('should cleanup when initializing the Angular CLI if a project was initialized before', async () => { - const cleanupSpy = spyOn(service, 'cleanup' as any); - - setValuesToInitializeProject(); - await service.init(); - - expect(cleanupSpy).not.toHaveBeenCalled(); - - setValuesToInitializeAngularCLI(); - await service.init(); - - expect(cleanupSpy).toHaveBeenCalledOnceWith(); - }); - - it('should cleanup when initializing a project if the Angular CLI was initialized before', async () => { - const cleanupSpy = spyOn(service, 'cleanup' as any); - - setValuesToInitializeAngularCLI(); - await service.init(); - - expect(cleanupSpy).not.toHaveBeenCalled(); - - setValuesToInitializeProject(); - await service.init(); - - expect(cleanupSpy).toHaveBeenCalledOnceWith(); - }); - it("should set the error state when an out of memory message is received from the web container's output", async () => { service['webContainerPromise'] = Promise.resolve( new FakeWebContainer() as unknown as WebContainer, diff --git a/adev/src/app/editor/node-runtime-sandbox.service.ts b/adev/src/app/editor/node-runtime-sandbox.service.ts index f036194ff36..2a98d781371 100644 --- a/adev/src/app/editor/node-runtime-sandbox.service.ts +++ b/adev/src/app/editor/node-runtime-sandbox.service.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {DestroyRef, inject, Injectable, signal} from '@angular/core'; +import {DestroyRef, effect, inject, Injectable, signal} from '@angular/core'; import {FileSystemTree, WebContainer, WebContainerProcess} from '@webcontainer/api'; import {BehaviorSubject, filter, map, Subject} from 'rxjs'; @@ -66,6 +66,20 @@ export class NodeRuntimeSandbox { private devServerProcess: WebContainerProcess | undefined; private webContainerPromise: Promise | undefined; + constructor() { + effect(() => { + const terminal = this.terminalHandler.interactiveTerminalInstance(); + terminal.onData((data) => { + this.interactiveShellWriter?.write(data); + }); + + terminal.breakProcess$.subscribe(() => { + // Write CTRL + C into shell to break active process + this.interactiveShellWriter?.write('\x03'); + }); + }); + } + get previewUrl$() { return this._previewUrl$; } @@ -97,11 +111,8 @@ export class NodeRuntimeSandbox { await this.startInteractiveTerminal(webContainer); this.terminalHandler.clearTerminals(); - if (this.embeddedTutorialManager.type() === TutorialType.CLI) { - await this.initAngularCli(); - } else { - await this.initProject(); - } + const startDevServer = this.embeddedTutorialManager.type() !== TutorialType.CLI; + await this.initProject(startDevServer); console.timeEnd('Load time'); } catch (error: any) { @@ -163,7 +174,7 @@ export class NodeRuntimeSandbox { /** * Initialize the WebContainer for an Angular project */ - private async initProject(): Promise { + private async initProject(startDevServer: boolean): Promise { // prevent re-initialization if (this._isProjectInitialized()) return; @@ -185,7 +196,11 @@ export class NodeRuntimeSandbox { if (![PROCESS_EXIT_CODE.SIGTERM, PROCESS_EXIT_CODE.SUCCESS].includes(exitCode)) throw new Error('Installation failed'); - await Promise.all([this.loadTypes(), this.startDevServer()]); + await Promise.all([ + this.loadTypes(), + startDevServer ? this.startDevServer() : Promise.resolve(), + ]); + this.setLoading(LoadingStep.READY); } private handleProjectChanges() { @@ -235,30 +250,6 @@ export class NodeRuntimeSandbox { await Promise.all([this.loadTypes(), this.startDevServer()]); } - /** - * Initialize the WebContainer for the Angular CLI - */ - private async initAngularCli() { - // prevent re-initialization - if (this._isAngularCliInitialized()) return; - - // clean up the sandbox if a project was initialized before so the CLI can - // be initialized without conflicts - if (this._isProjectInitialized()) { - await this.cleanup(); - this.urlToPreview$.next(null); - this._isProjectInitialized.set(false); - } - - this._isAngularCliInitialized.set(true); - - this.setLoading(LoadingStep.INSTALL); - const exitCode = await this.installAngularCli(); - - if (![PROCESS_EXIT_CODE.SIGTERM, PROCESS_EXIT_CODE.SUCCESS].includes(exitCode)) - this.setLoading(LoadingStep.READY); - } - async writeFile(path: string, content: string | Uint8Array): Promise { const webContainer = await this.webContainerPromise!; @@ -309,8 +300,6 @@ export class NodeRuntimeSandbox { // return existing shell process if it's already running if (this.interactiveShellProcess) return this.interactiveShellProcess; - const terminal = this.terminalHandler.interactiveTerminalInstance; - // use WebContainer spawn directly so that the process isn't killed on // cleanup const shellProcess = await webContainer.spawn('bash'); @@ -324,7 +313,7 @@ export class NodeRuntimeSandbox { new WritableStream({ write: (data) => { this.checkForOutOfMemoryError(data.toString()); - terminal.write(data); + this.terminalHandler.interactiveTerminalInstance().write(data); if (data.includes('CREATE') && data.endsWith('\r\n')) { const match = data.match(ngGenerateTerminalOutputRegex); @@ -339,17 +328,7 @@ export class NodeRuntimeSandbox { }), ); - const input = shellProcess.input.getWriter(); - this.interactiveShellWriter = input; - - terminal.onData((data) => { - input.write(data); - }); - - terminal.breakProcess$.subscribe(() => { - // Write CTRL + C into shell to break active process - input.write('\x03'); - }); + this.interactiveShellWriter = shellProcess.input.getWriter(); return shellProcess; } @@ -436,13 +415,17 @@ export class NodeRuntimeSandbox { installProcess.output.pipeTo( new WritableStream({ write: (data) => { - this.terminalHandler.readonlyTerminalInstance.write(data); + this.terminalHandler.readonlyTerminalInstance().write(data); + this.terminalHandler.interactiveTerminalInstance().write(data); }, }), ); // wait for install command to exit - return installProcess.exit; + const code = await installProcess.exit; + // Simulate pressing `Enter` in shell + this.interactiveShellWriter?.write('\x0D'); + return code; } private async loadTypes() { @@ -450,26 +433,6 @@ export class NodeRuntimeSandbox { await this.typingsLoader.retrieveTypeDefinitions(webContainer!); } - private async installAngularCli(): Promise { - // install Angular CLI - const installProcess = await this.spawn(PACKAGE_MANAGER, ['install', '@angular/cli@latest']); - - installProcess.output.pipeTo( - new WritableStream({ - write: (data) => { - this.terminalHandler.interactiveTerminalInstance.write(data); - }, - }), - ); - - const exitCode = await installProcess.exit; - - // Simulate pressing `Enter` in shell - this.interactiveShellWriter?.write('\x0D'); - - return exitCode; - } - private async startDevServer(): Promise { const webContainer = await this.webContainerPromise!; @@ -492,7 +455,7 @@ export class NodeRuntimeSandbox { this.devServerProcess.output.pipeTo( new WritableStream({ write: (data) => { - this.terminalHandler.readonlyTerminalInstance.write(data); + this.terminalHandler.readonlyTerminalInstance().write(data); if (this.checkForOutOfMemoryError(data.toString())) { reject(new Error(data.toString())); diff --git a/adev/src/app/editor/terminal/interactive-terminal.ts b/adev/src/app/editor/terminal/interactive-terminal.ts index 7a97964d9eb..33ed288cf61 100644 --- a/adev/src/app/editor/terminal/interactive-terminal.ts +++ b/adev/src/app/editor/terminal/interactive-terminal.ts @@ -6,12 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ -import {inject} from '@angular/core'; import {Subject} from 'rxjs'; import {Terminal} from '@xterm/xterm'; -import {WINDOW} from '@angular/docs'; - import {CommandValidator} from './command-validator.service'; export const NOT_VALID_COMMAND_MSG = 'Angular Documentation - Not allowed command!'; @@ -25,15 +22,15 @@ export const ALLOWED_KEYS: Array = [ ]; export class InteractiveTerminal extends Terminal { - private readonly window = inject(WINDOW); - private readonly commandValidator = inject(CommandValidator); - private readonly breakProcess = new Subject(); // Using this stream, the webcontainer shell can break current process. breakProcess$ = this.breakProcess.asObservable(); - constructor() { + constructor( + readonly window: Window, + readonly commandValidator: CommandValidator, + ) { super({convertEol: true, disableStdin: false}); // bypass command validation if sudo=true is present in the query string @@ -46,6 +43,11 @@ export class InteractiveTerminal extends Terminal { this.breakProcess.next(); } + override dispose(): void { + super.dispose(); + this.breakProcess.complete(); + } + // Method validate if provided command by user is on the list of the allowed commands. // If so, then command is executed, otherwise error message is displayed in the terminal. private handleCommandExecution(): void { diff --git a/adev/src/app/editor/terminal/terminal-handler.service.ts b/adev/src/app/editor/terminal/terminal-handler.service.ts index 20f7392dd96..2ae133803c7 100644 --- a/adev/src/app/editor/terminal/terminal-handler.service.ts +++ b/adev/src/app/editor/terminal/terminal-handler.service.ts @@ -6,10 +6,12 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Injectable} from '@angular/core'; +import {computed, inject, Injectable, signal, Signal, untracked} from '@angular/core'; import {Terminal} from '@xterm/xterm'; import {FitAddon} from '@xterm/addon-fit'; import {InteractiveTerminal} from './interactive-terminal'; +import {WINDOW} from '@angular/docs'; +import {CommandValidator} from './command-validator.service'; export enum TerminalType { READONLY, @@ -18,47 +20,66 @@ export enum TerminalType { @Injectable({providedIn: 'root'}) export class TerminalHandler { + private readonly window = inject(WINDOW); + private readonly commandValidator = inject(CommandValidator); + private terminals = { // Passing a theme with CSS custom properties colors does not work // Because colors are parsed // See https://github.com/xtermjs/xterm.js/blob/854e2736f66ca3e5d3ab5a7b65bf3fd6fba8b707/src/browser/services/ThemeService.ts#L125 - [TerminalType.READONLY]: { + [TerminalType.READONLY]: signal({ instance: new Terminal({convertEol: true, disableStdin: true}), fitAddon: new FitAddon(), - }, - [TerminalType.INTERACTIVE]: { - instance: new InteractiveTerminal(), + }), + [TerminalType.INTERACTIVE]: signal({ + instance: new InteractiveTerminal(this.window, this.commandValidator), fitAddon: new FitAddon(), - }, - } as const; + }), + }; constructor() { // Load fitAddon for each terminal instance - for (const {instance, fitAddon} of Object.values(this.terminals)) { + for (const val of Object.values(this.terminals)) { + const {instance, fitAddon} = untracked(val); instance.loadAddon(fitAddon); } } - get readonlyTerminalInstance(): Terminal { - return this.terminals[TerminalType.READONLY].instance; + get readonlyTerminalInstance(): Signal { + return computed(() => this.terminals[TerminalType.READONLY]().instance); } - get interactiveTerminalInstance(): InteractiveTerminal { - return this.terminals[TerminalType.INTERACTIVE].instance; + get interactiveTerminalInstance(): Signal { + return computed(() => this.terminals[TerminalType.INTERACTIVE]().instance); } registerTerminal(type: TerminalType, element: HTMLElement): void { - const terminal = this.terminals[type]; - this.mapTerminalToElement(terminal.instance, terminal.fitAddon, element); + let {instance, fitAddon} = untracked(this.terminals[type]); + if (instance.element && element !== instance.element) { + instance.dispose(); + fitAddon = new FitAddon(); + if (type === TerminalType.READONLY) { + instance = new Terminal({convertEol: true, disableStdin: true}); + this.terminals[type].set({instance, fitAddon}); + } else { + const newInstance = new InteractiveTerminal(this.window, this.commandValidator); + instance = newInstance; + this.terminals[type].set({instance: newInstance, fitAddon}); + } + instance.loadAddon(fitAddon); + } + this.mapTerminalToElement(instance, fitAddon, element); } resizeToFitParent(type: TerminalType): void { - this.terminals[type]?.fitAddon.fit(); + untracked(this.terminals[type])?.fitAddon.fit(); } clearTerminals() { - this.terminals[TerminalType.READONLY].instance.clear(); - this.terminals[TerminalType.INTERACTIVE].instance.clear(); + untracked(() => { + this.terminals[TerminalType.READONLY]().instance.clear(); + this.terminals[TerminalType.INTERACTIVE]().instance.clear(); + }); } private mapTerminalToElement(terminal: Terminal, fitAddon: FitAddon, element: HTMLElement): void { diff --git a/adev/src/app/editor/terminal/terminal.component.html b/adev/src/app/editor/terminal/terminal.component.html deleted file mode 100644 index cfb942dc222..00000000000 --- a/adev/src/app/editor/terminal/terminal.component.html +++ /dev/null @@ -1 +0,0 @@ -
diff --git a/adev/src/app/editor/terminal/terminal.component.spec.ts b/adev/src/app/editor/terminal/terminal.component.spec.ts index f24cae34e3e..09efec9fd0c 100644 --- a/adev/src/app/editor/terminal/terminal.component.spec.ts +++ b/adev/src/app/editor/terminal/terminal.component.spec.ts @@ -7,44 +7,63 @@ */ import {ComponentFixture, TestBed} from '@angular/core/testing'; -import {By} from '@angular/platform-browser'; +import {DOCUMENT, inject} from '@angular/core'; import {Terminal} from './terminal.component'; -import {TerminalHandler, TerminalType} from './terminal-handler.service'; -import {FakeEventTarget, WINDOW} from '@angular/docs'; +import {TerminalType, TerminalHandler} from './terminal-handler.service'; +import {WINDOW} from '@angular/docs'; describe('Terminal', () => { - let component: Terminal; let fixture: ComponentFixture; - let terminalHandlerSpy: jasmine.SpyObj; - - const fakeWindow = new FakeEventTarget(); - beforeEach(async () => { - terminalHandlerSpy = jasmine.createSpyObj('TerminalHandler', [ - 'registerTerminal', - 'resizeToFitParent', - ]); - - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [Terminal], providers: [ - {provide: TerminalHandler, useValue: terminalHandlerSpy}, { provide: WINDOW, - useValue: fakeWindow, + useFactory: () => inject(DOCUMENT).defaultView, }, ], }); fixture = TestBed.createComponent(Terminal); - component = fixture.componentInstance; - component.type = TerminalType.READONLY; - fixture.detectChanges(); + fixture.componentRef.setInput('type', TerminalType.READONLY); + await fixture.whenStable(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should write to terminal', async () => { + await expectWriteStringToTerminal('test string'); }); + + it('should write to terminal when using new instance', async () => { + await expectWriteStringToTerminal('first value'); + fixture.destroy(); + + fixture = TestBed.createComponent(Terminal); + fixture.componentRef.setInput('type', TerminalType.READONLY); + await fixture.whenStable(); + + await expectWriteStringToTerminal('second value'); + }); + + async function expectWriteStringToTerminal(v: string) { + TestBed.inject(TerminalHandler).readonlyTerminalInstance().write(v); + await expectAsync(until(() => fixture.nativeElement.innerHTML.indexOf(v) > -1)).toBeResolved(); + } }); + +async function until(fn: () => T): Promise { + const timeout = 100; + const start = performance.now(); + while (true) { + const result = fn(); + if (result) { + return result; + } + if (performance.now() - start > timeout) { + throw new Error(`condition not satisfied within ${timeout}ms.`); + } + await new Promise((r) => setTimeout(r, 1)); + } +} diff --git a/adev/src/app/editor/terminal/terminal.component.ts b/adev/src/app/editor/terminal/terminal.component.ts index 43ee4f11fc2..fd6c7b806ed 100644 --- a/adev/src/app/editor/terminal/terminal.component.ts +++ b/adev/src/app/editor/terminal/terminal.component.ts @@ -7,62 +7,56 @@ */ import { - AfterViewInit, ChangeDetectionStrategy, Component, - DestroyRef, ElementRef, - Input, + input, ViewEncapsulation, + afterNextRender, inject, viewChild, + DestroyRef, } from '@angular/core'; - -import {debounceTime} from 'rxjs/operators'; -import {TerminalHandler, TerminalType} from './terminal-handler.service'; -import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {Subject} from 'rxjs'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {debounceTime} from 'rxjs/operators'; + +import {TerminalHandler, TerminalType} from './terminal-handler.service'; @Component({ selector: 'docs-tutorial-terminal', - templateUrl: './terminal.component.html', + template: '
', styleUrls: ['./terminal.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [], // ViewEncapsulation is disabled to allow Xterm.js's styles to be applied // to the terminal element. encapsulation: ViewEncapsulation.None, }) -export class Terminal implements AfterViewInit { - @Input({required: true}) type!: TerminalType; - readonly terminalElementRef = viewChild.required>('terminalOutput'); +export class Terminal { + readonly type = input.required(); + private readonly terminalElementRef = + viewChild.required>('terminalOutput'); private readonly destroyRef = inject(DestroyRef); private readonly terminalHandler = inject(TerminalHandler); - private readonly resize$ = new Subject(); - - ngAfterViewInit() { - this.terminalHandler.registerTerminal(this.type, this.terminalElementRef().nativeElement); - - this.setResizeObserver(); - - this.resize$.pipe(debounceTime(50), takeUntilDestroyed(this.destroyRef)).subscribe(() => { - this.handleResize(); + constructor() { + afterNextRender({ + read: () => { + this.terminalHandler.registerTerminal(this.type(), this.terminalElementRef().nativeElement); + this.setResizeObserver(); + }, }); } private setResizeObserver(): void { - const resizeObserver = new ResizeObserver((_) => { - this.resize$.next(); - }); + const resize = new Subject(); + resize + .pipe(debounceTime(50), takeUntilDestroyed(this.destroyRef)) + .subscribe(() => void this.terminalHandler.resizeToFitParent(this.type())); + const resizeObserver = new ResizeObserver(() => void resize.next()); resizeObserver.observe(this.terminalElementRef().nativeElement); - - this.destroyRef.onDestroy(() => resizeObserver.disconnect()); - } - - private handleResize(): void { - this.terminalHandler.resizeToFitParent(this.type); + this.destroyRef.onDestroy(() => void resizeObserver.disconnect()); } } diff --git a/adev/src/app/features/tutorial/tutorial.component.ts b/adev/src/app/features/tutorial/tutorial.component.ts index 710791fdb00..9ec7c6cb191 100644 --- a/adev/src/app/features/tutorial/tutorial.component.ts +++ b/adev/src/app/features/tutorial/tutorial.component.ts @@ -195,7 +195,10 @@ export default class Tutorial { if (routeData.type === TutorialType.LOCAL) { this.setLocalTutorialData(routeData); - } else if (routeData.type === TutorialType.EDITOR && this.isBrowser) { + } else if ( + (routeData.type === TutorialType.EDITOR || routeData.type === TutorialType.CLI) && + this.isBrowser + ) { await this.setEditorTutorialData( tutorialNavigationItem.path.replace(`${PagePrefix.TUTORIALS}/`, ''), );