mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-25 21:55:49 +00:00
Inbuilt Terminal (#6066)
* add: terminal * added support for multiple terminal sessions and opening terminal from collection's working directory * Use PowerShell as default shell on Windows Replace cmd.exe with powershell.exe for terminal sessions on Windows. Falls back to PWSH environment variable if set (for PowerShell Core). * refactor(ui): improved session list by moving path display to hover for cleaner view * chore: format * refactor: improve terminal code quality and UI consistency - Add terminal icon to 'Open in Terminal' dropdown item in CollectionItem - Remove unused imports and functions (IconPlus, callIpc, canWriteToTerminal) - Fix React key prop placement in SessionList component - Replace deprecated substr with substring in terminal session ID generation - Improve error handling for terminal cleanup on app quit - Simplify terminal cleanup logic in window close handler --------- Co-authored-by: naman-bruno <naman@usebruno.com> Co-authored-by: Sid <siddharth@usebruno.com>
This commit is contained in:
committed by
GitHub
parent
a3d2d35d2e
commit
9159f523d9
110
package-lock.json
generated
110
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<StyledSessionList>
|
||||
{sessions.map((session) => {
|
||||
const { name } = getSessionDisplayInfo(session);
|
||||
return (
|
||||
<ToolHint
|
||||
key={session.sessionId}
|
||||
text={getFullPath(session)}
|
||||
toolhintId={`session-path-${session.sessionId}`}
|
||||
place="bottom-start"
|
||||
delayShow={100}
|
||||
>
|
||||
<div
|
||||
className={`session-list-item ${activeSessionId === session.sessionId ? 'active' : ''}`}
|
||||
onClick={() => onSelectSession(session.sessionId)}
|
||||
>
|
||||
<div className="session-name">
|
||||
<IconTerminal className="session-icon" size={14} />
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
<div
|
||||
className="session-close-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseSession(session.sessionId);
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</ToolHint>
|
||||
);
|
||||
})}
|
||||
</StyledSessionList>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionList;
|
||||
@@ -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;
|
||||
@@ -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<sessionId, { terminal, fitAddon, inputDisposable, resizeDisposable }>
|
||||
const terminalInstances = new Map();
|
||||
|
||||
// Data listeners per session - Map<sessionId, { onData, onExit }>
|
||||
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 (
|
||||
<StyledWrapper>
|
||||
<div className="terminal-content">
|
||||
{/* Left Sidebar */}
|
||||
<div className="terminal-sessions-sidebar">
|
||||
<div className="terminal-sessions-header">
|
||||
<span>Sessions</span>
|
||||
<IconPlus
|
||||
size={16}
|
||||
style={{ cursor: 'pointer', color: '#888' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
createNewSession();
|
||||
}}
|
||||
title="New Terminal Session"
|
||||
/>
|
||||
</div>
|
||||
<div className="terminal-sessions-list">
|
||||
{isLoading ? (
|
||||
<div style={{ padding: '12px', color: '#888', fontSize: '13px' }}>
|
||||
Loading sessions...
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div style={{ padding: '12px', color: '#888', fontSize: '13px' }}>
|
||||
No active sessions
|
||||
</div>
|
||||
) : (
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelectSession={setActiveSessionId}
|
||||
onCloseSession={closeSession}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Terminal Display */}
|
||||
<div className="terminal-display-container">
|
||||
{!activeSessionId && window.ipcRenderer && (
|
||||
<div className="terminal-loading">
|
||||
<IconTerminal2 size={24} strokeWidth={1.5} />
|
||||
<span>No terminal session selected</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="terminal-container"
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: activeSessionId ? 'block' : 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalTab;
|
||||
@@ -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 <NetworkTab />;
|
||||
case 'performance':
|
||||
return <Performance />;
|
||||
case 'terminal':
|
||||
return <TerminalTab />;
|
||||
// case 'debug':
|
||||
// return <DebugTab />;
|
||||
default:
|
||||
@@ -442,6 +445,8 @@ const Console = () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'terminal':
|
||||
return null; // No controls needed for terminal
|
||||
// case 'debug':
|
||||
// return (
|
||||
// <div className="tab-controls">
|
||||
@@ -497,6 +502,14 @@ const Console = () => {
|
||||
<span>Performance</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`console-tab ${activeTab === 'terminal' ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange('terminal')}
|
||||
>
|
||||
<IconTerminal2 size={16} strokeWidth={1.5} />
|
||||
<span>Terminal</span>
|
||||
</button>
|
||||
|
||||
{/* <button
|
||||
className={`console-tab ${activeTab === 'debug' ? 'active' : ''}`}
|
||||
onClick={() => handleTabChange('debug')}
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
IconFolder,
|
||||
IconTrash,
|
||||
IconSettings,
|
||||
IconInfoCircle
|
||||
IconInfoCircle,
|
||||
IconTerminal2
|
||||
} from '@tabler/icons';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -51,6 +52,7 @@ import { isEqual } from 'lodash';
|
||||
import { calculateDraggedItemNewPathname, getInitialExampleName } from 'utils/collections/index';
|
||||
import { sortByNameThenSequence } from 'utils/common/index';
|
||||
import CreateExampleModal from 'components/ResponseExample/CreateExampleModal';
|
||||
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
|
||||
|
||||
const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => {
|
||||
const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid });
|
||||
@@ -693,6 +695,22 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
Settings
|
||||
</div>
|
||||
)}
|
||||
{isFolder && (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={async (e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
// Get folder pathname
|
||||
const folderCwd = item.pathname || collectionPathname;
|
||||
await openDevtoolsAndSwitchToTerminal(dispatch, folderCwd);
|
||||
}}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconTerminal2 size={16} strokeWidth={2} />
|
||||
</span>
|
||||
Open in Terminal
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="dropdown-item delete-item"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
</span>
|
||||
Settings
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={async (_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
const collectionCwd = collection.pathname;
|
||||
await openDevtoolsAndSwitchToTerminal(dispatch, collectionCwd);
|
||||
}}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconTerminal2 size={16} strokeWidth={2} />
|
||||
</span>
|
||||
Open in Terminal
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(_e) => {
|
||||
|
||||
32
packages/bruno-app/src/utils/terminal.js
Normal file
32
packages/bruno-app/src/utils/terminal.js
Normal file
@@ -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();
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
182
packages/bruno-electron/src/ipc/terminal.js
Normal file
182
packages/bruno-electron/src/ipc/terminal.js
Normal file
@@ -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;
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user