Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"coverage": "jest build/test/* --silent --coverage"
},
"license": "(Apache-2.0)",
"devDependencies": {
"devDependencies": {
"@babel/preset-env": "^7.26.9",
"@babel/preset-typescript": "^7.27.0",
"@electron/rebuild": "3.7.1",
Expand Down Expand Up @@ -72,6 +72,7 @@
"@radix-ui/react-tooltip": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.19.0",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found only this version available for addon-webgl: "@xterm/addon-webgl": "0.18.0"

"@xterm/xterm": "^5.6.0-beta.119",
"assimpjs": "0.0.10",
"axios": "^1.12.0",
Expand Down
8 changes: 8 additions & 0 deletions editor/src/editor/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@
"component": "console",
"enableClose": false,
"enableRenderOnDemand": false
},
{
"type": "tab",
"id": "terminal",
"name": "Terminal",
"component": "terminal",
"enableClose": false,
"enableRenderOnDemand": false
}
]
}
Expand Down
8 changes: 7 additions & 1 deletion editor/src/editor/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { EditorConsole } from "./layout/console";
import { EditorInspector } from "./layout/inspector";
import { EditorAnimation } from "./layout/animation";
import { EditorAssetsBrowser } from "./layout/assets-browser";
import { EditorTerminal } from "./layout/terminal";

