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

View File

@@ -325,6 +325,8 @@ export function loadConfig(env: EnvLike): Config {
const pushStorePath =
env['PUSH_STORE_PATH'] || path.join(homeDir, '.web-terminal-push-subs.json')
const pushMaxSubs = parseNonNegativeInt(env['PUSH_MAX_SUBS'], 'PUSH_MAX_SUBS', DEFAULT_PUSH_MAX_SUBS)
const prefsStorePath =
env['PREFS_STORE_PATH'] || path.join(homeDir, '.web-terminal-prefs.json')
const notifyDone = parseBool(env['NOTIFY_DONE'], true) // default on
const notifyDnd = parseBool(env['NOTIFY_DND'], false) // default off (do not disturb)
// DECISION_TOKEN_TTL_MS defaults to permTimeoutMs (parsed after it, order matters)
@@ -416,6 +418,7 @@ export function loadConfig(env: EnvLike): Config {
vapidSubject,
pushStorePath,
pushMaxSubs,
prefsStorePath,
notifyDone,
notifyDnd,
decisionTokenTtlMs,

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);
}
},
};
}

View File

@@ -44,6 +44,7 @@ import { createWorktree } from './http/worktrees.js'
import { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, setClientDims } from './session/session.js'
import { loadSubscriptionStore } from './push/subscription-store.js'
import { loadPrefsStore } from './http/prefs-store.js'
import { createPushService } from './push/push-service.js'
import type {
Config,
@@ -190,6 +191,9 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
const pushService = createPushService(cfg, subStore)
const manager = createSessionManager(cfg, pushService)
// v0.6 Projects: cross-device UI prefs (favourites + group collapse-state).
const prefsStore = loadPrefsStore(cfg.prefsStorePath)
// Per-IP rate limiters for the new state-changing routes (SEC-H9).
const decisionLimiter = createRateLimiter(DECISION_RATE_MAX, RATE_LIMIT_WINDOW_MS)
const subscribeLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)
@@ -264,6 +268,25 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
}
})
// Projects UI prefs (v0.6) — favourites + group collapse-state, cross-device.
// GET is read-only (no secrets, just paths/booleans) → no Origin guard, like /projects.
app.get('/prefs', (_req, res) => {
res.json(prefsStore.get())
})
// PUT replaces the whole prefs blob. State-changing → Origin guard (CSRF, like
// /open-in-editor). Body is sanitized inside prefsStore.set (never trust it).
app.put('/prefs', express.json({ limit: '64kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
try {
prefsStore.set(req.body)
await prefsStore.persist()
res.json(prefsStore.get())
} catch (err) {
console.error('[server] PUT /prefs failed:', err instanceof Error ? err.message : String(err))
res.status(500).json({ error: 'failed to save prefs' })
}
})
// Project detail (v0.6) — branch/worktrees + running sessions for one repo.
// Read-only (git branch/status/worktree-list); same threat model as /projects.
// ?path must be an absolute existing directory (validated in buildProjectDetail).

View File

@@ -47,6 +47,7 @@ export interface Config {
readonly vapidSubject: string | undefined; // VAPID_SUBJECT (mailto:/url)
readonly pushStorePath: string; // PUSH_STORE_PATH, default ~/.web-terminal-push-subs.json
readonly pushMaxSubs: number; // PUSH_MAX_SUBS, default 50
readonly prefsStorePath: string; // PREFS_STORE_PATH, default ~/.web-terminal-prefs.json (Projects UI prefs)
readonly notifyDone: boolean; // NOTIFY_DONE, default true
readonly notifyDnd: boolean; // NOTIFY_DND, default false
readonly decisionTokenTtlMs: number; // DECISION_TOKEN_TTL_MS, default = permTimeoutMs
@@ -483,6 +484,17 @@ export interface CreateWorktreeResult {
error?: string;
}
/* ── v0.6 Projects UI preferences (server-persisted, cross-device) ── */
/** Cross-device UI preferences for the Projects launcher (impl: src/http/prefs-store.ts).
* Persisted host-side so favourites + group collapse-state follow the user from
* laptop to phone (localStorage was per-device). Holds NO secrets — just project
* paths and collapse booleans; served over GET /prefs, written via PUT /prefs. */
export interface UiPrefs {
favourites: string[]; // favourited project paths (★), replaces per-device proj-favs
collapsed: Record<string, boolean>; // namespace group-key → collapsed? (true = hidden)
}
/* ── GET /config/ui (review #4) ── */
/** Client-readable UI configuration (GET /config/ui). `allowAutoMode` mirrors