feat(desktop): keep markdown table column widths across turns and sessions
A markdown table has no id — it is re-parsed from text on every render, so any resize state hung off the transcript dies on the next turn. Key the record by a hash of the header row instead: the same table resolves to the same key after a re-render, a session switch, or a reload, without the transcript carrying anything. Widths are percentages of the table box, never pixels, so a restored table stays fluid in a narrow pane. The namespace is deliberately disposable — one key, 64 entries, 7-day expiry, swept on first access. Losing it costs one drag.
This commit is contained in:
committed by
brooklyn!
parent
a72c9ca248
commit
e361e70b3b
@@ -0,0 +1,126 @@
|
||||
// Column widths for markdown tables in the transcript.
|
||||
//
|
||||
// A markdown table has no id — it is re-parsed from text on every render, and
|
||||
// the same table reappears when the session is reloaded or switched back to. So
|
||||
// the record is keyed by the table's own *shape*: its header cells and column
|
||||
// count. That key survives re-render, streaming, session switches, and reloads
|
||||
// without the transcript having to carry any resize state.
|
||||
//
|
||||
// Widths are stored as percentages of the table box, never pixels, so a
|
||||
// restored table stays fluid and reads the same in a narrow pane as a wide one.
|
||||
//
|
||||
// The record is deliberately disposable: a small bounded namespace under one
|
||||
// key, swept for expiry on first access. Losing it costs the user one drag.
|
||||
|
||||
import { readJson, writeJson } from './storage'
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.mdTableColumns.v1'
|
||||
const STORE_VERSION = 1
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const MAX_ENTRIES = 64
|
||||
|
||||
interface WidthEntry {
|
||||
/** Column widths as percentages of the table box; sums to ~100. */
|
||||
w: number[]
|
||||
/** Last write, epoch ms — drives expiry and LRU eviction. */
|
||||
t: number
|
||||
}
|
||||
|
||||
interface WidthStore {
|
||||
e: Record<string, WidthEntry>
|
||||
v: typeof STORE_VERSION
|
||||
}
|
||||
|
||||
// In-memory mirror is the read path. It is seeded once from storage and stays
|
||||
// authoritative for the life of the renderer, so a drag never pays a parse and
|
||||
// a table restored mid-session sees the same value a reload would.
|
||||
let cache: null | Record<string, WidthEntry> = null
|
||||
|
||||
function isEntry(value: unknown): value is WidthEntry {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const { t, w } = value as Partial<WidthEntry>
|
||||
|
||||
return (
|
||||
typeof t === 'number' &&
|
||||
Array.isArray(w) &&
|
||||
w.length > 1 &&
|
||||
w.every(part => typeof part === 'number' && Number.isFinite(part) && part > 0)
|
||||
)
|
||||
}
|
||||
|
||||
function load(): Record<string, WidthEntry> {
|
||||
if (cache) {
|
||||
return cache
|
||||
}
|
||||
|
||||
const raw = readJson<Partial<WidthStore>>(STORAGE_KEY)
|
||||
const entries: Record<string, WidthEntry> = {}
|
||||
|
||||
if (raw?.v === STORE_VERSION && raw.e && typeof raw.e === 'object') {
|
||||
const cutoff = Date.now() - MAX_AGE_MS
|
||||
|
||||
for (const [key, entry] of Object.entries(raw.e)) {
|
||||
if (isEntry(entry) && entry.t > cutoff) {
|
||||
entries[key] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cache = entries
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function flush(entries: Record<string, WidthEntry>) {
|
||||
const keys = Object.keys(entries)
|
||||
// Bounded namespace: drop the coldest tables past MAX_ENTRIES. `Math.max`
|
||||
// is not decoration — a negative count makes `slice` count from the END and
|
||||
// would evict nearly the whole store every time it fits comfortably.
|
||||
const excess = Math.max(0, keys.length - MAX_ENTRIES)
|
||||
|
||||
for (const key of keys.sort((a, b) => entries[a].t - entries[b].t).slice(0, excess)) {
|
||||
delete entries[key]
|
||||
}
|
||||
|
||||
writeJson(STORAGE_KEY, keys.length === excess ? null : { e: entries, v: STORE_VERSION })
|
||||
}
|
||||
|
||||
/** Stable identity for a table, derived from its header row. Same hash shape as
|
||||
* `profileColor` — cheap, and collisions only ever mean two identically-headed
|
||||
* tables share a width record, which is the behaviour you'd want anyway. */
|
||||
export function markdownTableKey(headers: string[]): string {
|
||||
let hash = 0
|
||||
const joined = `${headers.length}\u0001${headers.join('\u0001')}`
|
||||
|
||||
for (let index = 0; index < joined.length; index += 1) {
|
||||
hash = (hash * 31 + joined.charCodeAt(index)) >>> 0
|
||||
}
|
||||
|
||||
return hash.toString(36)
|
||||
}
|
||||
|
||||
/** Stored percentages for a table, or null when it has never been resized (or
|
||||
* the record no longer matches its column count). */
|
||||
export function readTableWidths(key: string, columns: number): null | number[] {
|
||||
const entry = load()[key]
|
||||
|
||||
return entry && entry.w.length === columns ? entry.w : null
|
||||
}
|
||||
|
||||
export function writeTableWidths(key: string, widths: number[]) {
|
||||
const entries = load()
|
||||
entries[key] = { t: Date.now(), w: widths }
|
||||
flush(entries)
|
||||
}
|
||||
|
||||
export function clearTableWidths(key: string) {
|
||||
const entries = load()
|
||||
|
||||
if (entries[key]) {
|
||||
delete entries[key]
|
||||
flush(entries)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user