diff --git a/src/main/commands.ts b/src/main/commands.ts index f966899..e458d28 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -73,11 +73,15 @@ export class CommandsStore { /** Logs currently accumulating output, keyed by sessionId. */ private readonly activeBySession = new Map() /** - * 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>() + private readonly logBuffers = new Map() + /** In-flight drain loop per log file; absent when the file is settled. */ + private readonly logDrains = new Map>() /** Plain-text transformer per actively-logged session (see logSanitizer). */ private readonly sanitizers = new Map() @@ -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 { + const existing = this.logDrains.get(file) + if (existing) return existing + const run = (async (): Promise => { + 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) } diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index da7a64d..0a34b58 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -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 = 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 { + 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 { + return mutateSettings((current) => ({ ...current, ...next })) } export function registerSettingsIpc(): void { diff --git a/src/main/tray.ts b/src/main/tray.ts index 7786d90..6cc6a4d 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -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 @@ -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 { diff --git a/src/renderer/src/global.css b/src/renderer/src/global.css index e2f001b..563fca0 100644 --- a/src/renderer/src/global.css +++ b/src/renderer/src/global.css @@ -5,6 +5,14 @@ --chrome-border: #2d2d2d; --chrome-accent: #3b8eea; --chrome-hover: #2a2a2a; + --chrome-bg-deep: #101418; + /* Tab-bar overlays; overwritten per theme by applyChromeTheme(). */ + --chrome-tab-bg: rgba(255, 255, 255, 0.04); + --chrome-tab-bg-hover: rgba(255, 255, 255, 0.07); + --chrome-tab-ring: rgba(255, 255, 255, 0.1); + --chrome-tab-ring-hover: rgba(255, 255, 255, 0.2); + --chrome-tab-fg: #98a2ab; + --chrome-tab-fg-hover: #d0d0d0; } html, diff --git a/src/renderer/src/settings/highlight.css b/src/renderer/src/settings/highlight.css index 7d9545c..393ee44 100644 --- a/src/renderer/src/settings/highlight.css +++ b/src/renderer/src/settings/highlight.css @@ -1,5 +1,8 @@ /* ============================================================ Highlight settings tab + rule editor (settings/highlight.css) + Text/border tints derive from --chrome-fg via color-mix so the + panel follows the active terminal theme (white tints on dark + themes, black tints on light themes). ============================================================ */ .hl-tab { @@ -21,15 +24,15 @@ } .hl-table .ant-table-thead > tr > th { - background: rgba(255, 255, 255, 0.04); + background: color-mix(in srgb, var(--chrome-fg, #cccccc) 4%, transparent); border-bottom: 1px solid var(--chrome-border); - color: rgba(255, 255, 255, 0.85); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 85%, transparent); font-size: 12px; } .hl-table .ant-table-tbody > tr > td { background: transparent; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); + border-bottom: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 6%, transparent); } .hl-table .ant-table-tbody > tr:hover > td { @@ -48,7 +51,7 @@ .hl-pattern-text { font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace; font-size: 12px; - color: rgba(255, 255, 255, 0.88); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -71,17 +74,17 @@ .hl-cell-priority { font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace; font-size: 12px; - color: rgba(255, 255, 255, 0.88); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent); } .hl-cell-note { font-size: 12px; - color: rgba(255, 255, 255, 0.7); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 70%, transparent); } .hl-cell-empty { font-size: 12px; - color: rgba(255, 255, 255, 0.3); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 30%, transparent); } .hl-preview-text { @@ -114,7 +117,7 @@ .hl-editor-label { flex: 0 0 56px; - color: rgba(255, 255, 255, 0.88); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent); padding-top: 5px; } @@ -129,7 +132,7 @@ .hl-editor-hint { align-self: center; font-size: 12px; - color: rgba(255, 255, 255, 0.45); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent); } .hl-editor-error { @@ -149,7 +152,7 @@ .hl-editor-sub-label { font-size: 12px; - color: rgba(255, 255, 255, 0.6); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 60%, transparent); } .hl-editor-inline { @@ -170,7 +173,7 @@ width: 40px; height: 26px; padding: 0; - border: 1px solid rgba(255, 255, 255, 0.2); + border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 20%, transparent); border-radius: 4px; background: transparent; cursor: pointer; @@ -197,7 +200,7 @@ .hl-palette-tag { font-size: 12px; - color: rgba(255, 255, 255, 0.5); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent); margin-right: 2px; } @@ -205,7 +208,7 @@ width: 16px; height: 16px; border-radius: 3px; - border: 1px solid rgba(255, 255, 255, 0.3); + border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 30%, transparent); cursor: pointer; display: inline-block; transition: transform 0.12s ease, box-shadow 0.12s ease; @@ -218,4 +221,4 @@ .hl-swatch-selected { box-shadow: 0 0 0 2px var(--chrome-accent) inset; border-color: var(--chrome-accent); -} \ No newline at end of file +} diff --git a/src/renderer/src/settings/settings.css b/src/renderer/src/settings/settings.css index 297cf08..f03f623 100644 --- a/src/renderer/src/settings/settings.css +++ b/src/renderer/src/settings/settings.css @@ -1,5 +1,8 @@ /* ============================================================ Settings dialog + theme editor styles (settings/**, theme/**) + Text/border tints derive from --chrome-fg via color-mix so the + dialog follows the active terminal theme (white tints on dark + themes, black tints on light themes). ============================================================ */ /* ---------- settings dialog scaffolding ---------- */ @@ -106,7 +109,7 @@ } .settings-row:hover { - background: rgba(255, 255, 255, 0.03); + background: color-mix(in srgb, var(--chrome-fg, #cccccc) 3%, transparent); } .settings-row + .settings-row { @@ -115,7 +118,7 @@ .settings-row-label { flex: 0 0 46%; - color: rgba(255, 255, 255, 0.88); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent); font-size: 13px; line-height: 1.4; } @@ -125,7 +128,7 @@ margin-top: 2px; font-size: 12px; font-weight: 400; - color: rgba(255, 255, 255, 0.45); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent); } .settings-row-control { @@ -156,7 +159,7 @@ .settings-block-hint { font-weight: 400; font-size: 12px; - color: rgba(255, 255, 255, 0.45); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent); } .settings-radio-group { @@ -192,7 +195,7 @@ .settings-preview-title { display: block; font-size: 12px; - color: rgba(255, 255, 255, 0.5); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent); margin-bottom: 8px; } @@ -247,14 +250,14 @@ border-radius: 8px; overflow: hidden; cursor: pointer; - border: 2px solid rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.04); + border: 2px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 12%, transparent); + background: color-mix(in srgb, var(--chrome-fg, #cccccc) 4%, transparent); transition: border-color 0.15s ease, box-shadow 0.15s ease; } .theme-card:hover { border-color: var(--chrome-accent); - background: rgba(255, 255, 255, 0.07); + background: color-mix(in srgb, var(--chrome-fg, #cccccc) 7%, transparent); } .theme-card-selected { @@ -280,7 +283,7 @@ .theme-card-name { font-size: 13px; font-weight: 600; - color: rgba(255, 255, 255, 0.88); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -293,7 +296,7 @@ padding: 2px 5px; border-radius: 4px; background: rgba(59, 142, 234, 0.2); - color: #6fb3ff; + color: color-mix(in srgb, var(--chrome-accent, #3b8eea) 55%, var(--chrome-fg, #cccccc)); } .theme-card-dots { @@ -306,7 +309,7 @@ width: 14px; height: 14px; border-radius: 3px; - border: 1px solid rgba(255, 255, 255, 0.25); + border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 25%, transparent); display: inline-block; } @@ -336,12 +339,12 @@ .theme-card-new-icon { font-size: 26px; line-height: 1; - color: rgba(255, 255, 255, 0.55); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 55%, transparent); } .theme-card-new-text { font-size: 13px; - color: rgba(255, 255, 255, 0.6); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 60%, transparent); } /* ---------- theme editor ---------- */ @@ -359,7 +362,7 @@ .theme-editor-label { flex: 0 0 56px; - color: rgba(255, 255, 255, 0.88); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent); } .theme-editor-colors { @@ -379,7 +382,7 @@ .theme-editor-color-label { font-size: 12px; - color: rgba(255, 255, 255, 0.7); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 70%, transparent); flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; @@ -391,7 +394,7 @@ width: 40px; height: 26px; padding: 0; - border: 1px solid rgba(255, 255, 255, 0.2); + border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 20%, transparent); border-radius: 4px; background: transparent; cursor: pointer; @@ -408,7 +411,7 @@ .theme-editor-divider { grid-column: 1 / -1; - border-top: 1px solid rgba(255, 255, 255, 0.1); + border-top: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 10%, transparent); margin: 2px 0; } @@ -419,7 +422,7 @@ .theme-editor-preview-label { display: block; font-size: 12px; - color: rgba(255, 255, 255, 0.5); + color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent); margin-bottom: 6px; } @@ -495,7 +498,7 @@ } .about-changelog { - border-top: 1px solid rgba(255, 255, 255, 0.08); + border-top: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 8%, transparent); padding-top: 12px; } diff --git a/src/renderer/src/theme/chrome.ts b/src/renderer/src/theme/chrome.ts index 3aef4f0..ee61404 100644 --- a/src/renderer/src/theme/chrome.ts +++ b/src/renderer/src/theme/chrome.ts @@ -7,6 +7,17 @@ function parseHex(color: string): [number, number, number] | null { return [(n >> 16) & 255, (n >> 8) & 255, n & 255] } +/** WCAG relative luminance (0=black, 1=white); null for non-hex input. */ +function luminance(color: string): number | null { + const c = parseHex(color) + if (!c) return null + const lin = c.map((v) => { + const s = v / 255 + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 + }) + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2] +} + function toHex(v: number): string { return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0') } @@ -19,6 +30,10 @@ export function mix(a: string, b: string, t: number): string | null { return `#${toHex(ca[0] + (cb[0] - ca[0]) * t)}${toHex(ca[1] + (cb[1] - ca[1]) * t)}${toHex(ca[2] + (cb[2] - ca[2]) * t)}` } +/** Whether the active terminal theme has a light background. Tab bars derived + * from it flip their overlay direction (white-on-dark vs black-on-light). */ +let chromeIsLight = false + /** Derive the app chrome palette (sidebar, tab bars, dividers) from the active * terminal theme so the whole window follows theme switches instead of only * the terminal canvas. Values land on :root CSS variables; anything the theme @@ -30,13 +45,36 @@ export function applyChromeTheme(colors: ThemeColors): void { } set('--chrome-bg', colors.background) set('--chrome-fg', colors.foreground) - set('--chrome-bg-deep', mix(colors.background, '#000000', 0.35)) - set('--chrome-hover', mix(colors.background, colors.foreground, 0.08)) - set('--chrome-border', mix(colors.background, colors.foreground, 0.16)) + chromeIsLight = (luminance(colors.background) ?? 0) > 0.5 + if (chromeIsLight) { + // Light chrome: keep the bar close to the background (a big mix toward + // black turns pale themes into a muddy gray strip) and overlay tabs with + // translucent black instead of the translucent white used on dark themes. + set('--chrome-bg-deep', mix(colors.background, '#000000', 0.08)) + set('--chrome-hover', mix(colors.background, '#000000', 0.06)) + set('--chrome-border', mix(colors.background, '#000000', 0.2)) + set('--chrome-tab-bg', 'rgba(0, 0, 0, 0.045)') + set('--chrome-tab-bg-hover', 'rgba(0, 0, 0, 0.09)') + set('--chrome-tab-ring', 'rgba(0, 0, 0, 0.18)') + set('--chrome-tab-ring-hover', 'rgba(0, 0, 0, 0.32)') + set('--chrome-tab-fg', mix(colors.background, '#000000', 0.68)) + set('--chrome-tab-fg-hover', mix(colors.background, '#000000', 0.88)) + } else { + set('--chrome-bg-deep', mix(colors.background, '#000000', 0.35)) + set('--chrome-hover', mix(colors.background, colors.foreground, 0.08)) + set('--chrome-border', mix(colors.background, colors.foreground, 0.16)) + set('--chrome-tab-bg', 'rgba(255, 255, 255, 0.04)') + set('--chrome-tab-bg-hover', 'rgba(255, 255, 255, 0.07)') + set('--chrome-tab-ring', 'rgba(255, 255, 255, 0.1)') + set('--chrome-tab-ring-hover', 'rgba(255, 255, 255, 0.2)') + set('--chrome-tab-fg', '#98a2ab') + set('--chrome-tab-fg-hover', '#d0d0d0') + } } -/** Tab accent: base color + a lightened foreground variant for text on dark - * tinted backgrounds. Consumed via var(--tab-accent*) in workspace.css. */ +/** Tab accent: base color + a foreground variant that stays readable on the + * bar (lightened on dark chrome, darkened on light chrome). Consumed via + * var(--tab-accent*) in workspace.css. */ export function applyTabAccent(color: string): void { const style = document.documentElement.style // Settings hydrated from an older main process may lack the key; clear the @@ -47,5 +85,6 @@ export function applyTabAccent(color: string): void { return } style.setProperty('--tab-accent', color) - style.setProperty('--tab-accent-fg', mix(color, '#ffffff', 0.18) ?? color) + const fg = chromeIsLight ? mix(color, '#000000', 0.35) : mix(color, '#ffffff', 0.18) + style.setProperty('--tab-accent-fg', fg ?? color) } diff --git a/src/renderer/src/workspace/workspace.css b/src/renderer/src/workspace/workspace.css index 3c24128..6fd8380 100644 --- a/src/renderer/src/workspace/workspace.css +++ b/src/renderer/src/workspace/workspace.css @@ -35,14 +35,14 @@ border: none; border-radius: 5px; background: transparent; - color: #9a9a9a; + color: var(--chrome-tab-fg, #9a9a9a); font-size: 15px; cursor: pointer; } .workspace-rail-btn:hover { background: var(--chrome-hover); - color: #ffffff; + color: var(--chrome-fg, #ffffff); } .workspace-rail-divider { @@ -54,7 +54,7 @@ /* ---- M6.1 rail active state (mode entries) ---- */ .workspace-rail-btn.is-active { - color: var(--dv-active-tab-foreground-color, #e8e8e8); + color: var(--tab-accent-fg, #e8e8e8); box-shadow: inset 2px 0 0 var(--tab-accent, #3fb950); background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent); } @@ -179,6 +179,18 @@ /* Pane split dividers: soft slate band at rest, accent blue on hover/drag. */ --dv-sash-color: rgba(148, 163, 184, 0.28); --dv-active-sash-color: rgba(59, 142, 234, 0.75); + /* Tab surfaces. Dockview paints tabs through group-scoped rules that + outspecify our .dv-tab rules, so theme the tabs via the vars those rules + consume — otherwise the abyss defaults (dark navy) leak onto light themes. */ + --dv-activegroup-visiblepanel-tab-background-color: color-mix( + in srgb, var(--tab-accent, #3fb950) 10%, transparent); + --dv-activegroup-visiblepanel-tab-color: var(--tab-accent-fg, #3fd06a); + --dv-activegroup-hiddenpanel-tab-background-color: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04)); + --dv-activegroup-hiddenpanel-tab-color: var(--chrome-tab-fg, #98a2ab); + --dv-inactivegroup-visiblepanel-tab-background-color: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04)); + --dv-inactivegroup-visiblepanel-tab-color: var(--chrome-tab-fg, #98a2ab); + --dv-inactivegroup-hiddenpanel-tab-background-color: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04)); + --dv-inactivegroup-hiddenpanel-tab-color: var(--chrome-tab-fg, #98a2ab); } /* ---- tab polish: low rectangular tabs, quiet inactive, green active ---- */ @@ -191,16 +203,18 @@ border-radius: 3px; padding: 0 8px; margin-right: 4px; - background: rgba(255, 255, 255, 0.04); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10); - color: #98a2ab; + background: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04)); + box-shadow: inset 0 0 0 1px var(--chrome-tab-ring, rgba(255, 255, 255, 0.1)); + color: var(--chrome-tab-fg, #98a2ab); transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; } -.workspace-dockview .dv-tab.dv-inactive-tab:hover { - background: rgba(255, 255, 255, 0.07); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.20); - color: #d0d0d0; +/* Group-scoped chain matches dockview's own specificity so the hover tint + wins over its group-level background rules. */ +.workspace-dockview .dv-groupview .dv-tabs-container .dv-tab.dv-inactive-tab:hover { + background: var(--chrome-tab-bg-hover, rgba(255, 255, 255, 0.07)); + box-shadow: inset 0 0 0 1px var(--chrome-tab-ring-hover, rgba(255, 255, 255, 0.2)); + color: var(--chrome-tab-fg-hover, #d0d0d0); } /* XTerminal-style active tab: green outline + tinted bg + green title. */ @@ -229,7 +243,7 @@ width: 15px; height: 15px; border-radius: 2px; - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16); + box-shadow: inset 0 0 0 1px var(--chrome-tab-ring-hover, rgba(255, 255, 255, 0.16)); opacity: 0.8; transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; } @@ -432,9 +446,9 @@ body.ssh-resizing .ssh-bottom-divider { padding: 0; border: none; border-radius: 6px; - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16); + box-shadow: inset 0 0 0 1px var(--chrome-tab-ring, rgba(255, 255, 255, 0.16)); background: transparent; - color: #98a2ab; + color: var(--chrome-tab-fg, #98a2ab); font-size: 10px; cursor: pointer; transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; @@ -464,18 +478,18 @@ body.ssh-resizing .ssh-bottom-divider { padding: 0; border: none; border-radius: 6px; - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16); + box-shadow: inset 0 0 0 1px var(--chrome-tab-ring, rgba(255, 255, 255, 0.16)); background: transparent; - color: #98a2ab; + color: var(--chrome-tab-fg, #98a2ab); font-size: 10px; cursor: pointer; transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; } .workspace-tabbar-btn2:hover { - background: rgba(255, 255, 255, 0.07); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.24); - color: #ffffff; + background: var(--chrome-tab-bg-hover, rgba(255, 255, 255, 0.07)); + box-shadow: inset 0 0 0 1px var(--chrome-tab-ring-hover, rgba(255, 255, 255, 0.24)); + color: var(--chrome-tab-fg-hover, #ffffff); } .workspace-tabbar-btn2.is-active { diff --git a/tests/commands-store.mjs b/tests/commands-store.mjs index 1b2f96b..a2528c8 100644 --- a/tests/commands-store.mjs +++ b/tests/commands-store.mjs @@ -198,5 +198,23 @@ ok(logs.find((x) => x.file === start.file).endedAt !== undefined, 'old log final store.openLogsDir() ok(openedPath === join(userData, 'logs'), 'openLogsDir resolves to the logs dir') +// ---- 6. Burst ordering + stop tail (per-file buffer + single drain) ------------- +const sid2 = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' +const startB = store.logStart(sid2) +// Burst 1: 300 writes in one tick must coalesce into ordered appends. +for (let i = 0; i < 300; i++) store.logWrite(sid2, `a${String(i).padStart(3, '0')}\n`) +await wait(10) +// Burst 2 after a gap: may land while burst 1's drain is still in flight. +for (let i = 0; i < 100; i++) store.logWrite(sid2, `b${String(i).padStart(3, '0')}\n`) +// Trailing partial line must ride the same buffer via logStop. +store.logWrite(sid2, 'partial-tail') +store.logStop(sid2) +await wait(200) +const expected = + Array.from({ length: 300 }, (_, i) => `a${String(i).padStart(3, '0')}\n`).join('') + + Array.from({ length: 100 }, (_, i) => `b${String(i).padStart(3, '0')}\n`).join('') + + 'partial-tail' +ok(readFileSync(startB.file, 'utf8') === expected, 'burst writes + stop tail land in order') + console.log('\n[commands] ALL CHECKS PASSED') process.exit(0) \ No newline at end of file