From e377dabf99595a6783fd962a8765d2214a635ac2 Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Fri, 13 Mar 2026 17:28:27 -0500 Subject: Separate Terminal and Claude into independent pty instances - Support multiple named pty instances via ID-based IPC channels - Terminal tab spawns a shell, Claude tab spawns `claude` CLI separately - Fix pty race condition: old instance's onExit callback could delete the replacement instance from the Map during React StrictMode re-mount - Guard against StrictMode double-initialization in TerminalInstance Co-Authored-By: Claude Opus 4.6 --- src/main/index.ts | 46 ++++++---- src/preload/index.ts | 22 ++--- src/renderer/src/components/Terminal.tsx | 142 +++++++++++++++++++------------ 3 files changed, 128 insertions(+), 82 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 0d93b17..9be1a74 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,7 +11,7 @@ import { CompilationManager } from './compilationManager' import { FileSyncBridge } from './fileSyncBridge' let mainWindow: BrowserWindow | null = null -let ptyInstance: pty.IPty | null = null +const ptyInstances = new Map() let overleafSock: OverleafSocket | null = null let compilationManager: CompilationManager | null = null let fileSyncBridge: FileSyncBridge | null = null @@ -91,13 +91,16 @@ ipcMain.handle('synctex:editFromPdf', async (_e, pdfPath: string, page: number, // ── Terminal / PTY ─────────────────────────────────────────────── -ipcMain.handle('pty:spawn', async (_e, cwd: string) => { - if (ptyInstance) { - ptyInstance.kill() +ipcMain.handle('pty:spawn', async (_e, id: string, cwd: string, cmd?: string, args?: string[]) => { + const existing = ptyInstances.get(id) + if (existing) { + existing.kill() + ptyInstances.delete(id) } - const shellPath = process.env.SHELL || '/bin/zsh' - ptyInstance = pty.spawn(shellPath, ['-l'], { + const shellPath = cmd || process.env.SHELL || '/bin/zsh' + const shellArgs = args || ['-l'] + const instance = pty.spawn(shellPath, shellArgs, { name: 'xterm-256color', cols: 80, rows: 24, @@ -105,28 +108,37 @@ ipcMain.handle('pty:spawn', async (_e, cwd: string) => { env: process.env as Record }) - ptyInstance.onData((data) => { - sendToRenderer('pty:data', data) + ptyInstances.set(id, instance) + + instance.onData((data) => { + sendToRenderer(`pty:data:${id}`, data) }) - ptyInstance.onExit(() => { - sendToRenderer('pty:exit') + instance.onExit(() => { + // Only delete if this is still the current instance (avoid race with re-spawn) + if (ptyInstances.get(id) === instance) { + sendToRenderer(`pty:exit:${id}`) + ptyInstances.delete(id) + } }) }) -ipcMain.handle('pty:write', async (_e, data: string) => { - ptyInstance?.write(data) +ipcMain.handle('pty:write', async (_e, id: string, data: string) => { + ptyInstances.get(id)?.write(data) }) -ipcMain.handle('pty:resize', async (_e, cols: number, rows: number) => { +ipcMain.handle('pty:resize', async (_e, id: string, cols: number, rows: number) => { try { - ptyInstance?.resize(cols, rows) + ptyInstances.get(id)?.resize(cols, rows) } catch { /* ignore resize errors */ } }) -ipcMain.handle('pty:kill', async () => { - ptyInstance?.kill() - ptyInstance = null +ipcMain.handle('pty:kill', async (_e, id: string) => { + const instance = ptyInstances.get(id) + if (instance) { + instance.kill() + ptyInstances.delete(id) + } }) // ── Overleaf Web Session (for comments) ───────────────────────── diff --git a/src/preload/index.ts b/src/preload/index.ts index 1bf97b3..aa16872 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -16,20 +16,20 @@ const api = { return () => ipcRenderer.removeListener('latex:log', handler) }, - // Terminal - ptySpawn: (cwd: string) => ipcRenderer.invoke('pty:spawn', cwd), - ptyWrite: (data: string) => ipcRenderer.invoke('pty:write', data), - ptyResize: (cols: number, rows: number) => ipcRenderer.invoke('pty:resize', cols, rows), - ptyKill: () => ipcRenderer.invoke('pty:kill'), - onPtyData: (cb: (data: string) => void) => { + // Terminal (supports multiple named instances) + ptySpawn: (id: string, cwd: string, cmd?: string, args?: string[]) => ipcRenderer.invoke('pty:spawn', id, cwd, cmd, args), + ptyWrite: (id: string, data: string) => ipcRenderer.invoke('pty:write', id, data), + ptyResize: (id: string, cols: number, rows: number) => ipcRenderer.invoke('pty:resize', id, cols, rows), + ptyKill: (id: string) => ipcRenderer.invoke('pty:kill', id), + onPtyData: (id: string, cb: (data: string) => void) => { const handler = (_e: Electron.IpcRendererEvent, data: string) => cb(data) - ipcRenderer.on('pty:data', handler) - return () => ipcRenderer.removeListener('pty:data', handler) + ipcRenderer.on(`pty:data:${id}`, handler) + return () => ipcRenderer.removeListener(`pty:data:${id}`, handler) }, - onPtyExit: (cb: () => void) => { + onPtyExit: (id: string, cb: () => void) => { const handler = () => cb() - ipcRenderer.on('pty:exit', handler) - return () => ipcRenderer.removeListener('pty:exit', handler) + ipcRenderer.on(`pty:exit:${id}`, handler) + return () => ipcRenderer.removeListener(`pty:exit:${id}`, handler) }, // SyncTeX diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index e2eb5e8..9a2e24c 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1,44 +1,55 @@ // Copyright (c) 2026 Yuren Hao // Licensed under AGPL-3.0 - see LICENSE file -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, useCallback } from 'react' import { Terminal as XTerm } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import '@xterm/xterm/css/xterm.css' import { useAppStore } from '../stores/appStore' -export default function Terminal() { +const XTERM_THEME = { + background: '#2D2A24', + foreground: '#E8DFC0', + cursor: '#FFF8E7', + selectionBackground: '#5C5040', + black: '#2D2A24', + red: '#C75643', + green: '#5B8A3C', + yellow: '#B8860B', + blue: '#4A6FA5', + magenta: '#8B6B8B', + cyan: '#5B8A8A', + white: '#E8DFC0', + brightBlack: '#6B5B3E', + brightRed: '#D46A58', + brightGreen: '#6FA050', + brightYellow: '#D4A020', + brightBlue: '#5E84B8', + brightMagenta: '#A080A0', + brightCyan: '#6FA0A0', + brightWhite: '#FFF8E7' +} + +/** A single xterm + pty instance */ +function TerminalInstance({ id, cwd, cmd, args, visible }: { + id: string + cwd: string + cmd?: string + args?: string[] + visible: boolean +}) { const termRef = useRef(null) const xtermRef = useRef(null) const fitAddonRef = useRef(null) - const [mode, setMode] = useState<'terminal' | 'claude'>('terminal') + const spawnedRef = useRef(false) + const initializedRef = useRef(false) useEffect(() => { - if (!termRef.current) return + if (!termRef.current || initializedRef.current) return + initializedRef.current = true const xterm = new XTerm({ - theme: { - background: '#2D2A24', - foreground: '#E8DFC0', - cursor: '#FFF8E7', - selectionBackground: '#5C5040', - black: '#2D2A24', - red: '#C75643', - green: '#5B8A3C', - yellow: '#B8860B', - blue: '#4A6FA5', - magenta: '#8B6B8B', - cyan: '#5B8A8A', - white: '#E8DFC0', - brightBlack: '#6B5B3E', - brightRed: '#D46A58', - brightGreen: '#6FA050', - brightYellow: '#D4A020', - brightBlue: '#5E84B8', - brightMagenta: '#A080A0', - brightCyan: '#6FA0A0', - brightWhite: '#FFF8E7' - }, + theme: XTERM_THEME, fontFamily: '"SF Mono", "Fira Code", "JetBrains Mono", monospace', fontSize: 13, cursorBlink: true, @@ -49,58 +60,73 @@ export default function Terminal() { xterm.loadAddon(fitAddon) xterm.open(termRef.current) - // Fit after a small delay to ensure container is sized setTimeout(() => fitAddon.fit(), 100) xtermRef.current = xterm fitAddonRef.current = fitAddon - // Spawn shell in project sync directory - const syncDir = useAppStore.getState().syncDir || '/tmp' - window.api.ptySpawn(syncDir) + // Spawn pty + window.api.ptySpawn(id, cwd, cmd, args) + spawnedRef.current = true - // Pipe data - const unsubData = window.api.onPtyData((data) => { + const unsubData = window.api.onPtyData(id, (data) => { xterm.write(data) }) - const unsubExit = window.api.onPtyExit(() => { + const unsubExit = window.api.onPtyExit(id, () => { xterm.writeln('\r\n[Process exited]') + spawnedRef.current = false }) - // Send input xterm.onData((data) => { - window.api.ptyWrite(data) + window.api.ptyWrite(id, data) }) - // Handle resize const resizeObserver = new ResizeObserver(() => { try { fitAddon.fit() - window.api.ptyResize(xterm.cols, xterm.rows) + if (spawnedRef.current) { + window.api.ptyResize(id, xterm.cols, xterm.rows) + } } catch { /* ignore */ } }) resizeObserver.observe(termRef.current) return () => { + initializedRef.current = false resizeObserver.disconnect() unsubData() unsubExit() - window.api.ptyKill() + window.api.ptyKill(id) xterm.dispose() } - }, []) + }, [id, cwd, cmd]) - const launchClaude = () => { - if (!xtermRef.current) return - window.api.ptyWrite('claude\n') - setMode('claude') - } + // Re-fit when becoming visible + useEffect(() => { + if (visible && fitAddonRef.current) { + setTimeout(() => fitAddonRef.current?.fit(), 50) + } + }, [visible]) - const sendToClaude = (prompt: string) => { - if (!xtermRef.current) return - window.api.ptyWrite(prompt + '\n') - } + return ( +
+ ) +} + +export default function Terminal() { + const [mode, setMode] = useState<'terminal' | 'claude'>('terminal') + const [claudeSpawned, setClaudeSpawned] = useState(false) + const syncDir = useAppStore((s) => s.syncDir) || '/tmp' + + const launchClaude = useCallback(() => { + setClaudeSpawned(true) + setMode('claude') + }, []) return (
@@ -118,23 +144,31 @@ export default function Terminal() { Claude
- +
-
+ + + {claudeSpawned && ( + + )}
) } -function QuickActions({ onSend }: { onSend: (cmd: string) => void }) { +function QuickActions({ ptyId }: { ptyId: string }) { const { activeTab, fileContents } = useAppStore() + const send = (prompt: string) => { + window.api.ptyWrite(ptyId, prompt + '\n') + } + const actions = [ { label: 'Fix Errors', action: () => { const log = useAppStore.getState().compileLog if (log) { - onSend(`Fix these LaTeX compilation errors:\n${log.slice(-2000)}`) + send(`Fix these LaTeX compilation errors:\n${log.slice(-2000)}`) } } }, @@ -142,7 +176,7 @@ function QuickActions({ onSend }: { onSend: (cmd: string) => void }) { label: 'Review', action: () => { if (activeTab && fileContents[activeTab]) { - onSend(`Review this LaTeX file for issues and improvements: ${activeTab}`) + send(`Review this LaTeX file for issues and improvements: ${activeTab}`) } } }, @@ -150,7 +184,7 @@ function QuickActions({ onSend }: { onSend: (cmd: string) => void }) { label: 'Explain', action: () => { if (activeTab) { - onSend(`Explain the structure and content of: ${activeTab}`) + send(`Explain the structure and content of: ${activeTab}`) } } } -- cgit v1.2.3