// Portable grid-column preferences (visibility / order / width / sort). // // Stored in the DB settings table (so they travel with the logbook and // survive a reinstall) AND mirrored to the WebView localStorage as a fast, // flicker-free cache. On a fresh machine localStorage is empty, so we fall // back to the DB copy and re-seed the cache. import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App'; // The DB copy is ALREADY per-profile (the backend prefixes every ui.* key with // the active profile id). The localStorage cache, however, is one namespace for // the whole WebView, so without scoping it too a profile switch would keep // serving the previous profile's cached layout and the correct per-profile DB // value would never win. lsScope makes the cache per-profile as well; it's set // once the active profile is known and updated on every profile switch. let lsScope = ''; // setGridPrefsProfile scopes the localStorage cache to a profile. Call it before // the grids read their state (at startup) and again whenever the active profile // changes so each profile keeps its own column layout / widths. export function setGridPrefsProfile(id: number | string | null | undefined): void { lsScope = id == null || id === '' ? '' : `p${id}.`; markReady(); } // The active profile is only known after an async call, but the grids mount and // read their state on the first paint. A grid that read before the scope was set // looked up the UNSCOPED cache key (always a miss) and then saved under the // scoped one — so its layout could never be restored, only rewritten. Grids now // wait for this instead of racing it. // // The 5 s fallback matters: if the profile lookup fails outright, the grids must // still come up with whatever the unscoped cache holds rather than hang with no // columns restored. let markReady: () => void = () => {}; const gridPrefsReady = new Promise((resolve) => { markReady = resolve; setTimeout(resolve, 5000); }); export function whenGridPrefsReady(): Promise { return gridPrefsReady; } // lsKey scopes ONLY the localStorage cache key. The DB key passed to // GetUIPref/SetUIPref is left untouched — the backend already scopes it. function lsKey(key: string): string { return lsScope + key; } // loadLocal reads the cached column state synchronously (used in onGridReady // to apply instantly, before the async DB round-trip). export function loadLocal(key: string): any[] | null { try { const raw = localStorage.getItem(lsKey(key)); const v = raw ? JSON.parse(raw) : null; return Array.isArray(v) ? v : null; } catch { return null; } } // loadRemote pulls the portable copy from the DB (null if none / unset). // // GetUIPref returns an ERROR — not "" — while the settings store is still // coming up and not yet scoped to the active profile. Treating that as "no // preference" was the silent data-loss path: the grid rendered defaults and the // first column event then wrote those defaults over the good saved copy. So // retry for a few seconds instead, and only give up on a real absence. export async function loadRemote(key: string, attempts = 10): Promise { for (let i = 0; i < attempts; i++) { try { const v = await GetUIPref(key); const parsed = v ? JSON.parse(v) : null; return Array.isArray(parsed) ? parsed : null; } catch { // Not ready yet (or a parse failure on a corrupt value — one more read // costs nothing). 300 ms × 10 covers a slow startup without hanging. await new Promise((r) => setTimeout(r, 300)); } } return null; } // saveState write-throughs to both the cache and the DB (fire-and-forget). Only // the cache key is profile-scoped; the DB key is scoped by the backend. // The DB write is DEBOUNCED. AG-Grid emits a column-resized event per drag // frame, so dragging one border used to fire dozens of settings writes; the // cache write stays synchronous so nothing is lost if the window closes. const pendingDB = new Map(); const dbTimers = new Map(); export function saveState(key: string, state: any[]) { const json = JSON.stringify(state); try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ } pendingDB.set(key, json); const prev = dbTimers.get(key); if (prev) clearTimeout(prev); dbTimers.set(key, window.setTimeout(() => flushOne(key), 400)); } function flushOne(key: string) { const json = pendingDB.get(key); dbTimers.delete(key); if (json == null) return; pendingDB.delete(key); SetUIPref(key, json).catch(() => { // The store may not be scoped yet at startup. Keep the value and try once // more shortly — dropping it here is how a fresh profile ended up with no // portable copy at all. pendingDB.set(key, json); if (!dbTimers.has(key)) dbTimers.set(key, window.setTimeout(() => flushOne(key), 2000)); }); } // flushGridPrefs writes any debounced state immediately. Call it when the app is // about to close so a resize made in the last moments still reaches the DB. export function flushGridPrefs() { for (const key of Array.from(pendingDB.keys())) { const t = dbTimers.get(key); if (t) clearTimeout(t); flushOne(key); } } // seedLocal writes a value into the cache without touching the DB (used after // hydrating the cache from the DB on a fresh machine). export function seedLocal(key: string, state: any[]) { try { localStorage.setItem(lsKey(key), JSON.stringify(state)); } catch { /* ignore */ } }