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:
@@ -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
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
12
src/types.ts
12
src/types.ts
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user