fix: resolve new tab creation failure and shortcut passthrough

- Fix pty.spawn cwd tilde expansion: ptyManager now passes resolvedCwd (expanding ~ and verifying directory existence) so child processes do not exit immediately on launch
- Add pointer-events: none to SVGs inside buttons and tabs to prevent Electron window-dragging interception
- Extend xterm attachCustomKeyEventHandler to allow Ctrl+Shift+T, Ctrl+Shift+W, etc. through while terminal is focused
- Wrap remote SSH and serial spawns with fallback to local bash to prevent tab death loops on connection failures
This commit is contained in:
2026-09-24 01:48:43 +08:00
parent ebf454a250
commit 4707817053
7 changed files with 85 additions and 13 deletions
+16 -3
View File
@@ -16,8 +16,21 @@ class PtyManager {
try { try {
let resolvedCwd = cwd || process.env.HOME || process.cwd(); let resolvedCwd = cwd || process.env.HOME || process.cwd();
if (typeof resolvedCwd === 'string' && resolvedCwd.startsWith('~')) { if (typeof resolvedCwd === 'string') {
resolvedCwd = path.join(os.homedir(), resolvedCwd.slice(1)); if (resolvedCwd === '~') {
resolvedCwd = os.homedir();
} else if (resolvedCwd.startsWith('~/') || resolvedCwd.startsWith('~\\')) {
resolvedCwd = path.join(os.homedir(), resolvedCwd.slice(2));
}
}
const fs = require('fs');
try {
if (!resolvedCwd || !fs.existsSync(resolvedCwd) || !fs.statSync(resolvedCwd).isDirectory()) {
resolvedCwd = os.homedir() || process.cwd();
}
} catch (e) {
resolvedCwd = os.homedir() || process.cwd();
} }
const targetShell = shell || this.defaultShell; const targetShell = shell || this.defaultShell;
@@ -27,7 +40,7 @@ class PtyManager {
name: 'xterm-256color', name: 'xterm-256color',
cols, cols,
rows, rows,
cwd, cwd: resolvedCwd,
env: { env: {
...process.env, ...process.env,
TERM: 'xterm-256color', TERM: 'xterm-256color',
+12 -1
View File
@@ -100,6 +100,11 @@ body {
flex-shrink: 0; flex-shrink: 0;
} }
.btn-icon svg,
.btn-icon svg * {
pointer-events: none !important;
}
.btn-icon:hover { .btn-icon:hover {
background: var(--btn-hover, rgba(255, 255, 255, 0.08)); background: var(--btn-hover, rgba(255, 255, 255, 0.08));
color: var(--text-bright); color: var(--text-bright);
@@ -115,7 +120,7 @@ body {
align-items: center; align-items: center;
gap: 2px; gap: 2px;
margin-left: 6px; margin-left: 6px;
-webkit-app-region: no-drag; -webkit-app-region: no-drag !important;
} }
.win-btn { .win-btn {
@@ -130,6 +135,12 @@ body {
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
-webkit-app-region: no-drag !important;
}
.win-btn svg,
.win-btn svg * {
pointer-events: none !important;
} }
.win-btn:hover { .win-btn:hover {
+15 -2
View File
@@ -6,7 +6,7 @@
overflow-x: auto; overflow-x: auto;
scrollbar-width: none; scrollbar-width: none;
max-width: calc(100vw - 320px); max-width: calc(100vw - 320px);
-webkit-app-region: no-drag; -webkit-app-region: no-drag !important;
} }
.tab-bar::-webkit-scrollbar { .tab-bar::-webkit-scrollbar {
@@ -30,6 +30,12 @@
white-space: nowrap; white-space: nowrap;
max-width: 180px; max-width: 180px;
min-width: 90px; min-width: 90px;
-webkit-app-region: no-drag !important;
}
.tab-item svg,
.tab-item svg * {
pointer-events: none !important;
} }
.tab-item:hover { .tab-item:hover {
@@ -66,6 +72,8 @@
border-radius: 50%; border-radius: 50%;
opacity: 0; opacity: 0;
transition: all 0.12s ease; transition: all 0.12s ease;
-webkit-app-region: no-drag !important;
pointer-events: auto !important;
} }
.tab-item:hover .tab-close, .tab-item:hover .tab-close,
@@ -92,7 +100,12 @@
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
flex-shrink: 0; flex-shrink: 0;
-webkit-app-region: no-drag; -webkit-app-region: no-drag !important;
}
.new-tab-btn svg,
.new-tab-btn svg * {
pointer-events: none !important;
} }
.new-tab-btn:hover { .new-tab-btn:hover {
+1
View File
@@ -54,6 +54,7 @@ class TerminalApp {
this.tabManager = new TabManager({ this.tabManager = new TabManager({
tabBar: this.tabBar, tabBar: this.tabBar,
viewport: this.viewport, viewport: this.viewport,
getSpawnOptions: (wsId) => (this.workspaceManager ? this.workspaceManager.getSpawnOptions(wsId) : {}),
onActiveChange: (activeTerm) => { onActiveChange: (activeTerm) => {
if (activeTerm && this.statusShell) { if (activeTerm && this.statusShell) {
this.statusShell.textContent = activeTerm.title || 'bash'; this.statusShell.textContent = activeTerm.title || 'bash';
+8 -2
View File
@@ -4,6 +4,7 @@ export class TabManager {
constructor(options = {}) { constructor(options = {}) {
this.tabBar = options.tabBar; this.tabBar = options.tabBar;
this.viewport = options.viewport; this.viewport = options.viewport;
this.getSpawnOptions = options.getSpawnOptions || (() => ({}));
this.onActiveChange = options.onActiveChange || (() => {}); this.onActiveChange = options.onActiveChange || (() => {});
this.onTabListChange = options.onTabListChange || (() => {}); this.onTabListChange = options.onTabListChange || (() => {});
@@ -49,10 +50,15 @@ export class TabManager {
} }
createTab(options = {}) { createTab(options = {}) {
const title = typeof options === 'string' ? options : (options.title || 'bash');
const workspaceId = typeof options === 'object' && options.workspaceId ? options.workspaceId : this.activeWorkspaceId; const workspaceId = typeof options === 'object' && options.workspaceId ? options.workspaceId : this.activeWorkspaceId;
const defaultSpawn = this.getSpawnOptions(workspaceId) || {};
const title = typeof options === 'string' ? options : (options.title || defaultSpawn.title || 'bash');
const spawnOptions = typeof options === 'object' && options.spawnOptions ? options.spawnOptions : ( const spawnOptions = typeof options === 'object' && options.spawnOptions ? options.spawnOptions : (
typeof options === 'object' ? { cwd: options.cwd, shell: options.shell, args: options.args } : {} typeof options === 'object' ? {
cwd: options.cwd !== undefined ? options.cwd : defaultSpawn.cwd,
shell: options.shell !== undefined ? options.shell : defaultSpawn.shell,
args: options.args !== undefined ? options.args : defaultSpawn.args,
} : defaultSpawn
); );
const tabId = 'tab_' + Math.random().toString(36).substring(2, 9); const tabId = 'tab_' + Math.random().toString(36).substring(2, 9);
+27 -1
View File
@@ -75,11 +75,32 @@ export class TerminalView {
console.warn('WebGL addon not supported, fallback to default Canvas renderer:', e); console.warn('WebGL addon not supported, fallback to default Canvas renderer:', e);
} }
// 自定义按键过滤:允许应用级全局快捷键(如 Ctrl+B 切换侧边栏)穿透 // 自定义按键过滤:允许应用级全局快捷键穿透终端聚焦拦截
this.xterm.attachCustomKeyEventHandler((event) => { this.xterm.attachCustomKeyEventHandler((event) => {
// Ctrl+B: 侧边栏
if (event.ctrlKey && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'b') { if (event.ctrlKey && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'b') {
return false; return false;
} }
// Ctrl+Shift+T / W / E / O / F / P
if (event.ctrlKey && event.shiftKey && ['t', 'w', 'e', 'o', 'f', 'p'].includes(event.key.toLowerCase())) {
return false;
}
// F1: 命令面板
if (event.key === 'F1') {
return false;
}
// Ctrl+,: 设置
if (event.ctrlKey && !event.shiftKey && !event.altKey && event.key === ',') {
return false;
}
// Ctrl+Tab / Ctrl+Shift+Tab
if (event.ctrlKey && event.key === 'Tab') {
return false;
}
// Alt+1 .. Alt+9
if (event.altKey && event.key >= '1' && event.key <= '9') {
return false;
}
return true; return true;
}); });
@@ -126,12 +147,17 @@ export class TerminalView {
shell: this.shell, shell: this.shell,
args: this.args, args: this.args,
}).then((res) => { }).then((res) => {
if (res && res.error) {
this.xterm.writeln(`\r\n\x1b[31m[创建终端进程失败: ${res.error}]\x1b[0m\r\n`);
}
if (res && res.shell) { if (res && res.shell) {
if (!this.title || this.title === 'bash') { if (!this.title || this.title === 'bash') {
this.title = res.shell.split('/').pop() || 'bash'; this.title = res.shell.split('/').pop() || 'bash';
this.onTitleChange(this.title); this.onTitleChange(this.title);
} }
} }
}).catch((err) => {
this.xterm.writeln(`\r\n\x1b[31m[启动终端异常: ${err.message}]\x1b[0m\r\n`);
}); });
// 聚焦 // 聚焦
+6 -4
View File
@@ -229,9 +229,10 @@ export class WorkspaceManager {
const targetHost = ws.user ? `${ws.user}@${ws.host}` : ws.host; const targetHost = ws.user ? `${ws.user}@${ws.host}` : ws.host;
const portArgs = ws.port && Number(ws.port) !== 22 ? ['-p', String(ws.port)] : []; const portArgs = ws.port && Number(ws.port) !== 22 ? ['-p', String(ws.port)] : [];
const extra = ws.extraArgs ? ws.extraArgs.split(' ').filter(Boolean) : []; const extra = ws.extraArgs ? ws.extraArgs.split(' ').filter(Boolean) : [];
const sshCmd = `ssh ${[...portArgs, ...extra, targetHost].join(' ')}`;
return { return {
shell: 'ssh', shell: '/bin/bash',
args: [...portArgs, ...extra, targetHost], args: ['-c', `${sshCmd} || (echo -e "\\n\\x1b[33m[SSH 会话断开或连接失败,已保留本地交互终端]\\x1b[0m" && exec bash)`],
title: `ssh: ${ws.name || targetHost}`, title: `ssh: ${ws.name || targetHost}`,
}; };
} }
@@ -239,9 +240,10 @@ export class WorkspaceManager {
if (ws.type === WORKSPACE_TYPES.SERIAL) { if (ws.type === WORKSPACE_TYPES.SERIAL) {
const baud = String(ws.baudRate || 115200); const baud = String(ws.baudRate || 115200);
const dev = ws.device || '/dev/ttyUSB0'; const dev = ws.device || '/dev/ttyUSB0';
const tool = ws.tool || 'picocom';
return { return {
shell: 'picocom', shell: '/bin/bash',
args: ['-b', baud, dev], args: ['-c', `which ${tool} >/dev/null 2>&1 && ${tool} -b ${baud} ${dev} || (echo -e "\\n\\x1b[33m[串口工具 ${tool} 未安装或 ${dev} 无法连接,已保留本地交互终端]\\x1b[0m" && exec bash)`],
title: `serial: ${dev}`, title: `serial: ${dev}`,
}; };
} }