diff --git a/editor/package.json b/editor/package.json index 654a08432..cc1a6c99b 100644 --- a/editor/package.json +++ b/editor/package.json @@ -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", @@ -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", "@xterm/xterm": "^5.6.0-beta.119", "assimpjs": "0.0.10", "axios": "^1.12.0", diff --git a/editor/src/editor/layout.json b/editor/src/editor/layout.json index 12e9fa2b0..910bbadf8 100644 --- a/editor/src/editor/layout.json +++ b/editor/src/editor/layout.json @@ -78,6 +78,14 @@ "component": "console", "enableClose": false, "enableRenderOnDemand": false + }, + { + "type": "tab", + "id": "terminal", + "name": "Terminal", + "component": "terminal", + "enableClose": false, + "enableRenderOnDemand": false } ] } diff --git a/editor/src/editor/layout.tsx b/editor/src/editor/layout.tsx index 0e8f2d5c5..d1a191ff5 100644 --- a/editor/src/editor/layout.tsx +++ b/editor/src/editor/layout.tsx @@ -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 { /** @@ -50,6 +51,10 @@ export class EditorLayout extends Component { * 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); @@ -60,6 +65,7 @@ export class EditorLayout extends Component { graph: (this.graph = r!)} />, "assets-browser": (this.assets = r!)} />, animations: (this.animations = r!)} />, + terminal: (this.terminal = r!)} />, }; private _layoutVersion: string = "5.0.0-alpha.2"; @@ -133,7 +139,7 @@ export class EditorLayout extends Component { * 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)); } diff --git a/editor/src/editor/layout/terminal.tsx b/editor/src/editor/layout/terminal.tsx new file mode 100644 index 000000000..45c5ea335 --- /dev/null +++ b/editor/src/editor/layout/terminal.tsx @@ -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 { + 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) { + super(props); + this.state = { + hasProject: projectConfiguration.path !== null, + }; + } + + public render(): ReactNode { + return ( +
+
+
Terminal
+
+ +
+ {this.state.hasProject ? ( +
this._onTerminalContainerChanged(r)} className="w-full h-full overflow-hidden" /> + ) : ( +
+
+
No Project Open
+
Open a project to use the terminal
+
+
+ )} +
+
+ ); + } + + 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 { + 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(() => { + 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(() => { + if (this._terminal && this._fitAddon) { + this._fitAddon.fit(); + } + }); + }); + ro.observe(ref); + } + + private async _restartTerminal(): Promise { + 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(); + } +} diff --git a/editor/src/electron/node-pty.ts b/editor/src/electron/node-pty.ts index 6465d449d..4225460a2 100644 --- a/editor/src/electron/node-pty.ts +++ b/editor/src/electron/node-pty.ts @@ -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(), + env: (options as any)?.env ?? process.env, ...options, }); @@ -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) { + 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 diff --git a/editor/src/export.ts b/editor/src/export.ts index 9a4d040a1..7b4339af2 100644 --- a/editor/src/export.ts +++ b/editor/src/export.ts @@ -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"; diff --git a/editor/src/tools/node-pty.ts b/editor/src/tools/node-pty.ts index 04b0ef49d..3a67f3f7c 100644 --- a/editor/src/tools/node-pty.ts +++ b/editor/src/tools/node-pty.ts @@ -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 { +export async function execNodePty( + command: string, + options: IPtyForkOptions | IWindowsPtyForkOptions | (IPtyForkOptions & { interactive?: boolean }) | (IWindowsPtyForkOptions & { interactive?: boolean }) = {} +): Promise { const id = randomUUID(); await new Promise((resolve) => {