fix(terminal): 缩放/高频输出冻结修复(reflow 节流 + 写入批量化),新增诊断日志(7 天滚动 + 开关),v1.0.26
CI / typecheck + test + build (windows) (push) Canceled after 0s
CI / typecheck + test + build (windows) (push) Canceled after 0s
This commit is contained in:
1 parent
fd4f1a3956
commit
bca7033689
29 files changed
+728
-12
No files matched your search
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Diagnostic app log (appLog.ts).
|
||||
*
|
||||
* The "app froze, then recovered" watchdogs (`[main]` / `[renderer] stalled`)
|
||||
* used to reach only the console, which a packaged build never shows — field
|
||||
* reports came back with no evidence attached. This module persists them:
|
||||
* daily files `app-YYYY-MM-DD.log` under `<userData>/logs`, appended
|
||||
* asynchronously off the event loop's critical path, one event per line.
|
||||
*
|
||||
* Retention: files older than APP_LOG_RETENTION_DAYS are deleted at startup
|
||||
* and once a day afterwards (a long-running process crosses midnights). Only
|
||||
* names matching the app log pattern are touched — foreign files in the
|
||||
* directory are left alone. Session recordings (LOG_START) are user files
|
||||
* and never come near this mechanism.
|
||||
*
|
||||
* The pure naming/retention helpers are table-tested by tests/app-log.mjs
|
||||
* through the bundled build (tests/.app-log.cjs).
|
||||
*/
|
||||
import { app } from 'electron'
|
||||
import { appendFile, mkdir, readdir, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export const APP_LOG_RETENTION_DAYS = 7
|
||||
|
||||
const NAME_RE = /^app-(\d{4})-(\d{2})-(\d{2})\.log$/
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
/** One event is one line; runaway payloads are truncated, not trusted. */
|
||||
const MAX_LINE = 2000
|
||||
|
||||
const pad = (n: number, w = 2): string => String(n).padStart(w, '0')
|
||||
|
||||
/** `app-YYYY-MM-DD.log`, on the LOCAL date — users read these by hand. */
|
||||
export function appLogName(date: Date): string {
|
||||
return `app-${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}.log`
|
||||
}
|
||||
|
||||
/** Local midnight (ms) encoded in an app log file name, or null for foreign names. */
|
||||
export function parseAppLogName(name: string): number | null {
|
||||
const m = NAME_RE.exec(name)
|
||||
if (!m) return null
|
||||
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* A log file expires when its day STARTS more than `retentionDays` before
|
||||
* today's start: today plus the previous N days are kept, anything older
|
||||
* goes. Foreign names are never expired.
|
||||
*/
|
||||
export function isExpiredAppLog(name: string, now: Date, retentionDays = APP_LOG_RETENTION_DAYS): boolean {
|
||||
const day = parseAppLogName(name)
|
||||
if (day === null) return false
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
||||
return day < today - retentionDays * DAY_MS
|
||||
}
|
||||
|
||||
let logDir: string | null = null
|
||||
let queue: Promise<unknown> = Promise.resolve()
|
||||
// The settings gate (settings.system.diagnosticLog, default on). Off = appLog
|
||||
// records nothing; the retention sweep still runs, it only deletes.
|
||||
let enabled = true
|
||||
|
||||
/** Follow `settings.system.diagnosticLog`; called on every settings mutation. */
|
||||
export function setAppLogEnabled(on: boolean): void {
|
||||
enabled = on
|
||||
}
|
||||
|
||||
function enqueue(task: () => Promise<unknown>): Promise<void> {
|
||||
// Serialize appends, and never let a logging failure escape into the app.
|
||||
const next = queue.then(task).then(
|
||||
(): void => undefined,
|
||||
(): void => undefined
|
||||
)
|
||||
queue = next
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the log at `<userData>/logs` (or `dir` in tests), sweep expired
|
||||
* files, and re-sweep once a day so a process that never restarts still
|
||||
* cleans up. The daily timer is unref'd like every other timer in main.
|
||||
*/
|
||||
export function initAppLog(dir?: string): void {
|
||||
const target = dir ?? join(app.getPath('userData'), 'logs')
|
||||
logDir = target
|
||||
void enqueue(async () => {
|
||||
await mkdir(target, { recursive: true })
|
||||
await sweepAppLogs(target)
|
||||
})
|
||||
setInterval(() => {
|
||||
void sweepAppLogs(target).catch(() => undefined)
|
||||
}, DAY_MS).unref()
|
||||
}
|
||||
|
||||
/** Remove expired log files under `dir`; returns the removed names. */
|
||||
export async function sweepAppLogs(
|
||||
dir: string,
|
||||
now = new Date(),
|
||||
retentionDays = APP_LOG_RETENTION_DAYS
|
||||
): Promise<string[]> {
|
||||
const entries = await readdir(dir)
|
||||
const doomed = entries.filter((n) => isExpiredAppLog(n, now, retentionDays))
|
||||
await Promise.all(doomed.map((n) => rm(join(dir, n), { force: true })))
|
||||
return doomed
|
||||
}
|
||||
|
||||
const stamp = (d: Date): string =>
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
||||
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`
|
||||
|
||||
/**
|
||||
* Append one event line. Fire-and-forget for callers, but the returned
|
||||
* promise settles once the line is on disk (tests drain on it). Before
|
||||
* initAppLog — or with the setting gate off — the line is dropped, same as
|
||||
* the console-only past. The source column is padded so stacked lines align.
|
||||
*/
|
||||
export function appLog(source: 'main' | 'renderer', line: string): Promise<void> {
|
||||
if (!logDir || !enabled) return Promise.resolve()
|
||||
const dir = logDir
|
||||
const text = `[${stamp(new Date())}] [${source.padEnd(8)}] ${String(line).replace(/[\r\n]+/g, ' ').slice(0, MAX_LINE)}\n`
|
||||
return enqueue(() => appendFile(join(dir, appLogName(new Date())), text, 'utf8'))
|
||||
}
|
||||
+16
-1
@@ -11,6 +11,7 @@ import { applyMenuLockState } from './lockMenu'
|
||||
import { isLockBlockedShortcut, isPanicLockChord } from './lockShortcuts'
|
||||
import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray'
|
||||
import { applyUpdateSchedule, configureAutoUpdater, registerUpdateIpc } from './updater'
|
||||
import { appLog, initAppLog, setAppLogEnabled } from './appLog'
|
||||
import { applyWindowChrome } from './windowChrome'
|
||||
import { onLanguageChange, t } from '@shared/i18n'
|
||||
import { getThemeById } from '@shared/theme'
|
||||
@@ -72,6 +73,13 @@ if (!gotSingleInstanceLock) {
|
||||
app
|
||||
.whenReady()
|
||||
.then(() => {
|
||||
// The diagnostic log must exist before any IPC handler can reference it;
|
||||
// startup also sweeps log files past the retention window. The gate is
|
||||
// set from settings first, so even the startup line respects the switch.
|
||||
setAppLogEnabled(loadSettings().system.diagnosticLog !== false)
|
||||
initAppLog()
|
||||
void appLog('main', `app started: v${app.getVersion()} (${process.platform}/${process.arch})`)
|
||||
|
||||
// Serve the configured background image (path lives in settings; anything
|
||||
// else — including a path that is no longer configured — is refused, so the
|
||||
// protocol cannot be used to read arbitrary files).
|
||||
@@ -263,20 +271,27 @@ function createWindow(): void {
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('[main] unhandledRejection:', reason)
|
||||
void appLog('main', `unhandledRejection: ${reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)}`)
|
||||
})
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[main] uncaughtException:', err)
|
||||
void appLog('main', `uncaughtException: ${err.stack ?? err.message}`)
|
||||
})
|
||||
|
||||
// Diagnostics for "app froze then recovered" reports: WER logs an
|
||||
// AppHangTransient but keeps no stack. A stall of THIS (UI) thread is what
|
||||
// Windows flags as a hang, so measure event-loop lag and say so loudly when
|
||||
// it happens — that distinguishes a main-process block from a renderer one.
|
||||
// The same line goes to the on-disk diagnostic log (appLog.ts), which a
|
||||
// packaged build's console cannot reach.
|
||||
let lastTick = Date.now()
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
const lag = now - lastTick - 1000
|
||||
if (lag > 2000) console.error(`[main] event loop stalled ~${lag}ms`)
|
||||
if (lag > 2000) {
|
||||
console.error(`[main] event loop stalled ~${lag}ms`)
|
||||
void appLog('main', `event loop stalled ~${lag}ms`)
|
||||
}
|
||||
lastTick = now
|
||||
}, 1000).unref()
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import { CommandsStore, defaultCommandsPath } from './commands'
|
||||
import type { CommandItem } from '../shared/commands'
|
||||
import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay, sessionState, registerLogHooks } from './pty'
|
||||
import { respondZmodem } from './zmodem'
|
||||
import { appLog } from './appLog'
|
||||
import type { ZmodemResponse } from '../shared/ipc'
|
||||
|
||||
/** The renderer document this app loads (dev builds load it from vite instead). */
|
||||
@@ -149,6 +150,10 @@ export function registerIpc(): void {
|
||||
Ipc.APP_INFO,
|
||||
(): AppInfo => ({ platform: process.platform, appVersion: app.getVersion(), homeDir: homedir() })
|
||||
)
|
||||
// Renderer watchdog reports land in the same diagnostic log as main's own.
|
||||
ipcMain.on(Ipc.APP_LOG, (_event, line: string) => {
|
||||
if (typeof line === 'string') void appLog('renderer', line)
|
||||
})
|
||||
|
||||
ipcMain.handle(Ipc.PTY_CREATE, (event, opts?: PtyCreateOptions) => createPty(opts, event.sender.id))
|
||||
ipcMain.handle(Ipc.SESSION_OPEN, (event, opts: SessionOpenOptions) => openSession(opts, event.sender.id))
|
||||
|
||||
@@ -22,6 +22,7 @@ import { broadcast } from './broadcast'
|
||||
import { writeJson } from './store'
|
||||
import { applyGlobalShortcut } from './globalShortcuts'
|
||||
import { applyWindowChrome } from './windowChrome'
|
||||
import { setAppLogEnabled } from './appLog'
|
||||
|
||||
const settingsPath = (): string => join(app.getPath('userData'), 'settings.json')
|
||||
|
||||
@@ -372,6 +373,7 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
||||
autoDownloadUpdate: candidate.autoDownloadUpdate === true,
|
||||
// Both default on/off as in DEFAULT_SETTINGS; absent means "not chosen".
|
||||
restoreSession: candidate.restoreSession !== false,
|
||||
diagnosticLog: candidate.diagnosticLog !== false,
|
||||
shellIntegration: candidate.shellIntegration === true,
|
||||
language: isLanguage(candidate.language) ? candidate.language : DEFAULT_LANGUAGE
|
||||
}
|
||||
@@ -442,6 +444,9 @@ function applySystemSettings(system: SystemSettings): void {
|
||||
}
|
||||
// Re-register the global show/hide shortcut whenever system settings change.
|
||||
applyGlobalShortcut(system.globalShowHide)
|
||||
// The diagnostic log gate follows the setting live (appLog is imported here —
|
||||
// it only touches electron/fs, so there is no cycle).
|
||||
setAppLogEnabled(system.diagnosticLog !== false)
|
||||
// Re-arm (or disarm) the periodic update check the same way. Routed through the
|
||||
// injected applier instead of importing the updater, which would be a cycle.
|
||||
updateScheduleApplier?.(system)
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { TransferProgressEvent } from '../shared/sftp'
|
||||
|
||||
const api: AppApi = {
|
||||
appInfo: () => ipcRenderer.invoke(Ipc.APP_INFO),
|
||||
appLog: (line: string) => ipcRenderer.send(Ipc.APP_LOG, line),
|
||||
|
||||
createPty: (opts?: PtyCreateOptions) => ipcRenderer.invoke(Ipc.PTY_CREATE, opts),
|
||||
openSession: (opts: SessionOpenOptions) => ipcRenderer.invoke(Ipc.SESSION_OPEN, opts),
|
||||
|
||||
@@ -14,7 +14,9 @@ import './global.css'
|
||||
|
||||
// Diagnostics: report renderer main-thread freezes after recovery. console.error
|
||||
// is surfaced into the dev terminal by the main process's console-message hook,
|
||||
// so a "卡死又恢复" report tells us which process stalled and for how long.
|
||||
// and the same line goes through APP_LOG into the on-disk diagnostic log, so a
|
||||
// packaged build's "卡死又恢复" report also tells us which process stalled and
|
||||
// for how long.
|
||||
{
|
||||
let lastTick = performance.now()
|
||||
setInterval(() => {
|
||||
@@ -26,7 +28,13 @@ import './global.css'
|
||||
// tray. A hidden window also has nothing user-visible to freeze, so the
|
||||
// report is skipped until the page is visible again.
|
||||
if (lag > 3000 && !document.hidden) {
|
||||
console.error(`[renderer] main thread stalled ~${Math.round(lag)}ms`)
|
||||
const line = `main thread stalled ~${Math.round(lag)}ms`
|
||||
console.error(`[renderer] ${line}`)
|
||||
// The settings gate is enforced again in main; checking here just saves
|
||||
// the IPC round-trip for a stalled-process report nobody wants stored.
|
||||
if (useSettingsStore.getState().settings.system.diagnosticLog !== false) {
|
||||
window.api?.appLog?.(line)
|
||||
}
|
||||
}
|
||||
lastTick = now
|
||||
}, 2000)
|
||||
@@ -49,6 +57,7 @@ if (typeof window !== 'undefined' && !window.api) {
|
||||
})
|
||||
const api: AppApi = {
|
||||
appInfo: async () => ({ platform: 'browser', appVersion: 'dev', homeDir: '' }),
|
||||
appLog: noop,
|
||||
createPty: async () => ({ id: stubId(), shell: 'stub', cwd: '' }),
|
||||
openSession: async () => ({ id: stubId() }),
|
||||
getSessionReplay: async () => '',
|
||||
|
||||
@@ -416,6 +416,16 @@ export function SystemSettingsTab(): React.JSX.Element {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label={t('settings.system.diagnosticLog')}
|
||||
desc={t('settings.system.diagnosticLogDesc')}
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.system.diagnosticLog !== false}
|
||||
onChange={(checked) => void updateSystem({ diagnosticLog: checked })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label={t('settings.system.updateCheckInterval')}
|
||||
desc={t('settings.system.updateCheckIntervalDesc')}
|
||||
|
||||
@@ -19,6 +19,8 @@ import { compileRules, HighlightStream, type RuleStat } from './highlightEngine'
|
||||
import { publishHighlightStats } from './highlightStats'
|
||||
import { applyThemeColors } from '@renderer/theme/highlightColors'
|
||||
import { findUrls } from './urlLinks'
|
||||
import { WriteBatcher } from './writeBatcher'
|
||||
import { refitDelay } from './fitThrottle'
|
||||
import { subscribePtyData, subscribePtyExit } from './ptyDispatcher'
|
||||
import { cdArgument, conemuCwd } from './cwdTracker'
|
||||
import { getSessionCwd, setSessionCwd } from '@renderer/workspace/sessionCwdStore'
|
||||
@@ -348,6 +350,12 @@ export function TerminalView({
|
||||
const searchRef = useRef<SearchAddon | null>(null)
|
||||
const observerRef = useRef<ResizeObserver | null>(null)
|
||||
const timerRef = useRef<number>(0)
|
||||
// Reflow throttle: `lastFitAtRef` is the last fit attempt's timestamp (0 =
|
||||
// none yet, so a rebound session's first fit runs immediately),
|
||||
// `fitTrailingRef` is the trailing timer that guarantees a final fit at the
|
||||
// settled size once a drag's resize-event stream stops.
|
||||
const lastFitAtRef = useRef(0)
|
||||
const fitTrailingRef = useRef<number>(0)
|
||||
// PTY resize bookkeeping: `ptyTimerRef` debounces the resize while a window
|
||||
// drag/maximize settles, `ptySizeRef` drops the no-op resizes in between.
|
||||
// Poking the pty on every intermediate layout tick makes full-screen TUIs
|
||||
@@ -518,6 +526,24 @@ export function TerminalView({
|
||||
const scheduleFit = useCallback(() => {
|
||||
window.cancelAnimationFrame(timerRef.current)
|
||||
timerRef.current = window.requestAnimationFrame(() => {
|
||||
// Throttle reflows while a window drag resizes the pane every frame: one
|
||||
// fit rewraps the whole scrollback (tens of ms at the default 5000
|
||||
// lines, more with CJK), and one per frame saturates the main thread and
|
||||
// starves xterm's write queue — the UI then stays frozen for seconds
|
||||
// after the drag while the backlog drains. During a drag fits run at
|
||||
// most every FIT_REFLOW_THROTTLE_MS; the trailing timer lands a final
|
||||
// fit at the settled size.
|
||||
const wait = refitDelay(lastFitAtRef.current, performance.now())
|
||||
if (wait > 0) {
|
||||
if (!fitTrailingRef.current) {
|
||||
fitTrailingRef.current = window.setTimeout(() => {
|
||||
fitTrailingRef.current = 0
|
||||
scheduleFit()
|
||||
}, wait)
|
||||
}
|
||||
return
|
||||
}
|
||||
lastFitAtRef.current = performance.now()
|
||||
const fit = fitAddonRef.current
|
||||
if (!fit) return
|
||||
try {
|
||||
@@ -907,6 +933,10 @@ export function TerminalView({
|
||||
// from a template apply or panel reuse; recording resumes idle.
|
||||
lineBufRef.current = ''
|
||||
ptySizeRef.current = null
|
||||
// A rebound session's first fit must not inherit the previous session's
|
||||
// throttle window — the pty spawns at a placeholder size and the shell
|
||||
// may already be drawing.
|
||||
lastFitAtRef.current = 0
|
||||
setSuggestions([])
|
||||
setSuggestionIndex(0)
|
||||
if (recordingRef.current) {
|
||||
@@ -928,18 +958,28 @@ export function TerminalView({
|
||||
// A shell prompt is exactly such a run with no follow-up data, so flush the
|
||||
// carry on a short timer — prompts render within a frame, anchors survive.
|
||||
let carryTimer: number | undefined
|
||||
// Batch xterm writes: a busy TUI delivers one IPC message per pty read,
|
||||
// and every term.write re-arms xterm's parser scheduler — hundreds of
|
||||
// tiny writes a second keep the parser permanently behind the producer,
|
||||
// which is what froze the UI for seconds after an output burst. The
|
||||
// batcher hands output over in one write per ~16ms window (the carry
|
||||
// cadence), so a flood costs one parser run per frame instead of one per
|
||||
// IPC message.
|
||||
const batcher = new WriteBatcher((text) => {
|
||||
if (!deadRef.current) term.write(text)
|
||||
})
|
||||
const scheduleCarryFlush = (): void => {
|
||||
if (carryTimer !== undefined || !stream.hasPending()) return
|
||||
carryTimer = window.setTimeout(() => {
|
||||
carryTimer = undefined
|
||||
if (deadRef.current) return
|
||||
const rest = stream.flush()
|
||||
if (rest) term.write(rest)
|
||||
if (rest) batcher.push(rest)
|
||||
}, 16)
|
||||
}
|
||||
const writeHighlighted = (data: string) => {
|
||||
const out = stream.push(data)
|
||||
if (out) term.write(out)
|
||||
if (out) batcher.push(out)
|
||||
scheduleCarryFlush()
|
||||
}
|
||||
// Clickable URLs. Ctrl/Cmd+Click opens the target in the system browser;
|
||||
@@ -1076,6 +1116,10 @@ export function TerminalView({
|
||||
// Death state, shared by the PTY_EXIT subscription and the SESSION_STATE
|
||||
// query below: the two paths must land identically.
|
||||
const markDead = (code: number): void => {
|
||||
// The batcher may still hold the session's final output (PTY_EXIT lands
|
||||
// after the data but before the flush timer); release it before the
|
||||
// dead flag starts dropping writes.
|
||||
batcher.flushNow()
|
||||
deadRef.current = true
|
||||
setExitCode(code)
|
||||
setDead(true)
|
||||
@@ -1158,6 +1202,13 @@ export function TerminalView({
|
||||
return () => {
|
||||
if (carryTimer !== undefined) window.clearTimeout(carryTimer)
|
||||
if (ptyTimerRef.current) window.clearTimeout(ptyTimerRef.current)
|
||||
if (fitTrailingRef.current) {
|
||||
window.clearTimeout(fitTrailingRef.current)
|
||||
fitTrailingRef.current = 0
|
||||
}
|
||||
// Teardown/rebind drops whatever the batcher still holds; a rebound
|
||||
// pane re-fetches the session replay, so nothing visible is lost.
|
||||
batcher.dispose()
|
||||
host?.removeEventListener('keydown', onPasteKey, true)
|
||||
for (const unsubscribe of unsubscribes) unsubscribe()
|
||||
for (const disposable of disposables) disposable.dispose()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Refit throttle (fitThrottle.ts).
|
||||
*
|
||||
* `FitAddon.fit()` triggers an xterm resize, which reflows the WHOLE
|
||||
* scrollback — tens of milliseconds at the default 5000 lines, more with
|
||||
* CJK-dense buffers. A window drag fires the pane's ResizeObserver every
|
||||
* animation frame, so an unthrottled fit-per-frame saturates the main
|
||||
* thread and starves xterm's write queue; the UI then stays frozen for
|
||||
* seconds after the drag while the backlog drains. The throttle caps
|
||||
* reflows during a drag; a trailing timer in TerminalView lands the final
|
||||
* fit at the settled size.
|
||||
*
|
||||
* Pure decision function (the rAF/trailing-timer wiring stays in
|
||||
* TerminalView), table-tested by tests/refit-throttle.mjs.
|
||||
*/
|
||||
export const FIT_REFLOW_THROTTLE_MS = 150
|
||||
|
||||
/**
|
||||
* Milliseconds to wait before the next fit may reflow; 0 = fit now.
|
||||
* `lastFitAt <= 0` marks "no fit recorded yet" — a session's first fit must
|
||||
* never wait, because the pty spawns at a placeholder size and the shell may
|
||||
* already be drawing. `now` comes from a monotonic clock (performance.now).
|
||||
*/
|
||||
export function refitDelay(lastFitAt: number, now: number, throttleMs: number = FIT_REFLOW_THROTTLE_MS): number {
|
||||
if (lastFitAt <= 0) return 0
|
||||
const wait = throttleMs - (now - lastFitAt)
|
||||
return wait > 0 ? wait : 0
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Output write batching (writeBatcher.ts).
|
||||
*
|
||||
* A busy TUI delivers one IPC message per pty read, and every `term.write`
|
||||
* re-arms xterm's parser scheduler — hundreds of tiny writes a second keep
|
||||
* the parser permanently behind the producer, which is what freezes the UI
|
||||
* for seconds after an output burst while the backlog drains. This batcher
|
||||
* accumulates flushed output and hands it to xterm in ONE write per ~16ms
|
||||
* window, so a flood costs one parser run per frame instead of one per IPC
|
||||
* message. Ordering is preserved: pushes concatenate and flush in arrival
|
||||
* order.
|
||||
*
|
||||
* Pure + timer-only (no electron/xterm surface), so the self-test drives it
|
||||
* with real timers at a small delay: tests/write-batcher.mjs.
|
||||
*/
|
||||
export class WriteBatcher {
|
||||
private buf = ''
|
||||
private timer: ReturnType<typeof setTimeout> | undefined
|
||||
private readonly sink: (text: string) => void
|
||||
private readonly delayMs: number
|
||||
|
||||
constructor(sink: (text: string) => void, delayMs = 16) {
|
||||
this.sink = sink
|
||||
this.delayMs = delayMs
|
||||
}
|
||||
|
||||
/** True while buffered text is waiting for the flush timer. */
|
||||
hasPending(): boolean {
|
||||
return this.buf.length > 0
|
||||
}
|
||||
|
||||
/** Append `text`; the first push in a window arms the single flush timer. */
|
||||
push(text: string): void {
|
||||
if (!text) return
|
||||
this.buf += text
|
||||
if (this.timer === undefined) {
|
||||
this.timer = setTimeout(() => this.flushNow(), this.delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit everything buffered right now (no-op when empty). Used by the timer,
|
||||
* and by the session-death path: PTY_EXIT lands after the final data but
|
||||
* before the flush timer, and a dead session drops writes — so the exit
|
||||
* handler flushes first.
|
||||
*/
|
||||
flushNow(): void {
|
||||
if (this.timer !== undefined) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
}
|
||||
if (!this.buf) return
|
||||
const out = this.buf
|
||||
this.buf = ''
|
||||
this.sink(out)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop buffered text and cancel the timer (session teardown / rebind). A
|
||||
* rebound pane re-fetches the session replay, so nothing visible is lost.
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.timer !== undefined) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
}
|
||||
this.buf = ''
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ import type { ZmodemDoneEvent, ZmodemOfferEvent, ZmodemResponse } from './ipc'
|
||||
*/
|
||||
export interface AppApi {
|
||||
appInfo(): Promise<AppInfo>
|
||||
/** append one line to the diagnostic app log (watchdog reports) */
|
||||
appLog(line: string): void
|
||||
|
||||
// ---- sessions (pty data plane is shared by local and ssh sessions) ----
|
||||
createPty(opts?: PtyCreateOptions): Promise<PtyCreateResult>
|
||||
|
||||
@@ -110,6 +110,9 @@ const settings: Record<string, string> = {
|
||||
'settings.system.shellIntegration': 'Shell integration',
|
||||
'settings.system.shellIntegrationDesc':
|
||||
'Let the shell report its working directory (OSC 7) for more precise path tracking, including cd inside scripts; it wraps your prompt, so turn it off if the prompt theme looks wrong',
|
||||
'settings.system.diagnosticLog': 'Diagnostic log',
|
||||
'settings.system.diagnosticLogDesc':
|
||||
'Write key events (startup, UI freezes, crashes) to the log directory — one file per day, auto-deleted after 7 days; nothing is recorded while this is off',
|
||||
'settings.system.closeAction': 'Close button behavior',
|
||||
'settings.system.closeActionDesc':
|
||||
'What happens when you click the window close button; after minimizing to the tray you can quit from the tray icon context menu',
|
||||
|
||||
@@ -107,6 +107,9 @@ const settings: Record<string, string> = {
|
||||
'settings.system.shellIntegration': 'Shell 統合',
|
||||
'settings.system.shellIntegrationDesc':
|
||||
'shell に作業ディレクトリを報告させ(OSC 7)、パスの記憶をより正確にします(スクリプト内の cd も含みます)。プロンプトをラップするため、プロンプトの表示が崩れる場合はオフにしてください',
|
||||
'settings.system.diagnosticLog': '診断ログを記録',
|
||||
'settings.system.diagnosticLogDesc':
|
||||
'起動・UI フリーズ・クラッシュなどの主要イベントをログディレクトリに書き込みます(日ごとのファイル、7 日間保持で自動削除)。オフの間は記録しません',
|
||||
'settings.system.closeAction': '閉じるボタンの動作',
|
||||
'settings.system.closeActionDesc':
|
||||
'ウィンドウの閉じるボタンを押したときの動作。タスクトレイに最小化した後は、トレイアイコンの右クリックメニューから終了できます',
|
||||
|
||||
@@ -105,6 +105,9 @@ const settings: Record<string, string> = {
|
||||
'settings.system.shellIntegration': 'Shell 集成',
|
||||
'settings.system.shellIntegrationDesc':
|
||||
'让 shell 主动上报工作目录(OSC 7),路径记忆更精确(含脚本内的 cd);会包装你的命令提示符,遇到提示符主题异常时关掉即可',
|
||||
'settings.system.diagnosticLog': '记录诊断日志',
|
||||
'settings.system.diagnosticLogDesc':
|
||||
'把启动、界面卡死、崩溃等关键事件写入日志目录(按天一个文件,保留 7 天自动清理),排查问题时用;关闭后不再记录',
|
||||
'settings.system.closeAction': '关闭按钮行为',
|
||||
'settings.system.closeActionDesc':
|
||||
'点击窗口关闭按钮时的动作;最小化到托盘后可在托盘图标右键菜单中退出',
|
||||
|
||||
@@ -105,6 +105,9 @@ const settings: Record<string, string> = {
|
||||
'settings.system.shellIntegration': 'Shell 整合',
|
||||
'settings.system.shellIntegrationDesc':
|
||||
'讓 shell 主動回報工作目錄(OSC 7),路徑記憶更精確(含指令碼內的 cd);會包裝你的命令提示字元,提示字元主題顯示異常時關閉即可',
|
||||
'settings.system.diagnosticLog': '記錄診斷日誌',
|
||||
'settings.system.diagnosticLogDesc':
|
||||
'將啟動、介面凍結、當機等關鍵事件寫入日誌目錄(每日一個檔案,保留 7 天自動清除),排查問題時使用;關閉後不再記錄',
|
||||
'settings.system.closeAction': '關閉按鈕行為',
|
||||
'settings.system.closeActionDesc':
|
||||
'點擊視窗關閉按鈕時的動作;最小化至系統匣後,可從系統匣圖示的右鍵選單結束',
|
||||
|
||||
@@ -98,6 +98,8 @@ export const Ipc = {
|
||||
|
||||
// ---- misc ----
|
||||
APP_INFO: 'app:info',
|
||||
/** renderer -> main: append one line to the diagnostic app log (fire-and-forget) */
|
||||
APP_LOG: 'app:log',
|
||||
|
||||
// ---- updater (check/download/install + state broadcast) ----
|
||||
UPDATE_CHECK: 'update:check',
|
||||
|
||||
@@ -207,6 +207,12 @@ export interface SystemSettings {
|
||||
shellIntegration?: boolean
|
||||
/** interface language (see @shared/i18n); missing = 简体中文 */
|
||||
language?: Language
|
||||
/**
|
||||
* Persist the diagnostic log (startup, freeze watchdogs, crashes) to
|
||||
* <userData>/logs with a 7-day rolling cleanup. On by default — the log is
|
||||
* what makes "the app froze" field reports diagnosable. Off = record nothing.
|
||||
*/
|
||||
diagnosticLog?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -501,7 +507,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
autoDownloadUpdate: false,
|
||||
restoreSession: true,
|
||||
shellIntegration: false,
|
||||
language: 'zh-CN'
|
||||
language: 'zh-CN',
|
||||
diagnosticLog: true
|
||||
},
|
||||
// Off until the user sets a password and turns it on: an app that locks
|
||||
// itself out of the box would be a support ticket, not a feature.
|
||||
|
||||
Reference in new issue
Block a user