feat(v0.6): auto-group Projects by namespace + Active-now band + cross-device prefs
The flat Projects grid was overwhelming at 50-100 repos. Explored the UX with 4 parallel agents (auto-grouping / manual folders / density-segmentation / competitive research); all converged on namespace auto-grouping as the primary fix — the dotted repo names (Billo.Platform.*, Billo.Infrastructure.*) already encode the hierarchy, so it's a string-split, not a taxonomy the user builds. Frontend: - groupProjects(): depth-2 namespace grouping, >=2-member threshold (singletons fall to "Other"), degrade-to-flat when repos share no prefix, running projects duplicated into a pinned "Active now" section. - displayLabel(): cards show the namespace tail (Payment, not Billo.Platform.Payment); full name in Active/Other. - Collapsible sticky group headers with an "N active" badge so a collapsed section never hides running work; search force-expands matching groups. - public/prefs.ts: server-first load with a localStorage offline mirror, migrates the legacy per-device proj-favs. Backend (server-side prefs so favourites + collapse-state follow the user across devices — localStorage was per-device, which broke the multi-device premise): - src/http/prefs-store.ts: ~/.web-terminal-prefs.json store (mirrors push-store), every field bounded + sanitized. - GET /prefs (read-only) + PUT /prefs (Origin-guarded); UiPrefs type + PREFS_STORE_PATH config. Tests: +12 prefs-store, +21 groupProjects/displayLabel. Full suite 1227 passed. Verified live: grouping renders, collapse persists to the server and survives a full page reload (cross-device sync proven end-to-end).
This commit is contained in:
117
src/http/prefs-store.ts
Normal file
117
src/http/prefs-store.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* src/http/prefs-store.ts (v0.6 Projects) — persistent store of the Projects
|
||||
* launcher's cross-device UI preferences (`UiPrefs`).
|
||||
*
|
||||
* The app's whole point is multi-device access ("check from any device"), yet
|
||||
* favourites lived in per-device localStorage. This store persists them (and the
|
||||
* namespace group collapse-state) host-side so the view is identical on laptop
|
||||
* and phone. Served over GET /prefs, written via PUT /prefs (Origin-guarded).
|
||||
*
|
||||
* The file holds NO secrets (just project paths + collapse booleans), but it IS
|
||||
* attacker-writable via a same-origin PUT, so every field is bounded and coerced
|
||||
* on the way in (`sanitizePrefs`) — never trust the request body, never trust the
|
||||
* persisted JSON. Loading is best-effort: missing file → defaults; malformed →
|
||||
* defaults (logged). All mutations replace state immutably.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import type { UiPrefs } from '../types.js';
|
||||
|
||||
/* ── Bounds (DoS guard — a flood of PUTs cannot grow the file without limit) ── */
|
||||
const MAX_FAVOURITES = 500; // sane cap on starred projects
|
||||
const MAX_COLLAPSED = 1000; // sane cap on remembered group keys
|
||||
const MAX_STRING_LEN = 4096; // per path / group-key length cap
|
||||
|
||||
/** Empty defaults — a fresh install (or any load failure) is a usable blank slate. */
|
||||
export function emptyPrefs(): UiPrefs {
|
||||
return { favourites: [], collapsed: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce untrusted input (request body OR persisted JSON) into a safe `UiPrefs`.
|
||||
* Drops anything malformed; enforces length/size caps. Never throws.
|
||||
*/
|
||||
export function sanitizePrefs(input: unknown): UiPrefs {
|
||||
if (input === null || typeof input !== 'object') return emptyPrefs();
|
||||
const o = input as Record<string, unknown>;
|
||||
|
||||
const favSource = Array.isArray(o['favourites']) ? o['favourites'] : [];
|
||||
const favourites: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const v of favSource) {
|
||||
if (typeof v !== 'string' || v.length === 0 || v.length > MAX_STRING_LEN) continue;
|
||||
if (seen.has(v)) continue; // de-dup — a path is favourited once
|
||||
seen.add(v);
|
||||
favourites.push(v);
|
||||
if (favourites.length >= MAX_FAVOURITES) break;
|
||||
}
|
||||
|
||||
const collapsed: Record<string, boolean> = {};
|
||||
const collSource = o['collapsed'];
|
||||
if (collSource !== null && typeof collSource === 'object' && !Array.isArray(collSource)) {
|
||||
let count = 0;
|
||||
for (const [k, v] of Object.entries(collSource as Record<string, unknown>)) {
|
||||
if (k.length === 0 || k.length > MAX_STRING_LEN) continue;
|
||||
if (v !== true) continue; // only persist the "collapsed" fact; expanded is the default
|
||||
collapsed[k] = true;
|
||||
if (++count >= MAX_COLLAPSED) break;
|
||||
}
|
||||
}
|
||||
|
||||
return { favourites, collapsed };
|
||||
}
|
||||
|
||||
export interface PrefsStore {
|
||||
/** Current preferences (a fresh object each call — callers never mutate state). */
|
||||
get(): UiPrefs;
|
||||
/** Replace preferences with a sanitized copy of `next`. */
|
||||
set(next: unknown): void;
|
||||
/** Persist to disk. Best-effort: logs and resolves on failure. */
|
||||
persist(): Promise<void>;
|
||||
}
|
||||
|
||||
function loadPrefs(filePath: string): UiPrefs {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(filePath, 'utf8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
||||
console.error('prefs-store: load failed, starting empty', err);
|
||||
}
|
||||
return emptyPrefs();
|
||||
}
|
||||
try {
|
||||
return sanitizePrefs(JSON.parse(raw));
|
||||
} catch (err) {
|
||||
console.error('prefs-store: malformed JSON, starting empty', err);
|
||||
return emptyPrefs();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load (or initialise empty) a prefs store backed by `filePath`.
|
||||
* @param filePath absolute path to the JSON store (cfg.prefsStorePath)
|
||||
*/
|
||||
export function loadPrefsStore(filePath: string): PrefsStore {
|
||||
let prefs: UiPrefs = loadPrefs(filePath);
|
||||
|
||||
return {
|
||||
get(): UiPrefs {
|
||||
// Return a defensive copy so external callers can't mutate internal state.
|
||||
return { favourites: [...prefs.favourites], collapsed: { ...prefs.collapsed } };
|
||||
},
|
||||
|
||||
set(next: unknown): void {
|
||||
prefs = sanitizePrefs(next);
|
||||
},
|
||||
|
||||
async persist(): Promise<void> {
|
||||
try {
|
||||
await writeFile(filePath, JSON.stringify(prefs, null, 2));
|
||||
} catch (err) {
|
||||
console.error('prefs-store: persist failed', err);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user