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:
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;
|
||||
|
||||
Reference in New Issue
Block a user