mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
fix(docs-infra): Correctly support the CLI tutorial type (#61246)
There were a myriad of problems when using the type: 'cli' for a tutorial: * Editor component would flicker the non-cli version first since that was the default value. Fixed by not showing anything until type is available * Editor component still rendered a split screen when there was no preview. Fixed * Project initialization was incorrect because the terminal would appear to be available but really didn't work until dependencies were done installing. Fixed by sharing initialization with regular project init and piping the install output to the interactive terminal * Terminals would not work when going from type: 'cli' to another or vice versa. This is because the element instance of a terminal cannot change once it is opened. Fixed by dispoing and recreating terminals when the element changes PR Close #61246
This commit is contained in:
committed by
Jessica Janiuk
parent
02088d5c14
commit
cabca2d93f
@@ -1,81 +1,84 @@
|
||||
<div class="adev-editor-container" #editorContainer>
|
||||
<as-split class="docs-editor" [direction]="splitDirection()" restrictMove="true" gutterSize="5">
|
||||
<as-split-area class="adev-left-side" size="50">
|
||||
<!-- Code Editor -->
|
||||
@if (!displayOnlyTerminal()) {
|
||||
<docs-tutorial-code-editor class="adev-tutorial-code-editor" />
|
||||
}
|
||||
|
||||
<!-- CLI Terminal -->
|
||||
@if (displayOnlyTerminal()) {
|
||||
@if (this.editorUiState.tutorialType() !== undefined) {
|
||||
<div class="adev-editor-container" #editorContainer>
|
||||
@if (displayOnlyTerminal()) {
|
||||
<docs-tutorial-terminal
|
||||
class="docs-tutorial-terminal-only"
|
||||
[type]="TerminalType.INTERACTIVE"
|
||||
/>
|
||||
}
|
||||
</as-split-area>
|
||||
|
||||
<as-split-area size="50">
|
||||
<!-- Preview, Terminal & Console -->
|
||||
@if (!displayOnlyTerminal()) {
|
||||
<as-split class="docs-right-side" direction="vertical" restrictMove="true" gutterSize="5">
|
||||
<!-- Preview Section: for larger screens -->
|
||||
@if (!displayPreviewInMatTabGroup()) {
|
||||
<as-split-area size="50">
|
||||
<!-- Preview Section: for larger screens -->
|
||||
<div class="adev-preview-section">
|
||||
<div class="adev-preview-header">
|
||||
<span>Preview</span>
|
||||
</div>
|
||||
@if (!displayPreviewInMatTabGroup()) {
|
||||
<docs-tutorial-preview />
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<as-split class="docs-editor" [direction]="splitDirection()" restrictMove="true" gutterSize="5">
|
||||
<as-split-area class="adev-left-side" size="50">
|
||||
<docs-tutorial-code-editor class="adev-tutorial-code-editor" />
|
||||
</as-split-area>
|
||||
}
|
||||
|
||||
<as-split-area class="docs-editor-tabs-and-refresh" size="50">
|
||||
<!-- Container to hide preview, console and footer when only the interactive terminal is used -->
|
||||
<mat-tab-group class="docs-editor-tabs" animationDuration="0ms" mat-stretch-tabs="false">
|
||||
@if (displayPreviewInMatTabGroup()) {
|
||||
<mat-tab label="Preview">
|
||||
<docs-tutorial-preview />
|
||||
</mat-tab>
|
||||
<as-split-area size="50">
|
||||
<!-- Preview, Terminal & Console -->
|
||||
<as-split class="docs-right-side" direction="vertical" restrictMove="true" gutterSize="5">
|
||||
<!-- Preview Section: for larger screens -->
|
||||
@if (!displayPreviewInMatTabGroup()) {
|
||||
<as-split-area size="50">
|
||||
<!-- Preview Section: for larger screens -->
|
||||
<div class="adev-preview-section">
|
||||
<div class="adev-preview-header">
|
||||
<span>Preview</span>
|
||||
</div>
|
||||
@if (!displayPreviewInMatTabGroup()) {
|
||||
<docs-tutorial-preview />
|
||||
}
|
||||
</div>
|
||||
</as-split-area>
|
||||
}
|
||||
<mat-tab label="Console">
|
||||
<ng-template mat-tab-label>
|
||||
Console @if (errorsCount()) {
|
||||
<docs-icon class="docs-icon_high-contrast">error</docs-icon>
|
||||
<span>
|
||||
{{ errorsCount() }}
|
||||
</span>
|
||||
}
|
||||
</ng-template>
|
||||
<docs-tutorial-terminal
|
||||
[type]="TerminalType.READONLY"
|
||||
class="docs-tutorial-terminal"
|
||||
/>
|
||||
</mat-tab>
|
||||
<mat-tab label="Terminal">
|
||||
<docs-tutorial-terminal
|
||||
[type]="TerminalType.INTERACTIVE"
|
||||
class="docs-tutorial-terminal"
|
||||
/>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="reset()"
|
||||
title="Refresh the preview"
|
||||
[disabled]="!shouldEnableReset()"
|
||||
class="adev-refresh-btn"
|
||||
>
|
||||
<docs-icon class="docs-icon">refresh</docs-icon>
|
||||
</button>
|
||||
<as-split-area class="docs-editor-tabs-and-refresh" size="50">
|
||||
<!-- Container to hide preview, console and footer when only the interactive terminal is used -->
|
||||
<mat-tab-group
|
||||
class="docs-editor-tabs"
|
||||
animationDuration="0ms"
|
||||
mat-stretch-tabs="false"
|
||||
[selectedIndex]="selectedTabIndex()"
|
||||
(selectedIndexChange)="selectedTabIndex.set($event)"
|
||||
>
|
||||
@if (displayPreviewInMatTabGroup()) {
|
||||
<mat-tab label="Preview">
|
||||
<docs-tutorial-preview />
|
||||
</mat-tab>
|
||||
}
|
||||
<mat-tab label="Console">
|
||||
<ng-template mat-tab-label>
|
||||
Console
|
||||
@if (errorsCount()) {
|
||||
<docs-icon class="docs-icon_high-contrast">error</docs-icon>
|
||||
<span>
|
||||
{{ errorsCount() }}
|
||||
</span>
|
||||
}
|
||||
</ng-template>
|
||||
<docs-tutorial-terminal
|
||||
[type]="TerminalType.READONLY"
|
||||
class="docs-tutorial-terminal"
|
||||
/>
|
||||
</mat-tab>
|
||||
<mat-tab label="Terminal">
|
||||
<docs-tutorial-terminal
|
||||
[type]="TerminalType.INTERACTIVE"
|
||||
class="docs-tutorial-terminal"
|
||||
/>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="reset()"
|
||||
title="Refresh the preview"
|
||||
[disabled]="!shouldEnableReset()"
|
||||
class="adev-refresh-btn"
|
||||
>
|
||||
<docs-icon class="docs-icon">refresh</docs-icon>
|
||||
</button>
|
||||
</as-split-area>
|
||||
</as-split>
|
||||
</as-split-area>
|
||||
</as-split>
|
||||
}
|
||||
</as-split-area>
|
||||
</as-split>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<WebContainer> | 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<void> {
|
||||
private async initProject(startDevServer: boolean): Promise<void> {
|
||||
// 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<void> {
|
||||
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<number> {
|
||||
// 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<void> {
|
||||
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()));
|
||||
|
||||
@@ -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<KeyboardEvent['key']> = [
|
||||
];
|
||||
|
||||
export class InteractiveTerminal extends Terminal {
|
||||
private readonly window = inject(WINDOW);
|
||||
private readonly commandValidator = inject(CommandValidator);
|
||||
|
||||
private readonly breakProcess = new Subject<void>();
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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<Terminal> {
|
||||
return computed(() => this.terminals[TerminalType.READONLY]().instance);
|
||||
}
|
||||
|
||||
get interactiveTerminalInstance(): InteractiveTerminal {
|
||||
return this.terminals[TerminalType.INTERACTIVE].instance;
|
||||
get interactiveTerminalInstance(): Signal<InteractiveTerminal> {
|
||||
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 {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<div #terminalOutput class="adev-terminal-output"></div>
|
||||
@@ -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<Terminal>;
|
||||
|
||||
let terminalHandlerSpy: jasmine.SpyObj<TerminalHandler>;
|
||||
|
||||
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<T>(fn: () => T): Promise<T> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '<div #terminalOutput class="adev-terminal-output"></div>',
|
||||
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<ElementRef<HTMLElement>>('terminalOutput');
|
||||
export class Terminal {
|
||||
readonly type = input.required<TerminalType>();
|
||||
private readonly terminalElementRef =
|
||||
viewChild.required<ElementRef<HTMLElement>>('terminalOutput');
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly terminalHandler = inject(TerminalHandler);
|
||||
|
||||
private readonly resize$ = new Subject<void>();
|
||||
|
||||
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<void>();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}/`, ''),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user