From cff54543468e830b812d4ea82f1cba67b9440c1d Mon Sep 17 00:00:00 2001
From: Bill
Date: Thu, 15 Oct 2026 10:35:20 +0800
Subject: [PATCH 1/2] =?UTF-8?q?feat(workspace):=20=E6=A0=87=E7=AD=BE?=
=?UTF-8?q?=E5=85=B3=E9=97=AD=E4=BA=8C=E6=AC=A1=E7=A1=AE=E8=AE=A4=EF=BC=88?=
=?UTF-8?q?=E7=BB=88=E7=AB=AF/SSH=20=E5=8F=8C=E5=BC=80=E5=85=B3=20+=20?=
=?UTF-8?q?=E9=94=AE=E7=9B=98=20Backspace/Delete=20=E8=AF=AF=E5=85=B3?=
=?UTF-8?q?=E6=8B=A6=E6=88=AA=EF=BC=89=EF=BC=8Cv1.0.27?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 1 +
CHANGELOG.en.md | 14 ++
CHANGELOG.ja.md | 14 ++
CHANGELOG.md | 14 ++
CHANGELOG.zh-TW.md | 14 ++
package-lock.json | 4 +-
package.json | 2 +-
src/renderer/src/settings/SettingsTabs.tsx | 20 +++
src/renderer/src/workspace/TerminalTab.tsx | 197 ++++++++++++++++-----
src/shared/i18n/dicts/en/settings.ts | 6 +
src/shared/i18n/dicts/en/workspace.ts | 4 +
src/shared/i18n/dicts/ja/settings.ts | 6 +
src/shared/i18n/dicts/ja/workspace.ts | 4 +
src/shared/i18n/dicts/zh-CN/settings.ts | 4 +
src/shared/i18n/dicts/zh-CN/workspace.ts | 4 +
src/shared/i18n/dicts/zh-TW/settings.ts | 4 +
src/shared/i18n/dicts/zh-TW/workspace.ts | 4 +
src/shared/settings.ts | 6 +
tests/settings-store.mjs | 11 ++
19 files changed, 283 insertions(+), 50 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 2fe4642..1aa221b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -116,6 +116,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- 会话计数不靠累加器:`releaseSession` 直接扫 `api.panels` 判断还有没有面板在显示该会话——累加器与「`updateParameters` 原地换会话」「整块布局替换」这类无事件变化脱节,会漏杀或误杀
- **旧版(M6.1 前单 dockview)模板只喂 terminal dockview**:成功路径的 rebind 与收尾 kill 都必须按 `sshTouched` / `live` 集合门控——rebind 会把面板参数改写成新**本地**会话,对没被 fromJSON 触碰的 ssh dockview 跑一遍等于把活 SSH 面板降级成终端再杀会话
- 主机密钥队列只渲染队头,且主进程 `DEFAULT_TIMEOUTS.prompt`(120s)超时后**不再广播**:渲染层用同 TTL 的定时清扫(`HOST_KEY_PROMPT_TTL_MS`)移除过期条目,否则陈旧弹窗永远占住队头挡住后续主机;两处 TTL 必须同步改。`respondHostKey` 等 IPC 调用**不得写进 setState 的 updater**(React 可能重放 updater)
+- **tab 关闭二次确认**(`settings.terminal.confirmTabClose` / `confirmSshTabClose`,默认关):拦截点在 `TerminalTab.tsx` 的 `requestClose`——dockview 8.2.0 没有可否决的关闭前钩子,杀会话发生在 `onDidRemovePanel` 之后无法回滚,所以所有用户关闭入口(× 按钮、右键三关闭项、键盘 Delete/Backspace)必须在调用 `api.close()` 之前过它;键盘路径靠挂在 `.dv-tab` 外壳(自定义 div 的 dockview 祖先元素,DOM 焦点落点)上的原生捕获监听拦截。**新增用户可触发的关闭入口必须走 `requestClose`**,会话已死自动关闭/模板应用等程序性路径不走
## 关键词高亮
diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md
index 045d6a2..d373fa6 100644
--- a/CHANGELOG.en.md
+++ b/CHANGELOG.en.md
@@ -1,5 +1,19 @@
# OpenTerminal Changelog
+## v1.0.27 - 2026-10-15
+
+This release adds close confirmation for terminal tabs: an accidental close no longer kills a terminal or SSH session you are working in — especially the frequent case of pressing Backspace right after clicking a tab.
+
+### New: confirm before closing tabs
+- Two new switches under Settings → Rendering & Behavior (**off by default**; leaving them off keeps the previous behaviour exactly):
+ - **Confirm before closing terminal tabs**: ask before closing a local terminal tab
+ - **Confirm before closing SSH tabs**: ask before closing an SSH connection tab (SSH disconnects are costly, so you can enable it for SSH only)
+- Covers every user-initiated close path: the tab × button, the context menu's Close / Close others / Close right, and the Delete / Backspace keys
+- "Close others" / "Close right" ask once for the whole batch, with the number of tabs shown in the dialog
+- Pressing Backspace / Delete while a tab has keyboard focus (the main source of accidental closes) no longer closes it directly — it goes through the same confirmation
+- Switches take effect immediately after saving, no restart needed; typing Backspace / Delete inside the terminal is completely unaffected
+- Programmatic closes (a session exiting on its own, applying a layout template) never prompt
+
## v1.0.26 - 2026-10-11
This release is about performance and diagnosability: it fixes the multi-second UI freezes when dragging to resize the window or when a terminal floods output, and adds an on-disk diagnostic log (on by default, auto-cleaned after 7 days) so freeze reports finally come with evidence.
diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md
index dd0997a..c6ac17a 100644
--- a/CHANGELOG.ja.md
+++ b/CHANGELOG.ja.md
@@ -1,5 +1,19 @@
# OpenTerminal 更新履歴
+## v1.0.27 - 2026-10-15
+
+本リリースではタブを閉じる前の確認機能を追加しました。うっかり閉じて作業中のターミナルや SSH セッションを終了させてしまう——特にタブをクリックした直後に Backspace を押してタブごと閉じてしまう操作——を防ぎます。
+
+### 新機能:タブを閉じる前の確認
+- 設定 → 描画と動作 に 2 つのスイッチを追加(**既定はオフ**。オフのままなら従来の動作と完全に同じ):
+ - **ターミナルタブを閉じる前に確認**:ローカルターミナルタブを閉じる前に確認ダイアログを表示
+ - **SSH タブを閉じる前に確認**:SSH 接続タブを閉じる前に確認ダイアログを表示(SSH の切断はコストが高いため、SSH のみ有効にすることもできます)
+- ユーザーによるすべての閉じ操作を対象:タブの × ボタン、右クリックメニューの「閉じる / ほかを閉じる / 右側を閉じる」、および Delete / Backspace キー
+- 「ほかを閉じる / 右側を閉じる」はバッチ全体で 1 回だけ確認し、閉じるタブ数を表示します
+- タブにキーボードフォーカスがある状態での Backspace / Delete は直接閉じず、同じ確認フローを通ります——誤操作の主な原因でした
+- スイッチは保存後すぐに反映され、再起動は不要です。ターミナル内での通常の Backspace / Delete 入力には一切影響しません
+- セッションの自然終了やレイアウトテンプレート適用などのプログラム的な閉じでは確認しません
+
## v1.0.26 - 2026-10-11
今回の重点はパフォーマンスと診断性です:ウィンドウのドラッグリサイズや端末の高頻度出力で UI 全体が数秒間フリーズする問題を修正し、診断ログの保存(デフォルト有効・7 日で自動削除)を追加しました。フリーズ報告に証拠が添えられるようになります。
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e0e16c..f8c8812 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
# OpenTerminal 更新日志
+## v1.0.27 - 2026-10-15
+
+本次新增标签页关闭二次确认:误触关闭不再直接杀掉正在工作的终端或 SSH 会话,尤其是点击标签后顺手按 Backspace 把整个 tab 关掉的高频误操作。
+
+### 新增:关闭标签二次确认
+- 设置 → 渲染与行为 新增两个开关(**默认关闭**,不开启则与原行为完全一致):
+ - **关闭终端标签二次确认**:关闭本地终端标签前弹出确认框
+ - **关闭 SSH 连接标签二次确认**:关闭 SSH 连接标签前弹出确认框(SSH 断开代价高,可以只对它开启)
+- 覆盖所有用户关闭入口:标签 × 按钮、右键菜单的「关闭 / 关闭其他 / 关闭右侧」、以及键盘 Delete / Backspace
+- 「关闭其他 / 关闭右侧」对整批只弹一次确认,确认框会显示将关闭的数量
+- 点击标签获得焦点后按 Backspace / Delete 不再直接关闭标签,而是走同样的确认逻辑——这是误关闭的主要来源
+- 开关保存后立即生效,无需重启;终端内正常输入退格、删除完全不受影响
+- 会话自然退出、应用布局模板等程序性关闭不弹确认
+
## v1.0.26 - 2026-10-11
本次重点是性能与可诊断性:修复了拖动缩放窗口或终端高频输出时整个界面冻结数秒的问题;新增落盘的诊断日志(默认开启,7 天自动清理),以后反馈卡顿可以附上日志,定位不再靠猜。
diff --git a/CHANGELOG.zh-TW.md b/CHANGELOG.zh-TW.md
index 8f98b14..2369553 100644
--- a/CHANGELOG.zh-TW.md
+++ b/CHANGELOG.zh-TW.md
@@ -1,5 +1,19 @@
# OpenTerminal 更新日誌
+## v1.0.27 - 2026-10-15
+
+本次新增分頁關閉二次確認:誤觸關閉不再直接殺掉正在工作的終端機或 SSH 連線,尤其是點擊分頁後順手按 Backspace 把整個分頁關掉的高頻誤操作。
+
+### 新增:關閉分頁二次確認
+- 設定 → 渲染與行為 新增兩個開關(**預設關閉**,不開啟則與原行為完全一致):
+ - **關閉終端機標籤二次確認**:關閉本地終端機分頁前彈出確認框
+ - **關閉 SSH 連線標籤二次確認**:關閉 SSH 連線分頁前彈出確認框(SSH 斷開代價高,可以只對它開啟)
+- 涵蓋所有使用者關閉入口:分頁 × 按鈕、右鍵選單的「關閉 / 關閉其他 / 關閉右側」、以及鍵盤 Delete / Backspace
+- 「關閉其他 / 關閉右側」對整批只彈一次確認,確認框會顯示將關閉的數量
+- 點擊分頁取得焦點後按 Backspace / Delete 不再直接關閉分頁,而是走同樣的確認邏輯——這是誤關閉的主要來源
+- 開關儲存後立即生效,無需重啟;終端機內正常輸入退格、刪除完全不受影響
+- 連線自然退出、套用版面配置範本等程式性關閉不彈確認
+
## v1.0.26 - 2026-10-11
本次重點是效能與可診斷性:修復拖曳縮放視窗或終端機高頻輸出時整個介面凍結數秒的問題;新增落盤的診斷日誌(預設開啟,7 天自動清理),日後回報卡頓可附上月誌,定位不再靠猜。
diff --git a/package-lock.json b/package-lock.json
index ecaf203..37f3646 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "open-terminal",
- "version": "1.0.26",
+ "version": "1.0.27",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-terminal",
- "version": "1.0.26",
+ "version": "1.0.27",
"license": "MIT",
"dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15",
diff --git a/package.json b/package.json
index b4c5e6c..f9a159f 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
- "version": "1.0.26",
+ "version": "1.0.27",
"description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js",
"author": "CodingPlan.Site",
diff --git a/src/renderer/src/settings/SettingsTabs.tsx b/src/renderer/src/settings/SettingsTabs.tsx
index 036137a..553dc02 100644
--- a/src/renderer/src/settings/SettingsTabs.tsx
+++ b/src/renderer/src/settings/SettingsTabs.tsx
@@ -275,6 +275,26 @@ export function RenderSettingsTab(): React.JSX.Element {
/>
}
/>
+ void updateTerminal({ confirmTabClose: checked })}
+ />
+ }
+ />
+ void updateTerminal({ confirmSshTabClose: checked })}
+ />
+ }
+ />
(api.title)
+ const [pending, setPending] = useState(null)
+ const rootRef = useRef(null)
const selfIndex = panel.panels.findIndex((p) => p.api === api)
// dockview keeps the tab component in its own render slot — a re-render of
@@ -56,10 +61,86 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
return () => disposable.dispose()
}, [api])
- const closePanel = (target: typeof panel.panels[number]): void => {
- target.api.close()
+ // `api.group` is read live rather than reusing the render-time `panel`:
+ // dragging this tab into another group re-parents the DOM without re-mounting
+ // the React component (dockview keeps the portal element and only swaps the
+ // `.dv-tab` shell around it), so anything captured at render would still name
+ // the group this tab has left.
+ const selfPanel = (): TabPanel | undefined => api.group.panels.find((p) => p.api === api)
+
+ // Confirmation has to happen *before* `api.close()`: dockview has no
+ // vetoable close hook, and the workspace kills the session from
+ // `onDidRemovePanel`, which is already too late to ask. Every user-initiated
+ // close entry point funnels through here; programmatic closes (a dead
+ // session, applying a layout template) call `api.close()` directly.
+ //
+ // Read the settings at call time: this runs from long-lived menu / keydown
+ // handlers, so a value captured at render would go stale after a change.
+ const needsConfirmFor = (targets: TabPanel[]): boolean => {
+ const terminal = useSettingsStore.getState().settings.terminal
+ return targets.some((p) =>
+ (p.params as { sessionKind?: 'local' | 'ssh' } | undefined)?.sessionKind === 'ssh'
+ ? terminal.confirmSshTabClose
+ : terminal.confirmTabClose
+ )
}
+ const requestClose = (targets: TabPanel[]): void => {
+ if (targets.length === 0) return
+ if (!needsConfirmFor(targets)) {
+ for (const target of targets) target.api.close()
+ return
+ }
+ setPending(targets)
+ }
+
+ const closePending = (): void => {
+ if (!pending) return
+ // Liveness filter: something else (a dead session, another tab's menu) may
+ // have closed a target while the dialog was up. Asked of the panel itself —
+ // its `group` follows it across groups, unlike a render-time snapshot.
+ for (const target of pending) if (target.group.panels.includes(target)) target.api.close()
+ setPending(null)
+ }
+
+ // dockview closes the focused tab on Delete/Backspace from a keydown listener
+ // on the tab list, but only when the event target *is* the `.dv-tab` shell —
+ // which is what has focus, and an ancestor of this component's root, so a
+ // React handler on the root never sees the key. The listener therefore has to
+ // be native, on the shell, in the capture phase (dockview listens during the
+ // bubble phase). It is attached to the document rather than to the shell
+ // itself because dragging a tab into another group re-parents this component's
+ // DOM into a fresh shell *without* re-mounting or re-rendering it: a
+ // shell-scoped listener would be stranded on the discarded element and
+ // Delete/Backspace would silently stop working for that tab. Everything the
+ // handler reads is resolved live (`api.group`, the settings), so the closure
+ // from the first render stays correct.
+ //
+ // The event is only swallowed when a confirmation is actually required;
+ // otherwise dockview's own handler runs as before, focus moved to the
+ // neighbouring tab included, so "off" is indistinguishable from the old
+ // behaviour.
+ useEffect(() => {
+ const doc = rootRef.current?.ownerDocument ?? document
+ const onKeyDown = (event: KeyboardEvent): void => {
+ if (event.key !== 'Delete' && event.key !== 'Backspace') return
+ const shell = rootRef.current?.closest('.dv-tab')
+ if (!shell || event.target !== shell) return
+ // Only the real tab strip: the overflow dropdown renders a second
+ // `.dv-tab`-classed wrapper around a copy of this component, and dockview's
+ // own handler is scoped to the tab list, so match that scope instead of
+ // closing a panel the platform would have left alone.
+ if (!shell.closest('.dv-tabs-container')) return
+ const self = selfPanel()
+ if (!self || !needsConfirmFor([self])) return
+ event.preventDefault()
+ event.stopPropagation()
+ requestClose([self])
+ }
+ doc.addEventListener('keydown', onKeyDown, true)
+ return () => doc.removeEventListener('keydown', onKeyDown, true)
+ }, [api])
+
const menu: MenuProps = {
items: [
{ key: 'close', label: tFor(language, 'common.close') },
@@ -67,56 +148,78 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
{ key: 'closeRight', label: tFor(language, 'workspace.tab.closeRight'), disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 }
],
onClick: ({ key }) => {
- if (key === 'close') {
+ const self = selfPanel()
+ if (!self) {
api.close()
+ return
+ }
+ // `api.group.panels` is the live group array and `close()` splices it, so
+ // the targets must be snapshotted first — otherwise every element that
+ // shifts into the current index is skipped. Read through `api.group`
+ // rather than a render-time copy in case the tab has since been dragged
+ // into another group.
+ const siblings = [...api.group.panels]
+ if (key === 'close') {
+ requestClose([self])
} else if (key === 'closeOthers') {
- // `panel.panels` is the live group array and `close()` splices it, so
- // the targets must be snapshotted first — otherwise every element that
- // shifts into the current index is skipped.
- for (const sibling of [...panel.panels]) {
- if (sibling.api !== api) closePanel(sibling)
- }
+ requestClose(siblings.filter((sibling) => sibling.api !== api))
} else if (key === 'closeRight') {
- for (const target of [...panel.panels].slice(selfIndex + 1)) {
- closePanel(target)
- }
+ requestClose(siblings.slice(siblings.indexOf(self) + 1))
}
}
}
return (
-
-