diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 7d2a63f..8be7c5e 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -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**: ======================================================= --> +### 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)。 diff --git a/public/prefs.ts b/public/prefs.ts new file mode 100644 index 0000000..29694f1 --- /dev/null +++ b/public/prefs.ts @@ -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 + + const favourites: string[] = [] + const seen = new Set() + 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 = {} + const c = o['collapsed'] + if (c !== null && typeof c === 'object' && !Array.isArray(c)) { + for (const [k, v] of Object.entries(c as Record)) { + 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 { + 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 { + 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 + } +} diff --git a/public/projects.ts b/public/projects.ts index a4586f7..54b82c7 100644 Binary files a/public/projects.ts and b/public/projects.ts differ diff --git a/public/style.css b/public/style.css index 751aba3..5c6d186 100644 --- a/public/style.css +++ b/public/style.css @@ -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; diff --git a/src/config.ts b/src/config.ts index bf36218..aa4f10f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, diff --git a/src/http/prefs-store.ts b/src/http/prefs-store.ts new file mode 100644 index 0000000..0f92c64 --- /dev/null +++ b/src/http/prefs-store.ts @@ -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; + + const favSource = Array.isArray(o['favourites']) ? o['favourites'] : []; + const favourites: string[] = []; + const seen = new Set(); + 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 = {}; + 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)) { + 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; +} + +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 { + try { + await writeFile(filePath, JSON.stringify(prefs, null, 2)); + } catch (err) { + console.error('prefs-store: persist failed', err); + } + }, + }; +} diff --git a/src/server.ts b/src/server.ts index b1ede86..cfa597e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 } { 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 } { } }) + // 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). diff --git a/src/types.ts b/src/types.ts index 8130c9b..bedbfeb 100644 --- a/src/types.ts +++ b/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; // namespace group-key → collapsed? (true = hidden) +} + /* ── GET /config/ui (review #4) ── */ /** Client-readable UI configuration (GET /config/ui). `allowAutoMode` mirrors diff --git a/test/prefs-store.test.ts b/test/prefs-store.test.ts new file mode 100644 index 0000000..8b75132 --- /dev/null +++ b/test/prefs-store.test.ts @@ -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 }, + }) + }) +}) diff --git a/test/projects-panel.test.ts b/test/projects-panel.test.ts index 7827290..95ae75a 100644 --- a/test/projects-panel.test.ts +++ b/test/projects-panel.test.ts @@ -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 { + return makeProject({ name, path: `/repos/${name}`, ...over }) +} + +describe('groupProjects', () => { + const noFavs = new Set() + + 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') + }) +})