parent
bf697bd98e
commit
437c9356d7
|
|
@ -9,7 +9,8 @@
|
|||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"electron-squirrel-startup": "^1.0.1"
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"node-pty": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
|
|
@ -8895,6 +8896,12 @@
|
|||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.23.1",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz",
|
||||
"integrity": "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
|
|
@ -8989,6 +8996,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
|
||||
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nan": "^2.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@
|
|||
"vite": "^7.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-squirrel-startup": "^1.0.1"
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"node-pty": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@ vanX.list(EditorTabs, editors, EditorGrid);
|
|||
|
||||
document.addEventListener("keyup", (e) => {
|
||||
if (e.key === "t" && e.altKey) {
|
||||
console.log("Opening terminal");
|
||||
addTerminal();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ export class Terminal implements Displayable {
|
|||
currentTitle: string = "Terminal";
|
||||
del: () => void;
|
||||
dom: HTMLElement;
|
||||
private terminalId: string | null = null;
|
||||
private fitAddon: FitAddon;
|
||||
private resizeObserver: ResizeObserver;
|
||||
private unsubTerminalData?: () => void;
|
||||
private unsubTerminalExit?: () => void;
|
||||
|
||||
setDeleteFunction(del: () => void): void {
|
||||
this.del = del;
|
||||
|
|
@ -19,28 +24,97 @@ export class Terminal implements Displayable {
|
|||
}
|
||||
|
||||
constructor() {
|
||||
this.term = new xterm.Terminal();
|
||||
this.term = new xterm.Terminal({
|
||||
// cursorBlink: true,
|
||||
// fontSize: 14,
|
||||
// fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
this.term.loadAddon(fitAddon);
|
||||
this.fitAddon = new FitAddon();
|
||||
this.term.loadAddon(this.fitAddon);
|
||||
|
||||
this.dom = v.div({ class: "h-full w-full" });
|
||||
this.dom = v.div({ class: "h-full w-lg resize-x overflow-x-hidden" });
|
||||
const loaded = van.state(false);
|
||||
|
||||
van.derive(() => {
|
||||
if (loaded.val) {
|
||||
this.term.open(this.dom);
|
||||
fitAddon.fit();
|
||||
this.term.writeln("Welcome to the terminal!");
|
||||
this.initializeTerminal();
|
||||
}
|
||||
});
|
||||
loaded.val = true;
|
||||
}
|
||||
|
||||
private async initializeTerminal() {
|
||||
this.term.open(this.dom);
|
||||
|
||||
// Create terminal in main process
|
||||
try {
|
||||
this.terminalId = await window.electronAPI.createTerminal();
|
||||
|
||||
// Set up data handling (subscribe/unsubscribe)
|
||||
this.unsubTerminalData = window.electronAPI.onTerminalData(
|
||||
this.terminalId,
|
||||
(data) => this.term.write(data),
|
||||
);
|
||||
|
||||
this.unsubTerminalExit = window.electronAPI.onTerminalExit(
|
||||
this.terminalId,
|
||||
(exitCode) =>
|
||||
this.term.writeln(
|
||||
`\r\n[Process exited with code ${exitCode}]`,
|
||||
),
|
||||
);
|
||||
|
||||
// Handle user input
|
||||
this.term.onData((data) => {
|
||||
if (this.terminalId) {
|
||||
window.electronAPI.writeToTerminal(this.terminalId, data);
|
||||
}
|
||||
});
|
||||
|
||||
// Set up resize handling
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.handleResize();
|
||||
});
|
||||
this.resizeObserver.observe(this.dom);
|
||||
|
||||
// Initial fit
|
||||
setTimeout(() => {
|
||||
this.handleResize();
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize terminal:", error);
|
||||
this.term.writeln(
|
||||
"Failed to initialize terminal. Check console for details.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleResize() {
|
||||
if (
|
||||
this.terminalId &&
|
||||
this.dom.clientWidth > 0 &&
|
||||
this.dom.clientHeight > 0
|
||||
) {
|
||||
this.fitAddon.fit();
|
||||
const { cols, rows } = this.term;
|
||||
window.electronAPI.resizeTerminal(this.terminalId, cols, rows);
|
||||
}
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.term.focus();
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.terminalId) {
|
||||
window.electronAPI.closeTerminal(this.terminalId);
|
||||
}
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
if (this.unsubTerminalData) this.unsubTerminalData();
|
||||
if (this.unsubTerminalExit) this.unsubTerminalExit();
|
||||
this.term.dispose();
|
||||
this.del();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
/// <reference types="@electron-forge/plugin-vite/vite-env" />
|
||||
|
||||
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
|
||||
declare const MAIN_WINDOW_VITE_NAME: string;
|
||||
|
|
@ -8,8 +8,10 @@ import {
|
|||
getOpenedFiles,
|
||||
showConfirmDialog,
|
||||
} from "./fileOperations";
|
||||
import { terminalManager } from "./pty";
|
||||
import path from "node:path";
|
||||
import started from "electron-squirrel-startup";
|
||||
/// <reference types="./forge-vite-env.d.ts" />
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
|
|
@ -94,6 +96,32 @@ app.whenReady().then(() => {
|
|||
},
|
||||
);
|
||||
|
||||
// Terminal handlers
|
||||
ipcMain.handle(
|
||||
"terminal:create",
|
||||
async (event, shell?: string, args?: string[]) => {
|
||||
return terminalManager.createTerminal(event, shell, args);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"terminal:resize",
|
||||
async (event, id: string, cols: number, rows: number) => {
|
||||
return terminalManager.resizeTerminal(id, cols, rows);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"terminal:write",
|
||||
async (event, id: string, data: string) => {
|
||||
return terminalManager.writeToTerminal(id, data);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle("terminal:close", async (event, id: string) => {
|
||||
return terminalManager.closeTerminal(id);
|
||||
});
|
||||
|
||||
createWindow();
|
||||
if (process.platform === "darwin") {
|
||||
app.on("activate", function () {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import * as pty from "node-pty";
|
||||
|
||||
export interface TerminalInstance {
|
||||
ptyProcess: pty.IPty;
|
||||
}
|
||||
|
||||
export class TerminalManager {
|
||||
private terminals: Map<string, TerminalInstance> = new Map();
|
||||
private nextId: number = 1;
|
||||
|
||||
createTerminal(
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
shell?: string,
|
||||
args?: string[],
|
||||
): string {
|
||||
const id = `terminal-${this.nextId++}`;
|
||||
|
||||
// Default shell based on platform
|
||||
const defaultShell =
|
||||
process.platform === "win32" ? "powershell.exe" : "/bin/bash";
|
||||
const shellToUse = shell || defaultShell;
|
||||
|
||||
const ptyProcess = pty.spawn(shellToUse, args || [], {
|
||||
name: "xterm-color",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const terminal: TerminalInstance = {
|
||||
ptyProcess,
|
||||
};
|
||||
|
||||
ptyProcess.onData((data) => {
|
||||
console.log(`Terminal ${id} data:`, data);
|
||||
event.sender.send("terminal:data", id, data);
|
||||
});
|
||||
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
event.sender.send("terminal:exit", id, exitCode);
|
||||
this.terminals.delete(id);
|
||||
});
|
||||
|
||||
this.terminals.set(id, terminal);
|
||||
return id;
|
||||
}
|
||||
|
||||
resizeTerminal(id: string, cols: number, rows: number): boolean {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal) {
|
||||
terminal.ptyProcess.resize(cols, rows);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
writeToTerminal(id: string, data: string): boolean {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal) {
|
||||
terminal.ptyProcess.write(data);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
closeTerminal(id: string): boolean {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal) {
|
||||
terminal.ptyProcess.kill();
|
||||
this.terminals.delete(id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const terminalManager = new TerminalManager();
|
||||
|
|
@ -4,6 +4,25 @@
|
|||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { FolderTree } from "./types/global";
|
||||
|
||||
// Centralized routing for terminal events: keep a single ipcRenderer listener
|
||||
// and forward events to subscribed callbacks. Each `onTerminal*` returns an
|
||||
// unsubscribe function so individual renderer components can remove only
|
||||
// their own listeners.
|
||||
const terminalDataCallbacks = new Map<string, (data: string) => void>();
|
||||
const terminalExitCallbacks = new Map<string, (exitCode: number) => void>();
|
||||
|
||||
ipcRenderer.on("terminal:data", (_ev, id: string, data: string) => {
|
||||
const cb = terminalDataCallbacks.get(id);
|
||||
if (cb) cb(data);
|
||||
else console.warn(`No data callback for terminal ${id}`);
|
||||
});
|
||||
|
||||
ipcRenderer.on("terminal:exit", (_ev, id: string, exitCode: number) => {
|
||||
const cb = terminalExitCallbacks.get(id);
|
||||
if (cb) cb(exitCode);
|
||||
else console.warn(`No exit callback for terminal ${id}`);
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
openFolder: () =>
|
||||
ipcRenderer.invoke("dialog:openFolder") as Promise<FolderTree | null>,
|
||||
|
|
@ -43,4 +62,33 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||
title,
|
||||
buttons,
|
||||
) as Promise<string>,
|
||||
|
||||
// Terminal operations
|
||||
createTerminal: (shell?: string, args?: string[]) =>
|
||||
ipcRenderer.invoke("terminal:create", shell, args) as Promise<string>,
|
||||
|
||||
resizeTerminal: (id: string, cols: number, rows: number) =>
|
||||
ipcRenderer.invoke(
|
||||
"terminal:resize",
|
||||
id,
|
||||
cols,
|
||||
rows,
|
||||
) as Promise<boolean>,
|
||||
|
||||
writeToTerminal: (id: string, data: string) =>
|
||||
ipcRenderer.invoke("terminal:write", id, data) as Promise<boolean>,
|
||||
|
||||
closeTerminal: (id: string) =>
|
||||
ipcRenderer.invoke("terminal:close", id) as Promise<boolean>,
|
||||
|
||||
// Terminal events (subscribe/unsubscribe)
|
||||
onTerminalData: (id: string, callback: (data: string) => void) => {
|
||||
terminalDataCallbacks.set(id, callback);
|
||||
return () => terminalDataCallbacks.delete(id);
|
||||
},
|
||||
|
||||
onTerminalExit: (id: string, callback: (exitCode: number) => void) => {
|
||||
terminalExitCallbacks.set(id, callback);
|
||||
return () => terminalExitCallbacks.delete(id);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,6 +34,28 @@ declare global {
|
|||
title: string,
|
||||
buttons: string[],
|
||||
) => Promise<string>;
|
||||
|
||||
// Terminal operations
|
||||
createTerminal: (
|
||||
shell?: string,
|
||||
args?: string[],
|
||||
) => Promise<string>;
|
||||
resizeTerminal: (
|
||||
id: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
) => Promise<boolean>;
|
||||
writeToTerminal: (id: string, data: string) => Promise<boolean>;
|
||||
closeTerminal: (id: string) => Promise<boolean>;
|
||||
onTerminalData: (
|
||||
id: string,
|
||||
callback: (data: string) => void,
|
||||
) => () => void;
|
||||
onTerminalExit: (
|
||||
id: string,
|
||||
callback: (exitCode: number) => void,
|
||||
) => () => void;
|
||||
removeAllTerminalListeners: () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,12 @@ import tailwindcss from "@tailwindcss/vite";
|
|||
// https://vitejs.dev/config
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss()],
|
||||
optimizeDeps: {
|
||||
exclude: ["node-pty"],
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ["node-pty"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue