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

@@ -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 任务完成并验证**(P1P6,多 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 页扁平网格太乱(50100 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
View 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.

View File

@@ -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;

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

102
test/prefs-store.test.ts Normal file
View 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 },
})
})
})

View File

@@ -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')
})
})