From cff54543468e830b812d4ea82f1cba67b9440c1d Mon Sep 17 00:00:00 2001 From: Bill Date: Thu, 15 Oct 2026 10:35:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(workspace):=20=E6=A0=87=E7=AD=BE=E5=85=B3?= =?UTF-8?q?=E9=97=AD=E4=BA=8C=E6=AC=A1=E7=A1=AE=E8=AE=A4=EF=BC=88=E7=BB=88?= =?UTF-8?q?=E7=AB=AF/SSH=20=E5=8F=8C=E5=BC=80=E5=85=B3=20+=20=E9=94=AE?= =?UTF-8?q?=E7=9B=98=20Backspace/Delete=20=E8=AF=AF=E5=85=B3=E6=8B=A6?= =?UTF-8?q?=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 ( - -
- {isSsh && } - {title} - {isSsh && hostLabel && } - {isBroadcastTarget && ( - - {tFor(language, 'workspace.tab.broadcastBadge')} - - )} - -
-
+ {isSsh && } + {title} + {isSsh && hostLabel && } + {isBroadcastTarget && ( + + {tFor(language, 'workspace.tab.broadcastBadge')} + + )} + + + + setPending(null)} + > + {pending && pending.length === 1 + ? tFor(language, 'workspace.tab.closeConfirmOne', { title: pending[0].title ?? '' }) + : tFor(language, 'workspace.tab.closeConfirmMany', { count: pending?.length ?? 0 })} + + ) } diff --git a/src/shared/i18n/dicts/en/settings.ts b/src/shared/i18n/dicts/en/settings.ts index 3b54636..3307da6 100644 --- a/src/shared/i18n/dicts/en/settings.ts +++ b/src/shared/i18n/dicts/en/settings.ts @@ -74,6 +74,12 @@ const settings: Record = { 'settings.render.pasteRiskConfirm': 'Confirm risky paste', 'settings.render.pasteRiskConfirmDesc': 'Ask before pasting multi-line text (or a very long single line); single lines paste directly', + 'settings.render.confirmTabClose': 'Confirm before closing terminal tabs', + 'settings.render.confirmTabCloseDesc': + 'Ask for confirmation before closing a local terminal tab', + 'settings.render.confirmSshTabClose': 'Confirm before closing SSH tabs', + 'settings.render.confirmSshTabCloseDesc': + 'Ask for confirmation before closing an SSH connection tab', 'settings.render.suggest': 'Input suggestions', 'settings.render.suggestDesc': 'Show command suggestions while typing; Tab accepts, Enter always runs the command right away', diff --git a/src/shared/i18n/dicts/en/workspace.ts b/src/shared/i18n/dicts/en/workspace.ts index 9ced72b..8ba2049 100644 --- a/src/shared/i18n/dicts/en/workspace.ts +++ b/src/shared/i18n/dicts/en/workspace.ts @@ -4,6 +4,10 @@ const workspace: Record = { 'workspace.tab.localTerminal': 'Local terminal', 'workspace.tab.closeOthers': 'Close others', 'workspace.tab.closeRight': 'Close to the right', + 'workspace.tab.closeConfirmTitle': 'Close tab?', + 'workspace.tab.closeConfirmOne': 'Close "{title}"? Its session will be terminated.', + 'workspace.tab.closeConfirmMany': 'Close {count} tabs? Their sessions will be terminated.', + 'workspace.tab.closeConfirmOk': 'Close', 'workspace.tab.broadcastTarget': 'Broadcast target', 'workspace.tab.broadcastBadge': 'B', 'workspace.tab.addInSplit': 'New terminal in this split', diff --git a/src/shared/i18n/dicts/ja/settings.ts b/src/shared/i18n/dicts/ja/settings.ts index 8914781..5731424 100644 --- a/src/shared/i18n/dicts/ja/settings.ts +++ b/src/shared/i18n/dicts/ja/settings.ts @@ -72,6 +72,12 @@ const settings: Record = { 'settings.render.pasteRiskConfirm': '貼り付け前の確認', 'settings.render.pasteRiskConfirmDesc': '複数行(または極端に長い 1 行)を貼り付ける前に確認します。1 行はそのまま貼り付けます', + 'settings.render.confirmTabClose': 'ターミナルタブを閉じる前に確認', + 'settings.render.confirmTabCloseDesc': + 'ローカルターミナルタブを閉じる前に確認ダイアログを表示', + 'settings.render.confirmSshTabClose': 'SSH タブを閉じる前に確認', + 'settings.render.confirmSshTabCloseDesc': + 'SSH 接続タブを閉じる前に確認ダイアログを表示', 'settings.render.suggest': '入力候補', 'settings.render.suggestDesc': '入力中にコマンド候補を表示します。Tab で確定、Enter は常にそのまま実行します', diff --git a/src/shared/i18n/dicts/ja/workspace.ts b/src/shared/i18n/dicts/ja/workspace.ts index f97e1dd..1fc3a54 100644 --- a/src/shared/i18n/dicts/ja/workspace.ts +++ b/src/shared/i18n/dicts/ja/workspace.ts @@ -4,6 +4,10 @@ const workspace: Record = { 'workspace.tab.localTerminal': 'ローカルターミナル', 'workspace.tab.closeOthers': 'ほかを閉じる', 'workspace.tab.closeRight': '右側を閉じる', + 'workspace.tab.closeConfirmTitle': 'タブを閉じますか?', + 'workspace.tab.closeConfirmOne': '「{title}」を閉じますか?対応するセッションは終了します。', + 'workspace.tab.closeConfirmMany': '{count} 個のタブを閉じますか?対応するセッションは終了します。', + 'workspace.tab.closeConfirmOk': '閉じる', 'workspace.tab.broadcastTarget': 'ブロードキャスト対象', 'workspace.tab.broadcastBadge': '配', 'workspace.tab.addInSplit': 'この分割に新しいターミナル', diff --git a/src/shared/i18n/dicts/zh-CN/settings.ts b/src/shared/i18n/dicts/zh-CN/settings.ts index 61579b0..d4497d4 100644 --- a/src/shared/i18n/dicts/zh-CN/settings.ts +++ b/src/shared/i18n/dicts/zh-CN/settings.ts @@ -71,6 +71,10 @@ const settings: Record = { 'settings.render.copyOnSelectDesc': '鼠标选中文字时自动复制', 'settings.render.pasteRiskConfirm': '粘贴风险确认', 'settings.render.pasteRiskConfirmDesc': '多行文本(或超长单行)粘贴前弹出确认;单行直接粘贴', + 'settings.render.confirmTabClose': '关闭终端标签二次确认', + 'settings.render.confirmTabCloseDesc': '关闭本地终端标签前弹出确认框', + 'settings.render.confirmSshTabClose': '关闭 SSH 连接标签二次确认', + 'settings.render.confirmSshTabCloseDesc': '关闭 SSH 连接标签前弹出确认框', 'settings.render.suggest': '输入建议', 'settings.render.suggestDesc': '输入时弹出命令建议;Tab 接受,回车始终直接执行', 'settings.render.history': '记录命令历史', diff --git a/src/shared/i18n/dicts/zh-CN/workspace.ts b/src/shared/i18n/dicts/zh-CN/workspace.ts index c08243a..499a253 100644 --- a/src/shared/i18n/dicts/zh-CN/workspace.ts +++ b/src/shared/i18n/dicts/zh-CN/workspace.ts @@ -4,6 +4,10 @@ const workspace: Record = { 'workspace.tab.localTerminal': '本地终端', 'workspace.tab.closeOthers': '关闭其他', 'workspace.tab.closeRight': '关闭右侧', + 'workspace.tab.closeConfirmTitle': '确认关闭标签', + 'workspace.tab.closeConfirmOne': '确定要关闭「{title}」吗?对应的会话将被终止。', + 'workspace.tab.closeConfirmMany': '确定要关闭 {count} 个标签吗?对应的会话将被终止。', + 'workspace.tab.closeConfirmOk': '关闭', 'workspace.tab.broadcastTarget': '广播目标', 'workspace.tab.broadcastBadge': '播', 'workspace.tab.addInSplit': '在此分屏中新建终端', diff --git a/src/shared/i18n/dicts/zh-TW/settings.ts b/src/shared/i18n/dicts/zh-TW/settings.ts index 16a2bb3..9d7e1ee 100644 --- a/src/shared/i18n/dicts/zh-TW/settings.ts +++ b/src/shared/i18n/dicts/zh-TW/settings.ts @@ -71,6 +71,10 @@ const settings: Record = { 'settings.render.copyOnSelectDesc': '以滑鼠選取文字時自動複製', 'settings.render.pasteRiskConfirm': '貼上風險確認', 'settings.render.pasteRiskConfirmDesc': '多行文字(或超長單行)貼上前彈出確認;單行直接貼上', + 'settings.render.confirmTabClose': '關閉終端標籤二次確認', + 'settings.render.confirmTabCloseDesc': '關閉本地終端標籤前彈出確認框', + 'settings.render.confirmSshTabClose': '關閉 SSH 連線標籤二次確認', + 'settings.render.confirmSshTabCloseDesc': '關閉 SSH 連線標籤前彈出確認框', 'settings.render.suggest': '輸入建議', 'settings.render.suggestDesc': '輸入時顯示指令建議;Tab 接受,Enter 一律直接執行', 'settings.render.history': '記錄指令歷史', diff --git a/src/shared/i18n/dicts/zh-TW/workspace.ts b/src/shared/i18n/dicts/zh-TW/workspace.ts index 29e854a..59a11d6 100644 --- a/src/shared/i18n/dicts/zh-TW/workspace.ts +++ b/src/shared/i18n/dicts/zh-TW/workspace.ts @@ -4,6 +4,10 @@ const workspace: Record = { 'workspace.tab.localTerminal': '本機終端機', 'workspace.tab.closeOthers': '關閉其他', 'workspace.tab.closeRight': '關閉右側', + 'workspace.tab.closeConfirmTitle': '確認關閉標籤', + 'workspace.tab.closeConfirmOne': '確定要關閉「{title}」嗎?對應的會話將被終止。', + 'workspace.tab.closeConfirmMany': '確定要關閉 {count} 個標籤嗎?對應的會話將被終止。', + 'workspace.tab.closeConfirmOk': '關閉', 'workspace.tab.broadcastTarget': '廣播目標', 'workspace.tab.broadcastBadge': '播', 'workspace.tab.addInSplit': '在此分割中新增終端機', diff --git a/src/shared/settings.ts b/src/shared/settings.ts index c7eb035..1b0140d 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -66,6 +66,10 @@ export interface TerminalSettings { copyOnSelect: boolean /** confirm before pasting multi-line / suspicious text */ pasteRiskConfirm: boolean + /** Ask for confirmation before a user-initiated close of a local terminal tab. */ + confirmTabClose: boolean + /** Same for SSH connection tabs. */ + confirmSshTabClose: boolean /** inline command suggestion popup while typing (history + library) */ suggestEnabled: boolean /** record executed commands into the history panel */ @@ -476,6 +480,8 @@ export const DEFAULT_SETTINGS: AppSettings = { rendererMode: 'auto', copyOnSelect: false, pasteRiskConfirm: true, + confirmTabClose: false, + confirmSshTabClose: false, suggestEnabled: false, historyEnabled: false, historyLimit: 100, diff --git a/tests/settings-store.mjs b/tests/settings-store.mjs index 5c90033..1dbdff2 100644 --- a/tests/settings-store.mjs +++ b/tests/settings-store.mjs @@ -185,6 +185,17 @@ console.log('[value bands]') ok(back.terminal.highlightStats === on, `the stats switch '${on}' survives the round trip`) } } +{ + for (const on of [true, false]) { + const back = await roundTrip({ + terminal: { ...store.loadSettings().terminal, confirmTabClose: on, confirmSshTabClose: on } + }) + ok( + back.terminal.confirmTabClose === on && back.terminal.confirmSshTabClose === on, + `tab close confirm switches '${on}' survive the round trip` + ) + } +} { const rules = [rule({ id: 'keep-1' }), rule({ id: 'keep-2' })] await roundTrip({ highlightRules: rules })