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:
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