export interface IEditorLayoutProps {
/**
Expand Down Expand Up @@ -50,6 +51,10 @@ export class EditorLayout extends Component<IEditorLayoutProps> {
* The animation editor of the editor.
*/
public animations: EditorAnimation;
/**
* The terminal of the editor.
*/
public terminal: EditorTerminal;

private _layoutRef: Layout | null = null;
private _model: Model = Model.fromJson(layoutModel as any);
Expand All @@ -60,6 +65,7 @@ export class EditorLayout extends Component<IEditorLayoutProps> {
graph: <EditorGraph editor={this.props.editor} ref={(r) => (this.graph = r!)} />,
"assets-browser": <EditorAssetsBrowser editor={this.props.editor} ref={(r) => (this.assets = r!)} />,
animations: <EditorAnimation editor={this.props.editor} ref={(r) => (this.animations = r!)} />,
terminal: <EditorTerminal editor={this.props.editor} ref={(r) => (this.terminal = r!)} />,
};

private _layoutVersion: string = "5.0.0-alpha.2";
Expand Down Expand Up @@ -133,7 +139,7 @@ export class EditorLayout extends Component<IEditorLayoutProps> {
* If the tab is hidden, makes it visible and selected.
* @param tabId defines the id of the tab to make active.
*/
public selectTab(tabId: "graph" | "preview" | "assets-browser" | "console" | "inspector" | (string & {})): void {
public selectTab(tabId: "graph" | "preview" | "assets-browser" | "console" | "terminal" | "inspector" | (string & {})): void {
this._layoutRef?.props.model.doAction(Actions.selectTab(tabId));
}

Expand Down
208 changes: 208 additions & 0 deletions editor/src/editor/layout/terminal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { dirname } from "path/posix";
import { Component, ReactNode } from "react";

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";

import { Editor } from "../main";
import { execNodePty, NodePtyInstance } from "../../tools/node-pty";
import { projectConfiguration, onProjectConfigurationChangedObservable } from "../../project/configuration";

export interface IEditorTerminalProps {
editor: Editor;
}

export interface IEditorTerminalState {
hasProject: boolean;
}

export class EditorTerminal extends Component<IEditorTerminalProps, IEditorTerminalState> {
private _terminal: Terminal | null = null;
private _fitAddon: FitAddon | null = null;
private _webglAddon: WebglAddon | null = null;
private _pty: NodePtyInstance | null = null;
private _projectPath: string | null = null;

constructor(props: IEditorTerminalProps) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"public constructor"

super(props);
this.state = {
hasProject: projectConfiguration.path !== null,
};
}

public render(): ReactNode {
return (
<div className="relative w-full h-full">
<div className="sticky z-50 top-0 left-0 w-full h-10 bg-primary-foreground flex items-center px-2">
<div className="text-sm text-muted-foreground">Terminal</div>
</div>

<div className="w-full h-[calc(100%-40px)] p-2 overflow-hidden">
{this.state.hasProject ? (
<div ref={(r) => this._onTerminalContainerChanged(r)} className="w-full h-full overflow-hidden" />
) : (
<div className="flex items-center justify-center w-full h-full text-muted-foreground">
<div className="text-center">
<div className="text-lg mb-2">No Project Open</div>
<div className="text-sm">Open a project to use the terminal</div>
</div>
</div>
)}
</div>
</div>
);
}

public componentDidMount(): void {
// Set initial project path if available
if (projectConfiguration.path) {
this._projectPath = projectConfiguration.path;
}

onProjectConfigurationChangedObservable.add((config) => {
const newPath = config.path;
if (newPath && newPath !== this._projectPath) {
this._projectPath = newPath;

// Update state to trigger re-render and show terminal
this.setState({ hasProject: true });

// Restart terminal with new project path only if terminal is already initialized
if (this._terminal && this._pty) {
this._restartTerminal();
}
}
});
}

public componentWillUnmount(): void {
this._dispose();
}

private _dispose(): void {
this._fitAddon?.dispose();
this._fitAddon = null;

try {
this._webglAddon?.dispose();
} catch (e) {
// ignore
}
this._webglAddon = null;

this._terminal?.dispose();
this._terminal = null;

this._pty?.kill();
this._pty = null;
}

private _onTerminalContainerChanged(ref: HTMLDivElement | null): void {
if (!ref) {
this._dispose();
return;
}

if (!this._terminal) {
this._initializeTerminal(ref).catch((error) => {
console.error("Failed to initialize terminal:", error);
this._dispose();
});
}
}

private async _initializeTerminal(ref: HTMLDivElement): Promise<void> {
this._terminal = new Terminal({
fontSize: 15,
lineHeight: 1.2,
letterSpacing: 0,
fontWeight: "400",
fontWeightBold: "700",
fontFamily: "'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace",
allowTransparency: true,
cursorBlink: true,
convertEol: true,
theme: {
background: "#0000",
foreground: "#d4d4d4",
selectionBackground: "rgba(255, 255, 255, 0.3)",
selectionForeground: "#ffffff",
},
windowOptions: {
getWinSizePixels: true,
getCellSizePixels: true,
getWinSizeChars: true,
},
});

this._fitAddon = new FitAddon();
this._terminal.loadAddon(this._fitAddon);

this._terminal.open(ref);

// Try WebGL renderer for higher quality rendering; fall back silently if unavailable
try {
this._webglAddon = new WebglAddon();
this._terminal.loadAddon(this._webglAddon);
} catch (e) {
// WebGL not available; keep default renderer
}

requestAnimationFrame(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use await waitNextFrame() that is available in src/tools/tools.ts

if (this._terminal && this._fitAddon) {
this._fitAddon.fit();
}
});

const cwd = this._projectPath ? dirname(this._projectPath) : undefined;
this._pty = await execNodePty("", { interactive: true, cwd } as any);

this._pty.onGetDataObservable.add((data) => {
this._terminal?.write(data);
});

this._terminal.onData((data) => {
this._pty?.write(data);
});

this._terminal.onResize(({ cols, rows }) => {
this._pty?.resize(cols, rows);
});

const ro = new ResizeObserver(() => {
requestAnimationFrame(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is next frame needed here? Just for my culture

if (this._terminal && this._fitAddon) {
this._fitAddon.fit();
}
});
});
ro.observe(ref);
}

private async _restartTerminal(): Promise<void> {
if (!this._terminal || !this._pty) {
return;
}

// Kill the old PTY
this._pty.kill();

// Create new PTY with updated project path
const cwd = this._projectPath ? dirname(this._projectPath) : undefined;
this._pty = await execNodePty("", { interactive: true, cwd } as any);

// Reconnect event handlers
this._pty.onGetDataObservable.add((data) => {
this._terminal?.write(data);
});

// Update terminal size
if (this._terminal && this._fitAddon) {
this._pty.resize(this._terminal.cols, this._terminal.rows);
}

// Clear terminal and show new prompt
this._terminal.clear();
}
}
19 changes: 12 additions & 7 deletions editor/src/electron/node-pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ ipcMain.on("editor:create-node-pty", (ev, command, id, options) => {
name: "xterm-color",
encoding: "utf-8",
useConpty: false,
cwd: (options as any)?.cwd ?? process.cwd(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

options?.cwd ?? process.cwd() is enough

env: (options as any)?.env ?? process.env,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

options?.env ?? process.env is enough

...options,
});

Expand All @@ -70,14 +72,17 @@ ipcMain.on("editor:create-node-pty", (ev, command, id, options) => {

ev.sender.send(`editor:create-node-pty-${id}`);

const hasBackSlashes = shell!.toLowerCase() === process.env["COMSPEC"]?.toLowerCase();
if (hasBackSlashes) {
p.write(`${command.replace(/\//g, "\\")}\n\r`);
} else {
p.write(`${command}\n\r`);
}
const interactive: boolean = Boolean((options as any)?.interactive);
if (!interactive) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (!options?.interactive) { ... } is enough

const hasBackSlashes = shell!.toLowerCase() === process.env["COMSPEC"]?.toLowerCase();
if (hasBackSlashes) {
p.write(`${command.replace(/\//g, "\\")}\n\r`);
} else {
p.write(`${command}\n\r`);
}

p.write("exit\n\r");
p.write("exit\n\r");
}
});

// On write on a pty process
Expand Down
1 change: 1 addition & 0 deletions editor/src/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { EditorLayout } from "./editor/layout";
export { EditorGraph } from "./editor/layout/graph";
export { EditorToolbar } from "./editor/layout/toolbar";
export { EditorConsole } from "./editor/layout/console";
export { EditorTerminal } from "./editor/layout/terminal";

export { EditorPreview } from "./editor/layout/preview";
export * from "./editor/layout/preview/import/import";
Expand Down
5 changes: 4 additions & 1 deletion editor/src/tools/node-pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import { Observable } from "babylonjs";
* @param options The options to pass to the pty process.
* @returns A promise that resolves with the node-pty instance.
*/
export async function execNodePty(command: string, options: IPtyForkOptions | IWindowsPtyForkOptions = {}): Promise<NodePtyInstance> {
export async function execNodePty(
command: string,
options: IPtyForkOptions | IWindowsPtyForkOptions | (IPtyForkOptions & { interactive?: boolean }) | (IWindowsPtyForkOptions & { interactive?: boolean }) = {}
): Promise<NodePtyInstance> {
const id = randomUUID();

await new Promise<void>((resolve) => {
Expand Down