feat: multi-language interface (zh-CN/zh-TW/en/ja), multilingual offline changelog, settings robustness

i18n
- shared/i18n: dependency-free t() with flat per-namespace dictionaries
  (common/settings/workspace/terminal/ssh/panels/main), zh-CN fallback
- language picker in Settings -> System; antd ConfigProvider locale follows it
- main process tracks the language too: tray menu, close prompt, ssh/sftp/
  zmodem errors and the log TUI marker are translated; tray rebuilds on change

changelog
- CHANGELOG.md (zh-CN canonical) + .zh-TW/.en/.ja, bundled via ?raw and read
  per interface language with per-version fallback to zh-CN (no network)
- sync-changelog.cjs merges RELEASE_NOTES[.<lang>].md per release; release.cjs
  refuses to publish without a zh-CN entry for the version

settings robustness
- closeAction 'ask' survives the sanitizer (was silently coerced to 'tray',
  which made the 'ask every time' option dead)
- highlight rules are repaired instead of dropped: string priority, 0/1
  enabled, missing fg colour; unknown fields preserved
- load-time warnings are written to settings-warnings.log (deduped, capped)

terminal/UI
- configurable terminal toolbar: open working directory (default on), session
  log recording (off), open logs folder (off)
- global shortcut field records key combos (modifier or F-key required)
- input suggestions + command history default to off, with a one-time reset
  migration for existing installs
- settings dialog scrolling fixed (antd v6 renamed the tabs container), theme
  gallery nested scrollbar removed, joined segmented pickers with readable
  selected-state text

tests: settings-store.mjs (15 checks) added; commands-store.mjs updated for
the new off-by-default history setting
This commit is contained in:
Bill committed 2026-09-20 14:22:29 +08:00
1 parent 34094d607b
commit 4c6a29ef28
82 files changed
+3342 -608

No files matched your search

+20 -13
View File
@@ -23,6 +23,7 @@ import * as Zmodem from 'zmodem.js'
import { Ipc } from '../shared/ipc'
import type { ZmodemDoneEvent, ZmodemResponse } from '../shared/ipc'
import type { TransferProgressEvent } from '../shared/sftp'
import { t } from '../shared/i18n'
/** How long to wait for the renderer to answer a ZMODEM_OFFER (ms). */
const OFFER_TIMEOUT_MS = 120_000
@@ -137,7 +138,7 @@ function finalize(
engine.progressEmitted = true
if (ok) emitDone(engine, true)
else emitDone(engine, false, message ?? '传输失败')
else emitDone(engine, false, message ?? t('main.zmodem.transferFailed'))
}
/** Called by the zsession's own 'session_end' event (its `this` is the session). */
@@ -149,7 +150,7 @@ function touchActivity(engine: Engine): void {
if (engine.stallTimer) clearTimeout(engine.stallTimer)
const timer = setTimeout(() => {
if (engine.stallTimer === null) return // already finalized
finalize(engine, false, '传输超时')
finalize(engine, false, t('main.zmodem.transferTimeout'))
}, STALL_TIMEOUT_MS)
engine.stallTimer = timer
}
@@ -196,7 +197,7 @@ async function sendOneFile(
xfer.send(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength))
} catch (err) {
rs.destroy()
reject(err instanceof Error ? err : new Error('发送失败'))
reject(err instanceof Error ? err : new Error(t('main.zmodem.sendFailed')))
return
}
engine.bytes += buf.length
@@ -233,7 +234,7 @@ async function runSend(engine: Engine, paths: string[]): Promise<void> {
// Final state + ZMODEM_DONE come from the session_end event fired by close().
await session.close()
} catch (err) {
finalize(engine, false, err instanceof Error ? err.message : '上传失败')
finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.uploadFailed'))
}
}
@@ -277,7 +278,7 @@ function handleOffer(engine: Engine, offer: Zmodem.Offer): void {
} catch {
// already gone
}
finalize(engine, false, err instanceof Error ? err.message : '接收失败')
finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.receiveFailed'))
})
}
@@ -329,7 +330,9 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
}
touchActivity(engine)
engine.offerTimer = setTimeout(() => {
if (engine.detection && !engine.confirmed) abortWithoutSession(engine, '未选择文件,已取消')
if (engine.detection && !engine.confirmed) {
abortWithoutSession(engine, t('main.zmodem.offerTimedOut'))
}
}, OFFER_TIMEOUT_MS)
},
on_retract: () => {
@@ -405,7 +408,9 @@ export function feedZmodem(sessionId: string, data: Buffer): void {
engine.sentry.consume(data)
} catch (err) {
// A zmodem.js Error (peer abort / corrupt frame) ends the session.
if (!engine.doneEmitted) finalize(engine, false, err instanceof Error ? err.message : 'ZMODEM 传输异常')
if (!engine.doneEmitted) {
finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.abnormal'))
}
}
}
@@ -423,13 +428,13 @@ export function respondZmodem(resp: ZmodemResponse): void {
if (!engine || engine.confirmed) return
if (resp.cancelled) {
abortWithoutSession(engine, '已取消')
abortWithoutSession(engine, t('main.zmodem.cancelled'))
return
}
const detection = engine.detection
if (!detection || !detection.is_valid()) {
abortWithoutSession(engine, '会话已失效')
abortWithoutSession(engine, t('main.zmodem.sessionInvalid'))
return
}
@@ -441,14 +446,14 @@ export function respondZmodem(resp: ZmodemResponse): void {
}
engine.session = detection.confirm()
if (!engine.session) {
abortWithoutSession(engine, '无法建立 ZMODEM 会话')
abortWithoutSession(engine, t('main.zmodem.sessionCreateFailed'))
return
}
wireSession(engine)
if (engine.mode === 'receive') {
if (!resp.dir) {
abortSession(engine, '未指定保存目录')
abortSession(engine, t('main.zmodem.noSaveDir'))
return
}
engine.dir = resp.dir
@@ -456,11 +461,13 @@ export function respondZmodem(resp: ZmodemResponse): void {
session.on('offer', (offer) => handleOffer(engine, offer))
void session
.start()
.catch((err) => finalize(engine, false, err instanceof Error ? err.message : '接收失败'))
.catch((err) =>
finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.receiveFailed'))
)
} else {
const paths = resp.paths ?? []
if (paths.length === 0) {
abortSession(engine, '未选择文件')
abortSession(engine, t('main.zmodem.noFiles'))
return
}
void runSend(engine, paths)