diff --git a/AGENTS.md b/AGENTS.md
index c65336c..85e8c0d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -59,6 +59,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- **调度器是注入的**:`applyUpdateSchedule(system)` 由 `index.ts` 经 `settingsStore.setUpdateScheduleApplier` 注入——`updater.ts` 已 import `settingsStore`,反向 import 成环。applier 幂等(先清旧 interval 再建)且每次 `mutateSettings` 都会重跑;启动检查用模块级 `startupArmed` 保证**每进程只武装一次**,否则改一次设置就多排一次启动检查
- **自动下载**(`settings.system.autoDownloadUpdate`,默认关):`update-available` 时若开关开且无在途下载 → `handleDownload('auto')`(`UpdateState.auto` 标记)。`autoUpdater.autoDownload` **保持 false**——单一事实源在 settings,不交给 electron-updater。失败落 `status:'error'` 且**不重试**,等下一个周期
- **安装永远交给用户**:绝不因下载完成自动退出;`autoInstallOnAppQuit = true` 只覆盖「用户自己退出时装上」
+- **差量下载已禁用**(`autoUpdater.disableDifferentialDownload = true`,configureAutoUpdater 里):electron-updater 每次 `downloadUpdate()` 先试差量(拉新旧 blockmap + 多段 Range)失败再回退全量——用户看到进度条跑两遍、最坏近乎双倍流量(v1.0.x 用户报的「下载两次」)。而本项目三个前提让它注定失败:GitHub release 资产落 Azure Blob,多段 Range 返回 `501 Unsupported client range`(已实测);国内 Gitea 通道被 `pruneChannel` 删得只剩最新版 exe/blockmap,老客户端取老 blockmap 得 404;NSIS 是 LZMA 固实压缩,版本间小改动让后续压缩数据整体漂移,差量区间≈整个文件。所以直接全量是唯一正确行为
- **同版本抑制**(`actedRelease` 单槽记录,`actedFor(version)` 版本不同即整体替换):`announced`(发现气泡)、`fetchHandled`(已发起下载)、`downloaded`(包已落盘)、`downloadedAnnounced`(已下载气泡)——否则长开机的应用每 4 小时重复弹气泡/重复走一遍 `downloadUpdate`。`runCheck('manual')` **无条件清空**记录(手动是明确意图,必须如实反映);`update-not-available`/`error` **不清空**;下载失败回滚该次 `fetchHandled`
- `downloaded` 与 `fetchHandled` **必须分开**:前者只在 `update-downloaded` 置位,用它判断「已完成」;拿 `fetchHandled` 判断会把下载中的版本误跳过。`update-available` 里若该版本 `downloaded` 为真,要 `setState` 一个等价的 `downloaded` 状态(**不能**直接 return 不写——`performCheck` 已把状态置成 `checking`,早退会让「关于」页永远转圈),这样按钮不会从「重启安装」退回「下载」
- **通知**:`tray.ts` 的 `notifyUpdate(title, content)` 弹 Windows 托盘气泡;非 win32 / tray 为空 / **有可见窗口**(用户在看「关于」页)都不弹。自动下载开启时不弹「发现新版本」(有意义的是「已下载」那条),手动下载完成也不弹
@@ -118,6 +119,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..b3f173d 100644
--- a/CHANGELOG.en.md
+++ b/CHANGELOG.en.md
@@ -1,5 +1,28 @@
# OpenTerminal Changelog
+## v1.0.28 - 2026-10-15
+
+This release fixes the "downloads twice" issue during in-app updates: when updating from an older version the progress bar ran through twice, costing nearly double the traffic in the worst case.
+
+### Fixed: update downloads twice
+- The root cause: the updater tried a "differential download" (fetching only the changed chunks) on every download and fell back to a full re-download when it failed — and neither of our update sources can serve differential downloads, so every update ran as a differential attempt followed by a full package
+- Differential download is now disabled: **every update is exactly one full download**, and the progress bar runs through once
+- Note: users still on an older version will see the double download one last time while updating TO this version (the fix has to reach them first); once this version is installed, every future update downloads once
+
+## 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..b946fc6 100644
--- a/CHANGELOG.ja.md
+++ b/CHANGELOG.ja.md
@@ -1,5 +1,28 @@
# OpenTerminal 更新履歴
+## v1.0.28 - 2026-10-15
+
+本リリースではアプリ内アップデートの「2 回ダウンロード」問題を修正しました。旧バージョンからの更新時にプログレスバーが 2 周し、最悪の場合ほぼ倍の通信量が発生していました。
+
+### 修正:アップデートが 2 回ダウンロードされる
+- 根本原因は、アップデーターが毎回「差分ダウンロード」(変更されたチャンクのみ取得)を試み、失敗するとパッケージ全体を再ダウンロードすることでした——本プロジェクトの 2 つの更新ソースはいずれも差分ダウンロードの条件を満たさないため、毎回「差分試行 + 全パッケージ」の 2 段階になっていました
+- 差分ダウンロードを無効化し、**毎回ちょうど 1 回のフルダウンロード**になり、プログレスバーは 1 周だけです
+- 注意:旧バージョンのユーザーが本バージョンへ更新する際は、最後にもう一度だけ 2 回ダウンロードが発生します(修正コードが先に手元へ届く必要があるため)。本バージョン導入後の更新はすべて 1 回のダウンロードになります
+
+## 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..b439c62 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,28 @@
# OpenTerminal 更新日志
+## v1.0.28 - 2026-10-15
+
+本次修复应用内更新「下载两次」的问题:从旧版本更新时进度条会跑两遍,最坏情况消耗近双倍流量。
+
+### 修复:更新下载两次
+- 根因是更新组件每次下载都先尝试「差量下载」(只取变化的数据块),失败后回退整包重下——而本项目的两个更新源都不具备差量条件,导致每次更新都是"差量尝试 + 整包"两段式
+- 现已禁用差量下载,**每次更新恰好一次整包下载**,进度条只走一遍
+- 注意:还在旧版本上的用户,更新到本版本时会经历**最后一次**下载两次(修复代码要先到达用户手里);装上本版本后,之后的每次更新都是单次下载
+
+## 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..201433c 100644
--- a/CHANGELOG.zh-TW.md
+++ b/CHANGELOG.zh-TW.md
@@ -1,5 +1,28 @@
# OpenTerminal 更新日誌
+## v1.0.28 - 2026-10-15
+
+本次修復應用內更新「下載兩次」的問題:從舊版本更新時進度條會跑兩遍,最壞情況消耗近雙倍流量。
+
+### 修復:更新下載兩次
+- 根因是更新元件每次下載都先嘗試「差量下載」(只取變化的資料塊),失敗後回退整包重下——而本專案的兩個更新來源都不具備差量條件,導致每次更新都是「差量嘗試 + 整包」兩段式
+- 現已停用差量下載,**每次更新恰好一次整包下載**,進度條只走一遍
+- 注意:還在舊版本上的使用者,更新到此版本時會經歷**最後一次**下載兩次(修復程式要先到達使用者手裡);裝上此版本後,之後的每次更新都是單次下載
+
+## 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..70de57e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "open-terminal",
- "version": "1.0.26",
+ "version": "1.0.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-terminal",
- "version": "1.0.26",
+ "version": "1.0.28",
"license": "MIT",
"dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15",
diff --git a/package.json b/package.json
index 0c9b6b5..7697d7a 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
- "version": "1.0.26",
+ "version": "1.0.28",
"description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js",
"author": "CodingPlan.Site",
diff --git a/src/main/updater.ts b/src/main/updater.ts
index b2ce8d0..ead33cb 100644
--- a/src/main/updater.ts
+++ b/src/main/updater.ts
@@ -499,6 +499,21 @@ export function configureAutoUpdater(): void {
// ever installs on its own — autoInstallOnAppQuit only covers quitting.
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
+ // Differential download is off because it cannot succeed against either of
+ // our feeds, and every failed attempt costs the user a second full transfer:
+ // electron-updater's differential path fetches the old and new blockmaps and
+ // then multi-range-requests the changed chunks, falling back to a plain whole-
+ // file download when any of that fails. GitHub serves the release assets from
+ // Azure Blob, which answers multi-range requests with `501 Unsupported client
+ // range` (measured), and the Gitea channel keeps only the newest exe + blockmap
+ // (`pruneChannel` deletes the previous release's), so an older client's
+ // blockmap 404s. Even where both were available the NSIS payload is LZMA solid
+ // compression: a small version-to-version change shifts everything after it,
+ // making the differential range nearly the whole file. The user then watches
+ // the progress bar run twice — worst case for roughly double the traffic — so
+ // going straight to the full download is the only behavior that is actually
+ // correct here.
+ autoUpdater.disableDifferentialDownload = true
// The startup check and the periodic one are armed by applyUpdateSchedule
// (wired in index.ts through settingsStore's injected applier) instead of
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 (
-
-