diff --git a/package-lock.json b/package-lock.json index 80932bfc0..769d50f48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5078,6 +5078,98 @@ "jsep": "^0.4.0||^1.0.0" } }, + "node_modules/@lydell/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw==", + "license": "MIT", + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.1.0", + "@lydell/node-pty-darwin-x64": "1.1.0", + "@lydell/node-pty-linux-arm64": "1.1.0", + "@lydell/node-pty-linux-x64": "1.1.0", + "@lydell/node-pty-win32-arm64": "1.1.0", + "@lydell/node-pty-win32-x64": "1.1.0" + } + }, + "node_modules/@lydell/node-pty-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lydell/node-pty-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.1.0.tgz", + "integrity": "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lydell/node-pty-linux-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.1.0.tgz", + "integrity": "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lydell/node-pty-linux-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.1.0.tgz", + "integrity": "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lydell/node-pty-win32-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.1.0.tgz", + "integrity": "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lydell/node-pty-win32-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.1.0.tgz", + "integrity": "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@malept/flatpak-bundler": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", @@ -9133,6 +9225,21 @@ "node": ">=10.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -26841,6 +26948,8 @@ "@usebruno/common": "0.1.0", "@usebruno/graphql-docs": "0.1.0", "@usebruno/schema": "0.7.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "classnames": "^2.3.1", "codemirror": "5.65.2", "codemirror-graphql": "2.1.1", @@ -30234,6 +30343,7 @@ "@aws-sdk/credential-providers": "3.750.0", "@grpc/grpc-js": "^1.13.2", "@grpc/proto-loader": "^0.7.13", + "@lydell/node-pty": "^1.1.0", "@usebruno/common": "0.1.0", "@usebruno/converters": "^0.1.0", "@usebruno/filestore": "^0.1.0", diff --git a/packages/bruno-app/package.json b/packages/bruno-app/package.json index 8353b6074..9def0c5dd 100644 --- a/packages/bruno-app/package.json +++ b/packages/bruno-app/package.json @@ -21,6 +21,8 @@ "@usebruno/common": "0.1.0", "@usebruno/graphql-docs": "0.1.0", "@usebruno/schema": "0.7.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "classnames": "^2.3.1", "codemirror": "5.65.2", "codemirror-graphql": "2.1.1", diff --git a/packages/bruno-app/src/components/Devtools/Console/TerminalTab/SessionList.js b/packages/bruno-app/src/components/Devtools/Console/TerminalTab/SessionList.js new file mode 100644 index 000000000..5e26b4bbd --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Console/TerminalTab/SessionList.js @@ -0,0 +1,151 @@ +import React from 'react'; +import { IconTerminal, IconX } from '@tabler/icons'; +import styled from 'styled-components'; +import ToolHint from 'components/ToolHint/index'; + +const StyledSessionList = styled.div` + .session-list-item { + padding: 10px 12px; + cursor: pointer; + border-bottom: 1px solid ${(props) => props.theme.border || 'rgba(255, 255, 255, 0.05)'}; + transition: all 0.2s; + display: flex; + flex-direction: column; + gap: 4px; + position: relative; + + &:hover { + background: ${(props) => props.theme.sidebarHover || 'rgba(255, 255, 255, 0.05)'}; + + .session-close-btn { + opacity: 1; + } + } + + &.active { + background: ${(props) => props.theme.sidebarActive || 'rgba(59, 142, 234, 0.12)'}; + border-left: 2px solid ${(props) => props.theme.brandColor || '#3b8eea'}; + } + + &:last-child { + border-bottom: none; + } + } + + .session-close-btn { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + opacity: 0; + transition: opacity 0.2s; + padding: 4px; + cursor: pointer; + color: ${(props) => props.theme.textSecondary || '#888'}; + + &:hover { + color: ${(props) => props.theme.text}; + background: ${(props) => props.theme.sidebarHover || 'rgba(255, 255, 255, 0.1)'}; + border-radius: 4px; + } + } + + .session-name { + font-size: 13px; + font-weight: 500; + color: ${(props) => props.theme.text}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-right: 24px; + display: flex; + align-items: center; + gap: 6px; + } + + .session-icon { + flex-shrink: 0; + opacity: 0.7; + } + + .session-path { + font-size: 11px; + color: ${(props) => props.theme.textSecondary || '#888'}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +`; + +const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSession }) => { + const getSessionDisplayInfo = (session) => { + if (session.name) { + return { name: session.name }; + } + + if (session.cwd) { + // Normalize path and get the last directory name + const normalizedPath = session.cwd.replace(/\\/g, '/').replace(/\/$/, ''); + const pathParts = normalizedPath.split('/').filter((p) => p); + + if (pathParts.length > 0) { + const folderName = pathParts[pathParts.length - 1]; + return { name: folderName }; + } + + // If it's root or home directory + if (normalizedPath === '' || normalizedPath === '/' || normalizedPath.match(/^[A-Z]:\/?$/)) { + return { name: 'Root' }; + } + } + + // Fallback: use a cool name based on session ID + const shortId = session.sessionId.split('_')[1]?.slice(-6) || session.sessionId.slice(-6); + return { name: `Terminal ${shortId}` }; + }; + + const getFullPath = (session) => { + if (session.cwd) { + return session.cwd; + } + return '~ (Home Directory)'; + }; + + return ( + + {sessions.map((session) => { + const { name } = getSessionDisplayInfo(session); + return ( + +
onSelectSession(session.sessionId)} + > +
+ + {name} +
+
{ + e.stopPropagation(); + onCloseSession(session.sessionId); + }} + > + +
+
+
+ ); + })} +
+ ); +}; + +export default SessionList; diff --git a/packages/bruno-app/src/components/Devtools/Console/TerminalTab/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Console/TerminalTab/StyledWrapper.js new file mode 100644 index 000000000..378fa7494 --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Console/TerminalTab/StyledWrapper.js @@ -0,0 +1,201 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + color: ${(props) => props.theme.text}; + + .xterm-rows { + color: ${(props) => props.theme.text} !important; + } + + .terminal-content { + height: 100%; + width: 100%; + position: relative; + display: flex; + flex-direction: row; + } + + .terminal-sessions-sidebar { + width: 200px; + min-width: 200px; + border-right: 1px solid ${(props) => props.theme.border || 'rgba(255, 255, 255, 0.08)'}; + background: ${(props) => props.theme.sidebarBackground || props.theme.background}; + display: flex; + flex-direction: column; + overflow-y: auto; + } + + .terminal-sessions-header { + padding: 12px; + font-weight: 600; + font-size: 13px; + color: ${(props) => props.theme.text}; + border-bottom: 1px solid ${(props) => props.theme.border || 'rgba(255, 255, 255, 0.08)'}; + display: flex; + align-items: center; + justify-content: space-between; + } + + .terminal-sessions-list { + flex: 1; + overflow-y: auto; + + /* Custom scrollbar styling - subtle */ + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.15); + } + } + + .terminal-session-item { + padding: 10px 12px; + cursor: pointer; + border-bottom: 1px solid ${(props) => props.theme.border}; + transition: background 0.2s; + display: flex; + flex-direction: column; + gap: 4px; + + &:hover { + background: ${(props) => props.theme.sidebarHover || 'rgba(255, 255, 255, 0.05)'}; + } + + &.active { + background: ${(props) => props.theme.sidebarActive || 'rgba(59, 142, 234, 0.15)'}; + border-left: 3px solid ${(props) => props.theme.brandColor || '#3b8eea'}; + } + } + + .terminal-session-name { + font-size: 13px; + font-weight: 500; + color: ${(props) => props.theme.text}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .terminal-session-path { + font-size: 11px; + color: ${(props) => props.theme.textSecondary || '#888'}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .terminal-display-container { + flex: 1; + display: flex; + flex-direction: column; + position: relative; + } + + .terminal-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + color: #888; + font-size: 14px; + z-index: 10; + + svg { + opacity: 0.7; + } + + span { + font-weight: 500; + } + } + + .terminal-container { + flex: 1; + position: relative; + + .xterm { + height: 100% !important; + width: 100% !important; + padding: 8px; + } + + .xterm-viewport { + background: transparent !important; + } + + .xterm-screen { + background: transparent !important; + } + + .xterm-decoration-overview-ruler { + display: none; + } + + /* Custom scrollbar for terminal */ + .xterm-viewport::-webkit-scrollbar { + width: 8px; + } + + .xterm-viewport::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; + } + + .xterm-viewport::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 4px; + } + + .xterm-viewport::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); + } + } + + /* Dark theme adjustments */ + .xterm-helper-textarea { + position: absolute !important; + left: -9999px !important; + top: -9999px !important; + } + + /* Selection styling */ + .xterm .xterm-selection div { + background-color: rgba(255, 255, 255, 0.3) !important; + } + + /* Cursor styling */ + .xterm .xterm-cursor-layer .xterm-cursor { + background-color: #d4d4d4 !important; + } + + /* Link styling */ + .xterm .xterm-decoration-link { + text-decoration: underline; + color: #3b8eea; + } + + .xterm .xterm-decoration-link:hover { + color: #5ba7f7; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Devtools/Console/TerminalTab/index.js b/packages/bruno-app/src/components/Devtools/Console/TerminalTab/index.js new file mode 100644 index 000000000..5d1046070 --- /dev/null +++ b/packages/bruno-app/src/components/Devtools/Console/TerminalTab/index.js @@ -0,0 +1,449 @@ +import React, { useRef, useEffect, useState, useCallback } from 'react'; +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { IconTerminal2, IconPlus } from '@tabler/icons'; +import StyledWrapper from './StyledWrapper'; +import SessionList from './SessionList'; +import '@xterm/xterm/css/xterm.css'; + +// Terminal instances per session - Map +const terminalInstances = new Map(); + +// Data listeners per session - Map +const sessionListeners = new Map(); + +// Parking host for terminal DOM when view unmounts +let parkingHost = null; + +// Export function to get current session ID (for backward compatibility) +export const getSessionId = () => { + // Return the first active session ID if any + if (terminalInstances.size > 0) { + return Array.from(terminalInstances.keys())[0]; + } + return null; +}; + +const ensureParkingHost = () => { + if (parkingHost && document.body.contains(parkingHost)) return parkingHost; + parkingHost = document.createElement('div'); + parkingHost.style.display = 'none'; + parkingHost.setAttribute('data-terminal-parking-host', 'true'); + document.body.appendChild(parkingHost); + return parkingHost; +}; + +const createTerminalForSession = (sessionId) => { + if (terminalInstances.has(sessionId)) { + return terminalInstances.get(sessionId); + } + + const terminal = new Terminal({ + cursorBlink: true, + fontSize: 14, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + theme: { + background: '#1e1e1e', + foreground: '#d4d4d4', + cursor: '#d4d4d4', + selection: '#264f78', + black: '#1e1e1e', + red: '#f14c4c', + green: '#23d18b', + yellow: '#f5f543', + blue: '#3b8eea', + magenta: '#d670d6', + cyan: '#29b8db', + white: '#e5e5e5', + brightBlack: '#666666', + brightRed: '#f14c4c', + brightGreen: '#23d18b', + brightYellow: '#f5f543', + brightBlue: '#3b8eea', + brightMagenta: '#d670d6', + brightCyan: '#29b8db', + brightWhite: '#e5e5e5' + }, + allowProposedApi: true + }); + + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + + const inputDisposable = terminal.onData((data) => { + if (data && sessionId && window.ipcRenderer) { + window.ipcRenderer.send('terminal:input', sessionId, data); + } + }); + + const resizeDisposable = terminal.onResize(({ cols, rows }) => { + if (sessionId && window.ipcRenderer) { + window.ipcRenderer.send('terminal:resize', sessionId, { cols, rows }); + } + }); + + const instance = { + terminal, + fitAddon, + inputDisposable, + resizeDisposable + }; + + terminalInstances.set(sessionId, instance); + + // Setup IPC listeners for this session + if (window.ipcRenderer && !sessionListeners.has(sessionId)) { + const onData = (data) => { + if (!data) return; + const instance = terminalInstances.get(sessionId); + if (instance && instance.terminal) { + try { + instance.terminal.write(data); + } catch (err) { + console.warn('Failed to write terminal data:', err); + } + } + }; + + const onExit = ({ exitCode, signal } = {}) => { + const msg = `\r\n[Process exited with code ${exitCode ?? ''} ${signal ? `(signal ${signal})` : ''}]\r\n`; + const instance = terminalInstances.get(sessionId); + if (instance && instance.terminal) { + try { + instance.terminal.write(msg); + } catch (err) { + console.warn('Failed to write terminal exit message:', err); + } + } + // Cleanup on exit + cleanupTerminalInstance(sessionId); + }; + + window.ipcRenderer.on(`terminal:data:${sessionId}`, onData); + window.ipcRenderer.on(`terminal:exit:${sessionId}`, onExit); + + sessionListeners.set(sessionId, { onData, onExit }); + } + + return instance; +}; + +const cleanupTerminalInstance = (sessionId) => { + const instance = terminalInstances.get(sessionId); + if (instance) { + try { + if (instance.inputDisposable) instance.inputDisposable.dispose(); + if (instance.resizeDisposable) instance.resizeDisposable.dispose(); + if (instance.terminal) { + instance.terminal.dispose(); + } + } catch (err) { + console.warn('Error disposing terminal instance:', err); + } + terminalInstances.delete(sessionId); + } + + // Remove IPC listeners + const listeners = sessionListeners.get(sessionId); + if (listeners && window.ipcRenderer) { + try { + window.ipcRenderer.removeAllListeners(`terminal:data:${sessionId}`); + window.ipcRenderer.removeAllListeners(`terminal:exit:${sessionId}`); + } catch (err) { + console.warn('Error removing IPC listeners:', err); + } + sessionListeners.delete(sessionId); + } +}; + +const openTerminalIntoContainer = async (container, sessionId) => { + if (!container || !sessionId) return; + + const instance = createTerminalForSession(sessionId); + const { terminal, fitAddon } = instance; + + if (!terminal.element) { + terminal.open(container); + } else { + // Move terminal element to new container + if (terminal.element.parentElement !== container) { + container.appendChild(terminal.element); + } + } + + await new Promise((resolve) => setTimeout(resolve, 50)); + try { + fitAddon.fit(); + const { cols, rows } = terminal; + if (cols && rows && window.ipcRenderer) { + window.ipcRenderer.send('terminal:resize', sessionId, { cols, rows }); + } + } catch (e) { + console.warn('Error fitting terminal:', e); + } +}; + +const TerminalTab = () => { + const terminalRef = useRef(null); + const [sessions, setSessions] = useState([]); + const [activeSessionId, setActiveSessionId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // Load sessions list + const loadSessions = useCallback(async (currentActiveSessionId = null) => { + if (!window.ipcRenderer) return []; + + try { + const sessionList = await window.ipcRenderer.invoke('terminal:list-sessions'); + setSessions(sessionList); + + // Use functional state updates to get the current activeSessionId + setActiveSessionId((prevActiveSessionId) => { + const activeId = currentActiveSessionId !== null ? currentActiveSessionId : prevActiveSessionId; + + // Auto-select first session if none selected + if (!activeId && sessionList.length > 0) { + return sessionList[0].sessionId; + } + + // If active session no longer exists, select first available + if (activeId && !sessionList.find((s) => s.sessionId === activeId)) { + return sessionList.length > 0 ? sessionList[0].sessionId : null; + } + + // Keep current selection if it still exists + return activeId; + }); + + return sessionList; + } catch (err) { + console.error('Failed to load sessions:', err); + return []; + } + }, []); + + // Create new terminal session + const createNewSession = useCallback(async (cwd = null) => { + if (!window.ipcRenderer) return null; + + try { + const options = cwd ? { cwd } : {}; + const newSessionId = await window.ipcRenderer.invoke('terminal:create', options); + if (newSessionId) { + await loadSessions(newSessionId); + setActiveSessionId(newSessionId); + return newSessionId; + } + } catch (err) { + console.error('Failed to create terminal session:', err); + } + return null; + }, [loadSessions]); + + // Listen for requests to open terminal at specific CWD + useEffect(() => { + const normalizePath = (path) => { + if (!path) return ''; + // Normalize path separators and remove trailing separators for comparison + return path.replace(/\\/g, '/').replace(/\/$/, '') || '/'; + }; + + const handleOpenTerminalAtCwd = async (event) => { + const { cwd } = event.detail; + if (!cwd) return; + + const normalizedCwd = normalizePath(cwd); + + // Check if session already exists at this CWD + const sessionList = await window.ipcRenderer.invoke('terminal:list-sessions'); + const existingSession = sessionList.find((s) => normalizePath(s.cwd) === normalizedCwd); + + if (existingSession) { + // Switch to existing session + await loadSessions(existingSession.sessionId); + setActiveSessionId(existingSession.sessionId); + } else { + // Create new session at this CWD + await createNewSession(cwd); + } + }; + + window.addEventListener('terminal:open-at-cwd', handleOpenTerminalAtCwd); + + return () => { + window.removeEventListener('terminal:open-at-cwd', handleOpenTerminalAtCwd); + }; + }, [loadSessions, createNewSession]); + + // Close terminal session + const closeSession = async (sessionId) => { + if (!window.ipcRenderer) return; + + try { + window.ipcRenderer.send('terminal:kill', sessionId); + cleanupTerminalInstance(sessionId); + + // Load updated sessions (this will also handle active session switching) + const updatedSessions = await loadSessions(); + + // If we closed the active session and there are no sessions left, clear selection + if (activeSessionId === sessionId && updatedSessions.length === 0) { + setActiveSessionId(null); + } + } catch (err) { + console.error('Failed to close terminal session:', err); + } + }; + + // Load sessions on mount and set up polling + useEffect(() => { + if (!window.ipcRenderer) { + setIsLoading(false); + return; + } + + let mounted = true; + + const initialLoad = async () => { + const sessionList = await loadSessions(); + if (mounted) { + setIsLoading(false); + } + }; + + initialLoad(); + + // Poll for session updates every 2 seconds + // Note: We don't pass currentActiveSessionId here to avoid stale closures + // The functional update inside loadSessions will use the current state + const pollInterval = setInterval(() => { + if (mounted) { + loadSessions(); + } + }, 2000); + + return () => { + mounted = false; + clearInterval(pollInterval); + }; + }, []); + + // Handle terminal display for active session + useEffect(() => { + if (!activeSessionId || !terminalRef.current) return; + + let mounted = true; + + const setupTerminal = async () => { + await openTerminalIntoContainer(terminalRef.current, activeSessionId); + + if (mounted) { + const instance = terminalInstances.get(activeSessionId); + if (instance && instance.fitAddon) { + const onResize = () => { + try { + instance.fitAddon.fit(); + } catch (e) {} + }; + + window.addEventListener('resize', onResize); + + // Initial resize + setTimeout(() => { + try { + instance.fitAddon.fit(); + const { cols, rows } = instance.terminal; + if (cols && rows && window.ipcRenderer) { + window.ipcRenderer.send('terminal:resize', activeSessionId, { cols, rows }); + } + } catch (err) { + console.warn('Failed to perform initial resize:', err); + } + }, 100); + + return () => { + window.removeEventListener('resize', onResize); + + // Park terminal element when switching sessions + if (instance.terminal && instance.terminal.element) { + const host = ensureParkingHost(); + if (instance.terminal.element.parentElement !== host) { + host.appendChild(instance.terminal.element); + } + } + }; + } + } + }; + + const cleanup = setupTerminal(); + + return () => { + mounted = false; + Promise.resolve(cleanup).then((fn) => { + if (typeof fn === 'function') fn(); + }); + }; + }, [activeSessionId]); + + return ( + +
+ {/* Left Sidebar */} +
+
+ Sessions + { + e.stopPropagation(); + createNewSession(); + }} + title="New Terminal Session" + /> +
+
+ {isLoading ? ( +
+ Loading sessions... +
+ ) : sessions.length === 0 ? ( +
+ No active sessions +
+ ) : ( + + )} +
+
+ + {/* Right Terminal Display */} +
+ {!activeSessionId && window.ipcRenderer && ( +
+ + No terminal session selected +
+ )} +
+
+
+ + ); +}; + +export default TerminalTab; diff --git a/packages/bruno-app/src/components/Devtools/Console/index.js b/packages/bruno-app/src/components/Devtools/Console/index.js index 669fbf925..558acc79c 100644 --- a/packages/bruno-app/src/components/Devtools/Console/index.js +++ b/packages/bruno-app/src/components/Devtools/Console/index.js @@ -27,6 +27,7 @@ import { } from 'providers/ReduxStore/slices/logs'; import NetworkTab from './NetworkTab'; +import TerminalTab from './TerminalTab'; import RequestDetailsPanel from './RequestDetailsPanel'; // import DebugTab from './DebugTab'; import ErrorDetailsPanel from './ErrorDetailsPanel'; @@ -389,6 +390,8 @@ const Console = () => { return ; case 'performance': return ; + case 'terminal': + return ; // case 'debug': // return ; default: @@ -442,6 +445,8 @@ const Console = () => {
); + case 'terminal': + return null; // No controls needed for terminal // case 'debug': // return ( //
@@ -497,6 +502,14 @@ const Console = () => { Performance + + {/*
)} + {isFolder && ( +
{ + dropdownTippyRef.current.hide(); + // Get folder pathname + const folderCwd = item.pathname || collectionPathname; + await openDevtoolsAndSwitchToTerminal(dispatch, folderCwd); + }} + > + + + + Open in Terminal +
+ )}
{ diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index 3c8e07987..c8a534444 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -17,7 +17,8 @@ import { IconShare, IconFoldDown, IconX, - IconSettings + IconSettings, + IconTerminal2 } from '@tabler/icons'; import Dropdown from 'components/Dropdown'; import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections'; @@ -42,6 +43,7 @@ import { scrollToTheActiveTab } from 'utils/tabs'; import ShareCollection from 'components/ShareCollection/index'; import { CollectionItemDragPreview } from './CollectionItem/CollectionItemDragPreview/index'; import { sortByNameThenSequence } from 'utils/common/index'; +import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal'; const Collection = ({ collection, searchText }) => { const [showNewFolderModal, setShowNewFolderModal] = useState(false); @@ -422,6 +424,19 @@ const Collection = ({ collection, searchText }) => { Settings
+
{ + menuDropdownTippyRef.current.hide(); + const collectionCwd = collection.pathname; + await openDevtoolsAndSwitchToTerminal(dispatch, collectionCwd); + }} + > + + + + Open in Terminal +
{ diff --git a/packages/bruno-app/src/utils/terminal.js b/packages/bruno-app/src/utils/terminal.js new file mode 100644 index 000000000..0933f56f3 --- /dev/null +++ b/packages/bruno-app/src/utils/terminal.js @@ -0,0 +1,32 @@ +import { openConsole, setActiveTab } from 'providers/ReduxStore/slices/logs'; +import { getSessionId } from 'components/Devtools/Console/TerminalTab'; + +/** + * Opens the devtools console and switches to the terminal tab + * Optionally opens/switches to a terminal session at a specific CWD + * @param {Function} dispatch - Redux dispatch function + * @param {string} [cwd] - Optional CWD path. If provided, checks for existing session at that CWD or creates new one + */ +export const openDevtoolsAndSwitchToTerminal = async (dispatch, cwd = null) => { + // Open console if closed + dispatch(openConsole()); + + // Switch to terminal tab + dispatch(setActiveTab('terminal')); + + // If CWD is provided, dispatch event to TerminalTab to handle session selection/creation + if (cwd) { + // Small delay to ensure terminal tab is mounted + setTimeout(() => { + window.dispatchEvent(new CustomEvent('terminal:open-at-cwd', { detail: { cwd } })); + }, 100); + } +}; + +/** + * Gets the current terminal session ID if a terminal session is running + * @returns {string|null} The session ID if terminal session exists, null otherwise + */ +export const getSessionID = () => { + return getSessionId(); +}; diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json index a38233fe8..4e042394d 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -32,6 +32,7 @@ "@aws-sdk/credential-providers": "3.750.0", "@grpc/grpc-js": "^1.13.2", "@grpc/proto-loader": "^0.7.13", + "@lydell/node-pty": "^1.1.0", "@usebruno/common": "0.1.0", "@usebruno/converters": "^0.1.0", "@usebruno/filestore": "^0.1.0", diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 47453fbea..5b891ec0e 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -42,6 +42,7 @@ const collectionWatcher = require('./app/collection-watcher'); const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window'); const registerNotificationsIpc = require('./ipc/notifications'); const registerGlobalEnvironmentsIpc = require('./ipc/global-environments'); +const TerminalManager = require('./ipc/terminal'); const { safeParseJSON, safeStringifyJSON } = require('./utils/common'); const { getDomainsWithCookies } = require('./utils/cookies'); const { cookiesStore } = require('./store/cookies'); @@ -51,6 +52,7 @@ const { getIsRunningInRosetta } = require('./utils/arch'); const lastOpenedCollections = new LastOpenedCollections(); const systemMonitor = new SystemMonitor(); +const terminalManager = new TerminalManager(); // Reference: https://content-security-policy.com/ const contentSecurityPolicy = [ @@ -161,6 +163,7 @@ app.on('ready', async () => { mainWindow.on('unmaximize', () => saveMaximized(false)); mainWindow.on('close', (e) => { e.preventDefault(); + terminalManager.cleanup(mainWindow.webContents); ipcMain.emit('main:start-quit-flow'); }); @@ -230,6 +233,12 @@ app.on('before-quit', () => { // Stop system monitoring systemMonitor.stop(); + + try { + terminalManager.killAll(); + } catch (err) { + console.error('Failed to kill all terminals on quit', err); + } }); app.on('window-all-closed', app.quit); diff --git a/packages/bruno-electron/src/ipc/terminal.js b/packages/bruno-electron/src/ipc/terminal.js new file mode 100644 index 000000000..b6d8cc1ca --- /dev/null +++ b/packages/bruno-electron/src/ipc/terminal.js @@ -0,0 +1,182 @@ +const { ipcMain } = require('electron'); +const pty = require('@lydell/node-pty'); +const os = require('os'); +const path = require('path'); +const isDev = require('electron-is-dev'); + +class TerminalManager { + constructor() { + this.terminals = new Map(); + this.setupIpcHandlers(); + } + + setupIpcHandlers() { + // Create a new terminal session + ipcMain.handle('terminal:create', (event, options = {}) => { + try { + const sessionId = this.generateSessionId(); + const shell = this.getDefaultShell(); + // Use provided cwd or default to home directory + const cwd = options.cwd || this.getDefaultCwd(); + + if (isDev) { + console.log(`Creating new terminal session: ${sessionId} at ${cwd}`); + } + + const ptyProcess = pty.spawn(shell, [], { + name: 'xterm-color', + cols: 80, + rows: 24, + cwd: cwd, + env: process.env + }); + + // Store terminal session + this.terminals.set(sessionId, { + pty: ptyProcess, + webContents: event.sender, + cwd: cwd // Store initial cwd + }); + + // Handle terminal output + ptyProcess.onData((data) => { + try { + if (data && event.sender && !event.sender.isDestroyed()) { + event.sender.send(`terminal:data:${sessionId}`, data); + } + } catch (error) { + console.warn('Failed to send terminal data:', error); + } + }); + + // Handle terminal exit + ptyProcess.onExit(({ exitCode, signal }) => { + try { + this.terminals.delete(sessionId); + if (event.sender && !event.sender.isDestroyed()) { + event.sender.send(`terminal:exit:${sessionId}`, { exitCode, signal }); + } + } catch (error) { + console.warn('Failed to handle terminal exit:', error); + } + }); + + return sessionId; + } catch (error) { + console.error('Failed to create terminal session:', error); + return null; + } + }); + + // Send input to terminal + ipcMain.on('terminal:input', (event, sessionId, data) => { + try { + const terminal = this.terminals.get(sessionId); + if (terminal && terminal.pty && data) { + terminal.pty.write(data); + } + } catch (error) { + console.warn('Failed to send input to terminal:', error); + } + }); + + // Resize terminal + ipcMain.on('terminal:resize', (event, sessionId, { cols, rows }) => { + try { + const terminal = this.terminals.get(sessionId); + if (terminal && terminal.pty && cols > 0 && rows > 0) { + terminal.pty.resize(cols, rows); + } + } catch (error) { + console.warn('Failed to resize terminal:', error); + } + }); + + // Kill terminal session + ipcMain.on('terminal:kill', (event, sessionId) => { + const terminal = this.terminals.get(sessionId); + if (terminal && terminal.pty) { + try { + terminal.pty.kill(); + this.terminals.delete(sessionId); + } catch (error) { + console.error('Failed to kill terminal:', error); + } + } + }); + + // Get list of all active terminal sessions + ipcMain.handle('terminal:list-sessions', (event) => { + try { + const sessions = []; + for (const [sessionId, terminal] of this.terminals.entries()) { + if (terminal && terminal.pty) { + // Check if process is still alive + try { + const pid = terminal.pty.pid; + process.kill(pid, 0); // Signal 0 just checks if process exists + sessions.push({ + sessionId, + cwd: terminal.cwd || '', // Use stored cwd + pid: pid + }); + } catch (error) { + // Process doesn't exist, remove it + this.terminals.delete(sessionId); + } + } + } + return sessions; + } catch (error) { + console.error('Failed to list terminal sessions:', error); + return []; + } + }); + } + + getDefaultShell() { + if (process.platform === 'win32') { + // Try PowerShell Core first (pwsh.exe), then fall back to Windows PowerShell (powershell.exe) + return process.env.PWSH || 'powershell.exe'; + } else { + return process.env.SHELL || '/bin/bash'; + } + } + + getDefaultCwd() { + // Try to use user's home directory as default + return process.env.HOME || process.env.USERPROFILE || os.homedir(); + } + + generateSessionId() { + return `terminal_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } + + // Clean up terminals when window closes + cleanup(webContents) { + for (const [sessionId, terminal] of this.terminals.entries()) { + if (terminal.webContents === webContents) { + try { + terminal.pty.kill(); + this.terminals.delete(sessionId); + } catch (error) { + console.error('Failed to cleanup terminal:', error); + } + } + } + } + + // Kill all terminals + killAll() { + for (const [sessionId, terminal] of this.terminals.entries()) { + try { + terminal.pty.kill(); + } catch (error) { + console.error('Failed to kill terminal:', error); + } + } + this.terminals.clear(); + } +} + +module.exports = TerminalManager; diff --git a/packages/bruno-electron/src/preload.js b/packages/bruno-electron/src/preload.js index 49c9c1b8d..6ba81c073 100644 --- a/packages/bruno-electron/src/preload.js +++ b/packages/bruno-electron/src/preload.js @@ -2,9 +2,14 @@ const { ipcRenderer, contextBridge, webUtils, shell } = require('electron'); contextBridge.exposeInMainWorld('ipcRenderer', { invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), + send: (channel, ...args) => ipcRenderer.send(channel, ...args), on: (channel, handler) => { // Deliberately strip event as it includes `sender` - const subscription = (event, ...args) => handler(...args); + const subscription = (event, ...args) => { + // Ensure args is always an array to prevent undefined errors + const safeArgs = args && args.length ? args : []; + handler(...safeArgs); + }; ipcRenderer.on(channel, subscription); return () => {