fix: luminance-aware chrome theming for light themes; serialized settings writes; buffered log flush

- Derive tab-bar/chrome palette by background luminance: light themes keep
  a near-background bar with black-tinted tab overlays instead of a muddy
  gray strip; dark themes unchanged
- Theme dockview tabs via the group-scoped --dv-*-tab-* vars its own rules
  consume (they outspecify our .dv-tab rules and leaked abyss navy onto
  light tabs); bump inactive-tab hover specificity to match
- Convert settings dialog + highlight editor hardcoded white text/border
  tints to color-mix over --chrome-fg so panes stay readable on light themes
- commands.ts: per-file log write buffer with a single drain loop per file
  (burst output coalesces into one appendFile per IO tick, chain no longer
  grows per logWrite); stop-tail rides the same buffer
- settingsStore: serialize all writers through mutateSettings() queue that
  re-reads latest state per mutation (tray close-action vs settings UI full
  saves no longer clobber each other)
- tests: burst ordering + stop-tail case for the log buffer
This commit is contained in:
Bill committed 2026-09-17 09:11:05 +08:00
1 parent 9e453e272c
commit b3744b4705
9 files changed
+218 -99

No files matched your search

+37 -21
View File
@@ -73,11 +73,15 @@ export class CommandsStore {
/** Logs currently accumulating output, keyed by sessionId. */
private readonly activeBySession = new Map<string, SessionLogMeta>()
/**
* Pending append per log file. appendFile is async, so two rapid logWrite
* calls would otherwise race and land out of order. Chaining each new append
* onto the previous one keeps the file append-only in call order.
* Buffered plain text per log file, flushed by a single drain loop per file.
* Writes land in the buffer synchronously (so order is preserved) and the
* drain merges everything that accumulated during the previous appendFile
* round trip into one append call — burst output costs one syscall per IO
* tick instead of one Promise chain link per logWrite.
*/
private readonly pendingAppends = new Map<string, Promise<void>>()
private readonly logBuffers = new Map<string, string>()
/** In-flight drain loop per log file; absent when the file is settled. */
private readonly logDrains = new Map<string, Promise<void>>()
/** Plain-text transformer per actively-logged session (see logSanitizer). */
private readonly sanitizers = new Map<string, LogSanitizer>()
@@ -287,19 +291,35 @@ export class CommandsStore {
// Raw PTY output is escape-sequence soup in a text file; log plain text.
const clean = this.sanitizers.get(sessionId)?.push(data) ?? ''
if (!clean) return
// Chain onto any in-flight append so rapid writes land in call order.
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
const next = prev.then(() => appendFile(meta.file, clean, 'utf8')).then(
() => undefined,
() => {
// file may have been removed after stop -> ignore
this.logBuffers.set(meta.file, (this.logBuffers.get(meta.file) ?? '') + clean)
this.drainLog(meta.file)
}
/**
* Flush a log file's buffer, keeping at most one drain (and therefore one
* in-flight appendFile) per file. New writes that arrive mid-flush re-enter
* the buffer and are picked up by the next loop iteration, merged.
*/
private drainLog(file: string): Promise<void> {
const existing = this.logDrains.get(file)
if (existing) return existing
const run = (async (): Promise<void> => {
for (;;) {
const chunk = this.logBuffers.get(file)
if (chunk === undefined) break
this.logBuffers.delete(file)
try {
await appendFile(file, chunk, 'utf8')
} catch {
// file may have been removed after stop -> ignore
}
}
)
this.pendingAppends.set(meta.file, next)
// Avoid unbounded growth of the chain map once the file settles.
next.finally(() => {
if (this.pendingAppends.get(meta.file) === next) this.pendingAppends.delete(meta.file)
})()
run.finally(() => {
if (this.logDrains.get(file) === run) this.logDrains.delete(file)
})
this.logDrains.set(file, run)
return run
}
/** Stop logging a session, stamping endedAt into the registry + index. */
@@ -310,12 +330,8 @@ export class CommandsStore {
this.sanitizers.delete(sessionId)
if (tail) {
// Best effort: the trailing partial line belongs in the file too.
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
const next = prev.then(() => appendFile(meta.file, tail, 'utf8')).then(
() => undefined,
() => undefined
)
this.pendingAppends.set(meta.file, next)
this.logBuffers.set(meta.file, (this.logBuffers.get(meta.file) ?? '') + tail)
this.drainLog(meta.file)
}
this.finishLog(meta)
}
+32 -17
View File
@@ -159,25 +159,40 @@ export function loadSettings(): AppSettings {
}
}
export function saveSettings(next: AppSettings): AppSettings {
const merged = deepMerge({
terminal: next.terminal,
customThemes: next.customThemes,
highlightRules: next.highlightRules,
system: next.system ?? DEFAULT_SYSTEM
}).settings
applySystemSettings(merged.system)
applyWindowChrome(merged)
const pretty = JSON.stringify(merged, null, 2)
let settingsQueue: Promise<unknown> = Promise.resolve()
mkdirSync(app.getPath('userData'), { recursive: true })
const path = settingsPath()
const tmp = `${path}.tmp`
writeFileSync(tmp, pretty, 'utf8')
renameSync(tmp, path)
/**
* Serialize a settings mutation against every other writer. Each queued step
* re-reads the file so the mutation applies on top of the latest state — a
* full-snapshot save can no longer silently revert a change written between
* its read and its write (e.g. tray close-action vs settings UI save).
*/
export function mutateSettings(mutate: (settings: AppSettings) => AppSettings): Promise<AppSettings> {
const run = settingsQueue.then((): AppSettings => {
const merged = deepMerge(mutate(loadSettings())).settings
applySystemSettings(merged.system)
applyWindowChrome(merged)
broadcast(Ipc.SETTINGS_CHANGED, merged)
return merged
mkdirSync(app.getPath('userData'), { recursive: true })
const path = settingsPath()
const tmp = `${path}.tmp`
writeFileSync(tmp, JSON.stringify(merged, null, 2), 'utf8')
renameSync(tmp, path)
broadcast(Ipc.SETTINGS_CHANGED, merged)
return merged
})
// Keep the queue alive when a mutation throws; the caller still sees it.
settingsQueue = run.then(
() => undefined,
() => undefined
)
return run
}
/** Replace the persisted settings with `next` (serialized). */
export function saveSettings(next: AppSettings): Promise<AppSettings> {
return mutateSettings((current) => ({ ...current, ...next }))
}
export function registerSettingsIpc(): void {
+7 -4
View File
@@ -1,6 +1,6 @@
import { app, BrowserWindow, dialog, Menu, nativeImage, Tray } from 'electron'
import type { SystemSettings } from '@shared/settings'
import { loadSettings, saveSettings } from './settingsStore'
import { loadSettings, mutateSettings } from './settingsStore'
import trayIconPath from './assets/tray.png?asset'
export type CloseAction = NonNullable<SystemSettings['closeAction']>
@@ -15,9 +15,12 @@ export function markQuitting(): void {
}
function persistCloseAction(action: CloseAction): void {
const next = loadSettings()
next.system.closeAction = action
saveSettings(next)
// Serialized read-modify-write on the latest state: must not stomp (or be
// stomped by) a concurrent full-snapshot save from the settings UI.
void mutateSettings((current) => {
current.system.closeAction = action
return current
})
}
function showMainWindow(showOrCreate: () => void): void {