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:
Yaojia Wang
2026-07-01 04:25:57 +02:00
parent 7b3ba8491a
commit 03612323c0
10 changed files with 581 additions and 1 deletions

117
src/http/prefs-store.ts Normal file
View 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);
}
},
};
}