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:
1 parent
9e453e272c
commit
b3744b4705
9 files changed
+205
-86
No files matched your search
+36
-20
@@ -73,11 +73,15 @@ export class CommandsStore {
|
|||||||
/** Logs currently accumulating output, keyed by sessionId. */
|
/** Logs currently accumulating output, keyed by sessionId. */
|
||||||
private readonly activeBySession = new Map<string, SessionLogMeta>()
|
private readonly activeBySession = new Map<string, SessionLogMeta>()
|
||||||
/**
|
/**
|
||||||
* Pending append per log file. appendFile is async, so two rapid logWrite
|
* Buffered plain text per log file, flushed by a single drain loop per file.
|
||||||
* calls would otherwise race and land out of order. Chaining each new append
|
* Writes land in the buffer synchronously (so order is preserved) and the
|
||||||
* onto the previous one keeps the file append-only in call order.
|
* 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). */
|
/** Plain-text transformer per actively-logged session (see logSanitizer). */
|
||||||
private readonly sanitizers = new Map<string, 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.
|
// Raw PTY output is escape-sequence soup in a text file; log plain text.
|
||||||
const clean = this.sanitizers.get(sessionId)?.push(data) ?? ''
|
const clean = this.sanitizers.get(sessionId)?.push(data) ?? ''
|
||||||
if (!clean) return
|
if (!clean) return
|
||||||
// Chain onto any in-flight append so rapid writes land in call order.
|
this.logBuffers.set(meta.file, (this.logBuffers.get(meta.file) ?? '') + clean)
|
||||||
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
|
this.drainLog(meta.file)
|
||||||
const next = prev.then(() => appendFile(meta.file, clean, 'utf8')).then(
|
}
|
||||||
() => undefined,
|
|
||||||
() => {
|
/**
|
||||||
|
* 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
|
// 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.
|
run.finally(() => {
|
||||||
next.finally(() => {
|
if (this.logDrains.get(file) === run) this.logDrains.delete(file)
|
||||||
if (this.pendingAppends.get(meta.file) === next) this.pendingAppends.delete(meta.file)
|
|
||||||
})
|
})
|
||||||
|
this.logDrains.set(file, run)
|
||||||
|
return run
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop logging a session, stamping endedAt into the registry + index. */
|
/** Stop logging a session, stamping endedAt into the registry + index. */
|
||||||
@@ -310,12 +330,8 @@ export class CommandsStore {
|
|||||||
this.sanitizers.delete(sessionId)
|
this.sanitizers.delete(sessionId)
|
||||||
if (tail) {
|
if (tail) {
|
||||||
// Best effort: the trailing partial line belongs in the file too.
|
// Best effort: the trailing partial line belongs in the file too.
|
||||||
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
|
this.logBuffers.set(meta.file, (this.logBuffers.get(meta.file) ?? '') + tail)
|
||||||
const next = prev.then(() => appendFile(meta.file, tail, 'utf8')).then(
|
this.drainLog(meta.file)
|
||||||
() => undefined,
|
|
||||||
() => undefined
|
|
||||||
)
|
|
||||||
this.pendingAppends.set(meta.file, next)
|
|
||||||
}
|
}
|
||||||
this.finishLog(meta)
|
this.finishLog(meta)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,25 +159,40 @@ export function loadSettings(): AppSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveSettings(next: AppSettings): AppSettings {
|
let settingsQueue: Promise<unknown> = Promise.resolve()
|
||||||
const merged = deepMerge({
|
|
||||||
terminal: next.terminal,
|
/**
|
||||||
customThemes: next.customThemes,
|
* Serialize a settings mutation against every other writer. Each queued step
|
||||||
highlightRules: next.highlightRules,
|
* re-reads the file so the mutation applies on top of the latest state — a
|
||||||
system: next.system ?? DEFAULT_SYSTEM
|
* full-snapshot save can no longer silently revert a change written between
|
||||||
}).settings
|
* 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)
|
applySystemSettings(merged.system)
|
||||||
applyWindowChrome(merged)
|
applyWindowChrome(merged)
|
||||||
const pretty = JSON.stringify(merged, null, 2)
|
|
||||||
|
|
||||||
mkdirSync(app.getPath('userData'), { recursive: true })
|
mkdirSync(app.getPath('userData'), { recursive: true })
|
||||||
const path = settingsPath()
|
const path = settingsPath()
|
||||||
const tmp = `${path}.tmp`
|
const tmp = `${path}.tmp`
|
||||||
writeFileSync(tmp, pretty, 'utf8')
|
writeFileSync(tmp, JSON.stringify(merged, null, 2), 'utf8')
|
||||||
renameSync(tmp, path)
|
renameSync(tmp, path)
|
||||||
|
|
||||||
broadcast(Ipc.SETTINGS_CHANGED, merged)
|
broadcast(Ipc.SETTINGS_CHANGED, merged)
|
||||||
return 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 {
|
export function registerSettingsIpc(): void {
|
||||||
|
|||||||
+7
-4
@@ -1,6 +1,6 @@
|
|||||||
import { app, BrowserWindow, dialog, Menu, nativeImage, Tray } from 'electron'
|
import { app, BrowserWindow, dialog, Menu, nativeImage, Tray } from 'electron'
|
||||||
import type { SystemSettings } from '@shared/settings'
|
import type { SystemSettings } from '@shared/settings'
|
||||||
import { loadSettings, saveSettings } from './settingsStore'
|
import { loadSettings, mutateSettings } from './settingsStore'
|
||||||
import trayIconPath from './assets/tray.png?asset'
|
import trayIconPath from './assets/tray.png?asset'
|
||||||
|
|
||||||
export type CloseAction = NonNullable<SystemSettings['closeAction']>
|
export type CloseAction = NonNullable<SystemSettings['closeAction']>
|
||||||
@@ -15,9 +15,12 @@ export function markQuitting(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function persistCloseAction(action: CloseAction): void {
|
function persistCloseAction(action: CloseAction): void {
|
||||||
const next = loadSettings()
|
// Serialized read-modify-write on the latest state: must not stomp (or be
|
||||||
next.system.closeAction = action
|
// stomped by) a concurrent full-snapshot save from the settings UI.
|
||||||
saveSettings(next)
|
void mutateSettings((current) => {
|
||||||
|
current.system.closeAction = action
|
||||||
|
return current
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMainWindow(showOrCreate: () => void): void {
|
function showMainWindow(showOrCreate: () => void): void {
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
--chrome-border: #2d2d2d;
|
--chrome-border: #2d2d2d;
|
||||||
--chrome-accent: #3b8eea;
|
--chrome-accent: #3b8eea;
|
||||||
--chrome-hover: #2a2a2a;
|
--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,
|
html,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
Highlight settings tab + rule editor (settings/highlight.css)
|
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 {
|
.hl-tab {
|
||||||
@@ -21,15 +24,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hl-table .ant-table-thead > tr > th {
|
.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);
|
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;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hl-table .ant-table-tbody > tr > td {
|
.hl-table .ant-table-tbody > tr > td {
|
||||||
background: transparent;
|
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 {
|
.hl-table .ant-table-tbody > tr:hover > td {
|
||||||
@@ -48,7 +51,7 @@
|
|||||||
.hl-pattern-text {
|
.hl-pattern-text {
|
||||||
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
|
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.88);
|
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -71,17 +74,17 @@
|
|||||||
.hl-cell-priority {
|
.hl-cell-priority {
|
||||||
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
|
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.88);
|
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hl-cell-note {
|
.hl-cell-note {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 70%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hl-cell-empty {
|
.hl-cell-empty {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.3);
|
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hl-preview-text {
|
.hl-preview-text {
|
||||||
@@ -114,7 +117,7 @@
|
|||||||
|
|
||||||
.hl-editor-label {
|
.hl-editor-label {
|
||||||
flex: 0 0 56px;
|
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;
|
padding-top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +132,7 @@
|
|||||||
.hl-editor-hint {
|
.hl-editor-hint {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.45);
|
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hl-editor-error {
|
.hl-editor-error {
|
||||||
@@ -149,7 +152,7 @@
|
|||||||
|
|
||||||
.hl-editor-sub-label {
|
.hl-editor-sub-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 60%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hl-editor-inline {
|
.hl-editor-inline {
|
||||||
@@ -170,7 +173,7 @@
|
|||||||
width: 40px;
|
width: 40px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
padding: 0;
|
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;
|
border-radius: 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -197,7 +200,7 @@
|
|||||||
|
|
||||||
.hl-palette-tag {
|
.hl-palette-tag {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent);
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +208,7 @@
|
|||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
border-radius: 3px;
|
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;
|
cursor: pointer;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
Settings dialog + theme editor styles (settings/**, theme/**)
|
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 ---------- */
|
/* ---------- settings dialog scaffolding ---------- */
|
||||||
@@ -106,7 +109,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-row:hover {
|
.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 {
|
.settings-row + .settings-row {
|
||||||
@@ -115,7 +118,7 @@
|
|||||||
|
|
||||||
.settings-row-label {
|
.settings-row-label {
|
||||||
flex: 0 0 46%;
|
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;
|
font-size: 13px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
@@ -125,7 +128,7 @@
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: rgba(255, 255, 255, 0.45);
|
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-row-control {
|
.settings-row-control {
|
||||||
@@ -156,7 +159,7 @@
|
|||||||
.settings-block-hint {
|
.settings-block-hint {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.45);
|
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-radio-group {
|
.settings-radio-group {
|
||||||
@@ -192,7 +195,7 @@
|
|||||||
.settings-preview-title {
|
.settings-preview-title {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,14 +250,14 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.12);
|
border: 2px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 12%, transparent);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: color-mix(in srgb, var(--chrome-fg, #cccccc) 4%, transparent);
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-card:hover {
|
.theme-card:hover {
|
||||||
border-color: var(--chrome-accent);
|
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 {
|
.theme-card-selected {
|
||||||
@@ -280,7 +283,7 @@
|
|||||||
.theme-card-name {
|
.theme-card-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: rgba(255, 255, 255, 0.88);
|
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -293,7 +296,7 @@
|
|||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(59, 142, 234, 0.2);
|
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 {
|
.theme-card-dots {
|
||||||
@@ -306,7 +309,7 @@
|
|||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border-radius: 3px;
|
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;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,12 +339,12 @@
|
|||||||
.theme-card-new-icon {
|
.theme-card-new-icon {
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
line-height: 1;
|
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 {
|
.theme-card-new-text {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 60%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- theme editor ---------- */
|
/* ---------- theme editor ---------- */
|
||||||
@@ -359,7 +362,7 @@
|
|||||||
|
|
||||||
.theme-editor-label {
|
.theme-editor-label {
|
||||||
flex: 0 0 56px;
|
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 {
|
.theme-editor-colors {
|
||||||
@@ -379,7 +382,7 @@
|
|||||||
|
|
||||||
.theme-editor-color-label {
|
.theme-editor-color-label {
|
||||||
font-size: 12px;
|
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;
|
flex: 1 1 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -391,7 +394,7 @@
|
|||||||
width: 40px;
|
width: 40px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
padding: 0;
|
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;
|
border-radius: 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -408,7 +411,7 @@
|
|||||||
|
|
||||||
.theme-editor-divider {
|
.theme-editor-divider {
|
||||||
grid-column: 1 / -1;
|
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;
|
margin: 2px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +422,7 @@
|
|||||||
.theme-editor-preview-label {
|
.theme-editor-preview-label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,7 +498,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.about-changelog {
|
.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;
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ function parseHex(color: string): [number, number, number] | null {
|
|||||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
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 {
|
function toHex(v: number): string {
|
||||||
return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
|
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)}`
|
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
|
/** Derive the app chrome palette (sidebar, tab bars, dividers) from the active
|
||||||
* terminal theme so the whole window follows theme switches instead of only
|
* terminal theme so the whole window follows theme switches instead of only
|
||||||
* the terminal canvas. Values land on :root CSS variables; anything the theme
|
* 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-bg', colors.background)
|
||||||
set('--chrome-fg', colors.foreground)
|
set('--chrome-fg', colors.foreground)
|
||||||
|
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-bg-deep', mix(colors.background, '#000000', 0.35))
|
||||||
set('--chrome-hover', mix(colors.background, colors.foreground, 0.08))
|
set('--chrome-hover', mix(colors.background, colors.foreground, 0.08))
|
||||||
set('--chrome-border', mix(colors.background, colors.foreground, 0.16))
|
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
|
/** Tab accent: base color + a foreground variant that stays readable on the
|
||||||
* tinted backgrounds. Consumed via var(--tab-accent*) in workspace.css. */
|
* bar (lightened on dark chrome, darkened on light chrome). Consumed via
|
||||||
|
* var(--tab-accent*) in workspace.css. */
|
||||||
export function applyTabAccent(color: string): void {
|
export function applyTabAccent(color: string): void {
|
||||||
const style = document.documentElement.style
|
const style = document.documentElement.style
|
||||||
// Settings hydrated from an older main process may lack the key; clear the
|
// Settings hydrated from an older main process may lack the key; clear the
|
||||||
@@ -47,5 +85,6 @@ export function applyTabAccent(color: string): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
style.setProperty('--tab-accent', color)
|
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)
|
||||||
}
|
}
|
||||||
@@ -35,14 +35,14 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #9a9a9a;
|
color: var(--chrome-tab-fg, #9a9a9a);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-rail-btn:hover {
|
.workspace-rail-btn:hover {
|
||||||
background: var(--chrome-hover);
|
background: var(--chrome-hover);
|
||||||
color: #ffffff;
|
color: var(--chrome-fg, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-rail-divider {
|
.workspace-rail-divider {
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
|
|
||||||
/* ---- M6.1 rail active state (mode entries) ---- */
|
/* ---- M6.1 rail active state (mode entries) ---- */
|
||||||
.workspace-rail-btn.is-active {
|
.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);
|
box-shadow: inset 2px 0 0 var(--tab-accent, #3fb950);
|
||||||
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent);
|
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. */
|
/* Pane split dividers: soft slate band at rest, accent blue on hover/drag. */
|
||||||
--dv-sash-color: rgba(148, 163, 184, 0.28);
|
--dv-sash-color: rgba(148, 163, 184, 0.28);
|
||||||
--dv-active-sash-color: rgba(59, 142, 234, 0.75);
|
--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 ---- */
|
/* ---- tab polish: low rectangular tabs, quiet inactive, green active ---- */
|
||||||
@@ -191,16 +203,18 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04));
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10);
|
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring, rgba(255, 255, 255, 0.1));
|
||||||
color: #98a2ab;
|
color: var(--chrome-tab-fg, #98a2ab);
|
||||||
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
|
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-dockview .dv-tab.dv-inactive-tab:hover {
|
/* Group-scoped chain matches dockview's own specificity so the hover tint
|
||||||
background: rgba(255, 255, 255, 0.07);
|
wins over its group-level background rules. */
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.20);
|
.workspace-dockview .dv-groupview .dv-tabs-container .dv-tab.dv-inactive-tab:hover {
|
||||||
color: #d0d0d0;
|
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. */
|
/* XTerminal-style active tab: green outline + tinted bg + green title. */
|
||||||
@@ -229,7 +243,7 @@
|
|||||||
width: 15px;
|
width: 15px;
|
||||||
height: 15px;
|
height: 15px;
|
||||||
border-radius: 2px;
|
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;
|
opacity: 0.8;
|
||||||
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
|
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;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
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;
|
background: transparent;
|
||||||
color: #98a2ab;
|
color: var(--chrome-tab-fg, #98a2ab);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
|
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;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
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;
|
background: transparent;
|
||||||
color: #98a2ab;
|
color: var(--chrome-tab-fg, #98a2ab);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
|
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-tabbar-btn2:hover {
|
.workspace-tabbar-btn2:hover {
|
||||||
background: rgba(255, 255, 255, 0.07);
|
background: var(--chrome-tab-bg-hover, rgba(255, 255, 255, 0.07));
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.24);
|
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring-hover, rgba(255, 255, 255, 0.24));
|
||||||
color: #ffffff;
|
color: var(--chrome-tab-fg-hover, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-tabbar-btn2.is-active {
|
.workspace-tabbar-btn2.is-active {
|
||||||
|
|||||||
@@ -198,5 +198,23 @@ ok(logs.find((x) => x.file === start.file).endedAt !== undefined, 'old log final
|
|||||||
store.openLogsDir()
|
store.openLogsDir()
|
||||||
ok(openedPath === join(userData, 'logs'), 'openLogsDir resolves to the logs dir')
|
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')
|
console.log('\n[commands] ALL CHECKS PASSED')
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
Reference in new issue
Block a user