-
Notifications
You must be signed in to change notification settings - Fork 274
Add integrated terminal panel with xterm.js and node-pty #715
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
60afa82
641833b
e3e7bdf
73f102b
bb20d21
207d134
1f6fd66
cc79adb
b73d803
956c203
65792b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(() => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can use |
||
| 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(() => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| env: (options as any)?.env ?? process.env, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ...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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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 | ||
|
|
||
There was a problem hiding this comment.
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"