Skip to content
Closed
3 changes: 3 additions & 0 deletions editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
},
"license": "(Apache-2.0)",
"devDependencies": {
"@babel/preset-env": "^7.26.9",
"@babel/preset-typescript": "^7.27.0",
"@electron/rebuild": "4.0.2",
"@types/decompress": "4.2.7",
"@types/fluent-ffmpeg": "^2.1.27",
Expand Down Expand Up @@ -73,6 +75,7 @@
"@recast-navigation/core": "0.43.0",
"@recast-navigation/generators": "0.43.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-webgl": "0.20.0-beta.286",
"@xterm/xterm": "6.1.0-beta.22",
"assimpjs": "0.0.10",
"axios": "1.16.1",
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 @@ -18,6 +18,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";
import { EditorMarketplaceBrowser } from "./layout/marketplace";

export interface IEditorLayoutProps {
Expand Down Expand Up @@ -62,6 +63,10 @@ export class EditorLayout extends Component<IEditorLayoutProps> {
* The animation editor of the editor.
*/
public animations: EditorAnimation;
/**
* The terminal of the editor.
*/
public terminal: EditorTerminal;
/**
* The marketplace browser of the editor.
*/
Expand All @@ -81,6 +86,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!)} />,
marketplace: <EditorMarketplaceBrowser editor={this.props.editor} ref={(r) => (this.marketplace = r)} />,
};

Expand Down Expand Up @@ -189,7 +195,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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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 @@ -51,6 +51,8 @@ ipcMain.on("editor:create-node-pty", (ev, command, id, options, forcedShell) =>
name: "xterm-color",
encoding: "utf-8",
useConpty: false,
cwd: (options as any)?.cwd ?? process.cwd(),

Copy link
Copy Markdown
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
Copy Markdown
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 @@ -74,14 +76,17 @@ ipcMain.on("editor:create-node-pty", (ev, command, id, options, forcedShell) =>

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
Copy Markdown
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 @@ -13,7 +13,10 @@ import { tryGetTerminalFromLocalStorage } from "./local-storage";
* @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();

let forcedShell: string | null = null;
Expand Down