Compare commits
3
Commits
v1.0.17
...
4e5377f49c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e5377f49c | ||
|
|
33efbe921e | ||
|
|
83dc3f15cc |
No files matched your search
@@ -62,7 +62,8 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
- 冷却阶梯 1s→2s→5s→10s→30s,失败计数与冷却同样落盘;`setPassword`/`clearPassword`/`unlock` 走内部串行队列(`serialize`),否则并发调用会同时通过闸门绕过冷却
|
||||
- 闲置锁屏:`powerMonitor.getSystemIdleTime()`,15s 轮询;读不到(无会话/工作站已锁)一律当「不闲置」。`settings.lock.autoLockMinutes` 是白名单 `{0,1,5,15,30,60}`(`src/shared/settings.ts` 的 `LOCK_AUTO_DELAYS`),0 = 从不
|
||||
- **清除密码会一并把 `settings.lock.enabled`/`lockAtStartup` 置 false**(`LockControllerOptions.clearLockPreferences`,默认走 `mutateSettings`):设置页文案承诺「清除后锁屏会一并关闭」,留着会让用户下次设密码时被静默重新武装
|
||||
- 锁屏期间主进程在 `win.webContents.on('before-input-event')` 里吞掉 F5/Ctrl+R、Ctrl+±0(含 Shift 拼写)、Ctrl+Shift+I/J/C:遮罩是 DOM 层,拦不住浏览器进程处理的 Electron 默认菜单加速键,而重载会触发 `beforeunload` 把遮罩后面的会话全杀掉。渲染层另有一道 `document.documentElement.dataset.locked` 守卫(字体快捷键、`Ctrl+PgUp/PgDn`)
|
||||
- 锁屏期间主进程在 `win.webContents.on('before-input-event')` 里吞掉 F5/Ctrl+R、Ctrl+±0(含 Shift 拼写)、Ctrl+Shift+I/J/C:遮罩是 DOM 层,拦不住浏览器进程处理的 Electron 默认菜单加速键,而重载会触发 `beforeunload` 把遮罩后面的会话全杀掉。渲染层的 `document.documentElement.dataset.locked` 守卫(字体快捷键、`Ctrl+PgUp/PgDn`)**只允许 `return` 跳过自身逻辑,绝不能 `preventDefault`**——keydown 的默认动作就是「往聚焦输入框插字符」,窗口级 preventDefault 会把锁屏密码框的全部输入杀掉(v1.0.17 就是这么坏的,v1.0.18 修复)
|
||||
- **Ctrl+L = 立即锁屏**(同一 `before-input-event` 里捕获,终端里也生效——这正是它的意义):仅在锁定真的生效时才 `preventDefault`,未设置密码的应用保留 Ctrl+L 给 shell 的清屏;已锁定时不再拦截
|
||||
- 启动时**不要**用 `locked: true` 作渲染层初值再直接画锁屏:`App.tsx` 用 `null` 表示「主进程还没答复」,此时只画 `.lock-screen-boot` 纯色层,否则每次启动都会给没设密码的用户闪一帧锁屏。`getLockState()` 失败时要落到「locked 且未配置」的状态,让输入框可达(主进程对无 verifier 的解锁请求直接放行)
|
||||
- 相关测试:`node tests/lock-store.mjs`(verifier + 状态存储)、`node tests/lock-controller.mjs`(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动)
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# OpenTerminal Changelog
|
||||
|
||||
## v1.0.18 - 2026-09-24
|
||||
|
||||
- **Fixed a serious v1.0.17 regression: the password could not be typed on the lock screen**. v1.0.17 swallowed all keyboard input at the renderer level while locked (meant to block zoom/reload shortcuts), but the default action of a key press is exactly "insert the character into the focused field" — so the lock screen's password box received nothing and the app could not be unlocked.
|
||||
- Shortcut blocking now happens entirely in the main process (`before-input-event`) and only for reload / DevTools / zoom chords; normal typing is unaffected.
|
||||
- **New lock shortcut Ctrl+L**: press it anywhere inside the app — terminals included — to lock immediately. When no lock password is set the chord is left alone (a shell's Ctrl+L clear-screen keeps working).
|
||||
- **If v1.0.17 locked you out**: quit the app completely → delete `%APPDATA%\OpenTerminal\lock.json` → start it again (with the lock data gone the screen will not lock), then upgrade and set a new lock password.
|
||||
|
||||
## v1.0.17 - 2026-09-24
|
||||
|
||||
- **New Settings → Lock tab**: set, change or remove a lock password (stored on this machine only, scrypt one-way hash — no plaintext, no account system)
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# OpenTerminal 更新履歴
|
||||
|
||||
## v1.0.18 - 2026-09-24
|
||||
|
||||
- **v1.0.17 の重大なリグレッションを修正:ロック画面でパスワードを入力できない**。v1.0.17 ではロック中、レンダラー側ですべてのキーボード入力を握りつぶしていました(ズーム/再読み込みのショートカットを無効化する意図でした)。しかしキー入力の既定動作は「フォーカスされたフィールドへの文字挿入」であり、その結果、ロック画面のパスワード欄に何も入力できず、ロック解除もできなくなっていました。
|
||||
- ショートカットの無効化はすべてメインプロセス(`before-input-event`)に移し、再読み込み/開発者ツール/ズームの組み合わせだけを対象にしました。通常の入力には影響しません。
|
||||
- **ロック用ショートカット Ctrl+L を新設**:アプリ内のどこでも(ターミナル内を含む)押すとすぐロックします。ロックパスワード未設定のときはこの組み合わせを奪いません(shell の Ctrl+L クリアはそのまま使えます)。
|
||||
- **v1.0.17 でロックされたままの方の復旧手順**:アプリを完全に終了 → `%APPDATA%\OpenTerminal\lock.json` を削除 → 再起動(ロックデータがなければロック画面は出ません)。その後、本バージョンに更新してロックパスワードを再設定してください。
|
||||
|
||||
## v1.0.17 - 2026-09-24
|
||||
|
||||
- **設定 → ロック を新設**:ロック画面のパスワードを設定・変更・削除できます(このマシンにのみ保存、scrypt 一方向ハッシュ。平文なし・アカウント体系なし)
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# OpenTerminal 更新日志
|
||||
|
||||
## v1.0.18 - 2026-09-24
|
||||
|
||||
- **修复 v1.0.17 的严重回归:锁屏后无法输入密码**。v1.0.17 在锁屏期间由渲染层拦截了全部键盘输入(本意是挡住缩放/刷新快捷键),但按键的默认行为正是「向聚焦的输入框插入字符」,导致锁屏界面密码框收不到任何输入、无法解锁。
|
||||
- 快捷键拦截已全部移到主进程(`before-input-event`),只拦截刷新/开发者工具/缩放组合键,普通输入不受影响。
|
||||
- **新增锁屏快捷键 Ctrl+L**:应用内任意位置(包括终端里)按下立即锁屏;未设置锁屏密码时不占用该组合键(shell 的 Ctrl+L 清屏不受影响)。
|
||||
- **已被 v1.0.17 锁定的用户恢复方法**:完全退出应用 → 删除 `%APPDATA%\OpenTerminal\lock.json` → 重新启动(锁屏数据删除后不会再弹锁屏),升级到本版本后可重新设置锁屏密码。
|
||||
|
||||
## v1.0.17 - 2026-09-24
|
||||
|
||||
- **新增 设置 → 锁屏 选项卡**:可设置、修改、移除锁屏密码(仅存本机,scrypt 单向哈希,不存明文、无账号体系)
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# OpenTerminal 更新日誌
|
||||
|
||||
## v1.0.18 - 2026-09-24
|
||||
|
||||
- **修復 v1.0.17 的嚴重回歸:鎖定後無法輸入密碼**。v1.0.17 在鎖定期間由繪製程序攔截了全部鍵盤輸入(本意是擋住縮放/重新整理快捷鍵),但按鍵的預設行為正是「向聚焦的輸入框插入字元」,導致鎖定畫面密碼框收不到任何輸入、無法解鎖。
|
||||
- 快捷鍵攔截已全部移到主程序(`before-input-event`),只攔截重新整理/開發者工具/縮放組合鍵,一般輸入不受影響。
|
||||
- **新增鎖定快捷鍵 Ctrl+L**:應用內任意位置(包括終端機裡)按下立即鎖定;未設定鎖定密碼時不占用該組合鍵(shell 的 Ctrl+L 清畫面不受影響)。
|
||||
- **已被 v1.0.17 鎖定的使用者恢復方法**:完全結束應用 → 刪除 `%APPDATA%\OpenTerminal\lock.json` → 重新啟動(鎖定資料刪除後不會再彈鎖定畫面),升級到本版本後可重新設定鎖定密碼。
|
||||
|
||||
## v1.0.17 - 2026-09-24
|
||||
|
||||
- **新增 設定 → 鎖定 分頁**:可設定、修改、移除鎖定畫面密碼(僅存於本機、scrypt 單向雜湊,不存明文、無帳號體系)
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "open-terminal",
|
||||
"productName": "OpenTerminal",
|
||||
"version": "1.0.17",
|
||||
"version": "1.0.18",
|
||||
"description": "Open-source terminal with SSH, split panes, themes and fonts",
|
||||
"main": "out/main/index.js",
|
||||
"author": "CodingPlan.Site",
|
||||
|
||||
+41
-15
@@ -124,7 +124,12 @@ async function gitea() {
|
||||
}
|
||||
}
|
||||
const listed = await fetch(`${base}/releases/${rel.id}/assets`, { headers: auth })
|
||||
const attached = new Set(listed.ok ? (await listed.json()).map((a) => a.name) : [])
|
||||
// A failed listing must not read as "nothing attached": that would re-POST
|
||||
// every already-uploaded installer and abort the run on the duplicate-name
|
||||
// rejection, before the channel update could run. Fail loudly instead — a
|
||||
// re-run then resumes correctly.
|
||||
if (!listed.ok) throw new Error(`Gitea asset listing failed: HTTP ${listed.status}`)
|
||||
const attached = new Set((await listed.json()).map((a) => a.name))
|
||||
for (const f of files) {
|
||||
const name = path.basename(f)
|
||||
if (attached.has(name)) { console.log(` asset ${name}: already attached, skipped`); continue }
|
||||
@@ -237,20 +242,41 @@ async function giteaChannel() {
|
||||
|
||||
async function github() {
|
||||
console.log('=== GitHub release ===')
|
||||
const resp = await fetch('https://api.github.com/repos/billowliu2/OpenTerminal/releases', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.GH_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'OpenTerminal',
|
||||
Accept: 'application/vnd.github+json'
|
||||
},
|
||||
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false }),
|
||||
...(proxyAgent ? { dispatcher: proxyAgent } : {})
|
||||
})
|
||||
if (!resp.ok) { throw new Error(`GitHub release create failed: ${resp.status} ${(await resp.text()).slice(0, 300)}`) }
|
||||
const rel = await resp.json()
|
||||
console.log('release created, id =', rel.id)
|
||||
const api = 'https://api.github.com/repos/billowliu2/OpenTerminal'
|
||||
const ghHeaders = {
|
||||
Authorization: `Bearer ${env.GH_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'OpenTerminal',
|
||||
Accept: 'application/vnd.github+json'
|
||||
}
|
||||
const gh = (url, init) =>
|
||||
fetch(url, { ...init, headers: ghHeaders, ...(proxyAgent ? { dispatcher: proxyAgent } : {}) })
|
||||
// Idempotent like gitea(): a re-run after a partial publish must reuse the
|
||||
// release it created last time instead of dying on 422 already_exists and
|
||||
// leaving GitHub permanently half-uploaded until someone deletes it by hand.
|
||||
const byTag = (): Promise<Response> => gh(`${api}/releases/tags/v${V}`)
|
||||
let resp = await byTag()
|
||||
let rel
|
||||
if (resp.ok) {
|
||||
rel = await resp.json()
|
||||
console.log('release already exists, reusing id =', rel.id)
|
||||
} else {
|
||||
resp = await gh(`${api}/releases`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false })
|
||||
})
|
||||
if (resp.ok) {
|
||||
rel = await resp.json()
|
||||
console.log('release created, id =', rel.id)
|
||||
} else {
|
||||
const detail = (await resp.text()).slice(0, 300)
|
||||
// 422 already_exists = the tag carries a release from an earlier partial run.
|
||||
const again = resp.status === 422 ? await byTag() : undefined
|
||||
if (!again || !again.ok) throw new Error(`GitHub release create failed: ${resp.status} ${detail}`)
|
||||
rel = await again.json()
|
||||
console.log('release already exists, reusing id =', rel.id)
|
||||
}
|
||||
}
|
||||
const up = `https://uploads.github.com/repos/billowliu2/OpenTerminal/releases/${rel.id}/assets`
|
||||
for (const f of [...files, path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), path.join(R, 'latest.yml')]) {
|
||||
await upload(up, f, env.GH_TOKEN, 'Bearer', true)
|
||||
|
||||
@@ -33,10 +33,15 @@ const sync = ({ notes, changelog, header, required }) => {
|
||||
|
||||
const body = fs
|
||||
.readFileSync(notesPath, 'utf8')
|
||||
// Drop the notes' own title / `## vX` heading: the section heading is built
|
||||
// here so every changelog stays uniform (`## vX.Y.Z - date`).
|
||||
.replace(/^#.*\n/gm, '')
|
||||
.replace(new RegExp(`^## v${pkgVersion}.*\\n`), '')
|
||||
// Normalize newlines first: a CRLF notes file would otherwise keep its `\r`
|
||||
// in every line, and the join below re-adds the changelog's ending on top —
|
||||
// every line of the merged section rendering as `\r\r\n` (doubled blanks).
|
||||
.replace(/\r\n/g, '\n')
|
||||
// Drop only the notes' own title and the `## v<pkgVersion>` heading: the
|
||||
// section heading is built here so every changelog stays uniform
|
||||
// (`## vX.Y.Z - date`). Stripping every `#`-line (the old behaviour) also
|
||||
// removed `###` subheadings, merging 新功能/修复 groups into one flat list.
|
||||
.replace(new RegExp(`^# .*\\n|^## v${pkgVersion}.*\\n`, 'gm'), '')
|
||||
.replace(/^\s+/, '')
|
||||
.trimEnd()
|
||||
|
||||
|
||||
+11
-1
@@ -229,6 +229,7 @@ export class CommandsStore {
|
||||
if (!existsSync(this.indexFile)) return
|
||||
const raw: unknown = JSON.parse(readFileSync(this.indexFile, 'utf8'))
|
||||
if (!Array.isArray(raw)) return
|
||||
let closed = false
|
||||
for (const x of raw) {
|
||||
if (
|
||||
x &&
|
||||
@@ -239,10 +240,19 @@ export class CommandsStore {
|
||||
// index.json is data, not trust: a tampered or hand-edited `file`
|
||||
// must never turn logWrite into an arbitrary-path append.
|
||||
if (!this.isLoggableFile(meta.file)) continue
|
||||
// A log still "in progress" here was interrupted by a crash or a
|
||||
// force-kill: its session id belonged to the previous run, so no
|
||||
// logStop will ever match it and its sanitizer is gone (logWrite
|
||||
// would be a permanent no-op). Close it instead of reviving it as
|
||||
// an active entry nothing can ever end.
|
||||
if (meta.endedAt === undefined) {
|
||||
meta.endedAt = Date.now()
|
||||
closed = true
|
||||
}
|
||||
this.metasByFile.set(meta.file, meta)
|
||||
if (meta.endedAt === undefined) this.activeBySession.set(meta.sessionId, meta)
|
||||
}
|
||||
}
|
||||
if (closed) this.persistIndex()
|
||||
} catch {
|
||||
// index missing / corrupt -> rebuild on next write
|
||||
}
|
||||
|
||||
@@ -20,6 +20,41 @@ import { writeJson } from './store'
|
||||
const ENC_SUFFIX = '_enc'
|
||||
const PLAIN_PREFIX = 'plain:'
|
||||
const SECRET_KEYS = ['password', 'keyContent', 'passphrase'] as const
|
||||
const AUTH_METHODS: ReadonlySet<string> = new Set(['password', 'privateKey', 'agent'])
|
||||
|
||||
/**
|
||||
* Runtime coercion for renderer-supplied connection fields. saveConnection
|
||||
* copies input values verbatim, and they flow straight into ssh2's
|
||||
* ConnectConfig (port, keepalive) — a malformed save or a renderer regression
|
||||
* must not persist `port: "22"` or a NaN-bound keepalive. Repair over drop,
|
||||
* like every other store: strings stay strings, numbers are range-checked,
|
||||
* unknown auth falls back to password, optional fields collapse to undefined.
|
||||
*/
|
||||
function coerceConnectionInput(input: SshConnectionInput): SshConnectionInput {
|
||||
const out = { ...input } as Record<string, unknown>
|
||||
out.name = typeof input.name === 'string' ? input.name : ''
|
||||
out.group = typeof input.group === 'string' && input.group !== '' ? input.group : undefined
|
||||
out.host = typeof input.host === 'string' ? input.host.trim() : ''
|
||||
out.username = typeof input.username === 'string' ? input.username : ''
|
||||
const port = Number(input.port)
|
||||
out.port = Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 22
|
||||
out.auth = AUTH_METHODS.has(input.auth as string) ? input.auth : 'password'
|
||||
out.askPasswordAtConnect = input.askPasswordAtConnect === true
|
||||
out.askPassphraseAtConnect = input.askPassphraseAtConnect === true
|
||||
out.keyPath =
|
||||
typeof input.keyPath === 'string' && input.keyPath !== '' ? input.keyPath : undefined
|
||||
const keepalive = Number(input.keepaliveIntervalSec)
|
||||
out.keepaliveIntervalSec =
|
||||
Number.isFinite(keepalive) && keepalive >= 0 ? Math.round(keepalive) : 0
|
||||
out.highlightProfileId =
|
||||
typeof input.highlightProfileId === 'string' && input.highlightProfileId !== ''
|
||||
? input.highlightProfileId
|
||||
: undefined
|
||||
for (const key of SECRET_KEYS) {
|
||||
if (out[key] !== undefined && typeof out[key] !== 'string') out[key] = undefined
|
||||
}
|
||||
return out as unknown as SshConnectionInput
|
||||
}
|
||||
|
||||
/** Internal persisted record: public fields + encrypted secret fields. */
|
||||
interface StoredConnection {
|
||||
@@ -150,7 +185,8 @@ export class ConnectionsStore {
|
||||
return stored ? decrypt(stored[encField] as string | undefined) : undefined
|
||||
}
|
||||
|
||||
saveConnection(input: SshConnectionInput): SshConnection {
|
||||
saveConnection(rawInput: SshConnectionInput): SshConnection {
|
||||
const input = coerceConnectionInput(rawInput)
|
||||
const list = this.load()
|
||||
let stored: StoredConnection | undefined
|
||||
|
||||
|
||||
+18
-1
@@ -166,12 +166,29 @@ function createWindow(): void {
|
||||
}
|
||||
})
|
||||
|
||||
// Ctrl+L is the panic lock: it must work from anywhere in the app, terminals
|
||||
// included, so it is captured here ahead of the page. It only takes the chord
|
||||
// away when a lock actually engages — an unconfigured app keeps Ctrl+L for
|
||||
// the shell's clear-screen.
|
||||
// Swallow the menu accelerators that would otherwise act behind the lock
|
||||
// overlay (see isLockBlockedShortcut). A throw in here would break typing
|
||||
// altogether, so the whole guard is defensive.
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
try {
|
||||
if (input.type !== 'keyDown' || !getLockController().isLocked()) return
|
||||
if (input.type !== 'keyDown') return
|
||||
const lock = getLockController()
|
||||
if (
|
||||
!lock.isLocked() &&
|
||||
input.control &&
|
||||
!input.shift &&
|
||||
!input.alt &&
|
||||
!input.meta &&
|
||||
input.key.toLowerCase() === 'l'
|
||||
) {
|
||||
if (lock.lockNow().locked) event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!lock.isLocked()) return
|
||||
if (isLockBlockedShortcut(input)) event.preventDefault()
|
||||
} catch {
|
||||
/* an input guard must never take the window down with it */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { app, ipcMain, powerSaveBlocker } from 'electron'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { Ipc } from '../shared/ipc'
|
||||
import {
|
||||
@@ -435,13 +435,36 @@ function reportWarnings(warnings: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A file that exists but cannot be parsed must never be silently destroyed:
|
||||
* loadSettings falls back to defaults, and the next mutation (any settings save,
|
||||
* even the tray persisting its close action) would rewrite settings.json from
|
||||
* those defaults — taking the user's custom themes and highlight rules with it.
|
||||
* Keep a one-time copy the user can recover from before that can happen.
|
||||
*/
|
||||
function backupUnparseableSettings(err: unknown): void {
|
||||
try {
|
||||
const file = settingsPath()
|
||||
if (!existsSync(file)) return
|
||||
const bak = `${file}.bak`
|
||||
if (existsSync(bak)) return // keep the first backup; later loads must not clobber it
|
||||
copyFileSync(file, bak)
|
||||
reportWarnings([
|
||||
`settings.json unparseable (${err instanceof Error ? err.message : String(err)}) — original kept at settings.json.bak`
|
||||
])
|
||||
} catch {
|
||||
// best effort: the backup must never break settings loading
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSettings(): AppSettings {
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8'))
|
||||
const { settings, errors } = deepMerge(raw)
|
||||
reportWarnings(errors)
|
||||
return settings
|
||||
} catch {
|
||||
} catch (err) {
|
||||
backupUnparseableSettings(err)
|
||||
return {
|
||||
terminal: { ...DEFAULT_SETTINGS.terminal },
|
||||
customThemes: [...DEFAULT_SETTINGS.customThemes],
|
||||
|
||||
+18
-3
@@ -65,6 +65,14 @@ const sftpCache = new Map<string, SFTPWrapper>()
|
||||
*/
|
||||
class SessionGoneError extends Error {}
|
||||
|
||||
/**
|
||||
* Our own "operation failed for a non-transport reason" error (server-side
|
||||
* failure summary, user cancellation). isTransportError rejects these outright:
|
||||
* their messages are translated and can embed remote paths/output, so text
|
||||
* matching would make the retry decision locale- and filename-dependent.
|
||||
*/
|
||||
class OperationError extends Error {}
|
||||
|
||||
async function sftpOf(sessionId: string): Promise<SFTPWrapper> {
|
||||
const cached = sftpCache.get(sessionId)
|
||||
if (cached) return cached
|
||||
@@ -100,9 +108,16 @@ function evictSftp(sessionId: string): void {
|
||||
* already exists) mean the CHANNEL IS ALIVE — retrying them would just open
|
||||
* another subsystem channel, which strict sshd (MaxSessions 2) punishes by
|
||||
* dropping the whole connection. Only transport-level death retries.
|
||||
*
|
||||
* Classification is class-based for our own errors (SessionGoneError /
|
||||
* OperationError) and matches only the ssh2 library's own message strings for
|
||||
* the rest: ssh2 is English-only and never translated, so the decision cannot
|
||||
* drift with the UI locale (our translated messages arrive as OperationError
|
||||
* and are rejected before any text is inspected).
|
||||
*/
|
||||
function isTransportError(err: unknown): boolean {
|
||||
if (err instanceof SessionGoneError) return true
|
||||
if (err instanceof OperationError) return false
|
||||
const msg = (err as Error | undefined)?.message ?? ''
|
||||
return /not connected|ECONNRESET|EPIPE|timed out|disconnected|channel|read past end|no response/i.test(msg)
|
||||
}
|
||||
@@ -211,7 +226,7 @@ export function deleteRemote(sessionId: string, paths: string[]): Promise<void>
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(t('main.sftp.deleteFailed', { detail: failures.join('; ') }))
|
||||
throw new OperationError(t('main.sftp.deleteFailed', { detail: failures.join('; ') }))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -329,7 +344,7 @@ export function uploadRemote(
|
||||
let pos = 0
|
||||
let lastEmit = 0
|
||||
for (;;) {
|
||||
if (transfer.cancelled) throw new Error(t('main.sftp.cancelled'))
|
||||
if (transfer.cancelled) throw new OperationError(t('main.sftp.cancelled'))
|
||||
if (firstError) throw firstError
|
||||
while (inflight.size >= UPLOAD_WINDOW) {
|
||||
await Promise.race(inflight)
|
||||
@@ -420,7 +435,7 @@ export function downloadRemote(
|
||||
let lastEmit = 0
|
||||
const buf = Buffer.alloc(CHUNK)
|
||||
for (;;) {
|
||||
if (transfer.cancelled) throw new Error(t('main.sftp.cancelled'))
|
||||
if (transfer.cancelled) throw new OperationError(t('main.sftp.cancelled'))
|
||||
const { bytesRead } = await p<{ bytesRead: number; buffer: Buffer }>(cb =>
|
||||
sftp.read(handle, buf, 0, CHUNK, pos, cb)
|
||||
)
|
||||
|
||||
@@ -87,6 +87,9 @@ export function initTray(showOrCreate: () => void): void {
|
||||
}
|
||||
|
||||
function hideToTray(win: BrowserWindow): void {
|
||||
// A quit started while the close dialog was pending destroys the window
|
||||
// before its resolution runs; touching a destroyed window throws.
|
||||
if (win.isDestroyed()) return
|
||||
win.hide()
|
||||
if (process.platform === 'win32' && tray && !balloonShown) {
|
||||
balloonShown = true
|
||||
@@ -126,6 +129,11 @@ export async function onMainWindowClose(win: BrowserWindow, e: Electron.Event, s
|
||||
checkboxChecked: false,
|
||||
noLink: true
|
||||
})
|
||||
// The dialog resolves when the window is destroyed underneath it (tray exit
|
||||
// or OS shutdown called app.quit() while it was open): the quit is already
|
||||
// underway, so neither branch may run — and must not persist a choice the
|
||||
// user never made.
|
||||
if (win.isDestroyed()) return
|
||||
if (response === 0) {
|
||||
if (checkboxChecked) persistCloseAction('tray')
|
||||
hideToTray(win)
|
||||
|
||||
@@ -342,6 +342,11 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
// Clear any previous offer timer first: a re-detect while an earlier
|
||||
// offer is still pending overwrites engine.offerTimer, and the orphaned
|
||||
// timer would fire at its original deadline and abort a legitimate
|
||||
// pending offer ahead of its own timeout.
|
||||
if (engine.offerTimer) clearTimeout(engine.offerTimer)
|
||||
engine.offerTimer = setTimeout(() => {
|
||||
if (engine.detection && !engine.confirmed) {
|
||||
abortWithoutSession(engine, t('main.zmodem.offerTimedOut'))
|
||||
|
||||
@@ -31,6 +31,8 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
|
||||
const [name, setName] = useState('')
|
||||
const [command, setCommand] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
/** Save failure surfaced inside the dialog instead of an unhandled rejection. */
|
||||
const [saveError, setSaveError] = useState('')
|
||||
/** M6.1: current workspace group — the command panel's 发送目标 belongs to it. */
|
||||
const workspaceMode = useWorkspaceModeStore((s) => s.mode)
|
||||
|
||||
@@ -68,20 +70,28 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
|
||||
setName('')
|
||||
setCommand('')
|
||||
setNote('')
|
||||
setSaveError('')
|
||||
setAddOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
const trimmed = command.trim()
|
||||
if (!trimmed) return
|
||||
await window.api.saveLibraryItem({
|
||||
id: window.crypto.randomUUID(),
|
||||
name: name.trim() || undefined,
|
||||
command: trimmed,
|
||||
note: note.trim() || undefined,
|
||||
createdAt: Date.now(),
|
||||
lastUsedAt: Date.now()
|
||||
})
|
||||
try {
|
||||
await window.api.saveLibraryItem({
|
||||
id: window.crypto.randomUUID(),
|
||||
name: name.trim() || undefined,
|
||||
command: trimmed,
|
||||
note: note.trim() || undefined,
|
||||
createdAt: Date.now(),
|
||||
lastUsedAt: Date.now()
|
||||
})
|
||||
} catch (err) {
|
||||
// Keep the dialog open with the typed values; a silent no-op would look
|
||||
// like the save button doing nothing.
|
||||
setSaveError((err as Error)?.message || t('common.saveFailed'))
|
||||
return
|
||||
}
|
||||
setAddOpen(false)
|
||||
refresh()
|
||||
}, [name, command, note, refresh])
|
||||
@@ -182,6 +192,7 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
|
||||
className="commands-modal"
|
||||
>
|
||||
<div className="commands-form">
|
||||
{saveError && <div className="commands-save-error">{saveError}</div>}
|
||||
<label className="commands-field">
|
||||
<span className="commands-field-label">{t('panels.commands.name')}</span>
|
||||
<input
|
||||
|
||||
@@ -138,6 +138,14 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* IPC/store failure during save: shown inside the dialog (in the app's danger
|
||||
colour) so a failed save is visible instead of a silent no-op. */
|
||||
.commands-save-error {
|
||||
color: #f85149;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.commands-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -289,7 +289,7 @@ function Row({
|
||||
<div className="connections-row-main">
|
||||
<div className="connections-row-name">
|
||||
<span className="connections-row-label">{conn.name}</span>
|
||||
<Tooltip title={conn.host === conn.host ? t('ssh.sidebar.dblClickConnect', { host: conn.host }) : undefined}>
|
||||
<Tooltip title={t('ssh.sidebar.dblClickConnect', { host: conn.host })}>
|
||||
<AuthTags auth={conn.auth} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -157,15 +157,13 @@ function FontHotkeyListener(): null {
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
// The lock overlay leaves Workspace mounted; window-capture hotkeys must
|
||||
// not mutate font size on a shell hidden behind it. preventDefault matters
|
||||
// here: returning alone lets the chord reach Electron's default menu
|
||||
// accelerators (zoomIn/zoomOut/resetZoom), which rescale the whole UI
|
||||
// behind the opaque mask and make Chromium persist that zoom per origin.
|
||||
if (document.documentElement.dataset.locked === 'true') {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
// While locked, skip only this listener's own logic — and NEVER call
|
||||
// preventDefault here. The default action of a keydown is inserting the
|
||||
// character into the focused field, and it runs after the whole dispatch:
|
||||
// preventing it at window level kills every keystroke the lock screen's
|
||||
// password field would receive (shipped broken in v1.0.17). The menu
|
||||
// accelerators are stopped in main, on before-input-event, instead.
|
||||
if (document.documentElement.dataset.locked === 'true') return
|
||||
if (!e.ctrlKey || e.metaKey) return
|
||||
const target = e.target as HTMLElement | null
|
||||
const isXtermHelper =
|
||||
|
||||
@@ -233,7 +233,7 @@ export function HighlightTab(): React.JSX.Element {
|
||||
...(color.bg ? { background: color.bg, borderRadius: 3, padding: '0 4px' } : {})
|
||||
}}
|
||||
>
|
||||
Highlight
|
||||
{t('settings.highlight.sampleText')}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -45,10 +45,12 @@
|
||||
}
|
||||
|
||||
/* antd paints the active tab label in the primary blue, which is hard to read
|
||||
on the accent-tinted row; keep it identical to the inactive labels. The
|
||||
extra .ant-tabs-tab class out-specifies antd's runtime-injected rule. */
|
||||
on the accent-tinted row; keep it identical to the inactive labels. Theme-
|
||||
derived (not #ffffff): on the shipped light themes a white label on the
|
||||
near-white tinted row was invisible. The extra .ant-tabs-tab class
|
||||
out-specifies antd's runtime-injected rule. */
|
||||
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: #ffffff;
|
||||
color: var(--chrome-fg, #cccccc);
|
||||
}
|
||||
|
||||
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-ink-bar {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ArrowUpOutlined,
|
||||
CopyOutlined,
|
||||
@@ -332,19 +332,32 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
||||
/** the entry the context menu is operating on (selected when right-clicked) */
|
||||
const menuTarget = menuEntry ?? selected
|
||||
|
||||
/** Monotonic token: only the most recently issued listing may paint state. */
|
||||
const refreshSeqRef = useRef(0)
|
||||
/** Mirrors `dir` for callbacks created before a navigation (upload finish). */
|
||||
const dirRef = useRef(dir)
|
||||
|
||||
const refresh = useCallback(
|
||||
async (target: string): Promise<void> => {
|
||||
const seq = ++refreshSeqRef.current
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const list = await window.api.listRemote(sessionId, target)
|
||||
// A slower earlier listing (breadcrumb click racing a double-click) must
|
||||
// never overwrite a newer one; only the latest request may paint.
|
||||
if (seq !== refreshSeqRef.current) return
|
||||
dirRef.current = target
|
||||
setEntries(list)
|
||||
setDir(target)
|
||||
setSelected(null)
|
||||
} catch (err) {
|
||||
if (seq !== refreshSeqRef.current) return
|
||||
setError((err as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
// The spinner belongs to the latest request; a stale one completing
|
||||
// first must not hide it while the latest is still in flight.
|
||||
if (seq === refreshSeqRef.current) setLoading(false)
|
||||
}
|
||||
},
|
||||
[sessionId]
|
||||
@@ -377,7 +390,9 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
||||
if (settled) return
|
||||
settled = true
|
||||
off()
|
||||
void refresh(dir)
|
||||
// `dir` here is the directory the upload was started from; if the user
|
||||
// has navigated away since, refreshing it would yank the panel back.
|
||||
if (dirRef.current === dir) void refresh(dir)
|
||||
}
|
||||
const off = window.api.onTransferProgress((e: TransferProgressEvent) => {
|
||||
if (e.state !== 'done' && e.state !== 'error' && e.state !== 'cancelled') return
|
||||
@@ -604,8 +619,10 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
// `entries` is read by doUpload's overwrite check — a stale snapshot could
|
||||
// skip the "file exists" confirmation that protects main's truncating 'w'.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [menuTarget, dir, refresh, language])
|
||||
}, [menuTarget, dir, entries, refresh, language])
|
||||
|
||||
return (
|
||||
<div className="sftp-panel">
|
||||
|
||||
@@ -39,16 +39,24 @@ function toFontWeight(n: number): 'normal' | 'bold' | number {
|
||||
|
||||
/**
|
||||
* Paste-risk heuristic, split by shape rather than by raw length:
|
||||
* - a paste ending in a newline always confirms — the trailing \r is the
|
||||
* shell's run keystroke, so it executes before the user can review it;
|
||||
* - two or more lines always confirm — a stray newline executes a command
|
||||
* the user never reviewed;
|
||||
* - a single line pastes straight through, unless it is unusually long
|
||||
* (those are pasted scripts rather than something typed by hand).
|
||||
* - a single line without a trailing newline pastes straight through, unless
|
||||
* it is unusually long (those are pasted scripts rather than something
|
||||
* typed by hand).
|
||||
*/
|
||||
const SINGLE_LINE_CONFIRM_LENGTH = 1000
|
||||
function needsPasteConfirm(text: string): boolean {
|
||||
const normalized = text.replace(/\r\n?/g, '\n').replace(/\n$/, '')
|
||||
if (normalized.includes('\n')) return true
|
||||
return normalized.length > SINGLE_LINE_CONFIRM_LENGTH
|
||||
const normalized = text.replace(/\r\n?/g, '\n')
|
||||
// Decided on the raw shape: stripping the trailing newline first would let
|
||||
// the most common copy shape (`curl … | sh\n`, one line + newline from a
|
||||
// doc) straight through to immediate execution.
|
||||
if (normalized.endsWith('\n')) return true
|
||||
const trimmed = normalized.replace(/\n$/, '')
|
||||
if (trimmed.includes('\n')) return true
|
||||
return trimmed.length > SINGLE_LINE_CONFIRM_LENGTH
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,43 +6,61 @@ import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc'
|
||||
* listeners (N = pane count) and N-1 of them discarded it after an id
|
||||
* compare. With many terminals under heavy output that per-chunk fan-out is
|
||||
* pure overhead. Here one IPC listener dispatches through a Map keyed by
|
||||
* sessionId: one lookup per chunk, and only the owning pane runs.
|
||||
* sessionId: one lookup per chunk, and only the owning session's panes run.
|
||||
*
|
||||
* The per-id value is a Set, not a single handler: an SSH split deliberately
|
||||
* binds two panes to the same sessionId, so the second pane must not steal
|
||||
* (and its teardown must not revoke) the first pane's stream.
|
||||
*/
|
||||
|
||||
type DataHandler = (data: string) => void
|
||||
type ExitHandler = (exitCode: number) => void
|
||||
|
||||
const dataHandlers = new Map<string, DataHandler>()
|
||||
const exitHandlers = new Map<string, ExitHandler>()
|
||||
const dataHandlers = new Map<string, Set<DataHandler>>()
|
||||
const exitHandlers = new Map<string, Set<ExitHandler>>()
|
||||
let bound = false
|
||||
|
||||
function ensureBound(): void {
|
||||
if (bound) return
|
||||
bound = true
|
||||
window.api.onPtyData((e: PtyDataEvent) => {
|
||||
dataHandlers.get(e.id)?.(e.data)
|
||||
dataHandlers.get(e.id)?.forEach((cb) => cb(e.data))
|
||||
})
|
||||
window.api.onPtyExit((e: PtyExitEvent) => {
|
||||
exitHandlers.get(e.id)?.(e.exitCode)
|
||||
exitHandlers.get(e.id)?.forEach((cb) => cb(e.exitCode))
|
||||
})
|
||||
}
|
||||
|
||||
/** Subscribe to one session's output. Returns the unsubscribe function. */
|
||||
export function subscribePtyData(id: string, cb: DataHandler): () => void {
|
||||
ensureBound()
|
||||
dataHandlers.set(id, cb)
|
||||
let set = dataHandlers.get(id)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
dataHandlers.set(id, set)
|
||||
}
|
||||
set.add(cb)
|
||||
return () => {
|
||||
// A rebind (template apply) may have replaced the handler already; only
|
||||
// delete when this subscription is still the registered one.
|
||||
if (dataHandlers.get(id) === cb) dataHandlers.delete(id)
|
||||
const set = dataHandlers.get(id)
|
||||
if (!set) return
|
||||
set.delete(cb)
|
||||
if (set.size === 0) dataHandlers.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to one session's exit. Returns the unsubscribe function. */
|
||||
export function subscribePtyExit(id: string, cb: ExitHandler): () => void {
|
||||
ensureBound()
|
||||
exitHandlers.set(id, cb)
|
||||
let set = exitHandlers.get(id)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
exitHandlers.set(id, set)
|
||||
}
|
||||
set.add(cb)
|
||||
return () => {
|
||||
if (exitHandlers.get(id) === cb) exitHandlers.delete(id)
|
||||
const set = exitHandlers.get(id)
|
||||
if (!set) return
|
||||
set.delete(cb)
|
||||
if (set.size === 0) exitHandlers.delete(id)
|
||||
}
|
||||
}
|
||||
@@ -30,23 +30,24 @@ const headerKeys = (): FieldSpec[] => [
|
||||
{ key: 'selectionBackground', label: t('settings.color.selection') }
|
||||
]
|
||||
|
||||
const ANSI_KEYS: FieldSpec[] = [
|
||||
{ key: 'black', label: 'Black' },
|
||||
{ key: 'red', label: 'Red' },
|
||||
{ key: 'green', label: 'Green' },
|
||||
{ key: 'yellow', label: 'Yellow' },
|
||||
{ key: 'blue', label: 'Blue' },
|
||||
{ key: 'magenta', label: 'Magenta' },
|
||||
{ key: 'cyan', label: 'Cyan' },
|
||||
{ key: 'white', label: 'White' },
|
||||
{ key: 'brightBlack', label: 'Bright Black' },
|
||||
{ key: 'brightRed', label: 'Bright Red' },
|
||||
{ key: 'brightGreen', label: 'Bright Green' },
|
||||
{ key: 'brightYellow', label: 'Bright Yellow' },
|
||||
{ key: 'brightBlue', label: 'Bright Blue' },
|
||||
{ key: 'brightMagenta', label: 'Bright Magenta' },
|
||||
{ key: 'brightCyan', label: 'Bright Cyan' },
|
||||
{ key: 'brightWhite', label: 'Bright White' }
|
||||
/** Resolved per call (like headerKeys) so a language switch re-translates. */
|
||||
const ansiKeys = (): FieldSpec[] => [
|
||||
{ key: 'black', label: t('settings.color.ansi.black') },
|
||||
{ key: 'red', label: t('settings.color.ansi.red') },
|
||||
{ key: 'green', label: t('settings.color.ansi.green') },
|
||||
{ key: 'yellow', label: t('settings.color.ansi.yellow') },
|
||||
{ key: 'blue', label: t('settings.color.ansi.blue') },
|
||||
{ key: 'magenta', label: t('settings.color.ansi.magenta') },
|
||||
{ key: 'cyan', label: t('settings.color.ansi.cyan') },
|
||||
{ key: 'white', label: t('settings.color.ansi.white') },
|
||||
{ key: 'brightBlack', label: t('settings.color.ansi.brightBlack') },
|
||||
{ key: 'brightRed', label: t('settings.color.ansi.brightRed') },
|
||||
{ key: 'brightGreen', label: t('settings.color.ansi.brightGreen') },
|
||||
{ key: 'brightYellow', label: t('settings.color.ansi.brightYellow') },
|
||||
{ key: 'brightBlue', label: t('settings.color.ansi.brightBlue') },
|
||||
{ key: 'brightMagenta', label: t('settings.color.ansi.brightMagenta') },
|
||||
{ key: 'brightCyan', label: t('settings.color.ansi.brightCyan') },
|
||||
{ key: 'brightWhite', label: t('settings.color.ansi.brightWhite') }
|
||||
]
|
||||
|
||||
/** The native colour input only accepts #rrggbb: clamp every seeded value so a
|
||||
@@ -164,7 +165,7 @@ export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React
|
||||
<ColorField key={spec.key} spec={spec} value={colors[spec.key]} onChange={setColor} />
|
||||
))}
|
||||
<div className="theme-editor-divider" />
|
||||
{ANSI_KEYS.map((spec) => (
|
||||
{ansiKeys().map((spec) => (
|
||||
<ColorField key={spec.key} spec={spec} value={colors[spec.key]} onChange={setColor} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -490,8 +490,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
const cleanups = [
|
||||
api.onDidRemovePanel((panel: IDockviewPanel) => {
|
||||
// Closing a pane keeps its directory as the seed for the next one.
|
||||
const closing = getSessionCwd(sessionIdOf(panel))
|
||||
if (closing) lastLocalCwdRef.current = closing
|
||||
// SSH panes are excluded: their tracked cwd is a *remote* path (OSC 7
|
||||
// from the remote shell), and seeding a new local terminal with it
|
||||
// would silently break "start where I left off".
|
||||
if ((panel.params as TerminalParams | undefined)?.sessionKind !== 'ssh') {
|
||||
const closing = getSessionCwd(sessionIdOf(panel))
|
||||
if (closing) lastLocalCwdRef.current = closing
|
||||
}
|
||||
releaseSession(panel)
|
||||
recomputeLocalPanels()
|
||||
recountAll()
|
||||
|
||||
@@ -4,6 +4,7 @@ const common: Record<string, string> = {
|
||||
'common.cancel': 'Cancel',
|
||||
'common.close': 'Close',
|
||||
'common.save': 'Save',
|
||||
'common.saveFailed': 'Save failed, please try again',
|
||||
'common.delete': 'Delete',
|
||||
'common.edit': 'Edit',
|
||||
'common.add': 'Add',
|
||||
|
||||
@@ -16,6 +16,22 @@ const settings: Record<string, string> = {
|
||||
'settings.color.background': 'Background',
|
||||
'settings.color.cursor': 'Cursor',
|
||||
'settings.color.selection': 'Selection',
|
||||
'settings.color.ansi.black': 'Black',
|
||||
'settings.color.ansi.red': 'Red',
|
||||
'settings.color.ansi.green': 'Green',
|
||||
'settings.color.ansi.yellow': 'Yellow',
|
||||
'settings.color.ansi.blue': 'Blue',
|
||||
'settings.color.ansi.magenta': 'Magenta',
|
||||
'settings.color.ansi.cyan': 'Cyan',
|
||||
'settings.color.ansi.white': 'White',
|
||||
'settings.color.ansi.brightBlack': 'Bright Black',
|
||||
'settings.color.ansi.brightRed': 'Bright Red',
|
||||
'settings.color.ansi.brightGreen': 'Bright Green',
|
||||
'settings.color.ansi.brightYellow': 'Bright Yellow',
|
||||
'settings.color.ansi.brightBlue': 'Bright Blue',
|
||||
'settings.color.ansi.brightMagenta': 'Bright Magenta',
|
||||
'settings.color.ansi.brightCyan': 'Bright Cyan',
|
||||
'settings.color.ansi.brightWhite': 'Bright White',
|
||||
'settings.font.label': 'Font',
|
||||
'settings.font.desc': 'The built-in default or a system monospace font',
|
||||
'settings.font.placeholder': 'Select a font',
|
||||
@@ -134,7 +150,7 @@ const settings: Record<string, string> = {
|
||||
'settings.lock.lockAtStartup': 'Lock at startup',
|
||||
'settings.lock.lockAtStartupDesc': 'Ask for the password right after every launch',
|
||||
'settings.lock.lockNow': 'Lock now',
|
||||
'settings.lock.lockNowDesc': 'Lock the main window immediately (sessions keep running)',
|
||||
'settings.lock.lockNowDesc': 'Lock the main window immediately (sessions keep running, Ctrl+L)',
|
||||
'settings.lock.title': 'Locked',
|
||||
'settings.lock.screenDesc': 'Enter the lock password to unlock',
|
||||
'settings.lock.passwordPlaceholder': 'Password',
|
||||
@@ -162,6 +178,7 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.clearBg': 'Clear',
|
||||
'settings.highlight.addBg': 'Add',
|
||||
'settings.highlight.palette': 'Palette',
|
||||
'settings.highlight.sampleText': 'Highlight',
|
||||
'settings.highlight.note': 'Note',
|
||||
'settings.highlight.notePlaceholder': 'Rule description (optional)',
|
||||
'settings.highlight.caseInsensitive': 'Ignore case',
|
||||
|
||||
@@ -4,6 +4,7 @@ const common: Record<string, string> = {
|
||||
'common.cancel': 'キャンセル',
|
||||
'common.close': '閉じる',
|
||||
'common.save': '保存',
|
||||
'common.saveFailed': '保存に失敗しました。もう一度お試しください',
|
||||
'common.delete': '削除',
|
||||
'common.edit': '編集',
|
||||
'common.add': '追加',
|
||||
|
||||
@@ -16,6 +16,22 @@ const settings: Record<string, string> = {
|
||||
'settings.color.background': '背景',
|
||||
'settings.color.cursor': 'カーソル',
|
||||
'settings.color.selection': '選択範囲',
|
||||
'settings.color.ansi.black': '黒',
|
||||
'settings.color.ansi.red': '赤',
|
||||
'settings.color.ansi.green': '緑',
|
||||
'settings.color.ansi.yellow': '黄',
|
||||
'settings.color.ansi.blue': '青',
|
||||
'settings.color.ansi.magenta': 'マゼンタ',
|
||||
'settings.color.ansi.cyan': 'シアン',
|
||||
'settings.color.ansi.white': '白',
|
||||
'settings.color.ansi.brightBlack': '明るい黒',
|
||||
'settings.color.ansi.brightRed': '明るい赤',
|
||||
'settings.color.ansi.brightGreen': '明るい緑',
|
||||
'settings.color.ansi.brightYellow': '明るい黄',
|
||||
'settings.color.ansi.brightBlue': '明るい青',
|
||||
'settings.color.ansi.brightMagenta': '明るいマゼンタ',
|
||||
'settings.color.ansi.brightCyan': '明るいシアン',
|
||||
'settings.color.ansi.brightWhite': '明るい白',
|
||||
'settings.font.label': 'フォント',
|
||||
'settings.font.desc': '組み込みの既定フォント、またはシステムの等幅フォント',
|
||||
'settings.font.placeholder': 'フォントを選択',
|
||||
@@ -131,7 +147,7 @@ const settings: Record<string, string> = {
|
||||
'settings.lock.lockAtStartup': '起動時にロック',
|
||||
'settings.lock.lockAtStartupDesc': '起動直後にパスワードの入力を求めます',
|
||||
'settings.lock.lockNow': '今すぐロック',
|
||||
'settings.lock.lockNowDesc': 'メインウィンドウをすぐにロックします(セッションは動作を続けます)',
|
||||
'settings.lock.lockNowDesc': 'メインウィンドウをすぐにロックします(セッションは動作を続けます、Ctrl+L)',
|
||||
'settings.lock.title': 'ロック中',
|
||||
'settings.lock.screenDesc': 'ロックパスワードを入力して解除します',
|
||||
'settings.lock.passwordPlaceholder': 'パスワード',
|
||||
@@ -159,6 +175,7 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.clearBg': 'クリア',
|
||||
'settings.highlight.addBg': '追加',
|
||||
'settings.highlight.palette': 'パレット',
|
||||
'settings.highlight.sampleText': 'ハイライト',
|
||||
'settings.highlight.note': 'メモ',
|
||||
'settings.highlight.notePlaceholder': 'ルールの説明(任意)',
|
||||
'settings.highlight.caseInsensitive': '大文字小文字を無視',
|
||||
|
||||
@@ -4,6 +4,7 @@ const common: Record<string, string> = {
|
||||
'common.cancel': '取消',
|
||||
'common.close': '关闭',
|
||||
'common.save': '保存',
|
||||
'common.saveFailed': '保存失败,请重试',
|
||||
'common.delete': '删除',
|
||||
'common.edit': '编辑',
|
||||
'common.add': '新增',
|
||||
|
||||
@@ -16,6 +16,22 @@ const settings: Record<string, string> = {
|
||||
'settings.color.background': '背景',
|
||||
'settings.color.cursor': '光标',
|
||||
'settings.color.selection': '选区',
|
||||
'settings.color.ansi.black': '黑',
|
||||
'settings.color.ansi.red': '红',
|
||||
'settings.color.ansi.green': '绿',
|
||||
'settings.color.ansi.yellow': '黄',
|
||||
'settings.color.ansi.blue': '蓝',
|
||||
'settings.color.ansi.magenta': '品红',
|
||||
'settings.color.ansi.cyan': '青',
|
||||
'settings.color.ansi.white': '白',
|
||||
'settings.color.ansi.brightBlack': '亮黑',
|
||||
'settings.color.ansi.brightRed': '亮红',
|
||||
'settings.color.ansi.brightGreen': '亮绿',
|
||||
'settings.color.ansi.brightYellow': '亮黄',
|
||||
'settings.color.ansi.brightBlue': '亮蓝',
|
||||
'settings.color.ansi.brightMagenta': '亮品红',
|
||||
'settings.color.ansi.brightCyan': '亮青',
|
||||
'settings.color.ansi.brightWhite': '亮白',
|
||||
'settings.font.label': '字体',
|
||||
'settings.font.desc': '内置默认字体或系统等宽字体',
|
||||
'settings.font.placeholder': '选择字体',
|
||||
@@ -126,7 +142,7 @@ const settings: Record<string, string> = {
|
||||
'settings.lock.lockAtStartup': '启动时锁屏',
|
||||
'settings.lock.lockAtStartupDesc': '每次启动后先要求输入密码',
|
||||
'settings.lock.lockNow': '立即锁屏',
|
||||
'settings.lock.lockNowDesc': '马上锁定主窗口(会话保持运行)',
|
||||
'settings.lock.lockNowDesc': '马上锁定主窗口(会话保持运行,快捷键 Ctrl+L)',
|
||||
'settings.lock.title': '已锁定',
|
||||
'settings.lock.screenDesc': '输入锁屏密码解锁',
|
||||
'settings.lock.passwordPlaceholder': '密码',
|
||||
@@ -153,6 +169,7 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.clearBg': '清空',
|
||||
'settings.highlight.addBg': '添加',
|
||||
'settings.highlight.palette': '色板',
|
||||
'settings.highlight.sampleText': '高亮',
|
||||
'settings.highlight.note': '备注',
|
||||
'settings.highlight.notePlaceholder': '规则说明(可选)',
|
||||
'settings.highlight.caseInsensitive': '忽略大小写',
|
||||
|
||||
@@ -4,6 +4,7 @@ const common: Record<string, string> = {
|
||||
'common.cancel': '取消',
|
||||
'common.close': '關閉',
|
||||
'common.save': '儲存',
|
||||
'common.saveFailed': '儲存失敗,請重試',
|
||||
'common.delete': '刪除',
|
||||
'common.edit': '編輯',
|
||||
'common.add': '新增',
|
||||
|
||||
@@ -16,6 +16,22 @@ const settings: Record<string, string> = {
|
||||
'settings.color.background': '背景',
|
||||
'settings.color.cursor': '游標',
|
||||
'settings.color.selection': '選取區',
|
||||
'settings.color.ansi.black': '黑',
|
||||
'settings.color.ansi.red': '紅',
|
||||
'settings.color.ansi.green': '綠',
|
||||
'settings.color.ansi.yellow': '黃',
|
||||
'settings.color.ansi.blue': '藍',
|
||||
'settings.color.ansi.magenta': '洋紅',
|
||||
'settings.color.ansi.cyan': '青',
|
||||
'settings.color.ansi.white': '白',
|
||||
'settings.color.ansi.brightBlack': '亮黑',
|
||||
'settings.color.ansi.brightRed': '亮紅',
|
||||
'settings.color.ansi.brightGreen': '亮綠',
|
||||
'settings.color.ansi.brightYellow': '亮黃',
|
||||
'settings.color.ansi.brightBlue': '亮藍',
|
||||
'settings.color.ansi.brightMagenta': '亮洋紅',
|
||||
'settings.color.ansi.brightCyan': '亮青',
|
||||
'settings.color.ansi.brightWhite': '亮白',
|
||||
'settings.font.label': '字型',
|
||||
'settings.font.desc': '內建預設字型或系統等寬字型',
|
||||
'settings.font.placeholder': '選擇字型',
|
||||
@@ -126,7 +142,7 @@ const settings: Record<string, string> = {
|
||||
'settings.lock.lockAtStartup': '啟動時鎖定',
|
||||
'settings.lock.lockAtStartupDesc': '每次啟動後先要求輸入密碼',
|
||||
'settings.lock.lockNow': '立即鎖定',
|
||||
'settings.lock.lockNowDesc': '馬上鎖定主視窗(工作階段保持運作)',
|
||||
'settings.lock.lockNowDesc': '馬上鎖定主視窗(工作階段保持運作,快捷鍵 Ctrl+L)',
|
||||
'settings.lock.title': '已鎖定',
|
||||
'settings.lock.screenDesc': '輸入鎖定密碼以解鎖',
|
||||
'settings.lock.passwordPlaceholder': '密碼',
|
||||
@@ -153,6 +169,7 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.clearBg': '清除',
|
||||
'settings.highlight.addBg': '新增',
|
||||
'settings.highlight.palette': '色盤',
|
||||
'settings.highlight.sampleText': '高亮',
|
||||
'settings.highlight.note': '備註',
|
||||
'settings.highlight.notePlaceholder': '規則說明(選填)',
|
||||
'settings.highlight.caseInsensitive': '忽略大小寫',
|
||||
|
||||
@@ -144,10 +144,13 @@ writeSettings({ historyLimit: 50, historyEnabled: true })
|
||||
store.recordCommand('recorded-again')
|
||||
ok(store.listHistory()[0].command === 'recorded-again', 'historyEnabled=true resumes recording')
|
||||
|
||||
// Default (keys absent from settings.json) records, matching a fresh install.
|
||||
writeSettings({})
|
||||
store.recordCommand('default-on')
|
||||
ok(store.listHistory()[0].command === 'default-on', 'absent switch defaults to recording')
|
||||
// Default (key absent from settings.json): the shipped default is recording OFF
|
||||
// (DEFAULT_SETTINGS.terminal.historyEnabled = false, gate is `=== true`), and a
|
||||
// fresh install has no key at all. The writeSettings helper always injects
|
||||
// `historyEnabled: true`, so the absence case writes the file directly.
|
||||
writeFileSync(join(userData, 'settings.json'), JSON.stringify({ terminal: {}, system: {} }), 'utf8')
|
||||
store.recordCommand('default-off')
|
||||
ok(!store.listHistory().some((h) => h.command === 'default-off'), 'absent switch defaults to NOT recording (shipped default)')
|
||||
store.clearHistory()
|
||||
|
||||
// ---- 4. Library CRUD + ordering ------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user