Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cff5454346 |
No files matched your search
@@ -116,6 +116,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
|||||||
- 会话计数不靠累加器:`releaseSession` 直接扫 `api.panels` 判断还有没有面板在显示该会话——累加器与「`updateParameters` 原地换会话」「整块布局替换」这类无事件变化脱节,会漏杀或误杀
|
- 会话计数不靠累加器:`releaseSession` 直接扫 `api.panels` 判断还有没有面板在显示该会话——累加器与「`updateParameters` 原地换会话」「整块布局替换」这类无事件变化脱节,会漏杀或误杀
|
||||||
- **旧版(M6.1 前单 dockview)模板只喂 terminal dockview**:成功路径的 rebind 与收尾 kill 都必须按 `sshTouched` / `live` 集合门控——rebind 会把面板参数改写成新**本地**会话,对没被 fromJSON 触碰的 ssh dockview 跑一遍等于把活 SSH 面板降级成终端再杀会话
|
- **旧版(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)
|
- 主机密钥队列只渲染队头,且主进程 `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`**,会话已死自动关闭/模板应用等程序性路径不走
|
||||||
|
|
||||||
## 关键词高亮
|
## 关键词高亮
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# OpenTerminal Changelog
|
# 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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# OpenTerminal 更新履歴
|
# 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
|
## v1.0.26 - 2026-10-11
|
||||||
|
|
||||||
今回の重点はパフォーマンスと診断性です:ウィンドウのドラッグリサイズや端末の高頻度出力で UI 全体が数秒間フリーズする問題を修正し、診断ログの保存(デフォルト有効・7 日で自動削除)を追加しました。フリーズ報告に証拠が添えられるようになります。
|
今回の重点はパフォーマンスと診断性です:ウィンドウのドラッグリサイズや端末の高頻度出力で UI 全体が数秒間フリーズする問題を修正し、診断ログの保存(デフォルト有効・7 日で自動削除)を追加しました。フリーズ報告に証拠が添えられるようになります。
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# OpenTerminal 更新日志
|
# OpenTerminal 更新日志
|
||||||
|
|
||||||
|
## v1.0.27 - 2026-10-15
|
||||||
|
|
||||||
|
本次新增标签页关闭二次确认:误触关闭不再直接杀掉正在工作的终端或 SSH 会话,尤其是点击标签后顺手按 Backspace 把整个 tab 关掉的高频误操作。
|
||||||
|
|
||||||
|
### 新增:关闭标签二次确认
|
||||||
|
- 设置 → 渲染与行为 新增两个开关(**默认关闭**,不开启则与原行为完全一致):
|
||||||
|
- **关闭终端标签二次确认**:关闭本地终端标签前弹出确认框
|
||||||
|
- **关闭 SSH 连接标签二次确认**:关闭 SSH 连接标签前弹出确认框(SSH 断开代价高,可以只对它开启)
|
||||||
|
- 覆盖所有用户关闭入口:标签 × 按钮、右键菜单的「关闭 / 关闭其他 / 关闭右侧」、以及键盘 Delete / Backspace
|
||||||
|
- 「关闭其他 / 关闭右侧」对整批只弹一次确认,确认框会显示将关闭的数量
|
||||||
|
- 点击标签获得焦点后按 Backspace / Delete 不再直接关闭标签,而是走同样的确认逻辑——这是误关闭的主要来源
|
||||||
|
- 开关保存后立即生效,无需重启;终端内正常输入退格、删除完全不受影响
|
||||||
|
- 会话自然退出、应用布局模板等程序性关闭不弹确认
|
||||||
|
|
||||||
## v1.0.26 - 2026-10-11
|
## v1.0.26 - 2026-10-11
|
||||||
|
|
||||||
本次重点是性能与可诊断性:修复了拖动缩放窗口或终端高频输出时整个界面冻结数秒的问题;新增落盘的诊断日志(默认开启,7 天自动清理),以后反馈卡顿可以附上日志,定位不再靠猜。
|
本次重点是性能与可诊断性:修复了拖动缩放窗口或终端高频输出时整个界面冻结数秒的问题;新增落盘的诊断日志(默认开启,7 天自动清理),以后反馈卡顿可以附上日志,定位不再靠猜。
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# OpenTerminal 更新日誌
|
# OpenTerminal 更新日誌
|
||||||
|
|
||||||
|
## v1.0.27 - 2026-10-15
|
||||||
|
|
||||||
|
本次新增分頁關閉二次確認:誤觸關閉不再直接殺掉正在工作的終端機或 SSH 連線,尤其是點擊分頁後順手按 Backspace 把整個分頁關掉的高頻誤操作。
|
||||||
|
|
||||||
|
### 新增:關閉分頁二次確認
|
||||||
|
- 設定 → 渲染與行為 新增兩個開關(**預設關閉**,不開啟則與原行為完全一致):
|
||||||
|
- **關閉終端機標籤二次確認**:關閉本地終端機分頁前彈出確認框
|
||||||
|
- **關閉 SSH 連線標籤二次確認**:關閉 SSH 連線分頁前彈出確認框(SSH 斷開代價高,可以只對它開啟)
|
||||||
|
- 涵蓋所有使用者關閉入口:分頁 × 按鈕、右鍵選單的「關閉 / 關閉其他 / 關閉右側」、以及鍵盤 Delete / Backspace
|
||||||
|
- 「關閉其他 / 關閉右側」對整批只彈一次確認,確認框會顯示將關閉的數量
|
||||||
|
- 點擊分頁取得焦點後按 Backspace / Delete 不再直接關閉分頁,而是走同樣的確認邏輯——這是誤關閉的主要來源
|
||||||
|
- 開關儲存後立即生效,無需重啟;終端機內正常輸入退格、刪除完全不受影響
|
||||||
|
- 連線自然退出、套用版面配置範本等程式性關閉不彈確認
|
||||||
|
|
||||||
## v1.0.26 - 2026-10-11
|
## v1.0.26 - 2026-10-11
|
||||||
|
|
||||||
本次重點是效能與可診斷性:修復拖曳縮放視窗或終端機高頻輸出時整個介面凍結數秒的問題;新增落盤的診斷日誌(預設開啟,7 天自動清理),日後回報卡頓可附上月誌,定位不再靠猜。
|
本次重點是效能與可診斷性:修復拖曳縮放視窗或終端機高頻輸出時整個介面凍結數秒的問題;新增落盤的診斷日誌(預設開啟,7 天自動清理),日後回報卡頓可附上月誌,定位不再靠猜。
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "open-terminal",
|
"name": "open-terminal",
|
||||||
"version": "1.0.26",
|
"version": "1.0.27",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "open-terminal",
|
"name": "open-terminal",
|
||||||
"version": "1.0.26",
|
"version": "1.0.27",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lydell/node-pty": "^1.2.0-beta.15",
|
"@lydell/node-pty": "^1.2.0-beta.15",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "open-terminal",
|
"name": "open-terminal",
|
||||||
"productName": "OpenTerminal",
|
"productName": "OpenTerminal",
|
||||||
"version": "1.0.26",
|
"version": "1.0.27",
|
||||||
"description": "Open-source terminal with SSH, split panes, themes and fonts",
|
"description": "Open-source terminal with SSH, split panes, themes and fonts",
|
||||||
"main": "out/main/index.js",
|
"main": "out/main/index.js",
|
||||||
"author": "CodingPlan.Site",
|
"author": "CodingPlan.Site",
|
||||||
|
|||||||
@@ -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
|
<SettingRow
|
||||||
label={t('settings.render.suggest')}
|
label={t('settings.render.suggest')}
|
||||||
desc={t('settings.render.suggestDesc')}
|
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 { App, Button, Dropdown, Input, Modal, Popconfirm, Space } from 'antd'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import { CloudServerOutlined } from '@ant-design/icons'
|
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
|
* 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
|
* visually distinct from local terminals. Every user-initiated close button,
|
||||||
* directly through `api.close()`. The workspace's `onDidRemovePanel` handles
|
* menu action and the tablist's Delete/Backspace accelerator goes through
|
||||||
* the pty kill, so nothing leaks.
|
* `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 {
|
export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Element {
|
||||||
const panel = api.group // bound panel via the panel api
|
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 [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)
|
const selfIndex = panel.panels.findIndex((p) => p.api === api)
|
||||||
|
|
||||||
// dockview keeps the tab component in its own render slot — a re-render of
|
// 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()
|
return () => disposable.dispose()
|
||||||
}, [api])
|
}, [api])
|
||||||
|
|
||||||
const closePanel = (target: typeof panel.panels[number]): void => {
|
// `api.group` is read live rather than reusing the render-time `panel`:
|
||||||
target.api.close()
|
// 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 = {
|
const menu: MenuProps = {
|
||||||
items: [
|
items: [
|
||||||
{ key: 'close', label: tFor(language, 'common.close') },
|
{ 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 }
|
{ key: 'closeRight', label: tFor(language, 'workspace.tab.closeRight'), disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 }
|
||||||
],
|
],
|
||||||
onClick: ({ key }) => {
|
onClick: ({ key }) => {
|
||||||
if (key === 'close') {
|
const self = selfPanel()
|
||||||
|
if (!self) {
|
||||||
api.close()
|
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') {
|
} else if (key === 'closeOthers') {
|
||||||
// `panel.panels` is the live group array and `close()` splices it, so
|
requestClose(siblings.filter((sibling) => sibling.api !== api))
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
} else if (key === 'closeRight') {
|
} else if (key === 'closeRight') {
|
||||||
for (const target of [...panel.panels].slice(selfIndex + 1)) {
|
requestClose(siblings.slice(siblings.indexOf(self) + 1))
|
||||||
closePanel(target)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown menu={menu} trigger={['contextMenu']}>
|
<>
|
||||||
<div
|
<Dropdown menu={menu} trigger={['contextMenu']}>
|
||||||
className={`workspace-terminal-tab${isSsh ? ' workspace-terminal-tab-ssh' : ''}`}
|
<div
|
||||||
title={tooltip}
|
ref={rootRef}
|
||||||
>
|
className={`workspace-terminal-tab${isSsh ? ' workspace-terminal-tab-ssh' : ''}`}
|
||||||
{isSsh && <CloudServerOutlined className="workspace-terminal-tab-icon" />}
|
title={tooltip}
|
||||||
<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()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<CloseGlyph />
|
{isSsh && <CloudServerOutlined className="workspace-terminal-tab-icon" />}
|
||||||
</button>
|
<span className="workspace-terminal-tab-title">{title}</span>
|
||||||
</div>
|
{isSsh && hostLabel && <SshHostBadge label={hostLabel} language={language} />}
|
||||||
</Dropdown>
|
{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.pasteRiskConfirm': 'Confirm risky paste',
|
||||||
'settings.render.pasteRiskConfirmDesc':
|
'settings.render.pasteRiskConfirmDesc':
|
||||||
'Ask before pasting multi-line text (or a very long single line); single lines paste directly',
|
'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.suggest': 'Input suggestions',
|
||||||
'settings.render.suggestDesc':
|
'settings.render.suggestDesc':
|
||||||
'Show command suggestions while typing; Tab accepts, Enter always runs the command right away',
|
'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.localTerminal': 'Local terminal',
|
||||||
'workspace.tab.closeOthers': 'Close others',
|
'workspace.tab.closeOthers': 'Close others',
|
||||||
'workspace.tab.closeRight': 'Close to the right',
|
'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.broadcastTarget': 'Broadcast target',
|
||||||
'workspace.tab.broadcastBadge': 'B',
|
'workspace.tab.broadcastBadge': 'B',
|
||||||
'workspace.tab.addInSplit': 'New terminal in this split',
|
'workspace.tab.addInSplit': 'New terminal in this split',
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ const settings: Record<string, string> = {
|
|||||||
'settings.render.pasteRiskConfirm': '貼り付け前の確認',
|
'settings.render.pasteRiskConfirm': '貼り付け前の確認',
|
||||||
'settings.render.pasteRiskConfirmDesc':
|
'settings.render.pasteRiskConfirmDesc':
|
||||||
'複数行(または極端に長い 1 行)を貼り付ける前に確認します。1 行はそのまま貼り付けます',
|
'複数行(または極端に長い 1 行)を貼り付ける前に確認します。1 行はそのまま貼り付けます',
|
||||||
|
'settings.render.confirmTabClose': 'ターミナルタブを閉じる前に確認',
|
||||||
|
'settings.render.confirmTabCloseDesc':
|
||||||
|
'ローカルターミナルタブを閉じる前に確認ダイアログを表示',
|
||||||
|
'settings.render.confirmSshTabClose': 'SSH タブを閉じる前に確認',
|
||||||
|
'settings.render.confirmSshTabCloseDesc':
|
||||||
|
'SSH 接続タブを閉じる前に確認ダイアログを表示',
|
||||||
'settings.render.suggest': '入力候補',
|
'settings.render.suggest': '入力候補',
|
||||||
'settings.render.suggestDesc':
|
'settings.render.suggestDesc':
|
||||||
'入力中にコマンド候補を表示します。Tab で確定、Enter は常にそのまま実行します',
|
'入力中にコマンド候補を表示します。Tab で確定、Enter は常にそのまま実行します',
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ const workspace: Record<string, string> = {
|
|||||||
'workspace.tab.localTerminal': 'ローカルターミナル',
|
'workspace.tab.localTerminal': 'ローカルターミナル',
|
||||||
'workspace.tab.closeOthers': 'ほかを閉じる',
|
'workspace.tab.closeOthers': 'ほかを閉じる',
|
||||||
'workspace.tab.closeRight': '右側を閉じる',
|
'workspace.tab.closeRight': '右側を閉じる',
|
||||||
|
'workspace.tab.closeConfirmTitle': 'タブを閉じますか?',
|
||||||
|
'workspace.tab.closeConfirmOne': '「{title}」を閉じますか?対応するセッションは終了します。',
|
||||||
|
'workspace.tab.closeConfirmMany': '{count} 個のタブを閉じますか?対応するセッションは終了します。',
|
||||||
|
'workspace.tab.closeConfirmOk': '閉じる',
|
||||||
'workspace.tab.broadcastTarget': 'ブロードキャスト対象',
|
'workspace.tab.broadcastTarget': 'ブロードキャスト対象',
|
||||||
'workspace.tab.broadcastBadge': '配',
|
'workspace.tab.broadcastBadge': '配',
|
||||||
'workspace.tab.addInSplit': 'この分割に新しいターミナル',
|
'workspace.tab.addInSplit': 'この分割に新しいターミナル',
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ const settings: Record<string, string> = {
|
|||||||
'settings.render.copyOnSelectDesc': '鼠标选中文字时自动复制',
|
'settings.render.copyOnSelectDesc': '鼠标选中文字时自动复制',
|
||||||
'settings.render.pasteRiskConfirm': '粘贴风险确认',
|
'settings.render.pasteRiskConfirm': '粘贴风险确认',
|
||||||
'settings.render.pasteRiskConfirmDesc': '多行文本(或超长单行)粘贴前弹出确认;单行直接粘贴',
|
'settings.render.pasteRiskConfirmDesc': '多行文本(或超长单行)粘贴前弹出确认;单行直接粘贴',
|
||||||
|
'settings.render.confirmTabClose': '关闭终端标签二次确认',
|
||||||
|
'settings.render.confirmTabCloseDesc': '关闭本地终端标签前弹出确认框',
|
||||||
|
'settings.render.confirmSshTabClose': '关闭 SSH 连接标签二次确认',
|
||||||
|
'settings.render.confirmSshTabCloseDesc': '关闭 SSH 连接标签前弹出确认框',
|
||||||
'settings.render.suggest': '输入建议',
|
'settings.render.suggest': '输入建议',
|
||||||
'settings.render.suggestDesc': '输入时弹出命令建议;Tab 接受,回车始终直接执行',
|
'settings.render.suggestDesc': '输入时弹出命令建议;Tab 接受,回车始终直接执行',
|
||||||
'settings.render.history': '记录命令历史',
|
'settings.render.history': '记录命令历史',
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ const workspace: Record<string, string> = {
|
|||||||
'workspace.tab.localTerminal': '本地终端',
|
'workspace.tab.localTerminal': '本地终端',
|
||||||
'workspace.tab.closeOthers': '关闭其他',
|
'workspace.tab.closeOthers': '关闭其他',
|
||||||
'workspace.tab.closeRight': '关闭右侧',
|
'workspace.tab.closeRight': '关闭右侧',
|
||||||
|
'workspace.tab.closeConfirmTitle': '确认关闭标签',
|
||||||
|
'workspace.tab.closeConfirmOne': '确定要关闭「{title}」吗?对应的会话将被终止。',
|
||||||
|
'workspace.tab.closeConfirmMany': '确定要关闭 {count} 个标签吗?对应的会话将被终止。',
|
||||||
|
'workspace.tab.closeConfirmOk': '关闭',
|
||||||
'workspace.tab.broadcastTarget': '广播目标',
|
'workspace.tab.broadcastTarget': '广播目标',
|
||||||
'workspace.tab.broadcastBadge': '播',
|
'workspace.tab.broadcastBadge': '播',
|
||||||
'workspace.tab.addInSplit': '在此分屏中新建终端',
|
'workspace.tab.addInSplit': '在此分屏中新建终端',
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ const settings: Record<string, string> = {
|
|||||||
'settings.render.copyOnSelectDesc': '以滑鼠選取文字時自動複製',
|
'settings.render.copyOnSelectDesc': '以滑鼠選取文字時自動複製',
|
||||||
'settings.render.pasteRiskConfirm': '貼上風險確認',
|
'settings.render.pasteRiskConfirm': '貼上風險確認',
|
||||||
'settings.render.pasteRiskConfirmDesc': '多行文字(或超長單行)貼上前彈出確認;單行直接貼上',
|
'settings.render.pasteRiskConfirmDesc': '多行文字(或超長單行)貼上前彈出確認;單行直接貼上',
|
||||||
|
'settings.render.confirmTabClose': '關閉終端標籤二次確認',
|
||||||
|
'settings.render.confirmTabCloseDesc': '關閉本地終端標籤前彈出確認框',
|
||||||
|
'settings.render.confirmSshTabClose': '關閉 SSH 連線標籤二次確認',
|
||||||
|
'settings.render.confirmSshTabCloseDesc': '關閉 SSH 連線標籤前彈出確認框',
|
||||||
'settings.render.suggest': '輸入建議',
|
'settings.render.suggest': '輸入建議',
|
||||||
'settings.render.suggestDesc': '輸入時顯示指令建議;Tab 接受,Enter 一律直接執行',
|
'settings.render.suggestDesc': '輸入時顯示指令建議;Tab 接受,Enter 一律直接執行',
|
||||||
'settings.render.history': '記錄指令歷史',
|
'settings.render.history': '記錄指令歷史',
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ const workspace: Record<string, string> = {
|
|||||||
'workspace.tab.localTerminal': '本機終端機',
|
'workspace.tab.localTerminal': '本機終端機',
|
||||||
'workspace.tab.closeOthers': '關閉其他',
|
'workspace.tab.closeOthers': '關閉其他',
|
||||||
'workspace.tab.closeRight': '關閉右側',
|
'workspace.tab.closeRight': '關閉右側',
|
||||||
|
'workspace.tab.closeConfirmTitle': '確認關閉標籤',
|
||||||
|
'workspace.tab.closeConfirmOne': '確定要關閉「{title}」嗎?對應的會話將被終止。',
|
||||||
|
'workspace.tab.closeConfirmMany': '確定要關閉 {count} 個標籤嗎?對應的會話將被終止。',
|
||||||
|
'workspace.tab.closeConfirmOk': '關閉',
|
||||||
'workspace.tab.broadcastTarget': '廣播目標',
|
'workspace.tab.broadcastTarget': '廣播目標',
|
||||||
'workspace.tab.broadcastBadge': '播',
|
'workspace.tab.broadcastBadge': '播',
|
||||||
'workspace.tab.addInSplit': '在此分割中新增終端機',
|
'workspace.tab.addInSplit': '在此分割中新增終端機',
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export interface TerminalSettings {
|
|||||||
copyOnSelect: boolean
|
copyOnSelect: boolean
|
||||||
/** confirm before pasting multi-line / suspicious text */
|
/** confirm before pasting multi-line / suspicious text */
|
||||||
pasteRiskConfirm: boolean
|
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) */
|
/** inline command suggestion popup while typing (history + library) */
|
||||||
suggestEnabled: boolean
|
suggestEnabled: boolean
|
||||||
/** record executed commands into the history panel */
|
/** record executed commands into the history panel */
|
||||||
@@ -476,6 +480,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
rendererMode: 'auto',
|
rendererMode: 'auto',
|
||||||
copyOnSelect: false,
|
copyOnSelect: false,
|
||||||
pasteRiskConfirm: true,
|
pasteRiskConfirm: true,
|
||||||
|
confirmTabClose: false,
|
||||||
|
confirmSshTabClose: false,
|
||||||
suggestEnabled: false,
|
suggestEnabled: false,
|
||||||
historyEnabled: false,
|
historyEnabled: false,
|
||||||
historyLimit: 100,
|
historyLimit: 100,
|
||||||
|
|||||||
@@ -185,6 +185,17 @@ console.log('[value bands]')
|
|||||||
ok(back.terminal.highlightStats === on, `the stats switch '${on}' survives the round trip`)
|
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' })]
|
const rules = [rule({ id: 'keep-1' }), rule({ id: 'keep-2' })]
|
||||||
await roundTrip({ highlightRules: rules })
|
await roundTrip({ highlightRules: rules })
|
||||||
|
|||||||
Reference in new issue
Block a user