feat(workspace): 标签关闭二次确认(终端/SSH 双开关 + 键盘 Backspace/Delete 误关拦截),v1.0.27
CI / typecheck + test + build (windows) (push) Canceled after 0s

This commit is contained in:
Bill committed 2026-10-15 10:35:20 +08:00
1 parent bca7033689
commit cff5454346
19 files changed
+283 -50

No files matched your search

@@ -275,6 +275,26 @@ export function RenderSettingsTab(): React.JSX.Element {
/>
}
/>
<SettingRow
label={t('settings.render.confirmTabClose')}
desc={t('settings.render.confirmTabCloseDesc')}
control={
<Switch
checked={settings.terminal.confirmTabClose}
onChange={(checked) => void updateTerminal({ confirmTabClose: checked })}
/>
}
/>
<SettingRow
label={t('settings.render.confirmSshTabClose')}
desc={t('settings.render.confirmSshTabCloseDesc')}
control={
<Switch
checked={settings.terminal.confirmSshTabClose}
onChange={(checked) => void updateTerminal({ confirmSshTabClose: checked })}
/>
}
/>
<SettingRow
label={t('settings.render.suggest')}
desc={t('settings.render.suggestDesc')}
+150 -47
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { App, Button, Dropdown, Input, Modal, Popconfirm, Space } from 'antd'
import type { MenuProps } from 'antd'
import { CloudServerOutlined } from '@ant-design/icons'
@@ -22,13 +22,18 @@ export type TerminalTabProps = IDockviewPanelHeaderProps<{
* 关闭 / 关闭其他 / 关闭右侧
*
* SSH sessions get a 🖥 server icon and a green left-border accent so they're
* visually distinct from local terminals. All menu actions close panels
* directly through `api.close()`. The workspace's `onDidRemovePanel` handles
* the pty kill, so nothing leaks.
* visually distinct from local terminals. Every user-initiated close button,
* menu action and the tablist's Delete/Backspace accelerator goes through
* `requestClose`, which may ask for confirmation first (see below). Whatever
* the path, the workspace's `onDidRemovePanel` handles the pty kill, so
* nothing leaks.
*/
export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Element {
const panel = api.group // bound panel via the panel api
type TabPanel = (typeof panel.panels)[number]
const [title, setTitle] = useState<string | undefined>(api.title)
const [pending, setPending] = useState<TabPanel[] | null>(null)
const rootRef = useRef<HTMLDivElement>(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 (
<Dropdown menu={menu} trigger={['contextMenu']}>
<div
className={`workspace-terminal-tab${isSsh ? ' workspace-terminal-tab-ssh' : ''}`}
title={tooltip}
>
{isSsh && <CloudServerOutlined className="workspace-terminal-tab-icon" />}
<span className="workspace-terminal-tab-title">{title}</span>
{isSsh && hostLabel && <SshHostBadge label={hostLabel} language={language} />}
{isBroadcastTarget && (
<span
className="workspace-terminal-tab-bcast"
title={tFor(language, 'workspace.tab.broadcastTarget')}
aria-label={tFor(language, 'workspace.tab.broadcastTarget')}
>
{tFor(language, 'workspace.tab.broadcastBadge')}
</span>
)}
<button
type="button"
className="workspace-terminal-tab-close"
aria-label={tFor(language, 'common.close')}
onPointerDown={(event) => event.preventDefault()}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
api.close()
}}
<>
<Dropdown menu={menu} trigger={['contextMenu']}>
<div
ref={rootRef}
className={`workspace-terminal-tab${isSsh ? ' workspace-terminal-tab-ssh' : ''}`}
title={tooltip}
>
<CloseGlyph />
</button>
</div>
</Dropdown>
{isSsh && <CloudServerOutlined className="workspace-terminal-tab-icon" />}
<span className="workspace-terminal-tab-title">{title}</span>
{isSsh && hostLabel && <SshHostBadge label={hostLabel} language={language} />}
{isBroadcastTarget && (
<span
className="workspace-terminal-tab-bcast"
title={tFor(language, 'workspace.tab.broadcastTarget')}
aria-label={tFor(language, 'workspace.tab.broadcastTarget')}
>
{tFor(language, 'workspace.tab.broadcastBadge')}
</span>
)}
<button
type="button"
className="workspace-terminal-tab-close"
aria-label={tFor(language, 'common.close')}
onPointerDown={(event) => event.preventDefault()}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
const self = selfPanel()
if (self) requestClose([self])
}}
>
<CloseGlyph />
</button>
</div>
</Dropdown>
<Modal
open={pending !== null}
title={tFor(language, 'workspace.tab.closeConfirmTitle')}
okText={tFor(language, 'workspace.tab.closeConfirmOk')}
okButtonProps={{ danger: true }}
cancelText={tFor(language, 'common.cancel')}
maskClosable={false}
onOk={closePending}
onCancel={() => setPending(null)}
>
{pending && pending.length === 1
? tFor(language, 'workspace.tab.closeConfirmOne', { title: pending[0].title ?? '' })
: tFor(language, 'workspace.tab.closeConfirmMany', { count: pending?.length ?? 0 })}
</Modal>
</>
)
}
+6
View File
@@ -74,6 +74,12 @@ const settings: Record<string, string> = {
'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',
+4
View File
@@ -4,6 +4,10 @@ const workspace: Record<string, string> = {
'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',
+6
View File
@@ -72,6 +72,12 @@ const settings: Record<string, string> = {
'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 は常にそのまま実行します',
+4
View File
@@ -4,6 +4,10 @@ const workspace: Record<string, string> = {
'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': 'この分割に新しいターミナル',
+4
View File
@@ -71,6 +71,10 @@ const settings: Record<string, string> = {
'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': '记录命令历史',
+4
View File
@@ -4,6 +4,10 @@ const workspace: Record<string, string> = {
'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': '在此分屏中新建终端',
+4
View File
@@ -71,6 +71,10 @@ const settings: Record<string, string> = {
'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': '記錄指令歷史',
+4
View File
@@ -4,6 +4,10 @@ const workspace: Record<string, string> = {
'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': '在此分割中新增終端機',
+6
View File
@@ -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,