feat(workspace): 标签关闭二次确认(终端/SSH 双开关 + 键盘 Backspace/Delete 误关拦截),v1.0.27
CI / typecheck + test + build (windows) (push) Canceled after 0s
CI / typecheck + test + build (windows) (push) Canceled after 0s
This commit is contained in:
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')}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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,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': 'この分割に新しいターミナル',
|
||||
|
||||
@@ -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,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': '在此分屏中新建终端',
|
||||
|
||||
@@ -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,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': '在此分割中新增終端機',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user