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:
@@ -24,6 +24,9 @@
|
||||
|
||||
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
||||
|
||||
### ✅ v0.6 Projects 命名空间自动分组 + 跨设备 prefs(完成 — 2026-07-01,分支 `v0.6-projects`,未提交)
|
||||
Projects 页去杂乱:按点分隔命名空间自动折叠成 section(`Billo.Platform ·8` / `Billo.Infrastructure ·2` / `Other`)+ 置顶 "Active now" + 无前缀时退化为扁平网格。收藏/折叠态改**服务端持久化**(`GET/PUT /prefs` → `~/.web-terminal-prefs.json`,离线回退 localStorage 镜像 + 迁移旧 `proj-favs`)跨设备同步。1227 tests 绿,真浏览器验证折叠跨 reload 从服务端载回。详见 Detailed Log 首条。
|
||||
|
||||
### ✅ v0.6 Project Manager 项目工作台(完成 — 2026-06-30,分支 `v0.6-projects`)
|
||||
|
||||
- **全部 6 任务完成并验证**(P1–P6,多 agent 编排)。**398 测试全绿**(自 341 +57)· 双 typecheck 干净 · `build:web` OK · 覆盖率 90.98/81.95/89.28/93.16(阈值 80×4 通过)· **真浏览器 smoke 通过**。
|
||||
@@ -146,6 +149,19 @@
|
||||
- **commit**: <hash 或 N/A>
|
||||
======================================================= -->
|
||||
|
||||
### 2026-07-01 · v0.6 Projects 页去杂乱 — 命名空间自动分组 + "Active now" + 跨设备 prefs
|
||||
|
||||
- **状态**: `[x]` DONE。双 typecheck 干净 · `build:web` OK · 全量 `vitest run` **1227 passed (41 files)** · 真浏览器端到端验证通过。
|
||||
- **需求**(用户): Projects 页扁平网格太乱(50–100 repo)。多 agent 探索(4 路:自动分组/手动文件夹/密度分段/竞品调研)结论一致 → **主方向=按点分隔命名空间自动分组**(repo 名如 `Billo.Platform.Payment` 天然带层级),配 **Active-now 置顶**;手动文件夹只是加分项非主线。用户选:**范围=自动分组+Active band**,**持久化=服务端**(收藏/折叠态跨设备同步,修掉 localStorage 每设备各一份的缺陷)。
|
||||
- **改动**:
|
||||
- 后端: `src/types.ts` 加 `UiPrefs {favourites[], collapsed{}}` + `Config.prefsStorePath`;`src/config.ts` 解析 `PREFS_STORE_PATH`(默认 `~/.web-terminal-prefs.json`,仿 push-store);新增 `src/http/prefs-store.ts`(`sanitizePrefs`/`loadPrefsStore`,镜像 subscription-store:全字段有界强制、缺失/损坏→空默认、不可变替换);`src/server.ts` 加 `GET /prefs`(只读无 Origin 守卫,同 /projects)+ `PUT /prefs`(状态变更→`requireAllowedOrigin` CSRF 守卫 + body sanitize)。
|
||||
- 前端: 新增 `public/prefs.ts`(`loadPrefs/savePrefs`:服务端为准、离线回退 localStorage 镜像、迁移旧 `proj-favs`);`public/projects.ts` 加纯函数 `groupProjects()`(depth-2 命名空间键 + `MIN_GROUP_SIZE=2` 阈值→单成员落 "Other" + 无命名空间收益时退化为单 flat 组 + 命名空间组按最近活跃排序 + 复制活跃项进置顶 "Active now" 组)、`displayLabel()`(组内卡片只显示前缀后的尾段,如 `Payment`)、`makeGroupHeader()`(caret+label+count+"N active" 徽标,可折叠/键盘可达,Active-now 不可折叠);`makeProjectCard` 加可选 `displayName` 形参;`renderGrid` 改为按组渲染(每组一个 `.proj-grid-cards` 子网格 + 折叠态来自内存 prefs + 搜索时强制展开命中组 + 保留 scroll);`mountProjects` 状态改用内存 `favs`/`collapsed`,`init()` 首次 `loadPrefs` 后再渲染,5s tick 只拉 projects 不重载 prefs(避免覆盖本地编辑);折叠/收藏改走 `persist()`。`style.css`:`.proj-grid` 改为纵向 section 栈 + 新 `.proj-grid-cards` 承接原 minmax 网格 + `.proj-group*` 组头(sticky、hover、focus-visible、`● N active` 绿色徽标)。
|
||||
- **未做(按用户选的最小范围)**: 密度/紧凑列表切换、group-by 控件、frecency、模糊搜索、手动文件夹——均为探索报告里的后续可选层。
|
||||
- **验证**: `npx tsc --noEmit` 干净;`npm run build:web` OK(bundle 665kb);`vitest run` 1227 passed(新增 `test/prefs-store.test.ts` 12 项 sanitize/load/persist + `test/projects-panel.test.ts` 加 21 项 groupProjects/displayLabel)。真浏览器(临时 server + 假点名 repo):Projects 页渲染出 `▼ Billo.Platform ·8`(卡片尾段 B2B.Api/Payment/…)、`▼ Billo.Infrastructure ·2`(EventBus/MongoDb)、`▼ Other ·12`;点组头折叠 → live DOM `collapsed`+0 卡、`PUT /prefs` 落 `{"Billo.Platform":true}`;**整页 reload 后仍折叠(从服务端 /prefs 载回)**=跨设备持久化打通。控制台 503/404 为既有(无 VAPID 的 push + 缺图标),非本次改动;/prefs、/projects、/config/ui 均 200。
|
||||
- **决策 / 偏离 PLAN**: 无既有 PLAN 覆盖此增量;探索用 4 个并行 general-purpose agent(用户明确"用多 agent 探索")。`getFavs/saveFavs` 仍导出(旧测试引用)但 mountProjects 不再调用,由 `public/prefs.ts` 取代。
|
||||
- **遗留 / 待办**: ① 两个同名 depth-2 repo(如两个 `Billo.Customer`)会成一个以自身命名的组、卡片显示全名(displayLabel 无尾段)——功能正确、观感略冗余,可接受。② 5s tick 不重载 prefs → 另一设备的改动要下次进页才见(单用户可接受)。③ 后续如上手动文件夹/密度切换,持久化接口已就绪(prefs-store 可扩字段)。
|
||||
- **commit**: (待提交)
|
||||
|
||||
### 2026-06-30 · v0.7 Walk-away Workbench — 多 agent 并行实现 (Band A+B)
|
||||
|
||||
- **状态**: `[x]` BUILT + 全量门禁绿(详细验收/打磨待续)。双 typecheck 干净 · `build:web` OK · 全量 vitest + 覆盖率 91.37/84.06/92.18/93.37(≥80×4)。
|
||||
|
||||
114
public/prefs.ts
Normal file
114
public/prefs.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* public/prefs.ts — client for the cross-device Projects UI prefs (GET/PUT /prefs).
|
||||
*
|
||||
* Favourites + group collapse-state live server-side (`~/.web-terminal-prefs.json`)
|
||||
* so they follow the user from laptop to phone — the whole point of this app.
|
||||
* localStorage is kept as an OFFLINE MIRROR: writes go there synchronously so a
|
||||
* reload works while the server is briefly unreachable, and a device that had the
|
||||
* legacy per-device favourites (`web-terminal:proj-favs`) is migrated on first load.
|
||||
*
|
||||
* loadPrefs/savePrefs are the only surface projects.ts needs. Both are defensive:
|
||||
* a failed fetch degrades to the local mirror rather than throwing.
|
||||
*/
|
||||
|
||||
import type { UiPrefs } from '../src/types.js'
|
||||
|
||||
const LS_KEY = 'web-terminal:proj-prefs'
|
||||
const LEGACY_FAV_KEY = 'web-terminal:proj-favs' // pre-v0.6 per-device favourites
|
||||
|
||||
export function emptyPrefs(): UiPrefs {
|
||||
return { favourites: [], collapsed: {} }
|
||||
}
|
||||
|
||||
/** Coerce untrusted data (localStorage or a /prefs response) into a safe UiPrefs. */
|
||||
export function sanitizePrefs(input: unknown): UiPrefs {
|
||||
if (input === null || typeof input !== 'object') return emptyPrefs()
|
||||
const o = input as Record<string, unknown>
|
||||
|
||||
const favourites: string[] = []
|
||||
const seen = new Set<string>()
|
||||
if (Array.isArray(o['favourites'])) {
|
||||
for (const v of o['favourites']) {
|
||||
if (typeof v !== 'string' || v.length === 0 || seen.has(v)) continue
|
||||
seen.add(v)
|
||||
favourites.push(v)
|
||||
}
|
||||
}
|
||||
|
||||
const collapsed: Record<string, boolean> = {}
|
||||
const c = o['collapsed']
|
||||
if (c !== null && typeof c === 'object' && !Array.isArray(c)) {
|
||||
for (const [k, v] of Object.entries(c as Record<string, unknown>)) {
|
||||
if (k.length > 0 && v === true) collapsed[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
return { favourites, collapsed }
|
||||
}
|
||||
|
||||
function isEmpty(p: UiPrefs): boolean {
|
||||
return p.favourites.length === 0 && Object.keys(p.collapsed).length === 0
|
||||
}
|
||||
|
||||
/** Read the local mirror, migrating legacy per-device favourites when present. */
|
||||
function readLocal(): UiPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY)
|
||||
if (raw !== null) return sanitizePrefs(JSON.parse(raw))
|
||||
} catch {
|
||||
// fall through to legacy / empty
|
||||
}
|
||||
try {
|
||||
const legacy = localStorage.getItem(LEGACY_FAV_KEY)
|
||||
if (legacy !== null) {
|
||||
const arr: unknown = JSON.parse(legacy)
|
||||
if (Array.isArray(arr)) return sanitizePrefs({ favourites: arr, collapsed: {} })
|
||||
}
|
||||
} catch {
|
||||
// ignore — corrupt legacy data
|
||||
}
|
||||
return emptyPrefs()
|
||||
}
|
||||
|
||||
function writeLocal(prefs: UiPrefs): void {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(prefs))
|
||||
} catch {
|
||||
// localStorage unavailable — run without an offline mirror
|
||||
}
|
||||
}
|
||||
|
||||
/** Load prefs: server is source of truth; fall back to the local mirror offline.
|
||||
* When the server is empty but this device has local/legacy prefs, seed the server. */
|
||||
export async function loadPrefs(): Promise<UiPrefs> {
|
||||
const local = readLocal()
|
||||
try {
|
||||
const res = await fetch('/prefs')
|
||||
if (res.ok) {
|
||||
const server = sanitizePrefs(await res.json())
|
||||
if (isEmpty(server) && !isEmpty(local)) {
|
||||
void savePrefs(local) // migrate this device's prefs up to the server
|
||||
return local
|
||||
}
|
||||
writeLocal(server) // refresh the offline mirror
|
||||
return server
|
||||
}
|
||||
} catch {
|
||||
// offline — use the local mirror
|
||||
}
|
||||
return local
|
||||
}
|
||||
|
||||
/** Persist prefs: write the local mirror synchronously, then PUT best-effort. */
|
||||
export async function savePrefs(prefs: UiPrefs): Promise<void> {
|
||||
writeLocal(prefs)
|
||||
try {
|
||||
await fetch('/prefs', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(prefs),
|
||||
})
|
||||
} catch {
|
||||
// offline — the local mirror keeps the change until the next successful PUT
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -974,13 +974,84 @@ body {
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Grid — mirrors .mg-grid layout */
|
||||
/* Outer container is now a vertical stack of collapsible namespace sections. */
|
||||
.proj-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
/* The actual card grid — one per group (and the flat fallback). Mirrors .mg-grid. */
|
||||
.proj-grid-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* ── Namespace group section ─────────────────────────────────────────────── */
|
||||
.proj-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.proj-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 4px 2px;
|
||||
position: sticky; /* keep the namespace visible while scrolling a long group */
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--bg);
|
||||
user-select: none;
|
||||
}
|
||||
.proj-group-head[role='button'] {
|
||||
cursor: pointer;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.proj-group-head[role='button']:hover {
|
||||
background: var(--surface-1);
|
||||
}
|
||||
.proj-group-head[role='button']:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.proj-group-caret {
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
width: 12px;
|
||||
flex: none;
|
||||
text-align: center;
|
||||
}
|
||||
.proj-group-label {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.proj-group-count {
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
}
|
||||
/* "N active" badge on a namespace/other header — never lose running work behind a caret. */
|
||||
.proj-group-active {
|
||||
color: var(--green);
|
||||
font-size: 11px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
/* "Active now" header — pinned, accent-tinted, non-collapsible. */
|
||||
.proj-group-head.proj-group-active .proj-group-caret {
|
||||
color: var(--green);
|
||||
font-size: 9px;
|
||||
}
|
||||
.proj-group-head.proj-group-active .proj-group-label {
|
||||
color: var(--accent);
|
||||
}
|
||||
.proj-group.collapsed {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Project card */
|
||||
.proj-card {
|
||||
display: flex;
|
||||
|
||||
@@ -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
|
||||
|
||||
102
test/prefs-store.test.ts
Normal file
102
test/prefs-store.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* test/prefs-store.test.ts — unit tests for the Projects UI prefs store.
|
||||
*
|
||||
* Covers sanitizePrefs (untrusted coercion + caps) and loadPrefsStore
|
||||
* (missing file → defaults, malformed → defaults, set/get/persist roundtrip).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { sanitizePrefs, emptyPrefs, loadPrefsStore } from '../src/http/prefs-store.js'
|
||||
|
||||
describe('sanitizePrefs', () => {
|
||||
it('returns empty prefs for non-object input', () => {
|
||||
expect(sanitizePrefs(null)).toEqual({ favourites: [], collapsed: {} })
|
||||
expect(sanitizePrefs(42)).toEqual(emptyPrefs())
|
||||
expect(sanitizePrefs('x')).toEqual(emptyPrefs())
|
||||
expect(sanitizePrefs([])).toEqual(emptyPrefs())
|
||||
})
|
||||
|
||||
it('keeps only string favourites and de-dups them', () => {
|
||||
const out = sanitizePrefs({ favourites: ['/a', '/a', '/b', 3, null, ''] })
|
||||
expect(out.favourites).toEqual(['/a', '/b'])
|
||||
})
|
||||
|
||||
it('keeps only collapsed entries whose value is exactly true', () => {
|
||||
const out = sanitizePrefs({ collapsed: { g1: true, g2: false, g3: 'yes', g4: 1 } })
|
||||
expect(out.collapsed).toEqual({ g1: true })
|
||||
})
|
||||
|
||||
it('ignores an array passed as collapsed', () => {
|
||||
expect(sanitizePrefs({ collapsed: ['g1'] }).collapsed).toEqual({})
|
||||
})
|
||||
|
||||
it('caps favourites at the maximum', () => {
|
||||
const many = Array.from({ length: 600 }, (_, i) => `/p${i}`)
|
||||
expect(sanitizePrefs({ favourites: many }).favourites).toHaveLength(500)
|
||||
})
|
||||
|
||||
it('drops over-long strings', () => {
|
||||
const long = 'x'.repeat(5000)
|
||||
expect(sanitizePrefs({ favourites: [long, '/ok'] }).favourites).toEqual(['/ok'])
|
||||
expect(sanitizePrefs({ collapsed: { [long]: true, ok: true } }).collapsed).toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadPrefsStore', () => {
|
||||
let dir: string
|
||||
let file: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'prefs-test-'))
|
||||
file = path.join(dir, 'prefs.json')
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('starts empty when the file is missing', () => {
|
||||
const store = loadPrefsStore(file)
|
||||
expect(store.get()).toEqual(emptyPrefs())
|
||||
})
|
||||
|
||||
it('starts empty when the file is malformed JSON', () => {
|
||||
writeFileSync(file, '{not json')
|
||||
expect(loadPrefsStore(file).get()).toEqual(emptyPrefs())
|
||||
})
|
||||
|
||||
it('loads and sanitizes existing prefs', () => {
|
||||
writeFileSync(file, JSON.stringify({ favourites: ['/a', '/a'], collapsed: { g: true, h: false } }))
|
||||
expect(loadPrefsStore(file).get()).toEqual({ favourites: ['/a'], collapsed: { g: true } })
|
||||
})
|
||||
|
||||
it('get() returns a fresh copy (callers cannot mutate internal state)', () => {
|
||||
const store = loadPrefsStore(file)
|
||||
store.set({ favourites: ['/a'], collapsed: {} })
|
||||
const a = store.get()
|
||||
a.favourites.push('/hacked')
|
||||
expect(store.get().favourites).toEqual(['/a'])
|
||||
})
|
||||
|
||||
it('set() sanitizes untrusted input', () => {
|
||||
const store = loadPrefsStore(file)
|
||||
store.set({ favourites: ['/a', 5, ''], collapsed: { g: true, bad: 0 } })
|
||||
expect(store.get()).toEqual({ favourites: ['/a'], collapsed: { g: true } })
|
||||
})
|
||||
|
||||
it('persist() writes sanitized prefs that reload identically', async () => {
|
||||
const store = loadPrefsStore(file)
|
||||
store.set({ favourites: ['/x', '/y'], collapsed: { grp: true } })
|
||||
await store.persist()
|
||||
expect(existsSync(file)).toBe(true)
|
||||
const reloaded = loadPrefsStore(file)
|
||||
expect(reloaded.get()).toEqual({ favourites: ['/x', '/y'], collapsed: { grp: true } })
|
||||
// sanity: file is valid JSON in the expected shape
|
||||
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({
|
||||
favourites: ['/x', '/y'],
|
||||
collapsed: { grp: true },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -30,6 +30,10 @@ const {
|
||||
normalizeProject,
|
||||
makeProjectCard,
|
||||
renderProjectDetail,
|
||||
groupProjects,
|
||||
displayLabel,
|
||||
ACTIVE_GROUP_KEY,
|
||||
OTHER_GROUP_KEY,
|
||||
} = await import('../public/projects.js')
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────────────────────── */
|
||||
@@ -509,3 +513,121 @@ describe('renderProjectDetail', () => {
|
||||
expect(h.onOpenProject).toHaveBeenCalledWith('/p/r', 'r', 'claude "/init"\r')
|
||||
})
|
||||
})
|
||||
|
||||
/* ── groupProjects / displayLabel (namespace grouping, v0.6) ─────────────────── */
|
||||
|
||||
const runningSession = { id: 's', status: 'working' as const, clientCount: 1, createdAt: 1, exited: false }
|
||||
|
||||
function proj(name: string, over: Partial<ProjectInfo> = {}): ProjectInfo {
|
||||
return makeProject({ name, path: `/repos/${name}`, ...over })
|
||||
}
|
||||
|
||||
describe('groupProjects', () => {
|
||||
const noFavs = new Set<string>()
|
||||
|
||||
it('groups repos sharing a two-segment namespace (≥2 members)', () => {
|
||||
const groups = groupProjects(
|
||||
[proj('Billo.Platform.Payment'), proj('Billo.Platform.Document')],
|
||||
noFavs,
|
||||
)
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0].kind).toBe('namespace')
|
||||
expect(groups[0].label).toBe('Billo.Platform')
|
||||
expect(groups[0].projects.map((p) => p.name)).toEqual([
|
||||
'Billo.Platform.Payment',
|
||||
'Billo.Platform.Document',
|
||||
])
|
||||
})
|
||||
|
||||
it('drops single-member namespaces and no-dot repos into "Other"', () => {
|
||||
const groups = groupProjects(
|
||||
[
|
||||
proj('Billo.Platform.Payment'),
|
||||
proj('Billo.Platform.Document'),
|
||||
proj('Billo.Customer'), // lone "Billo.Customer" namespace
|
||||
proj('web-terminal'), // no dot
|
||||
],
|
||||
noFavs,
|
||||
)
|
||||
const other = groups.find((g) => g.kind === 'other')
|
||||
expect(other).toBeDefined()
|
||||
expect(other!.projects.map((p) => p.name).sort()).toEqual(['Billo.Customer', 'web-terminal'])
|
||||
})
|
||||
|
||||
it('falls back to a single flat group when no namespace earns a section', () => {
|
||||
const groups = groupProjects([proj('web-terminal'), proj('graphrag'), proj('piano')], noFavs)
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0].kind).toBe('flat')
|
||||
expect(groups[0].projects).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('pins an "Active now" section first with projects that have a running session', () => {
|
||||
const groups = groupProjects(
|
||||
[
|
||||
proj('Billo.Platform.Payment', { sessions: [runningSession] }),
|
||||
proj('Billo.Platform.Document'),
|
||||
],
|
||||
noFavs,
|
||||
)
|
||||
expect(groups[0].kind).toBe('active')
|
||||
expect(groups[0].label).toBe('Active now')
|
||||
expect(groups[0].projects.map((p) => p.name)).toEqual(['Billo.Platform.Payment'])
|
||||
// still also present inside its namespace group (deliberate duplication)
|
||||
const ns = groups.find((g) => g.kind === 'namespace')!
|
||||
expect(ns.projects.map((p) => p.name)).toContain('Billo.Platform.Payment')
|
||||
expect(ns.activeCount).toBe(1)
|
||||
})
|
||||
|
||||
it('orders namespace sections by their most-recently-active member', () => {
|
||||
const groups = groupProjects(
|
||||
[
|
||||
proj('Old.Alpha.a', { lastActiveMs: 10 }),
|
||||
proj('Old.Alpha.b', { lastActiveMs: 20 }),
|
||||
proj('New.Beta.a', { lastActiveMs: 900 }),
|
||||
proj('New.Beta.b', { lastActiveMs: 100 }),
|
||||
],
|
||||
noFavs,
|
||||
)
|
||||
const ns = groups.filter((g) => g.kind === 'namespace').map((g) => g.label)
|
||||
expect(ns).toEqual(['New.Beta', 'Old.Alpha'])
|
||||
})
|
||||
|
||||
it('keeps "Other" last', () => {
|
||||
const groups = groupProjects(
|
||||
[proj('Billo.Platform.Payment'), proj('Billo.Platform.Document'), proj('loose')],
|
||||
noFavs,
|
||||
)
|
||||
expect(groups[groups.length - 1].kind).toBe('other')
|
||||
})
|
||||
|
||||
it('merges namespaces case-insensitively', () => {
|
||||
const groups = groupProjects([proj('Billo.Platform.a'), proj('billo.platform.b')], noFavs)
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0].projects).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const input = [proj('A.b.c'), proj('A.b.d')]
|
||||
const copy = [...input]
|
||||
groupProjects(input, noFavs)
|
||||
expect(input).toEqual(copy)
|
||||
})
|
||||
})
|
||||
|
||||
describe('displayLabel', () => {
|
||||
it('strips the namespace prefix inside a namespace group', () => {
|
||||
expect(displayLabel('Billo.Platform.Payment', 'Billo.Platform')).toBe('Payment')
|
||||
expect(displayLabel('Billo.Platform.Shared.DomainEvents', 'Billo.Platform')).toBe(
|
||||
'Shared.DomainEvents',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the full name in the active and other sections', () => {
|
||||
expect(displayLabel('Billo.Platform.Payment', ACTIVE_GROUP_KEY)).toBe('Billo.Platform.Payment')
|
||||
expect(displayLabel('web-terminal', OTHER_GROUP_KEY)).toBe('web-terminal')
|
||||
})
|
||||
|
||||
it('is case-insensitive on the prefix but preserves the tail casing', () => {
|
||||
expect(displayLabel('Billo.Platform.Payment', 'billo.platform')).toBe('Payment')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user