diff --git a/claude-notes/instructions/hub-client-storage.md b/claude-notes/instructions/hub-client-storage.md index b6882921b..cfd08351a 100644 --- a/claude-notes/instructions/hub-client-storage.md +++ b/claude-notes/instructions/hub-client-storage.md @@ -121,10 +121,24 @@ export interface NewStoreEntry { | Store | Key | Purpose | |-------|-----|---------| -| `projects` | `id` (UUID) | Project connection info (sync server, index doc ID) | +| `projects` | `id` (UUID) | Legacy project connection info (sync server, index doc ID). Kept as a safety net / migration source; superseded by the synced project-set document. | +| `projectSet` | `key` | Pointers to the synced Automerge document(s). Holds the legacy singleton `'projectSet'` pointer **and** the `'collections'` pointer array (see Migration History v4/v5). | | `userSettings` | `key` (singleton: `'identity'`) | User identity for presence features | | `_meta` | `key` (singleton: `'schema'`) | Schema version and migration history | +## Migration History + +| Schema | DB ver | Kind | What it does | +|-------:|-------:|------|--------------| +| v2 | 2 | structural + transform | Add `_meta` and `userSettings` stores; seed a default user identity. | +| v3 | 3 | no-op | Formerly a `sassCache` store; caching moved to the `quarto-cache` DB. Kept so existing v3 databases don't mismatch. | +| v4 | 4 | structural | Add the `projectSet` store. The project list moves from the `projects` store into a synced Automerge **ProjectSetDocument**; IndexedDB keeps only a singleton `'projectSet'` pointer `{ projectSetDocId, syncServer }` to it. | +| v5 | 4 | transform-only | **Collections.** The root goes from a single project set to an array of collections (each collection is its own ProjectSetDocument). The `'projectSet'` singleton pointer is converted into a one-element `'collections'` array (`{ key:'collections', collections:[{ projectSetDocId, syncServer }] }`), stored in the same `projectSet` store. The legacy singleton is **retained untouched** as a safety net. See `migratePointerToCollections` in `migrations.ts` and the plan `claude-notes/plans/2026-07-10-collections-as-project-sets.md`. | + +**Note on v5 (transform-only):** it bumps `CURRENT_SCHEMA_VERSION` (5) but **not** `CURRENT_DB_VERSION` (stays 4), because it adds no store/index — it only rewrites a record. The migration runner keys off the stored schema version, so it applies the v5 `transform` even though the IndexedDB version is unchanged. `getCollectionPointers` in `projectSetStorage.ts` also self-heals (runs the same conversion lazily) as defense in depth. Tests: `migrations.test.ts` and the collection-pointer suite in `projectSetStorage.test.ts`. + +**Second migration (app-level, not IndexedDB):** legacy per-browser "collections" that lived in `localStorage` under `qh-collections-v1` (the pre-synced exploration) are converted into real synced collection documents on first load by `migrateLocalCollections` in `hooks/useCollectionSets.ts`; the original JSON is preserved under `qh-collections-v1-migrated` and never re-imported. + ## Export/Import Format The `exportData()` function produces JSON with schema version for forward compatibility: diff --git a/claude-notes/plans/2026-07-10-collections-as-project-sets.md b/claude-notes/plans/2026-07-10-collections-as-project-sets.md new file mode 100644 index 000000000..96da43788 --- /dev/null +++ b/claude-notes/plans/2026-07-10-collections-as-project-sets.md @@ -0,0 +1,125 @@ +# Collections as project sets + +## Overview + +Re-founder the projects-home "collections" (branch `explore/projects-collections-ui`) +on real synced documents, per the 2026-07-09 design discussion (Andrew, Carlos, +Elliot). Today a collection is a localStorage grouping of indexDocIds inside a +single ProjectSetDocument world; membership, sharing, and invites are mock. +After this change: + +- **Each collection IS a ProjectSetDocument** (same schema, plus an optional + `name`). We reuse all existing set logic: entries, dedupe keys, sync, + link-another-browser. +- **The browser's root pointer becomes an array**: IndexedDB goes from one + `{projectSetDocId, syncServer}` pointer to a list of collection pointers. + "The root of all your information" is now a set of project sets. +- **Sharing a collection = sharing its doc id**, exactly like today's + link-project-set flow. The join-collection invite flow stops being theater: + joining appends the collection's doc id to your array. +- **Real contributor identities** come from each project index doc's + `identities` map (per Carlos: the blessed, self-populating source), not + presence-at-open. + +Everything about individual projects (index docs, files, share links) is +untouched. "Minimum blast surface." + +## Checklist + +### Phase 1 — schema + tests +- [x] `ProjectSetDocument` gains optional `name?: string` (collection display + name) with helper `setProjectSetName` + tests +- [x] New IDB shape: `CollectionsPointer { key: 'collections', collections: + Array<{ projectSetDocId, syncServer }> }` in storage types +- [x] IDB migration 4→5 (structural no-op, transform converts the singleton + `projectSet` pointer into a one-element `collections` array; old pointer + kept as safety net, never deleted by the migration) +- [x] Migration tests: fresh install, existing pointer, re-run idempotence + +### Phase 2 — service layer +- [x] `projectSetService` → manages a MAP of doc handles keyed by docId + (connect-all on startup; add/remove collection connections at runtime) +- [x] Operations gain a collection-doc parameter: addProject(collectionId, + entry), removeProject, updateSummary, touch, rename collection +- [x] Create-collection (new empty set doc), subscribe-collection (existing + doc id), unsubscribe (drop from array; doc untouched — "leave") +- [x] `useProjectSet` → `useCollectionSets`: exposes + `Array<{ docId, name, entries, syncServer }>` + actions + +### Phase 3 — localStorage collections migration +- [x] On first load with the new code: for each entry in `qh-collections-v1`, + create a new collection doc named after it, copy the matching entries + from the personal set into it, append to the pointer array +- [x] The original set becomes the personal root collection (name it + "My projects" when the name field is empty; user-editable) +- [x] Mark `qh-collections-v1` migrated (rename key, don't delete) — one-way, + idempotent, safe to interrupt (commit point = pointer-array write) + +### Phase 4 — UI rewiring +- [x] ProjectsHome reads collections from the hook instead of localStorage; + "Everything else" = personal-root entries not present in any other + collection (display-level; the root stays a superset so nothing is + ever lost) +- [x] Move keeps current semantics (remove+add across docs); new "Add to + collection" menu item adds without removing (multi-membership is now + natural — entries in several sets) +- [x] Share/members popover backed by the real doc: invite link carries the + collection doc id + server only (entries no longer inlined in the URL) +- [x] JoinCollectionLanding: real subscribe (append docId to pointer array), + auto-create personal root for fresh browsers as today +- [x] Facepiles/peek contributors read from project index-doc `identities` + (one-shot read piggybacked on the existing summary write; peek refresh + also picks them up) + +### Phase 5 — verification +- [x] `npm run build:all` + full `npm run test:ci` +- [x] Manual: fresh browser, upgraded browser (with and without localStorage + collections), two-browser share/join round trip, debug.html inspection + of resulting docs + +## Details and open questions + +**Everything-else semantics.** Keeping the personal root as a superset of all +your projects (and filtering the home display) means deleting a shared +collection can never orphan a project you've opened, matching "no action +deletes entries for people who are part of a collection." Alternative — root +holds only unfiled projects — is cleaner data but loses the safety property. +Recommend superset. **Flag for Carlos.** + +**Summaries per entry.** The peek `summary` cache rides on entries, so a +project in three collections has three copies. Acceptable duplication for now; +revisit when identities move to index docs (Phase 4 reduces what summary needs +to carry). + +**Members display.** A shared collection's facepile can be derived from the +union of its projects' index-doc identities, replacing mock members. A +dedicated `members` map on the collection doc (self-reported on join) is a +possible follow-on, not required for this plan. + +**History viewer** (Carlos's ask: when were entries added/removed + restore) +is deliberately out of scope here; it layers on automerge history of the +collection doc afterward. Charlie's history view is prior art. + +**Race note.** The current app connects the singleton set on init and the +link-project-set route races it (observed 2026-07-09: link failed to displace +a fresh pointer). The multi-connection refactor should make "append a +collection" not contend with init at all, fixing that class of bug. + +## Local-prod verification (2026-07-14) + +Ran the full stack via `npm run local-prod` (hub binary + local sync +server on 127.0.0.1:8080, fresh `.local-prod-data`). Verified end to end: +fresh-browser project-set create; new project; new collection ("Team +docs") + drag a project in; invite link is doc-id-only (`server=`, no +`entries=`); a second browser joined via the invite and saw the synced +project; created "meeting agenda" targeted at Team docs on browser 2; +it appeared **live** on browser 1 (no reload). Hub log clean (only the +expected `auth_disabled` warnings under `--allow-insecure-auth`). + +Found + fixed one bug: the empty-state guard hid a subscribed collection +when the personal root was still empty (85108dde). + +Deferred/known: the People popover shows "only you" until members open +projects (contributors derive from cached summaries; a real members map +is the documented follow-on). + diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 5d56aba9d..068fee8c5 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -23,8 +23,42 @@ WASM rebuild is needed for a changelog-only edit. --> +### 2026-07-15 + +- [`3992e45d`](https://github.com/quarto-dev/q2/commits/3992e45d): UI exploration (branch only): opening a project on a local hub without sign-in now records you as a contributor, so collection avatar chips fill in during local testing instead of staying empty. +- [`3992e45d`](https://github.com/quarto-dev/q2/commits/3992e45d): UI exploration (branch only): the Automerge debugger now opens against an auth-disabled local hub and lists every collection as its own named synced document, instead of showing only a single project set. +- [`3992e45d`](https://github.com/quarto-dev/q2/commits/3992e45d): UI exploration (branch only): project cards fade their hover action icons (peek, fork, menu) beneath long titles so they no longer overlap the text, and the empty-state now reads "Everything is in a collection". + +### 2026-07-10 + +- [`a2391089`](https://github.com/quarto-dev/q2/commits/a2391089): UI exploration (branch only): projects can be duplicated from the projects home (project menu → Duplicate) — the copy gets all files, the name "(copy)", and the same collection as the original. +- [`02095b9a`](https://github.com/quarto-dev/q2/commits/02095b9a): UI exploration (branch only): the Peek preview no longer repeats Rename/Open/Remove (those are in the ⋯ menu) — it's a read-only preview with just a Refresh action. +- [`f0ce3a31`](https://github.com/quarto-dev/q2/commits/f0ce3a31): UI exploration (branch only): Peek is now a magnifying-glass icon next to the fork icon on each project — hover it to get a floating preview of the project (files, who's joined), instead of a menu item. +- [`214d62e4`](https://github.com/quarto-dev/q2/commits/214d62e4): UI exploration (branch only): author chips on shared collections now accumulate instead of overwriting — when a second person opens a shared project, both show as authors rather than the latest editor replacing the previous one. +- [`7a7dafdf`](https://github.com/quarto-dev/q2/commits/7a7dafdf): UI exploration (branch only): project cards no longer show placeholder "authors" — a new file starts with just you, and cards show the real people who've opened the project. +- [`645d5b53`](https://github.com/quarto-dev/q2/commits/645d5b53): UI exploration (branch only): opening a collection invite in a browser that already had a project no longer shows a confusing "migrate your project list" screen — it shows the invitation and quietly folds any existing project into your list. +- [`85108dde`](https://github.com/quarto-dev/q2/commits/85108dde): UI exploration (branch only): fixed a just-joined collection not appearing when your own project list was still empty. +- [`19c69ff3`](https://github.com/quarto-dev/q2/commits/19c69ff3): UI exploration (branch only): collections are now real synced documents rather than a per-browser list — they sync across your browsers, sharing one genuinely gives collaborators access, and a project can live in more than one collection ("Add to collection"). Existing collections migrate automatically on first load. +- [`a997d3fb`](https://github.com/quarto-dev/q2/commits/a997d3fb): UI exploration (branch only): duplicating a project now opens a dialog to rename the copy and pick its collection, and a fork button appears on hover on every project card and row. +- [`3e3e1495`](https://github.com/quarto-dev/q2/commits/3e3e1495): UI exploration (branch only): project cards now show real file counts, facepiles show the actual people seen on a project once you've opened it, and Peek is available on every project — opening instantly with the cached file list, plus a refresh that fetches details for projects never opened on this device. +- [`60abc1db`](https://github.com/quarto-dev/q2/commits/60abc1db): UI exploration (branch only): fixed "New collection" doing nothing in some browsers — creating and renaming collections now use proper dialogs, and remove/leave/delete confirmations are in-app dialogs instead of native popups. + +### 2026-07-09 + +- [`52c92f94`](https://github.com/quarto-dev/q2/commits/52c92f94): UI exploration (branch only): projects can be downloaded as a ZIP straight from the projects home (project menu → "Download as ZIP") without opening them; the avatar-menu backup entries are now named "Export/Import project list (JSON)". +- [`1c447eea`](https://github.com/quarto-dev/q2/commits/1c447eea): UI exploration (branch only): moving a project out of a collection that's shared with other people now asks for confirmation ("you're changing other people's view of this collection"), with a "Don't show this again" opt-out. Private collections are unaffected. +- [`dbd1bc44`](https://github.com/quarto-dev/q2/commits/dbd1bc44): UI exploration (branch only): every collection header now shows avatar chips — dimmed and just you while private, glyph plus member facepile when shared. Clicking the chips opens the members popover; for a private collection, copying the invite link is what turns sharing on. +- [`b379c89d`](https://github.com/quarto-dev/q2/commits/b379c89d): UI exploration (branch only): "shelf" is now called "collection" everywhere — menus, dialogs, and invite links. Existing arrangements carry over automatically. + +### 2026-07-08 + +- [`71b6c585`](https://github.com/quarto-dev/q2/commits/71b6c585): UI exploration (branch only): shelves can be explicitly shared — a people glyph and member facepile appear on shared shelves, opening a members popover with an invite link. Invite links carry the shelf's projects, so joining from another browser delivers them for real; a brand-new browser skips setup and is asked only for a name and cursor color. Membership itself is still local mock data. + ### 2026-07-07 +- [`0f338475`](https://github.com/quarto-dev/q2/commits/0f338475): UI exploration (branch only): project cards, shelf headers, and the Peek popover show collaborator facepiles (colored initial disks). Collaborators are mock data for now — real attribution needs automerge-history plumbing. +- [`1a37f9eb`](https://github.com/quarto-dev/q2/commits/1a37f9eb): UI exploration (branch only): projects can be dragged between shelves and the unshelved list, with the drop target highlighted while dragging. +- [`389af46b`](https://github.com/quarto-dev/q2/commits/389af46b): UI exploration (branch only): a shelves-based full-page projects home replaces the project-selector modal — search with Cmd+K, personal shelves to group projects, streamlined New/Connect/Import dialogs, and an avatar menu holding identity, cursor color, device linking, and JSON backup. An avatar-menu item switches back to the classic UI. - [`4af08ef3`](https://github.com/quarto-dev/q2/commits/4af08ef3): Dragging a text selection in the preview now opens the rich-text editor with that selection already active (so Bold/Italic/Link apply immediately); a selection dragged across multiple blocks no longer opens an editor, keeping the selection available for copying. - [`6cfc098f`](https://github.com/quarto-dev/q2/commits/6cfc098f): Raised the service-worker precache size limit so the (now about 37 MB) WASM module is cached again for offline use, and to stop continued WASM growth from breaking the production build. diff --git a/hub-client/src/App.css b/hub-client/src/App.css index 59cd6b24b..ea7cfb1cd 100644 --- a/hub-client/src/App.css +++ b/hub-client/src/App.css @@ -1 +1,20 @@ /* Minimal app styles - component-specific styles in their own CSS files */ + +/* Floating switch back to the collections UI exploration (explore/projects-collections-ui) */ +.ui-variant-toggle { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 2000; + padding: 8px 14px; + font-size: 12.5px; + font-weight: 600; + color: #ffffff; + background: var(--posit-teal); + border: none; + border-radius: 8px; + cursor: pointer; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2); +} + +.ui-variant-toggle:hover { filter: brightness(1.08); } diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index 08db74471..58c60932d 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -1,6 +1,8 @@ import { useState, useCallback, useEffect, useRef, lazy, Suspense } from 'react'; import type { ProjectEntry, FileEntry } from '@quarto/preview-renderer/types/project'; import ProjectSelector from './components/ProjectSelector'; +import ProjectsHome from './components/ProjectsHome'; +import JoinCollectionLanding from './components/JoinCollectionLanding'; import ProjectSetSetup from './components/ProjectSetSetup'; // Lazy-loaded dev harness — only fetched when navigating to #/dev/... routes. @@ -32,15 +34,15 @@ import { import type { ProjectFile } from '@quarto/preview-runtime'; import * as projectStorage from './services/projectStorage'; import { installDebugApi } from './services/debugApi'; -import { getUserIdentity, updateUserName } from './services/userSettings'; +import { getUserIdentity, updateUserName, actorIdFromUserId } from './services/userSettings'; import { useRouting } from './hooks/useRouting'; -import { useProjectSet } from './hooks/useProjectSet'; +import { useCollectionSets } from './hooks/useCollectionSets'; import { useAuth } from './hooks/useAuth'; import { useAuthProbe } from './hooks/useAuthProbe'; import { useExecutionChannel } from './hooks/useExecutionChannel'; import { resolveActorId as resolveActorIdRequest } from './services/authService'; import type { Route, ShareRoute, LinkProjectSetRoute } from './utils/routing'; -import { resolveSyncServerUrl } from './utils/routing'; +import { resolveSyncServerUrl, DEFAULT_SYNC_SERVER } from './utils/routing'; import './App.css'; /** @@ -102,6 +104,8 @@ function App() { const [showSaveToast, setShowSaveToast] = useState(false); const [screenName, setScreenName] = useState(); const [cursorColor, setCursorColor] = useState(); + // Local user id → stable Automerge actor when auth is disabled (local-prod). + const [localActorId, setLocalActorId] = useState(); const [identities, setIdentities] = useState>({}); // bd-sfet3264 (Phase 1C): IndexDocument V2 capture sidecar (path → CaptureRef). // Populated by the sync client's onCapturesChange; threaded down to the @@ -130,7 +134,7 @@ function App() { }); // Project set management (synced project list) - const [projectSetState, projectSetActions] = useProjectSet(); + const [projectSetState, projectSetActions] = useCollectionSets(); // Keep a ref so callbacks that intentionally omit projectSetState from their // dependency arrays (to avoid re-creation churn) can still read the latest status. @@ -141,8 +145,8 @@ function App() { // `resolveActorIdRequest` for the three-valued contract; callers abandon // the open only on `null` (auth failure), proceed on `string`/`undefined`. const resolveActorId = useCallback( - (indexDocId: string) => resolveActorIdRequest(indexDocId, AUTH_ENABLED, triggerRefresh), - [triggerRefresh], + (indexDocId: string) => resolveActorIdRequest(indexDocId, AUTH_ENABLED, triggerRefresh, localActorId), + [triggerRefresh, localActorId], ); // Capture auth error from redirect query param (once, before URL is cleaned). @@ -158,6 +162,7 @@ function App() { useEffect(() => { if (AUTH_ENABLED && authLoading) return; getUserIdentity().then(async (settings) => { + setLocalActorId(actorIdFromUserId(settings.userId)); if (auth?.name && settings.createdAt === settings.updatedAt) { const updated = await updateUserName(auth.name); setScreenName(updated.userName); @@ -173,6 +178,17 @@ function App() { // Track if we've done the initial URL-based navigation const initialLoadRef = useRef(false); + // UI exploration (explore/projects-collections-ui): choose between the new + // collections-based projects home and the classic modal selector. Persisted so + // UX testing can flip back and forth across reloads. + const [uiVariant, setUiVariant] = useState<'collections' | 'classic'>(() => + localStorage.getItem('qh-ui-variant') === 'classic' ? 'classic' : 'collections', + ); + const switchUiVariant = useCallback((variant: 'collections' | 'classic') => { + localStorage.setItem('qh-ui-variant', variant); + setUiVariant(variant); + }, []); + // URL-based routing const { route, @@ -181,6 +197,50 @@ function App() { navigateToFile, } = useRouting(); + // Invite-first onboarding: opening a collection invite establishes a + // personal root behind the landing screen, so the invitee only ever sees + // "join Team docs" — never the setup or migration prompts. A browser with a + // stray legacy project (needs-migration) migrates it silently into the new + // root (non-destructive: the legacy store is retained); a fresh browser + // (needs-setup) just creates an empty root. The landing shows "Connecting…" + // until the root is ready, then Join subscribes to the collection. + const inviteRootInitiatedRef = useRef(false); + useEffect(() => { + if (route.type !== 'join-collection' || inviteRootInitiatedRef.current) return; + if (projectSetState.status === 'needs-setup') { + inviteRootInitiatedRef.current = true; + projectSetActions.createProjectSet(DEFAULT_SYNC_SERVER); + } else if (projectSetState.status === 'needs-migration') { + // Fire once: migrateProjects resets to needs-migration on failure, so + // an unguarded effect would retry-loop against an unreachable server. + inviteRootInitiatedRef.current = true; + projectSetActions.migrateProjects(DEFAULT_SYNC_SERVER); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [route.type, projectSetState.status]); + + // Denormalize a peek summary onto this user's project-set entry while a + // project is open. Kept current as files and identities change (both are + // low-frequency: file add/remove/rename, presence join). This is a per-user + // cache of "the project as I last saw it" — list surfaces (cards, peek) + // read it so they never need a sync connection per project. + useEffect(() => { + if (!project || projectSetState.status !== 'connected' || files.length === 0) return; + // You are always a contributor; presence identities fill in everyone else + // (they can lag connection, so self is added explicitly). + const seen = screenName ? [{ name: screenName, color: cursorColor ?? '#447099' }] : []; + for (const i of Object.values(identities)) { + if (!seen.some((s) => s.name === i.name)) seen.push({ name: i.name, color: i.color }); + } + projectSetActions.updateProjectSummary(project.indexDocId, { + fileCount: files.length, + topFiles: files.slice(0, 5).map((f) => f.path), + contributors: seen.slice(0, 6), + asOf: new Date().toISOString(), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [project, files, identities, screenName, cursorColor, projectSetState.status]); + // Live refs for the dev-only console debug API. The API itself // is installed once (per the gate below) and reads current state // through these refs, so it doesn't churn on every project / @@ -629,6 +689,21 @@ function App() { return ; } + // Collection invite landing (explore/projects-collections-ui). Always shown + // for an invite route, ahead of the setup/migration screens: the effect + // above establishes the personal root (creating or silently migrating) so + // the invitee only ever sees "join ", never onboarding prompts. + if (route.type === 'join-collection') { + return ( + navigateToProjectSelector({ replace: true })} + /> + ); + } + // Show project set setup/migration screen if needed if ( projectSetState.status === 'needs-setup' || @@ -667,25 +742,64 @@ function App() { return ( <> {!project ? ( - + uiVariant === 'collections' ? ( + switchUiVariant('classic')} + /> + ) : ( + <> + + + + ) ) : ( diff --git a/hub-client/src/components/JoinCollectionLanding.tsx b/hub-client/src/components/JoinCollectionLanding.tsx new file mode 100644 index 000000000..46930185d --- /dev/null +++ b/hub-client/src/components/JoinCollectionLanding.tsx @@ -0,0 +1,133 @@ +/** + * JoinCollectionLanding — invite-first onboarding for a shared collection. + * + * Rendered for #/join-collection/ links. A fresh browser never sees the + * setup screen: App auto-creates a personal root collection silently while + * this screen asks the one question that matters — who you are. Joining + * subscribes this browser to the collection document (appending its doc id + * to the collections pointer array); the collection's projects arrive by + * sync, shared for real. + */ + +import { useState, useEffect } from 'react'; +import type { CollectionsStatus } from '../hooks/useCollectionSets'; +import type { UserSettings } from '../services/storage/types'; +import * as userSettingsService from '../services/userSettings'; +import type { JoinCollectionRoute } from '../utils/routing'; +import { DEFAULT_SYNC_SERVER } from '../utils/routing'; +import './ProjectsHome.css'; + +const COLOR_PALETTE = [ + '#E91E63', '#9C27B0', '#3F51B5', '#2196F3', + '#00BCD4', '#009688', '#4CAF50', '#FF9800', + '#FF5722', '#795548', +]; + +function initialsFor(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return '?'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} + +interface Props { + route: JoinCollectionRoute; + status: CollectionsStatus; + /** Subscribe this browser to the collection document. */ + onSubscribe: (collectionDocId: string, syncServer: string) => Promise; + /** Navigate home once the join completes. */ + onDone: () => void; +} + +export default function JoinCollectionLanding({ route, status, onSubscribe, onDone }: Props) { + const [userSettings, setUserSettings] = useState(null); + const [name, setName] = useState(''); + const [color, setColor] = useState(COLOR_PALETTE[0]); + const [joining, setJoining] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + userSettingsService.getUserIdentity().then((s) => { + setUserSettings(s); + setName(s.userName); + setColor(s.userColor); + }).catch((err) => console.error('Failed to load identity:', err)); + }, []); + + const ready = status === 'connected'; + + const handleJoin = async () => { + if (!name.trim() || !ready || joining) return; + setJoining(true); + setError(null); + try { + if (userSettings && name.trim() !== userSettings.userName) { + await userSettingsService.updateUserName(name.trim()); + } + if (userSettings && color !== userSettings.userColor) { + await userSettingsService.updateUserColor(color); + } + await onSubscribe(route.collectionId, route.syncServer || DEFAULT_SYNC_SERVER); + onDone(); + } catch (err) { + console.error('Join failed:', err); + setError(err instanceof Error ? err.message : 'Could not join the collection.'); + } finally { + setJoining(false); + } + }; + + return ( +
+
+
+
COLLECTION INVITATION
+

+ {route.inviter} invited you to {route.collectionName} +

+

+ A shared collection of Quarto projects — its contents sync to you when you join. +

+ {error &&
{error}
} + +
+
How you'll appear to the team
+
+ + {initialsFor(name || '?')} + + setName(e.target.value)} + placeholder="Your name" + autoFocus + /> +
+
+ {COLOR_PALETTE.map((c) => ( +
+
+ +
+ +
+

+ Joining subscribes you to this collection — anyone with the link can join and + add or remove projects. +

+
+
+
+ ); +} diff --git a/hub-client/src/components/ProjectsHome.css b/hub-client/src/components/ProjectsHome.css new file mode 100644 index 000000000..b38538304 --- /dev/null +++ b/hub-client/src/components/ProjectsHome.css @@ -0,0 +1,1072 @@ +/* ProjectsHome — collections-based projects view (explore/projects-collections-ui). + Visual language from QH-ProjectManagement-July26.fig "Short term" section: + full-page surface, hairline borders, teal primary actions, navy text. + Colors route through theme.css tokens so dark mode keeps working. */ + +.projects-home { + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + background: var(--bg-modal); + color: var(--text-primary); + overflow-y: auto; + z-index: 1000; +} + +.projects-home .mono { + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.ph-loading, +.ph-connecting { + padding: 48px; + text-align: center; + color: var(--text-secondary); + font-size: 14px; +} + +.ph-flex-spacer { flex: 1; } + +/* ---- header bar ---- */ + +.ph-header { + display: flex; + align-items: center; + gap: 14px; + padding: 11px 22px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-modal); + position: sticky; + top: 0; + z-index: 40; +} + +.ph-logo { + display: flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + white-space: nowrap; +} + +.ph-search { + position: relative; + width: 264px; +} + +.ph-search input { + width: 100%; + box-sizing: border-box; + padding: 8px 40px 8px 13px; + font-size: 13px; + color: var(--text-primary); + background: var(--input-bg-alpha); + border: 1px solid var(--border-color); + border-radius: 7px; + outline: none; +} + +.ph-search input:focus { + border-color: var(--border-focus); + background: var(--bg-input); +} + +.ph-search-kbd { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + font-size: 10.5px; + color: var(--text-muted); + pointer-events: none; +} + +.ph-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +/* ---- buttons ---- */ + +.ph-btn { + font-size: 13px; + font-weight: 600; + padding: 8px 14px; + border-radius: 7px; + border: 1px solid transparent; + cursor: pointer; + background: none; + white-space: nowrap; +} + +.ph-btn.primary { + background: var(--posit-teal); + color: #ffffff; +} + +.ph-btn.primary:hover { filter: brightness(1.08); } +.ph-btn.primary:disabled { opacity: 0.5; cursor: default; } + +.ph-btn.outline { + border-color: var(--border-color); + color: var(--accent-secondary); +} + +.ph-btn.outline:hover { background: var(--input-bg-alpha); } +.ph-btn.outline:disabled { opacity: 0.5; cursor: default; } + +.ph-btn.danger { + background: #b3261e; + color: #ffffff; +} + +.ph-btn.danger:hover { filter: brightness(1.1); } + +.ph-btn.ghost-accent { + border: 1px solid var(--posit-teal); + color: var(--posit-teal); + font-size: 12.5px; + padding: 7px 14px; +} + +.ph-btn.ghost-accent:hover { background: rgba(65, 149, 153, 0.08); } + +.ph-btn.small { + font-size: 12px; + font-weight: 400; + padding: 5px 11px; + border-radius: 6px; + color: var(--text-primary); +} + +.ph-caret { font-size: 9px; } + +.ph-link { + background: none; + border: none; + padding: 0; + font-size: 11.5px; + font-weight: 600; + color: var(--accent-secondary); + cursor: pointer; +} + +.ph-link:hover { text-decoration: underline; } +.ph-link.muted { color: var(--text-secondary); font-weight: 400; } +.ph-link.danger { color: var(--error-text); } + +.ph-icon-btn { + background: none; + border: none; + padding: 2px 8px; + font-size: 15px; + color: var(--text-secondary); + cursor: pointer; + border-radius: 5px; + line-height: 1; +} + +.ph-icon-btn:hover { background: var(--input-bg-alpha); color: var(--text-primary); } + +.ph-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + border: none; + color: #ffffff; + font-size: 12px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.ph-avatar.big { + width: 38px; + height: 38px; + font-size: 14px; + cursor: default; + flex-shrink: 0; +} + +/* ---- menus ---- */ + +.qh-menu-anchor { position: relative; } + +.ph-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 250px; + background: var(--context-menu-bg); + border: 1px solid var(--context-menu-border); + border-radius: 9px; + box-shadow: 0 8px 28px var(--context-menu-shadow); + padding: 6px; + z-index: 60; + display: flex; + flex-direction: column; +} + +.ph-menu-right { left: auto; right: 0; } + +.ph-menu-label { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--text-secondary); + padding: 7px 11px 5px; +} + +.ph-menu-item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1px; + width: 100%; + box-sizing: border-box; + background: none; + border: none; + border-radius: 6px; + padding: 8px 11px; + font-size: 13px; + color: var(--text-primary); + text-align: left; + cursor: pointer; +} + +.ph-menu-item:hover { background: var(--context-menu-hover); } +.ph-menu-item.strong { font-weight: 600; } +.ph-menu-item.accent { color: var(--posit-teal); font-weight: 600; } +.ph-menu-item.danger { color: var(--error-text); } + +.ph-menu-item.two-line .strong { font-weight: 600; } + +.ph-menu-item.with-hint { + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.ph-menu-hint { font-size: 10.5px; color: var(--text-muted); } + +.ph-menu-subtext { + font-size: 11px; + font-weight: 400; + color: var(--text-secondary); +} + +.ph-menu-divider { + height: 1px; + background: var(--border-color); + margin: 5px 8px; +} + +.ph-submenu-parent { position: relative; padding: 0; } + +.ph-menu-item-inner { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + box-sizing: border-box; + background: none; + border: none; + border-radius: 6px; + padding: 8px 11px; + font-size: 13px; + color: var(--text-primary); + cursor: pointer; +} + +.ph-menu-item-inner:hover { background: var(--context-menu-hover); } +.ph-submenu-arrow { color: var(--text-secondary); } + +.ph-submenu { + top: -6px; + left: calc(100% + 4px); + min-width: 180px; +} + +/* ---- collections ---- */ + +.ph-main { + padding: 24px 30px 40px; + max-width: 1060px; + width: 100%; + margin: 0 auto; + box-sizing: border-box; + flex: 1; +} + +.ph-collection { margin-bottom: 26px; } + +.ph-collection-header { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 10px; +} + +.ph-collection-name { font-size: 13px; font-weight: 700; } +.ph-collection-count { font-size: 11.5px; color: var(--text-secondary); } + +.ph-collection-empty, +.ph-rest-empty { + font-size: 12.5px; + color: var(--text-secondary); + padding: 14px 2px; +} + +.ph-collection-row { + display: flex; + align-items: stretch; + gap: 8px; +} + +.ph-card-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + flex: 1; +} + +.ph-pager { + width: 22px; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + background: none; + border: none; + border-radius: 7px; + color: var(--text-secondary); + font-size: 16px; + cursor: pointer; +} + +.ph-pager:hover { background: var(--input-bg-alpha); color: var(--text-primary); } +.ph-pager-idle { cursor: default; } +.ph-pager-idle:hover { background: none; } +.ph-pager-pos { font-size: 9.5px; font-weight: 700; } + +.ph-card { + position: relative; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-modal); +} + +.ph-card:hover { + border-color: var(--posit-blue-light-3); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +} + +.ph-card-body { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + box-sizing: border-box; + padding: 13px 15px; + background: none; + border: none; + text-align: left; + cursor: pointer; +} + +.ph-card-name { + font-size: 13.5px; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + padding-right: 18px; +} + +.ph-card-name.unnamed, +.ph-row-name.unnamed { + font-style: italic; + font-weight: 600; + color: var(--text-secondary); +} + +.ph-card-meta { + font-size: 11.5px; + color: var(--text-secondary); +} + +.ph-card-actions { + position: absolute; + top: 8px; + right: 8px; + display: flex; + align-items: center; + gap: 1px; + opacity: 0; + /* Three icons (peek/fork/⋯) overlay the top-right corner on hover. Fade the + card background in beneath them so a long title reads cleanly instead of + colliding with the glyphs. The card background is unchanged on hover, so + the solid part is invisible and only masks the title's tail. */ + padding-left: 20px; + border-radius: 6px; + background: linear-gradient(to right, transparent, var(--bg-modal) 20px); +} + +.ph-card:hover .ph-card-actions, +.ph-card.peek-open .ph-card-actions { opacity: 1; } + +.ph-card-menu-btn { + background: none; + border: none; + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; + border-radius: 5px; + padding: 1px 6px; +} + +.ph-card-menu-btn:hover { background: var(--input-bg-alpha); color: var(--text-primary); } + +.ph-card-actions .ph-fork-btn, +.ph-card-actions .ph-peek-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + border-radius: 5px; + padding: 3px 5px 1px; +} + +.ph-fork-btn:hover { background: var(--input-bg-alpha); color: var(--posit-teal); } +.ph-fork-btn:disabled { opacity: 0.4; cursor: default; } + +/* Hover-to-peek: the magnifying-glass icon and its floating popover live in + this anchor so the pointer can travel from icon to popover without the + hover being lost. The anchor is a non-positioning wrapper (position: + static) so the popover anchors to the nearest positioned ancestor — the + card or row — and stays on-screen regardless of the icon's spot. */ +.ph-peek-anchor { display: inline-flex; align-items: center; } +.ph-peek-btn:hover { background: var(--input-bg-alpha); color: var(--posit-teal); } + +.ph-card .ph-menu { top: 34px; left: auto; right: 8px; } + +.ph-new-collection-row { margin: 2px 0 24px; } + +/* ---- facepiles (mock collaborators for the exploration) ---- */ + +.ph-facepile { + display: inline-flex; + align-items: center; +} + +.ph-face { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + border: 2px solid var(--bg-modal); + color: #ffffff; + font-weight: 700; + flex-shrink: 0; +} + +.ph-face + .ph-face { margin-left: -7px; } + +.ph-face.more { + background: var(--input-bg-alpha-hover); + color: var(--text-secondary); +} + +.ph-facepile.sm .ph-face { width: 18px; height: 18px; font-size: 7.5px; } +.ph-facepile.md .ph-face { width: 20px; height: 20px; font-size: 8px; } +.ph-facepile.lg .ph-face { width: 24px; height: 24px; font-size: 9px; } + +.ph-collection-header .ph-facepile { margin-left: 6px; align-self: center; } + +.ph-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; +} + +.ph-peek-people { + display: flex; + align-items: center; + gap: 8px; + padding: 2px 0 8px; +} + +.ph-peek-people-label { + font-size: 12px; + color: var(--text-primary); +} + +/* ---- collection sharing: people button, members popover, join landing ---- */ + +.ph-collection-people { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 8px; + padding: 2px 7px 2px 6px; + background: none; + border: none; + border-radius: 12px; + color: var(--text-secondary); + cursor: pointer; + align-self: center; +} + +.ph-collection-people:hover { + background: var(--input-bg-alpha); + color: var(--text-primary); +} + +/* Private collections show just your chip, dimmed until hover — sharing + stays visually distinct (glyph + full-strength facepile). */ +.ph-collection-people.private { opacity: 0.55; } +.ph-collection-people.private:hover { opacity: 1; } + +.ph-members { + left: 0; + right: auto; + top: calc(100% + 6px); + width: 340px; + padding: 10px; +} + +.ph-members-list { + display: flex; + flex-direction: column; + padding: 2px 4px 6px; +} + +.ph-member-row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 7px; + border-radius: 6px; +} + +.ph-member-row:hover { background: var(--bg-subtle); } + +.ph-member-row .ph-face.lg { + width: 26px; + height: 26px; + font-size: 10px; + border: none; +} + +.ph-member-name { + flex: 1; + font-size: 13px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ph-member-you { color: var(--text-secondary); } + +.ph-member-badge { + font-size: 10.5px; + font-weight: 700; + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 9px; + padding: 1px 8px; +} + +.ph-member-remove { + opacity: 0; + font-size: 11.5px; +} + +.ph-member-row:hover .ph-member-remove { opacity: 1; } + +.ph-members-invite { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 11px 2px; +} + +.ph-invite-url { + flex: 1; + font-size: 11px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ph-btn.primary.small-invite { + font-size: 12px; + padding: 6px 11px; + flex-shrink: 0; +} + +.ph-invite-note { + font-size: 11px; + color: var(--text-secondary); + padding: 8px 11px 6px; + line-height: 1.4; +} + +.ph-join { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 40px 20px; +} + +.ph-join-card { + width: 480px; + max-width: 100%; + background: var(--bg-modal); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 34px 38px; + box-shadow: var(--modal-shadow); +} + +.ph-join-kicker { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--posit-teal); + margin-bottom: 10px; +} + +.ph-join-card h1 { + font-size: 21px; + margin: 0 0 6px; + color: var(--text-primary); + line-height: 1.3; +} + +.ph-join-collection-name { color: var(--posit-teal); } + +.ph-join-sub { + font-size: 13.5px; + color: var(--text-secondary); + margin: 0 0 8px; +} + +.ph-join-projects { + margin: 0 0 10px; + padding-left: 20px; + font-size: 13px; + color: var(--text-primary); +} + +.ph-join-projects li { padding: 1.5px 0; } +.ph-join-projects li.muted { color: var(--text-secondary); } + +.ph-join-identity { margin-top: 18px; } + +.ph-join-identity-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 4px; +} + +.ph-join-identity-row .ph-input { flex: 1; } + +.ph-join-identity .ph-swatches { padding: 8px 0 0; } + +.ph-join-actions { margin-top: 20px; } + +.ph-join-actions .ph-btn { width: 100%; padding: 11px; font-size: 14px; } + +.ph-join-note { + font-size: 11.5px; + color: var(--text-secondary); + margin: 14px 0 0; + line-height: 1.45; +} + +/* ---- drag and drop ---- */ + +.ph-card[draggable="true"], +.ph-row[draggable="true"] { + cursor: grab; +} + +.ph-card.dragging, +.ph-row.dragging { + opacity: 0.4; +} + +.ph-collection.drop-target, +.ph-rest.drop-target { + outline: 2px dashed var(--posit-teal); + outline-offset: 8px; + border-radius: 6px; + background: rgba(65, 149, 153, 0.05); +} + +/* ---- everything else ---- */ + +.ph-rest-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.ph-rest-title { font-size: 13px; font-weight: 700; color: var(--text-primary); } +.ph-rest-count { font-size: 11.5px; color: var(--text-secondary); } + +.ph-rest-list { + border: 1px solid var(--border-color); + border-radius: 9px; + overflow: visible; +} + +.ph-row { + display: flex; + align-items: center; + gap: 14px; + padding: 0 12px 0 0; + border-bottom: 1px solid var(--border-color); +} + +.ph-row:last-child { border-bottom: none; } +.ph-row:hover { background: var(--bg-subtle); } + +.ph-row-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background: none; + border: none; + text-align: left; + padding: 13px 0 13px 14px; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + cursor: pointer; +} + +.ph-row-meta { + font-size: 11.5px; + color: var(--text-secondary); + white-space: nowrap; +} + +.ph-row .ph-menu { top: 38px; left: auto; right: 10px; } + +/* ---- peek popover ---- */ + +.ph-peek { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: auto; + width: 320px; + background: var(--context-menu-bg); + border: 1px solid var(--context-menu-border); + border-radius: 11px; + box-shadow: 0 10px 32px var(--context-menu-shadow); + padding: 17px 19px; + z-index: 70; + cursor: default; +} + +.ph-peek-header { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.03em; + color: var(--text-secondary); + margin-bottom: 10px; +} + +.ph-peek-row { + font-size: 12px; + color: var(--text-primary); + padding: 3px 0; +} + +.ph-peek-note { + font-size: 11.5px; + color: var(--text-secondary); + margin-top: 8px; + line-height: 1.4; +} + +.ph-peek-files { + padding: 4px 0 6px; +} + +.ph-peek-file { + font-size: 12px; + color: var(--text-primary); + padding: 2.5px 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ph-peek-file.more { + color: var(--text-secondary); + font-family: inherit; +} + +.ph-peek-divider { + height: 1px; + background: var(--border-color); + margin: 12px 0 10px; +} + +.ph-peek-actions { + display: flex; + align-items: center; + gap: 9px; +} + +.ph-peek-actions .ph-link.danger { margin-left: auto; } + +.ph-peek-footnote { + font-size: 11px; + color: var(--text-secondary); + margin-top: 10px; +} + +/* ---- avatar menu ---- */ + +.ph-avatar-menu { width: 320px; padding: 12px; } + +.ph-avatar-menu-id { + display: flex; + gap: 12px; + align-items: center; + padding: 5px 5px 12px; +} + +.ph-avatar-menu-who { min-width: 0; } + +.ph-avatar-menu-name { + display: flex; + align-items: baseline; + gap: 7px; + font-size: 14px; +} + +.ph-avatar-menu-mail { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; +} + +.ph-name-input { + font-size: 13px; + padding: 4px 8px; + border: 1px solid var(--border-focus); + border-radius: 6px; + background: var(--bg-input); + color: var(--text-primary); + outline: none; +} + +.ph-swatches { + display: flex; + gap: 6px; + padding: 6px 11px 10px; +} + +.ph-swatch { + width: 24px; + height: 24px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + padding: 0; +} + +.ph-swatch.selected { + outline: 2px solid var(--text-primary); + outline-offset: 2px; +} + +/* ---- dialogs ---- */ + +.ph-dialog-backdrop { + position: fixed; + inset: 0; + background: rgba(23, 50, 77, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.ph-dialog { + width: 520px; + max-width: calc(100vw - 48px); + background: var(--dialog-bg); + border-radius: 12px; + padding: 26px 30px; + box-shadow: var(--modal-shadow); + box-sizing: border-box; +} + +.ph-dialog.wide { width: 560px; } + +.ph-dialog h2 { + margin: 0 0 4px; + font-size: 19px; + color: var(--text-primary); +} + +.ph-dialog-hint { + margin: 0 0 6px; + font-size: 13px; + color: var(--text-secondary); +} + +.ph-field-label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + margin: 16px 0 6px; +} + +.ph-input { + width: 100%; + box-sizing: border-box; + padding: 12px 15px; + font-size: 14px; + color: var(--text-primary); + background: var(--bg-input); + border: 1px solid var(--dialog-input-border); + border-radius: 8px; + outline: none; +} + +.ph-input:focus { border-color: var(--border-focus); } +.ph-input.focus-accent:focus { border-color: var(--posit-teal); } + +select.ph-input { appearance: auto; } + +.ph-server-line { + font-size: 12px; + color: var(--text-secondary); + margin-top: 12px; +} + +.ph-server-line .ph-link { font-size: 12px; } + +.ph-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 22px; +} + +.ph-checkbox-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 16px; + font-size: 12.5px; + color: var(--text-secondary); + cursor: pointer; + user-select: none; +} + +.ph-checkbox-row input { accent-color: var(--posit-teal); } + +.ph-tabs { + display: flex; + background: var(--input-bg-alpha); + border-radius: 8px; + padding: 3px; + margin-top: 12px; +} + +.ph-tab { + flex: 1; + padding: 8px 0; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + background: none; + border: none; + border-radius: 6px; + cursor: pointer; +} + +.ph-tab.active { + background: var(--bg-modal); + color: var(--text-primary); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +/* ---- misc ---- */ + +.ph-error { + margin: 14px 30px 0; + padding: 10px 14px; + border-radius: 8px; + background: var(--error-bg-subtle); + color: var(--error-text); + font-size: 13px; + white-space: pre-wrap; +} + +.ph-error.inline { margin: 12px 0 0; } + +.ph-empty-state { + text-align: center; + padding: 90px 20px; +} + +.ph-empty-state h2 { font-size: 20px; margin: 0 0 8px; } +.ph-empty-state p { color: var(--text-secondary); font-size: 14px; margin: 0 0 22px; } + +.ph-empty-actions { + display: flex; + gap: 12px; + justify-content: center; +} + +.ph-footer { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 14px; + font-size: 10px; + color: var(--text-muted); +} + +.ph-footer-note { opacity: 0.8; } + +@media (max-width: 980px) { + .ph-card-grid { grid-template-columns: repeat(3, 1fr); } + .ph-search { width: 200px; } +} + +@media (max-width: 760px) { + .ph-card-grid { grid-template-columns: repeat(2, 1fr); } + .ph-logo span { display: none; } +} diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx new file mode 100644 index 000000000..40e5f6f77 --- /dev/null +++ b/hub-client/src/components/ProjectsHome.tsx @@ -0,0 +1,2084 @@ +/** + * ProjectsHome — full-page projects view (explore/projects-collections-ui). + * + * Implements the "Short term" design from QH-ProjectManagement-July26.fig: + * collections + streamlined entry, buildable on today's metadata. Replaces the + * ProjectSelector modal on this exploration branch: + * - header bar: logo, search (⌘K), Connect/Import ▾, + New ▾, avatar menu + * - personal collections with project cards (paged at 6+) + * - "Everything else" list with per-project ⋯ menu, Rename and Peek for + * unnamed projects + * - identity / cursor color / device linking / JSON backup relocated into + * the avatar menu; plumbing (doc IDs, wss) behind ⋯ → Copy + */ + +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useTheme } from './ThemeContext'; +import type { ProjectEntry } from '@quarto/preview-renderer/types/project'; +import type { ProjectSetEntry, ProjectSetEntrySummary } from '@quarto/quarto-automerge-schema'; +import type { CollectionsStatus as ProjectSetStatus } from '../hooks/useCollectionSets'; +import type { UserSettings } from '../services/storage/types'; +import * as projectStorage from '../services/projectStorage'; +import * as userSettingsService from '../services/userSettings'; +import { + getProjectChoices, + createProject as wasmCreateProject, + importProjectFromZip, + exportProjectAsZip, + connect, + disconnect, + getFileContent, + getBinaryFileContent, + isFileBinary, + type ProjectChoice, + type ProjectFile, +} from '@quarto/preview-runtime'; +import { + DEFAULT_SYNC_SERVER, + buildProjectSetLinkUrl, + buildShareableUrl, + buildFullUrl, + resolveSyncServerUrl, +} from '../utils/routing'; +import ShareDialog from './ShareDialog'; +import type { Face } from '../utils/facepile'; +import type { CollectionSnapshot } from '../services/projectSetService'; +import './ProjectsHome.css'; + +interface Props { + onSelectProject: (project: ProjectEntry, filePathOverride?: string) => void; + isConnecting?: boolean; + error?: string | null; + onProjectCreated?: (files: ProjectFile[], title: string, projectType: string, syncServer: string) => void; + onSignOut?: () => void; + authEmail?: string; + onScreenNameChange?: (name: string) => void; + onColorChange?: (color: string) => void; + projectSetDocId?: string | null; + projectSetSyncServer?: string | null; + projectSetStatus?: ProjectSetStatus; + projectSetEntries?: ProjectSetEntry[]; + onRemoveProjectFromSet?: (indexDocId: string) => void; + onTouchProject?: (indexDocId: string) => void; + onAddProjectToSet?: (entry: Omit) => void; + onRenameProject?: (indexDocId: string, description: string) => void; + /** Replace a project's cached peek summary (used by Peek's refresh). */ + onUpdateProjectSummary?: (indexDocId: string, summary: ProjectSetEntrySummary) => void; + /** All connected collections, root first (from useCollectionSets). */ + collections?: CollectionSnapshot[]; + onCreateCollection?: (name: string) => Promise; + onUnsubscribeCollection?: (collectionDocId: string) => Promise; + onRenameCollection?: (collectionDocId: string, name: string) => void; + onAddProjectToCollection?: (collectionDocId: string, entry: Omit) => void; + onRemoveProjectFromCollection?: (collectionDocId: string, indexDocId: string) => void; + onMoveProjectBetweenCollections?: (fromDocId: string, toDocId: string, indexDocId: string) => void; + onSwitchToClassicUi?: () => void; +} + +/** In-component view of a non-root collection (adapted from a snapshot). */ +interface CollectionView { + /** The collection's ProjectSetDocument id. */ + id: string; + name: string; + syncServer: string; + entries: ProjectSetEntry[]; + /** Entry keys (indexDocId without prefix), for membership checks. */ + projectIds: string[]; +} + +/** Unified view of a project regardless of source (synced set vs legacy IDB). */ +interface ProjectItem { + indexDocId: string; + syncServer: string; + description: string; + addedAt: string; + lastAccessed: string; + summary?: ProjectSetEntrySummary; +} + +const COLOR_PALETTE = [ + '#E91E63', '#9C27B0', '#3F51B5', '#2196F3', + '#00BCD4', '#009688', '#4CAF50', '#FF9800', + '#FF5722', '#795548', +]; + +const COLLECTION_PAGE_SIZE = 8; // two rows of four cards + +const UNNAMED_RE = /^Project \d{4}-\d{2}-\d{2}T/; + +/** Set to '1' once the user opts out of the shared-collection move warning. */ +const MOVE_WARNING_KEY = 'qh-collection-move-warning-dismissed'; + +/** + * Pending "add to collection on create": new-project flows don't know the + * project's doc id until the parent app finishes creating it, so the + * assignment is recorded by title and reconciled when the entry appears. + */ +const PENDING_ASSIGNMENT_KEY = 'qh-collection-pending-v1'; + +function setPendingCollectionAssignment(title: string, collectionId: string): void { + localStorage.setItem( + PENDING_ASSIGNMENT_KEY, + JSON.stringify({ title, collectionId, ts: Date.now() }), + ); +} + +/** Fork glyph for the duplicate affordance (three nodes, branch lines). */ +const forkIcon = ( + +); + +/** Magnifying glass for the hover-to-peek affordance. */ +const peekIcon = ( + +); + +/** Base64-encode without blowing the arg-spread limit on large files. */ +function toBase64(bytes: Uint8Array): string { + let binary = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); +} + +function isUnnamed(description: string): boolean { + return UNNAMED_RE.test(description); +} + +function initialsFor(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return '?'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} + +function shortId(indexDocId: string): string { + return indexDocId.replace(/^automerge:/, '').slice(0, 8) + '…'; +} + +function formatOpened(iso: string): string { + const then = new Date(iso); + if (isNaN(then.getTime())) return ''; + const now = new Date(); + const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + const dayDiff = Math.round((startOfDay(now) - startOfDay(then)) / 86_400_000); + if (dayDiff <= 0) return 'today'; + if (dayDiff === 1) return 'yesterday'; + if (dayDiff < 7) return then.toLocaleDateString(undefined, { weekday: 'short' }); + if (then.getFullYear() === now.getFullYear()) { + return then.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } + return then.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function serverHost(syncServer: string): string { + try { + return new URL(syncServer).host; + } catch { + return syncServer; + } +} + +/** + * Parse the Connect input: accepts a share link + * (…#/share/?server=…&name=…) or a bare bs58 document ID. + */ +function parseConnectInput(input: string): { docId: string; server?: string; name?: string } | null { + const trimmed = input.trim(); + if (!trimmed) return null; + const shareMatch = trimmed.match(/#\/share\/([^?\s]+)(?:\?([^\s]*))?/); + if (shareMatch) { + const params = new URLSearchParams(shareMatch[2] ?? ''); + return { + docId: shareMatch[1], + server: params.get('server') ?? undefined, + name: params.get('name') ?? undefined, + }; + } + if (/^(automerge:)?[1-9A-HJ-NP-Za-km-z]{10,}$/.test(trimmed)) { + return { docId: trimmed }; + } + return null; +} + +type SortOrder = 'newest' | 'oldest' | 'name'; + +export default function ProjectsHome({ + onSelectProject, + isConnecting, + error: connectionError, + onProjectCreated, + onSignOut, + authEmail, + onScreenNameChange, + onColorChange, + projectSetDocId, + projectSetSyncServer, + projectSetStatus, + projectSetEntries, + onRemoveProjectFromSet, + onTouchProject, + onAddProjectToSet, + onRenameProject, + onUpdateProjectSummary, + collections: collectionsProp, + onCreateCollection, + onUnsubscribeCollection, + onRenameCollection, + onAddProjectToCollection, + onRemoveProjectFromCollection, + onMoveProjectBetweenCollections, + onSwitchToClassicUi, +}: Props) { + const projectSetConnecting = projectSetStatus === 'loading' || projectSetStatus === 'connecting'; + const useProjectSet = !!projectSetEntries || projectSetConnecting; + + // Legacy IDB fallback (only when no project set is in play) + const [legacyProjects, setLegacyProjects] = useState([]); + const [loading, setLoading] = useState(true); + + const [search, setSearch] = useState(''); + const searchRef = useRef(null); + + // Menus / popovers. openMenu identifies the ⋯ menu by project id or + // `collection:`; submenus and the peek popover are tracked separately. + const [openMenu, setOpenMenu] = useState(null); + const [moveSubmenuOpen, setMoveSubmenuOpen] = useState(false); + const [newMenuOpen, setNewMenuOpen] = useState(false); + const [avatarMenuOpen, setAvatarMenuOpen] = useState(false); + const [peekFor, setPeekFor] = useState(null); + // Peek is a hover card: open on hover of the magnifying-glass icon, and + // stay open while the pointer is over the icon or the popover. A short + // close delay (cancelled by re-entering) bridges the gap between them and + // lets the pointer reach the popover's action buttons. + const peekTimerRef = useRef(null); + const openPeekHover = useCallback((indexDocId: string) => { + if (peekTimerRef.current) { window.clearTimeout(peekTimerRef.current); peekTimerRef.current = null; } + setPeekFor(indexDocId); + }, []); + const closePeekHoverSoon = useCallback(() => { + if (peekTimerRef.current) window.clearTimeout(peekTimerRef.current); + peekTimerRef.current = window.setTimeout(() => setPeekFor(null), 180); + }, []); + const [peekRefreshing, setPeekRefreshing] = useState(false); + const [copied, setCopied] = useState(null); + + // Dialogs + const [newDialogChoice, setNewDialogChoice] = useState(null); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [showLinkDialog, setShowLinkDialog] = useState(false); + const [renameFor, setRenameFor] = useState(null); + const [renameValue, setRenameValue] = useState(''); + + const [formError, setFormError] = useState(null); + + // + New dialog state + const [newTitle, setNewTitle] = useState(''); + const [newCollectionId, setNewCollectionId] = useState(''); + const [newServer, setNewServer] = useState(DEFAULT_SYNC_SERVER); + const [showServerField, setShowServerField] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [projectChoices, setProjectChoices] = useState([]); + + // Connect / Import dialog state + const [addTab, setAddTab] = useState<'connect' | 'import'>('connect'); + const [connectInput, setConnectInput] = useState(''); + const [connectServer, setConnectServer] = useState(DEFAULT_SYNC_SERVER); + const [showConnectServer, setShowConnectServer] = useState(false); + const [connectName, setConnectName] = useState(''); + const [importFile, setImportFile] = useState(null); + const [importTitle, setImportTitle] = useState(''); + const [isImporting, setIsImporting] = useState(false); + + // Identity + const [userSettings, setUserSettings] = useState(null); + const [editingName, setEditingName] = useState(false); + const [editNameValue, setEditNameValue] = useState(''); + + const { colorScheme, cycleColorScheme } = useTheme(); + + // ---- collections adapter ---- + // Collections are real ProjectSetDocuments delivered via props (root + // first). The root is the personal superset; the sections on this page + // render the non-root collections, and "Everything else" is computed as + // root entries not present in any of them. + const collectionViews: CollectionView[] = useMemo( + () => + (collectionsProp ?? []) + .filter((c) => !c.isRoot) + .map((c) => ({ + id: c.docId, + name: c.name ?? 'Untitled collection', + syncServer: c.syncServer, + entries: c.entries, + projectIds: c.entries.map((e) => e.indexDocId.replace(/^automerge:/, '')), + })), + [collectionsProp], + ); + const collections = collectionViews; + // Which collection's members-and-invite popover is open + const [membersFor, setMembersFor] = useState(null); + // Pending move out of a shared collection, awaiting the user's OK + const [pendingMove, setPendingMove] = useState<{ + indexDocId: string; + name: string; + fromName: string; + othersCount: number; + target: string | null; + } | null>(null); + const [moveWarnChecked, setMoveWarnChecked] = useState(false); + // In-app replacements for native prompt()/confirm(): embedded browsers can + // throw on prompt() and auto-accept confirm(), so collection naming and + // destructive confirmations get real dialogs. + const [newCollectionDialog, setNewCollectionDialog] = useState(null); + const [newCollectionName, setNewCollectionName] = useState(''); + const [renameCollectionTarget, setRenameCollectionTarget] = useState(null); + const [renameCollectionValue, setRenameCollectionValue] = useState(''); + const [confirmState, setConfirmState] = useState void; + }>(null); + const [collectionPages, setCollectionPages] = useState>({}); + // Drag-and-drop between collections and the unshelved list. dropTarget is a + // collection id or 'unshelved'. + const [draggingId, setDraggingId] = useState(null); + const [dropTarget, setDropTarget] = useState(null); + const [sortOrder, setSortOrder] = useState('newest'); + const [sortMenuOpen, setSortMenuOpen] = useState(false); + + const projectSetLinkUrl = projectSetDocId && projectSetSyncServer + ? buildProjectSetLinkUrl(projectSetDocId, projectSetSyncServer) + : undefined; + + // ---- data loading ---- + + useEffect(() => { + if (useProjectSet) { + setLoading(false); + return; + } + let cancelled = false; + (async () => { + try { + const entries = await projectStorage.listProjects(); + if (!cancelled) setLegacyProjects(entries); + } catch (err) { + console.error('Failed to load projects:', err); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [useProjectSet]); + + useEffect(() => { + userSettingsService.getUserIdentity().then(setUserSettings).catch((err) => { + console.error('Failed to load user settings:', err); + }); + getProjectChoices().then(setProjectChoices).catch((err) => { + console.error('Failed to load project choices:', err); + }); + }, []); + + const items: ProjectItem[] = useMemo(() => { + if (useProjectSet) { + return (projectSetEntries ?? []).map((e) => ({ + indexDocId: e.indexDocId, + syncServer: e.syncServer, + description: e.description, + addedAt: e.addedAt, + lastAccessed: e.lastAccessed, + summary: e.summary, + })); + } + return legacyProjects.map((p) => ({ + indexDocId: p.indexDocId, + syncServer: p.syncServer, + description: p.description, + addedAt: p.createdAt, + lastAccessed: p.lastAccessed, + })); + }, [useProjectSet, projectSetEntries, legacyProjects]); + + const byId = useMemo(() => { + const m = new Map(); + for (const it of items) m.set(it.indexDocId, it); + return m; + }, [items]); + + // Apply any pending "add to collection on create" once the new entry appears. + useEffect(() => { + const raw = localStorage.getItem(PENDING_ASSIGNMENT_KEY); + if (!raw) return; + try { + const pending: { title: string; collectionId: string; ts: number } = JSON.parse(raw); + if (Date.now() - pending.ts > 24 * 3600 * 1000) { + localStorage.removeItem(PENDING_ASSIGNMENT_KEY); + return; + } + const match = items.find( + (e) => e.description === pending.title && new Date(e.addedAt).getTime() >= pending.ts - 60_000, + ); + if (match) { + onAddProjectToCollection?.(pending.collectionId, { + indexDocId: match.indexDocId, + syncServer: match.syncServer, + description: match.description, + }); + localStorage.removeItem(PENDING_ASSIGNMENT_KEY); + } + } catch { + localStorage.removeItem(PENDING_ASSIGNMENT_KEY); + } + }, [items, onAddProjectToCollection]); + + // ---- global listeners ---- + + const closeAllMenus = useCallback(() => { + setOpenMenu(null); + setMoveSubmenuOpen(false); + setNewMenuOpen(false); + setAvatarMenuOpen(false); + setPeekFor(null); + setSortMenuOpen(false); + setMembersFor(null); + }, []); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + searchRef.current?.focus(); + } + if (e.key === 'Escape') closeAllMenus(); + }; + const onDown = (e: MouseEvent) => { + if (!(e.target as HTMLElement).closest('.qh-menu-anchor, .qh-peek')) { + closeAllMenus(); + } + }; + window.addEventListener('keydown', onKey); + document.addEventListener('mousedown', onDown); + return () => { + window.removeEventListener('keydown', onKey); + document.removeEventListener('mousedown', onDown); + }; + }, [closeAllMenus]); + + // ---- actions ---- + + // Custom MIME type marks drags originating from a project card/row. + // Drop zones key off dataTransfer.types (readable during dragover, unlike + // the payload) rather than React state, which lags the native events. + const DRAG_TYPE = 'application/x-qh-project'; + + const collectionOf = useCallback((indexDocId: string): CollectionView | undefined => { + const short = indexDocId.replace(/^automerge:/, ''); + return collections.find((c) => + c.projectIds.some((id) => id === indexDocId || id === short || `automerge:${id}` === indexDocId), + ); + }, [collections]); + + const entryFor = useCallback((indexDocId: string): Omit | null => { + const item = byId.get(indexDocId) + ?? byId.get(indexDocId.replace(/^automerge:/, '')) + ?? byId.get(`automerge:${indexDocId}`); + if (item) return { indexDocId: item.indexDocId, syncServer: item.syncServer, description: item.description }; + for (const c of collections) { + const e = c.entries.find((en) => en.indexDocId.replace(/^automerge:/, '') === indexDocId.replace(/^automerge:/, '')); + if (e) return { indexDocId: e.indexDocId, syncServer: e.syncServer, description: e.description }; + } + return null; + }, [byId, collections]); + + /** + * Apply a move: target collection gets the entry; a non-root source + * collection loses it; target null means "no collection" (the entry + * remains in the personal root superset either way). + */ + const moveProject = useCallback((indexDocId: string, target: string | null) => { + const from = collectionOf(indexDocId); + if (target) { + if (from && from.id !== target && onMoveProjectBetweenCollections) { + onMoveProjectBetweenCollections(from.id, target, indexDocId); + } else if (!from || from.id !== target) { + const entry = entryFor(indexDocId); + if (entry) onAddProjectToCollection?.(target, entry); + } + } else if (from) { + onRemoveProjectFromCollection?.(from.id, indexDocId); + } + }, [collectionOf, entryFor, onMoveProjectBetweenCollections, onAddProjectToCollection, onRemoveProjectFromCollection]); + + /** Add without removing — a project can sit in several collections. */ + const addToCollection = useCallback((indexDocId: string, target: string) => { + const entry = entryFor(indexDocId); + if (entry) onAddProjectToCollection?.(target, entry); + }, [entryFor, onAddProjectToCollection]); + + /** People other than you seen on a collection's projects (contributor + * union from cached summaries). We can't know access for sure — bearer + * links — so this is the acceptable-risk heuristic for the move warning. */ + const otherPeopleOn = useCallback((collection: CollectionView): number => { + const names = new Set(); + for (const e of collection.entries) { + for (const c of e.summary?.contributors ?? []) names.add(c.name); + } + if (userSettings?.userName) names.delete(userSettings.userName); + return names.size; + }, [userSettings]); + + const requestMove = useCallback((indexDocId: string, target: string | null) => { + const from = collectionOf(indexDocId); + const othersCount = from ? otherPeopleOn(from) : 0; + const suppressed = localStorage.getItem(MOVE_WARNING_KEY) === '1'; + if (from && from.id !== target && othersCount > 0 && !suppressed) { + const item = byId.get(indexDocId) ?? byId.get(indexDocId.replace(/^automerge:/, '')) ?? byId.get(`automerge:${indexDocId}`); + setMoveWarnChecked(false); + setPendingMove({ + indexDocId, + name: item?.description ?? 'this project', + fromName: from.name, + othersCount, + target, + }); + closeAllMenus(); + return; + } + moveProject(indexDocId, target); + }, [collectionOf, otherPeopleOn, byId, moveProject, closeAllMenus]); + + const handleDragStart = useCallback((item: ProjectItem) => (e: React.DragEvent) => { + e.dataTransfer.setData(DRAG_TYPE, item.indexDocId); + e.dataTransfer.setData('text/plain', item.indexDocId); + e.dataTransfer.effectAllowed = 'move'; + setDraggingId(item.indexDocId); + closeAllMenus(); + }, [closeAllMenus]); + + const handleDragEnd = useCallback(() => { + setDraggingId(null); + setDropTarget(null); + }, []); + + /** Drop-zone props for a collection section (or 'unshelved' for the bottom list). */ + const dropZoneProps = useCallback((target: string) => ({ + onDragOver: (e: React.DragEvent) => { + if (!e.dataTransfer.types.includes(DRAG_TYPE)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (dropTarget !== target) setDropTarget(target); + }, + onDragLeave: (e: React.DragEvent) => { + // Ignore leave events fired when moving over the zone's own children + if (e.currentTarget.contains(e.relatedTarget as Node)) return; + setDropTarget((t) => (t === target ? null : t)); + }, + onDrop: (e: React.DragEvent) => { + e.preventDefault(); + const docId = e.dataTransfer.getData(DRAG_TYPE) || draggingId; + if (docId) requestMove(docId, target === 'unshelved' ? null : target); + handleDragEnd(); + }, + }), [draggingId, dropTarget, requestMove, handleDragEnd]); + + const handleOpen = useCallback(async (item: ProjectItem) => { + // Ensure a local IDB entry exists (URL routing uses local ids) + let localProject = await projectStorage.getProjectByIndexDocId(item.indexDocId); + if (!localProject) { + localProject = await projectStorage.addProject(item.indexDocId, item.syncServer, item.description); + } + await projectStorage.touchProject(localProject.id); + onTouchProject?.(item.indexDocId); + onSelectProject(localProject); + }, [onSelectProject, onTouchProject]); + + // Which project is currently being exported as a ZIP (background connect) + const [exportingId, setExportingId] = useState(null); + + /** + * Download a project's files as a ZIP without opening it: connect in the + * background, pull every file's content (exportProjectAsZip reads only + * what's loaded), zip, then tear the connection down. Safe from the home + * page because nothing else holds the singleton sync client here. + */ + const handleDownloadZip = useCallback(async (item: ProjectItem) => { + if (exportingId) return; + setExportingId(item.indexDocId); + setFormError(null); + try { + await connect(resolveSyncServerUrl(item.syncServer), item.indexDocId); + const bytes = exportProjectAsZip(item.description); + const blob = new Blob([bytes as BlobPart], { type: 'application/zip' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${item.description.replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'}.zip`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + console.error('ZIP export failed:', err); + setFormError(err instanceof Error ? `Export failed: ${err.message}` : 'Export failed.'); + } finally { + try { await disconnect(); } catch { /* connection already down */ } + setExportingId(null); + closeAllMenus(); + } + }, [exportingId, closeAllMenus]); + + // Which project is being duplicated (background connect + re-create) + const [duplicatingId, setDuplicatingId] = useState(null); + // Duplicate dialog state: the fork source plus editable name/destination + const [duplicateFor, setDuplicateFor] = useState(null); + const [duplicateName, setDuplicateName] = useState(''); + const [duplicateCollectionId, setDuplicateCollectionId] = useState(''); + + /** Open the duplicate (fork) dialog: name prefilled with "(copy)", + * destination defaulting to the source's collection. */ + const openDuplicateDialog = useCallback((item: ProjectItem) => { + setDuplicateFor(item); + setDuplicateName(`${item.description} (copy)`); + setDuplicateCollectionId(collectionOf(item.indexDocId)?.id ?? ''); + setFormError(null); + closeAllMenus(); + }, [collectionOf, closeAllMenus]); + + /** + * Duplicate (fork) a project: background-connect to the source, read every + * file (text and binary), and feed them through the same creation path new + * projects use — fresh documents, no history carried over. The chosen + * collection is applied via the pending-assignment mechanism (the new doc + * id isn't known until the parent finishes creating it). + */ + const handleDuplicate = useCallback(async () => { + if (!duplicateFor || duplicatingId || exportingId || !duplicateName.trim()) return; + const item = duplicateFor; + const title = duplicateName.trim(); + setDuplicatingId(item.indexDocId); + setFormError(null); + try { + const files = await connect(resolveSyncServerUrl(item.syncServer), item.indexDocId); + const projectFiles: ProjectFile[] = []; + for (const f of files) { + if (isFileBinary(f.path)) { + const bin = getBinaryFileContent(f.path); + if (bin) { + projectFiles.push({ path: f.path, content_type: 'binary', content: toBase64(bin.content), mime_type: bin.mimeType }); + } + } else { + const text = getFileContent(f.path); + if (text !== null) { + projectFiles.push({ path: f.path, content_type: 'text', content: text }); + } + } + } + if (projectFiles.length === 0) { + setFormError('Nothing to duplicate — the project has no readable files.'); + return; + } + await disconnect(); + if (duplicateCollectionId) { + setPendingCollectionAssignment(title, duplicateCollectionId); + } + setDuplicateFor(null); + onProjectCreated?.(projectFiles, title, 'duplicate', item.syncServer); + } catch (err) { + console.error('Duplicate failed:', err); + setFormError(err instanceof Error ? `Duplicate failed: ${err.message}` : 'Duplicate failed.'); + try { await disconnect(); } catch { /* connection already down */ } + } finally { + setDuplicatingId(null); + } + }, [duplicateFor, duplicateName, duplicateCollectionId, duplicatingId, exportingId, onProjectCreated]); + + const handleRemove = useCallback((item: ProjectItem) => { + closeAllMenus(); + setConfirmState({ + title: `Remove "${item.description}" from this device?`, + body: "This doesn't delete the project for others — anyone who has it keeps it.", + confirmLabel: 'Remove', + action: () => { + moveProject(item.indexDocId, null); + onRemoveProjectFromSet?.(item.indexDocId); + }, + }); + }, [moveProject, onRemoveProjectFromSet, closeAllMenus]); + + const copyToClipboard = useCallback(async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(label); + setTimeout(() => setCopied(null), 2000); + } catch (err) { + console.error('Clipboard write failed:', err); + } + }, []); + + const startRename = useCallback((item: ProjectItem) => { + setRenameFor(item.indexDocId); + setRenameValue(isUnnamed(item.description) ? '' : item.description); + closeAllMenus(); + }, [closeAllMenus]); + + const commitRename = useCallback(() => { + if (renameFor && renameValue.trim()) { + onRenameProject?.(renameFor, renameValue.trim()); + } + setRenameFor(null); + setRenameValue(''); + }, [renameFor, renameValue, onRenameProject]); + + /** Open the new-collection dialog; when a project id is given, the + * project moves onto the collection once it's created. */ + const openNewCollection = useCallback((forProject?: string) => { + setNewCollectionName(''); + setNewCollectionDialog({ forProject }); + closeAllMenus(); + }, [closeAllMenus]); + + const commitNewCollection = useCallback(async () => { + if (!newCollectionDialog || !newCollectionName.trim() || !onCreateCollection) return; + try { + const id = await onCreateCollection(newCollectionName.trim()); + if (newCollectionDialog.forProject) { + requestMove(newCollectionDialog.forProject, id); + } + setNewCollectionDialog(null); + setNewCollectionName(''); + } catch (err) { + setFormError(err instanceof Error ? `Could not create collection: ${err.message}` : 'Could not create collection.'); + } + }, [newCollectionDialog, newCollectionName, onCreateCollection, requestMove]); + + const openNewDialog = useCallback((choice: ProjectChoice) => { + setNewDialogChoice(choice); + setNewTitle(''); + setNewCollectionId(''); + setShowServerField(false); + setFormError(null); + setNewMenuOpen(false); + }, []); + + const handleCreate = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + if (!newDialogChoice || !newTitle.trim() || !newServer.trim()) return; + setIsCreating(true); + setFormError(null); + try { + const result = await wasmCreateProject(newDialogChoice.id, newTitle.trim()); + if (!result.success || !result.files || result.files.length === 0) { + setFormError(result.error || 'Failed to create project'); + return; + } + if (newCollectionId) { + setPendingCollectionAssignment(newTitle.trim(), newCollectionId); + } + onProjectCreated?.(result.files, newTitle.trim(), newDialogChoice.id, newServer.trim()); + setNewDialogChoice(null); + } catch (err) { + setFormError(err instanceof Error ? err.message : 'Failed to create project'); + } finally { + setIsCreating(false); + } + }, [newDialogChoice, newTitle, newServer, newCollectionId, onProjectCreated]); + + const handleConnect = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + const parsed = parseConnectInput(connectInput); + if (!parsed) { + setFormError('Paste a share link or a project ID'); + return; + } + const server = parsed.server ?? (showConnectServer ? connectServer.trim() : DEFAULT_SYNC_SERVER); + if (!server) { + setFormError('Sync server is required'); + return; + } + try { + let normalizedDocId = parsed.docId; + if (!normalizedDocId.startsWith('automerge:')) { + normalizedDocId = `automerge:${normalizedDocId}`; + } + const name = connectName.trim() || parsed.name; + let project = await projectStorage.getProjectByIndexDocId(normalizedDocId); + if (!project) { + project = await projectStorage.addProject(normalizedDocId, server, name); + } + if (useProjectSet && onAddProjectToSet) { + try { + onAddProjectToSet({ + indexDocId: normalizedDocId, + syncServer: server, + description: name ?? project.description, + }); + } catch (err) { + console.warn('Failed to add to synced project set:', err); + } + } + setAddDialogOpen(false); + setConnectInput(''); + setConnectName(''); + onSelectProject(project); + } catch (err) { + setFormError(err instanceof Error ? `Failed to connect: ${err.message}` : 'Failed to connect.'); + } + }, [connectInput, connectName, connectServer, showConnectServer, useProjectSet, onAddProjectToSet, onSelectProject]); + + const handleImportZip = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + if (!importFile || !importTitle.trim()) { + setFormError('Choose a ZIP file and a name'); + return; + } + setIsImporting(true); + try { + const bytes = new Uint8Array(await importFile.arrayBuffer()); + const files = importProjectFromZip(bytes); + if (files.length === 0) { + setFormError('The archive contains no usable files'); + return; + } + onProjectCreated?.(files, importTitle.trim(), 'imported', DEFAULT_SYNC_SERVER); + setAddDialogOpen(false); + setImportFile(null); + setImportTitle(''); + } catch (err) { + setFormError(err instanceof Error ? `Failed to import ZIP: ${err.message}` : 'Failed to import ZIP.'); + } finally { + setIsImporting(false); + } + }, [importFile, importTitle, onProjectCreated]); + + const handleSaveName = useCallback(async () => { + if (!editNameValue.trim()) return; + try { + const updated = await userSettingsService.updateUserName(editNameValue.trim()); + setUserSettings(updated); + setEditingName(false); + onScreenNameChange?.(updated.userName); + } catch (err) { + console.error('Failed to update name:', err); + } + }, [editNameValue, onScreenNameChange]); + + const handleColorChange = useCallback(async (color: string) => { + try { + const updated = await userSettingsService.updateUserColor(color); + setUserSettings(updated); + onColorChange?.(updated.userColor); + } catch (err) { + console.error('Failed to update color:', err); + } + }, [onColorChange]); + + const handleExportJson = useCallback(() => { + const exportData = { + schemaVersion: 4, + exportedAt: new Date().toISOString(), + projects: items.map((e) => ({ + id: '', + indexDocId: e.indexDocId, + syncServer: e.syncServer, + description: e.description, + createdAt: e.addedAt, + lastAccessed: e.lastAccessed, + })), + }; + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'quarto-hub-projects.json'; + a.click(); + URL.revokeObjectURL(url); + }, [items]); + + const handleImportJson = useCallback(() => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json'; + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + try { + const count = await projectStorage.importProjects(await file.text()); + alert(`Imported ${count} project(s)`); + } catch (err) { + console.error('Failed to import:', err); + alert('Failed to import projects. Invalid JSON format.'); + } + }; + input.click(); + }, []); + + // ---- derived view data ---- + + const query = search.trim().toLowerCase(); + const matches = useCallback( + (item: ProjectItem) => !query || item.description.toLowerCase().includes(query), + [query], + ); + + const shelvedIds = useMemo(() => { + // Collection project ids may be stored with or without the 'automerge:' + // prefix (invite links carry the short form); index both. + const s = new Set(); + for (const collection of collections) { + for (const id of collection.projectIds) { + s.add(id); + s.add(id.startsWith('automerge:') ? id.replace(/^automerge:/, '') : `automerge:${id}`); + } + } + return s; + }, [collections]); + + const everythingElse = useMemo(() => { + const rest = items.filter((it) => !shelvedIds.has(it.indexDocId)).filter(matches); + const sorted = [...rest]; + if (sortOrder === 'newest') { + sorted.sort((a, b) => (a.lastAccessed < b.lastAccessed ? 1 : -1)); + } else if (sortOrder === 'oldest') { + sorted.sort((a, b) => (a.lastAccessed > b.lastAccessed ? 1 : -1)); + } else { + sorted.sort((a, b) => a.description.localeCompare(b.description)); + } + return sorted; + }, [items, shelvedIds, matches, sortOrder]); + + const sortLabel = sortOrder === 'newest' ? 'newest first' : sortOrder === 'oldest' ? 'oldest first' : 'A to Z'; + + // The current user, as a face (real identity from user settings). + const selfUser: Face | undefined = userSettings + ? { name: `${userSettings.userName} (you)`, initials: initialsFor(userSettings.userName), color: userSettings.userColor } + : undefined; + + // Real contributors for a project: the identities cached on its summary + // (populated from the index doc when anyone opens it). A project nobody + // else has touched shows just you — never fabricated authors. + const contributorsFor = useCallback( + (item: ProjectItem): Face[] => { + const real = (item.summary?.contributors ?? []).map((c) => ({ + name: c.name, + color: c.color, + initials: initialsFor(c.name), + })); + if (real.length > 0) return real; + return selfUser ? [{ ...selfUser, name: selfUser.name.replace(/ \(you\)$/, '') }] : []; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [userSettings?.userName, userSettings?.userColor], + ); + + const renderFacepile = (users: Face[], size: 'sm' | 'md' | 'lg', max = 3) => { + const shown = users.slice(0, max); + const extra = users.length - shown.length; + return ( + + {shown.map((u, i) => ( + + {u.initials} + + ))} + {extra > 0 && +{extra}} + + ); + }; + + // ---- rendering ---- + + if (loading || projectSetConnecting) { + return ( +
+
+ {projectSetConnecting ? 'Connecting to project set…' : 'Loading projects…'} +
+
+ ); + } + + const renderProjectMenu = (item: ProjectItem) => ( +
+ +
+ + {moveSubmenuOpen === 'move' && ( +
+ {collections + .filter((c) => !c.projectIds.includes(item.indexDocId.replace(/^automerge:/, ''))) + .map((collection) => ( + + ))} + {collectionOf(item.indexDocId) && ( + + )} + +
+ )} +
+
+ + {moveSubmenuOpen === 'add' && ( +
+ {collections + .filter((c) => !c.projectIds.includes(item.indexDocId.replace(/^automerge:/, ''))) + .map((collection) => ( + + ))} + {collections.every((c) => c.projectIds.includes(item.indexDocId.replace(/^automerge:/, ''))) && ( +
+ Already in every collection +
+ )} +
+ )} +
+ + + + + +
+ +
+ ); + + /** Refresh a peek summary via a short background connection. Contributors + * are carried over: they only update on a real open, when presence data + * is flowing. */ + const refreshPeek = async (item: ProjectItem) => { + if (peekRefreshing || exportingId || duplicatingId) return; + setPeekRefreshing(true); + try { + const files = await connect(resolveSyncServerUrl(item.syncServer), item.indexDocId); + onUpdateProjectSummary?.(item.indexDocId, { + fileCount: files.length, + topFiles: files.slice(0, 5).map((f) => f.path), + contributors: item.summary?.contributors ?? [], + asOf: new Date().toISOString(), + }); + } catch (err) { + console.error('Peek refresh failed:', err); + } finally { + try { await disconnect(); } catch { /* connection already down */ } + setPeekRefreshing(false); + } + }; + + const renderPeek = (item: ProjectItem) => { + const s = item.summary; + return ( +
e.stopPropagation()}> + {s ? ( + <> +
+ {s.fileCount} {s.fileCount === 1 ? 'FILE' : 'FILES'} · AS OF {formatOpened(s.asOf).toUpperCase()} +
+ {s.contributors.length > 0 && ( +
+ {renderFacepile( + s.contributors.map((c) => ({ name: c.name, color: c.color, initials: initialsFor(c.name) })), + 'lg', 3, + )} + + {s.contributors.length === 1 + ? `${s.contributors[0].name} has joined` + : `${s.contributors.map((c) => c.name.split(' ')[0]).join(', ')} have joined`} + +
+ )} +
+ {s.topFiles.map((f) => ( +
{f}
+ ))} + {s.fileCount > s.topFiles.length && ( +
and {s.fileCount - s.topFiles.length} more…
+ )} +
+ + ) : ( + <> +
NOT OPENED ON THIS DEVICE YET
+
+ Details are cached when you open a project — or load them now without opening it. +
+ + )} +
+ {serverHost(item.syncServer)} · {shortId(item.indexDocId)} +
+
+
+ +
+
Peeking is read-only — use the ⋯ menu to act on the project.
+
+ ); + }; + + // ---- collection sharing (real synced documents) ---- + + /** Items on a collection come from its own document's entries — a shared + * collection can hold projects this user has never opened. */ + const collectionItemsOf = (collection: CollectionView): ProjectItem[] => + collection.entries.map((e) => ({ + indexDocId: e.indexDocId, + syncServer: e.syncServer, + description: e.description, + addedAt: e.addedAt, + lastAccessed: e.lastAccessed, + summary: e.summary, + })); + + /** Invite = the collection document's id + server. Nothing else travels. */ + const buildInviteUrl = (collection: CollectionView): string => + buildFullUrl({ + type: 'join-collection', + collectionId: collection.id, + collectionName: collection.name, + inviter: userSettings?.userName ?? 'A collaborator', + syncServer: collection.syncServer, + entries: [], + }); + + /** People seen on a collection: you plus the contributor union from the + * projects' cached summaries. Derived, not a stored member list. */ + const peopleOn = (collection: CollectionView): Face[] => { + const people: Face[] = selfUser + ? [{ ...selfUser, name: selfUser.name.replace(/ \(you\)$/, '') }] + : []; + for (const e of collection.entries) { + for (const c of e.summary?.contributors ?? []) { + if (!people.some((p) => p.name === c.name)) { + people.push({ name: c.name, color: c.color, initials: initialsFor(c.name) }); + } + } + } + return people; + }; + + const renderMembersPopover = (collection: CollectionView) => { + const people = peopleOn(collection); + const inviteUrl = buildInviteUrl(collection); + const copyKey = `collection:${collection.id}:invite`; + return ( +
+
+ {people.length <= 1 + ? 'ONLY YOU SO FAR' + : `${people.length} PEOPLE SEEN ON THESE PROJECTS`} +
+
+ {people.map((m, i) => ( +
+ {m.initials} + + {m.name} + {i === 0 && selfUser && (you)} + +
+ ))} +
+
+
INVITE BY LINK
+
+ {inviteUrl.replace(/^https?:\/\//, '').slice(0, 34)}… + +
+
+ Anyone with this link can join this collection and add or remove projects. + Its contents sync to them for real. +
+
+ ); + }; + + const renderCard = (item: ProjectItem) => ( +
+ + + openPeekHover(item.indexDocId)} + onMouseOut={closePeekHoverSoon} + > + + {peekFor === item.indexDocId && renderPeek(item)} + + + + + {openMenu === item.indexDocId && renderProjectMenu(item)} +
+ ); + + const renderCollection = (collection: CollectionView) => { + // Collection order is by recency (lastAccessed, newest first), not by stored + // position — paging walks toward older projects, per the design. True + // "recent edits" ordering needs automerge-history attribution (Future + // phase); last-opened is the closest signal in today's metadata. + const collectionItems = collectionItemsOf(collection) + .filter(matches) + .sort((a, b) => (a.lastAccessed < b.lastAccessed ? 1 : -1)); + if (query && collectionItems.length === 0) return null; + const pageCount = Math.max(1, Math.ceil(collectionItems.length / COLLECTION_PAGE_SIZE)); + const page = Math.min(collectionPages[collection.id] ?? 0, pageCount - 1); + const pageItems = collectionItems.slice(page * COLLECTION_PAGE_SIZE, (page + 1) * COLLECTION_PAGE_SIZE); + const menuKey = `collection:${collection.id}`; + return ( +
+
+ {collection.name} + {collectionItems.length} + {(() => { + const people = peopleOn(collection); + const hasOthers = people.length > 1; + return ( + + ); + })()} + + + {membersFor === collection.id && renderMembersPopover(collection)} + {openMenu === menuKey && ( +
+ + +
+ +
+ )} +
+ {collectionItems.length === 0 ? ( +
Empty collection — drag a project here, or use its ⋯ menu.
+ ) : ( +
+ {page > 0 && ( + + )} +
{pageItems.map(renderCard)}
+ {page < pageCount - 1 ? ( + + ) : pageCount > 1 ? ( +
+ {page + 1}/{pageCount} +
+ ) : null} +
+ )} +
+ ); + }; + + return ( +
+
+
+ + Quarto Hub +
+
+ setSearch(e.target.value)} + /> + ⌘K +
+
+
+ +
+ + {newMenuOpen && ( +
+
START FROM — QUARTO PROJECT TYPES
+ {(projectChoices.length > 0 + ? projectChoices + : [{ id: 'default', name: 'Default', description: 'A minimal Quarto project' }] + ).map((choice) => ( + + ))} +
+ )} +
+
+ + {avatarMenuOpen && userSettings && ( +
+
+ + {initialsFor(userSettings.userName)} + +
+ {editingName ? ( + setEditNameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveName(); + if (e.key === 'Escape') setEditingName(false); + }} + onBlur={handleSaveName} + autoFocus + /> + ) : ( +
+ {userSettings.userName} + +
+ )} +
+ {authEmail ?? 'Not signed in'} + {onSignOut && <> · } +
+
+
+
CURSOR COLOR
+
+ {COLOR_PALETTE.map((color) => ( +
+
+ {projectSetLinkUrl && ( + + )} + + + + {onSwitchToClassicUi && ( + <> +
+ + + )} +
+ )} +
+
+
+ + {(connectionError || formError) && !addDialogOpen && !newDialogChoice && ( +
{connectionError || formError}
+ )} + {isConnecting &&
Connecting to sync server…
} + +
+ {items.length === 0 && collections.every((c) => c.entries.length === 0) ? ( +
+

No projects yet

+

Create your first Quarto project, or connect to one a collaborator shared.

+
+ + +
+
+ ) : ( + <> + {collections.map(renderCollection)} + +
+ +
+ +
+
+ Everything else + {everythingElse.length} · {sortLabel} + + + {sortMenuOpen && ( +
+ {(['newest', 'oldest', 'name'] as SortOrder[]).map((o) => ( + + ))} +
+ )} +
+ {everythingElse.length === 0 ? ( +
+ {query ? 'No projects match your search.' : 'Everything is in a collection.'} +
+ ) : ( +
+ {everythingElse.map((item) => ( +
+ + {isUnnamed(item.description) && ( + + )} + + {item.summary ? `${item.summary.fileCount} ${item.summary.fileCount === 1 ? 'file' : 'files'} · ` : ''} + opened {formatOpened(item.lastAccessed)} + + openPeekHover(item.indexDocId)} + onMouseOut={closePeekHoverSoon} + > + + {peekFor === item.indexDocId && renderPeek(item)} + + + + {openMenu === item.indexDocId && renderProjectMenu(item)} +
+ ))} +
+ )} +
+ + )} +
+ + {/* Duplicate (fork) */} + {duplicateFor && ( +
{ if (!duplicatingId) setDuplicateFor(null); }}> +
e.stopPropagation()}> +

Duplicate "{duplicateFor.description}"

+

+ A fresh copy of all {duplicateFor.summary ? `${duplicateFor.summary.fileCount} ` : ''}files — no + edit history carries over. +

+ {formError &&
{formError}
} +
{ e.preventDefault(); handleDuplicate(); }}> + + setDuplicateName(e.target.value)} + autoFocus + onFocus={(e) => e.target.select()} + /> + + +
+ + +
+
+
+
+ )} + + {/* New collection */} + {newCollectionDialog && ( +
setNewCollectionDialog(null)}> +
e.stopPropagation()}> +

New collection

+ {newCollectionDialog.forProject && ( +

The project will be moved onto it.

+ )} +
{ e.preventDefault(); commitNewCollection(); }}> + + setNewCollectionName(e.target.value)} + placeholder="e.g. Board prep" + autoFocus + /> +
+ + +
+
+
+
+ )} + + {/* Rename collection */} + {renameCollectionTarget && ( +
setRenameCollectionTarget(null)}> +
e.stopPropagation()}> +

Rename collection

+

Renames it for everyone subscribed to it.

+
{ + e.preventDefault(); + if (renameCollectionValue.trim()) { + onRenameCollection?.(renameCollectionTarget.id, renameCollectionValue.trim()); + } + setRenameCollectionTarget(null); + }} + > + + setRenameCollectionValue(e.target.value)} + autoFocus + /> +
+ + +
+
+
+
+ )} + + {/* Generic destructive confirmation */} + {confirmState && ( +
setConfirmState(null)}> +
e.stopPropagation()}> +

{confirmState.title}

+

{confirmState.body}

+
+ + +
+
+
+ )} + + {/* Shared-collection move warning */} + {pendingMove && ( +
setPendingMove(null)}> +
e.stopPropagation()}> +

Move "{pendingMove.name}" out of {pendingMove.fromName}?

+

+ Please note you're changing {pendingMove.othersCount === 1 + ? "another person's" + : `${pendingMove.othersCount} other people's`} view of this collection — it will + no longer appear in {pendingMove.fromName} for them. The project itself isn't + deleted or changed. +

+ +
+ + +
+
+
+ )} + + {/* Rename dialog */} + {renameFor && ( +
setRenameFor(null)}> +
e.stopPropagation()}> +

Rename project

+
{ e.preventDefault(); commitRename(); }}> + + setRenameValue(e.target.value)} + placeholder="e.g. Lab retreat agenda" + autoFocus + /> +
+ + +
+
+
+
+ )} + + {/* New project dialog */} + {newDialogChoice && ( +
setNewDialogChoice(null)}> +
e.stopPropagation()}> +

New {newDialogChoice.name.toLowerCase()}

+

Starter files will be created for you

+ {formError &&
{formError}
} +
+ + setNewTitle(e.target.value)} + placeholder="Q3 all-hands deck" + autoFocus + /> + + + {showServerField ? ( + <> + + setNewServer(e.target.value)} + /> + + ) : ( +
+ Syncs to {serverHost(newServer)}{' '} + +
+ )} +
+ + +
+
+
+
+ )} + + {/* Connect / Import dialog */} + {addDialogOpen && ( +
setAddDialogOpen(false)}> +
e.stopPropagation()}> +

Add an existing project

+
+ + +
+ {formError &&
{formError}
} + {addTab === 'connect' ? ( +
+ + setConnectInput(e.target.value)} + placeholder="https://quarto-hub.com/#/share/… or bs58 ID" + autoFocus + /> + {showConnectServer ? ( + <> + + setConnectServer(e.target.value)} + /> + + ) : ( +
+ Server is read from the link · advanced:{' '} + +
+ )} + + setConnectName(e.target.value)} + placeholder="e.g. Lab retreat agenda" + /> +
+ + +
+
+ ) : ( +
+ + { + const file = e.target.files?.[0] ?? null; + setImportFile(file); + if (file && !importTitle.trim()) { + setImportTitle(file.name.replace(/\.zip$/i, '')); + } + }} + /> + + setImportTitle(e.target.value)} + placeholder="My imported project" + /> +
+ + +
+
+ )} +
+
+ )} + + setShowLinkDialog(false)} + /> + +
+ + {__GIT_COMMIT_HASH__} + + collections UI exploration +
+
+ ); +} diff --git a/hub-client/src/debug/components/QuickPick.test.ts b/hub-client/src/debug/components/QuickPick.test.ts new file mode 100644 index 000000000..fc45a7bfa --- /dev/null +++ b/hub-client/src/debug/components/QuickPick.test.ts @@ -0,0 +1,40 @@ +/** + * Unit tests for the QuickPick collection-label logic. + * + * The collections quick-pick list shows each collection's real name (read from + * its ProjectSetDocument once loaded), falling back to a generic label while + * the doc is still loading or when it has no name. The root collection is + * tagged so it's distinguishable from the named ones. + */ + +import { describe, it, expect } from 'vitest' +import { collectionLabel } from './QuickPick' + +describe('collectionLabel', () => { + it('uses the document name once loaded', () => { + expect(collectionLabel({ name: 'Team docs' }, false)).toBe('Team docs') + }) + + it('tags the root collection', () => { + expect(collectionLabel({ name: 'My projects' }, true)).toBe('My projects (root)') + }) + + it('falls back to a generic label while the doc is still loading (undefined)', () => { + expect(collectionLabel(undefined, false)).toBe('Collection') + expect(collectionLabel(undefined, true)).toBe('Collection (root)') + }) + + it('falls back when the doc has no name', () => { + expect(collectionLabel({ projects: {} }, false)).toBe('Collection') + expect(collectionLabel({ projects: {} }, true)).toBe('Collection (root)') + }) + + it('treats a blank/whitespace name as no name', () => { + expect(collectionLabel({ name: ' ' }, false)).toBe('Collection') + }) + + it('is defensive against non-object docs', () => { + expect(collectionLabel(null, false)).toBe('Collection') + expect(collectionLabel('nope', false)).toBe('Collection') + }) +}) diff --git a/hub-client/src/debug/components/QuickPick.tsx b/hub-client/src/debug/components/QuickPick.tsx index 1502bbeee..30243c686 100644 --- a/hub-client/src/debug/components/QuickPick.tsx +++ b/hub-client/src/debug/components/QuickPick.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { useRepo } from '@automerge/automerge-repo-react-hooks' +import { useRepo, useDocument } from '@automerge/automerge-repo-react-hooks' import { isValidAutomergeUrl, type AutomergeUrl, @@ -18,8 +18,56 @@ function toAutomergeUrl(docId: string): AutomergeUrl | null { return null } +/** + * Label for a collection quick-pick card: the collection's own name (from its + * loaded ProjectSetDocument) with a `(root)` tag for the personal root set. + * Falls back to a generic label while the doc is still loading or when it has + * no name. + */ +export function collectionLabel(doc: unknown, isRoot: boolean): string { + const rawName = + doc && typeof doc === 'object' + ? (doc as { name?: unknown }).name + : undefined + const name = typeof rawName === 'string' && rawName.trim() ? rawName : null + const base = name ?? 'Collection' + return isRoot ? `${base} (root)` : base +} + +interface CollectionItemProps { + url: AutomergeUrl + rawDocId: string + isRoot: boolean + subscribing: string | null + onSubscribe: (rawDocId: string) => void +} + +/** + * One collection card. Reads the collection's ProjectSetDocument via the repo + * so the card can show its real name; updates reactively once it syncs. + */ +function CollectionItem({ url, rawDocId, isRoot, subscribing, onSubscribe }: CollectionItemProps) { + const [doc] = useDocument<{ name?: string }>(url) + return ( +
  • + +
  • + ) +} + +const stripPrefix = (id: string) => id.replace(/^automerge:/, '') + export function QuickPick({ documents, onAdd }: Props) { - const { loading, projects, projectSetPointer, error } = useLocalProjects() + const { loading, projects, projectSetPointer, collectionPointers, error } = + useLocalProjects() const repo = useRepo() const [subscribing, setSubscribing] = useState(null) const [subscribeError, setSubscribeError] = useState(null) @@ -48,9 +96,18 @@ export function QuickPick({ documents, onAdd }: Props) { if (loading) return null - const hasAny = projects.length > 0 || projectSetPointer !== null + const hasAny = + projects.length > 0 || + projectSetPointer !== null || + collectionPointers.length > 0 if (!hasAny && !error) return null + // The root collection is the one whose doc id matches the legacy singleton + // pointer (identity, not array position — the stored order can be scrambled). + const rootDocId = projectSetPointer + ? stripPrefix(projectSetPointer.projectSetDocId) + : null + return (

    On this device

    @@ -61,23 +118,61 @@ export function QuickPick({ documents, onAdd }: Props) {
    )} - {projectSetPointer && ( + {collectionPointers.length > 0 ? (
    -

    Project set

    +

    Collections

      -
    • - -
    • + {collectionPointers.map((c) => { + const isRoot = rootDocId !== null && stripPrefix(c.projectSetDocId) === rootDocId + const url = toAutomergeUrl(c.projectSetDocId) + // Invalid doc id: no name lookup possible, render a plain card. + if (!url) { + return ( +
    • + +
    • + ) + } + return ( + void subscribe(id)} + /> + ) + })}
    + ) : ( + projectSetPointer && ( +
    +

    Project set

    +
      +
    • + +
    • +
    +
    + ) )} {projects.length > 0 && ( diff --git a/hub-client/src/debug/hooks/useDebugAuthGate.test.ts b/hub-client/src/debug/hooks/useDebugAuthGate.test.ts index 43529ae8d..0a281c93f 100644 --- a/hub-client/src/debug/hooks/useDebugAuthGate.test.ts +++ b/hub-client/src/debug/hooks/useDebugAuthGate.test.ts @@ -7,22 +7,41 @@ * @vitest-environment jsdom */ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, waitFor } from '@testing-library/react' vi.mock('../../services/authService', () => ({ fetchAuthMe: vi.fn(), })) -import { useDebugAuthGate } from './useDebugAuthGate' +import { useDebugAuthGate, isLoopbackHost } from './useDebugAuthGate' import { fetchAuthMe } from '../../services/authService' const mockFetchAuthMe = vi.mocked(fetchAuthMe) +describe('isLoopbackHost', () => { + it('recognizes loopback hosts (local dev / local-prod)', () => { + expect(isLoopbackHost('localhost')).toBe(true) + expect(isLoopbackHost('127.0.0.1')).toBe(true) + expect(isLoopbackHost('::1')).toBe(true) + expect(isLoopbackHost('[::1]')).toBe(true) + expect(isLoopbackHost('foo.localhost')).toBe(true) + }) + + it('treats real deployment hosts as non-loopback', () => { + expect(isLoopbackHost('hub.example.com')).toBe(false) + expect(isLoopbackHost('quarto-hub.posit.co')).toBe(false) + expect(isLoopbackHost('192.168.1.10')).toBe(false) + }) +}) + describe('useDebugAuthGate', () => { beforeEach(() => { vi.clearAllMocks() }) + afterEach(() => { + vi.unstubAllGlobals() + }) it('starts in the checking state', () => { mockFetchAuthMe.mockReturnValue(new Promise(() => {})) // never resolves @@ -40,7 +59,9 @@ describe('useDebugAuthGate', () => { expect(result.current.user).toEqual(user) }) - it('resolves to anon when /auth/me returns 401 (null)', async () => { + it('resolves to anon when /auth/me returns 401 (null) on a real (non-loopback) host', async () => { + // A deployed hub that enforces auth: 401 means "sign in first". + vi.stubGlobal('location', { hostname: 'hub.example.com' }) mockFetchAuthMe.mockResolvedValue(null) const { result } = renderHook(() => useDebugAuthGate()) @@ -48,6 +69,20 @@ describe('useDebugAuthGate', () => { expect(result.current.user).toBeUndefined() }) + it('resolves to unverified when /auth/me returns 401 on a loopback host (auth-disabled local hub)', async () => { + // `npm run local-prod` runs the hub with `--allow-insecure-auth`, so + // `/auth/me` returns 401 (no session) even though there is no sign-in to + // perform. On loopback we proceed into the inspector with a notice rather + // than dead-ending at the sign-in gate. + vi.stubGlobal('location', { hostname: '127.0.0.1' }) + mockFetchAuthMe.mockResolvedValue(null) + + const { result } = renderHook(() => useDebugAuthGate()) + await waitFor(() => expect(result.current.state).toBe('unverified')) + expect(result.current.user).toBeUndefined() + expect(result.current.reason).toMatch(/auth disabled/i) + }) + it('resolves to unverified when /auth/me throws (auth-less deployment or proxy broken)', async () => { // `/auth/me` returning 500 (as happens when the hub server has no auth // system at all) must NOT block the debugger — the user should still @@ -62,6 +97,7 @@ describe('useDebugAuthGate', () => { }) it('calls fetchAuthMe exactly once on mount', async () => { + vi.stubGlobal('location', { hostname: 'hub.example.com' }) mockFetchAuthMe.mockResolvedValue(null) const { result } = renderHook(() => useDebugAuthGate()) await waitFor(() => expect(result.current.state).toBe('anon')) diff --git a/hub-client/src/debug/hooks/useDebugAuthGate.ts b/hub-client/src/debug/hooks/useDebugAuthGate.ts index 64ce128c1..cb4392198 100644 --- a/hub-client/src/debug/hooks/useDebugAuthGate.ts +++ b/hub-client/src/debug/hooks/useDebugAuthGate.ts @@ -7,19 +7,38 @@ export type DebugAuthGateState = | { state: 'anon'; user?: undefined; reason?: undefined } | { state: 'unverified'; user?: undefined; reason: string } +/** + * True for loopback hosts (local dev / `npm run local-prod`). Used to relax + * the auth gate: a local hub run with `--allow-insecure-auth` still returns + * 401 from `/auth/me` (no session), which is indistinguishable from an + * auth-enforcing hub. On loopback we assume the former and let the inspector + * open; on a real domain we keep the strict sign-in gate. + */ +export function isLoopbackHost(hostname: string): boolean { + return ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + hostname === '[::1]' || + hostname.endsWith('.localhost') + ) +} + /** * Reports whether the browser is authenticated with the hub server * (HttpOnly cookie present and accepted). * - * Three terminal states: + * Terminal states: * - `authed` — `/auth/me` returned a user (200). Enter the inspector. - * - `anon` — `/auth/me` returned 401/403. The hub enforces auth and the - * user needs to sign in via the main app first. Show the gate. - * - `unverified` — `/auth/me` failed for some other reason (500, 404, - * network error). This is the normal case for hub deployments that run - * without authentication — `/auth/me` may not exist at all. Enter the - * inspector; the sync server will either accept the connection (auth- - * less hub) or refuse it (user will see the disconnect). + * - `anon` — `/auth/me` returned 401/403 on a **non-loopback** host. The + * hub enforces auth and the user must sign in via the main app first. + * Show the gate. + * - `unverified` — either `/auth/me` failed for some other reason (500, 404, + * network error — the normal case for auth-less deployments where the + * endpoint may not exist), **or** it returned 401/403 on a loopback host + * (local-prod / dev, where the hub runs with `--allow-insecure-auth` and + * 401 just means "no session"). Enter the inspector; the sync server is + * the real boundary and will refuse the connection if it enforces auth. * * The debug page intentionally does not initiate sign-in itself; that * remains the main app's responsibility. @@ -32,8 +51,17 @@ export function useDebugAuthGate(): DebugAuthGateState { fetchAuthMe() .then((user) => { if (cancelled) return - if (user) setGate({ state: 'authed', user }) - else setGate({ state: 'anon' }) + if (user) { + setGate({ state: 'authed', user }) + } else if (isLoopbackHost(window.location.hostname)) { + // Local/dev hub with auth disabled — 401 is expected; proceed. + setGate({ + state: 'unverified', + reason: 'Local hub with auth disabled (loopback host): /auth/me returned 401. Proceeding read-only.', + }) + } else { + setGate({ state: 'anon' }) + } }) .catch((err: unknown) => { if (cancelled) return diff --git a/hub-client/src/debug/hooks/useLocalProjects.test.ts b/hub-client/src/debug/hooks/useLocalProjects.test.ts index a2552f0fa..8994328bb 100644 --- a/hub-client/src/debug/hooks/useLocalProjects.test.ts +++ b/hub-client/src/debug/hooks/useLocalProjects.test.ts @@ -11,15 +11,24 @@ import { DB_NAME, STORES } from '../../services/storage/types' import { _resetLocalDbCacheForTesting } from '../services/localProjects' import { useLocalProjects } from './useLocalProjects' -async function seed(projects: unknown[]) { +async function seed( + projects: unknown[], + collections?: Array<{ projectSetDocId: string; syncServer: string }>, +) { const db = await openDB(DB_NAME, 1, { upgrade(db) { db.createObjectStore(STORES.PROJECTS, { keyPath: 'id' }) + if (collections) { + db.createObjectStore(STORES.PROJECT_SET, { keyPath: 'key' }) + } }, }) for (const p of projects) { await db.put(STORES.PROJECTS, p) } + if (collections) { + await db.put(STORES.PROJECT_SET, { key: 'collections', collections }) + } db.close() } @@ -62,5 +71,20 @@ describe('useLocalProjects', () => { await waitFor(() => expect(result.current.loading).toBe(false)) expect(result.current.projects).toEqual([]) expect(result.current.projectSetPointer).toBeNull() + expect(result.current.collectionPointers).toEqual([]) + }) + + it('surfaces every collection pointer so each synced doc is inspectable', async () => { + await seed([], [ + { projectSetDocId: 'root-doc', syncServer: 'wss://h' }, + { projectSetDocId: 'team-doc', syncServer: 'wss://h' }, + ]) + + const { result } = renderHook(() => useLocalProjects()) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.collectionPointers.map((c) => c.projectSetDocId)).toEqual([ + 'root-doc', + 'team-doc', + ]) }) }) diff --git a/hub-client/src/debug/hooks/useLocalProjects.ts b/hub-client/src/debug/hooks/useLocalProjects.ts index 2f1dc43a3..ec3daacc8 100644 --- a/hub-client/src/debug/hooks/useLocalProjects.ts +++ b/hub-client/src/debug/hooks/useLocalProjects.ts @@ -2,14 +2,16 @@ import { useEffect, useState } from 'react' import { listLocalProjects, getLocalProjectSetPointer, + getLocalCollectionPointers, } from '../services/localProjects' import type { ProjectEntry } from '@quarto/preview-renderer/types/project' -import type { ProjectSetPointer } from '../../services/storage/types' +import type { ProjectSetPointer, CollectionPointerEntry } from '../../services/storage/types' export interface LocalProjectsState { loading: boolean projects: ProjectEntry[] projectSetPointer: ProjectSetPointer | null + collectionPointers: CollectionPointerEntry[] error?: string } @@ -25,14 +27,19 @@ export function useLocalProjects(): LocalProjectsState { loading: true, projects: [], projectSetPointer: null, + collectionPointers: [], }) useEffect(() => { let cancelled = false - Promise.all([listLocalProjects(), getLocalProjectSetPointer()]) - .then(([projects, projectSetPointer]) => { + Promise.all([ + listLocalProjects(), + getLocalProjectSetPointer(), + getLocalCollectionPointers(), + ]) + .then(([projects, projectSetPointer, collectionPointers]) => { if (cancelled) return - setState({ loading: false, projects, projectSetPointer }) + setState({ loading: false, projects, projectSetPointer, collectionPointers }) }) .catch((err: unknown) => { if (cancelled) return @@ -41,6 +48,7 @@ export function useLocalProjects(): LocalProjectsState { loading: false, projects: [], projectSetPointer: null, + collectionPointers: [], error: message, }) }) diff --git a/hub-client/src/debug/services/localProjects.test.ts b/hub-client/src/debug/services/localProjects.test.ts index ad5ae8e1b..d7e832d59 100644 --- a/hub-client/src/debug/services/localProjects.test.ts +++ b/hub-client/src/debug/services/localProjects.test.ts @@ -17,20 +17,23 @@ import { DB_NAME, STORES } from '../../services/storage/types' import { listLocalProjects, getLocalProjectSetPointer, + getLocalCollectionPointers, _resetLocalDbCacheForTesting, } from './localProjects' async function seedDatabase( projects: Array>, pointer?: { projectSetDocId: string; syncServer: string }, + collections?: Array<{ projectSetDocId: string; syncServer: string }>, ) { + const needsProjectSetStore = !!pointer || !!collections const db = await openDB(DB_NAME, 1, { upgrade(db) { db.createObjectStore(STORES.PROJECTS, { keyPath: 'id' }) // Only create the projectSet store when the caller actually wants to // seed a pointer — this lets tests verify the read helper's behavior // when the store is missing entirely. - if (pointer) { + if (needsProjectSetStore) { db.createObjectStore(STORES.PROJECT_SET, { keyPath: 'key' }) } }, @@ -41,6 +44,9 @@ async function seedDatabase( if (pointer) { await db.put(STORES.PROJECT_SET, { key: 'projectSet', ...pointer }) } + if (collections) { + await db.put(STORES.PROJECT_SET, { key: 'collections', collections }) + } db.close() } @@ -106,6 +112,32 @@ describe('localProjects (debug page IndexedDB read helpers)', () => { expect(pointer!.syncServer).toBe('wss://sync.example.com') }) + it('returns [] for collection pointers when none are seeded', async () => { + expect(await getLocalCollectionPointers()).toEqual([]) + }) + + it('returns every collection pointer (each its own synced ProjectSetDocument)', async () => { + await seedDatabase([], undefined, [ + { projectSetDocId: 'root-doc', syncServer: 'wss://s' }, + { projectSetDocId: 'team-doc', syncServer: 'wss://s' }, + { projectSetDocId: 'synctest-doc', syncServer: 'wss://s' }, + ]) + + const collections = await getLocalCollectionPointers() + expect(collections.map((c) => c.projectSetDocId)).toEqual([ + 'root-doc', + 'team-doc', + 'synctest-doc', + ]) + }) + + it('does not run the pointer→collections migration (read-only)', async () => { + // A browser with only the legacy singleton (no collections record yet): + // the read helper must NOT synthesize/migrate — it just reports []. + await seedDatabase([], { projectSetDocId: 'legacy-root', syncServer: 'wss://s' }) + expect(await getLocalCollectionPointers()).toEqual([]) + }) + it('does not upgrade or create stores that are missing (read-only)', async () => { // Seed only the projects store (no projectSet store at all) await seedDatabase([]) diff --git a/hub-client/src/debug/services/localProjects.ts b/hub-client/src/debug/services/localProjects.ts index a4ac7333c..e9ee54c49 100644 --- a/hub-client/src/debug/services/localProjects.ts +++ b/hub-client/src/debug/services/localProjects.ts @@ -13,7 +13,13 @@ */ import { openDB, type IDBPDatabase } from 'idb' -import { DB_NAME, STORES, type ProjectSetPointer } from '../../services/storage/types' +import { + DB_NAME, + STORES, + type ProjectSetPointer, + type CollectionsPointer, + type CollectionPointerEntry, +} from '../../services/storage/types' import type { ProjectEntry } from '@quarto/preview-renderer/types/project' let dbPromise: Promise | null = null @@ -63,6 +69,29 @@ export async function getLocalProjectSetPointer(): Promise { + const db = await getReadOnlyDb() + if (!db.objectStoreNames.contains(STORES.PROJECT_SET)) { + return [] + } + const entry = (await db.get(STORES.PROJECT_SET, 'collections')) as + | CollectionsPointer + | undefined + return entry?.collections ?? [] +} + /** @internal Test-only helper to reset the cached DB promise between tests. */ export function _resetLocalDbCacheForTesting(): void { dbPromise = null diff --git a/hub-client/src/hooks/useCollectionSets.ts b/hub-client/src/hooks/useCollectionSets.ts new file mode 100644 index 000000000..517374f36 --- /dev/null +++ b/hub-client/src/hooks/useCollectionSets.ts @@ -0,0 +1,459 @@ +/** + * Hook for the collections-of-project-sets lifecycle. + * + * Successor to useProjectSet (which managed exactly one set document). + * On mount it reads the collections pointer array from IndexedDB + * (self-healing from the legacy singleton pointer), connects every + * collection document, and runs the one-time migration of legacy + * localStorage collections (qh-collections-v1) into real synced + * ProjectSetDocuments. + * + * The first pointer is the personal root collection: the superset of the + * user's projects. Other collections reference (a subset of) the same + * projects; the home view computes "Everything else" as root entries not + * present in any other collection. + * + * See claude-notes/plans/2026-07-10-collections-as-project-sets.md. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { ProjectSetEntry, ProjectSetEntrySummary } from '@quarto/quarto-automerge-schema'; +import { projectSetKey } from '@quarto/quarto-automerge-schema'; +import { + getCollectionPointers, + addCollectionPointer, + removeCollectionPointer, + setProjectSetPointer, +} from '../services/projectSetStorage'; +import type { CollectionPointerEntry } from '../services/storage/types'; +import * as projectSetService from '../services/projectSetService'; +import type { CollectionSnapshot } from '../services/projectSetService'; +import * as projectStorage from '../services/projectStorage'; +import { reconcileIntoConnectedProjectSet } from '../services/projectSetReconciler'; +import type { ProjectEntry } from '@quarto/preview-renderer/types/project'; + +// ============================================================================ +// Types +// ============================================================================ + +export type CollectionsStatus = + | 'loading' // Reading pointers from IDB + | 'needs-setup' // No pointers, no old projects → fresh setup + | 'needs-migration' // No pointers, has old IDB projects → migration + | 'connecting' // Connecting to collection documents + | 'connected' // Root collection connected (others may have failed) + | 'error'; // Root connection failed + +export interface CollectionSetsState { + status: CollectionsStatus; + /** All connected collections, root first. */ + collections: CollectionSnapshot[]; + /** Root collection entries (compat with the classic single-set UI). */ + projects: ProjectSetEntry[]; + /** Collections whose documents could not be loaded this session. */ + unreachable: Array<{ pointer: CollectionPointerEntry; error: string }>; + error: string | null; + /** Old IDB projects that need migration (only during 'needs-migration'). */ + legacyProjects: ProjectEntry[]; +} + +export interface CollectionSetsActions { + // ---- setup / linking (mirrors useProjectSet for the setup screens) ---- + createProjectSet: (syncServer: string) => Promise; + linkProjectSet: (projectSetDocId: string, syncServer: string) => Promise; + migrateProjects: (syncServer: string) => Promise; + mergeIntoProjectSet: (projectSetDocId: string, syncServer: string) => Promise; + + // ---- root-set compat operations ---- + addProject: (entry: Omit) => void; + removeProject: (indexDocId: string) => void; + updateProjectDescription: (indexDocId: string, description: string) => void; + updateProjectSummary: (indexDocId: string, summary: ProjectSetEntrySummary) => void; + touchProject: (indexDocId: string) => void; + getProjectSetDocId: () => string | null; + getSyncServer: () => string | null; + + // ---- collection operations ---- + /** Create a new (empty) collection on the root's sync server. */ + createCollection: (name: string) => Promise; + /** Subscribe to an existing collection document (join). */ + subscribeCollection: (projectSetDocId: string, syncServer: string) => Promise; + /** Unsubscribe (leave): drop the pointer; the document is untouched. */ + unsubscribeCollection: (collectionDocId: string) => Promise; + renameCollection: (collectionDocId: string, name: string) => void; + addProjectToCollection: (collectionDocId: string, entry: Omit) => void; + removeProjectFromCollection: (collectionDocId: string, indexDocId: string) => void; + moveProjectBetweenCollections: (fromDocId: string, toDocId: string, indexDocId: string) => void; +} + +// ============================================================================ +// localStorage collections migration (phase 3) +// ============================================================================ + +const LEGACY_LOCAL_KEY = 'qh-collections-v1'; +const LEGACY_LOCAL_MIGRATED_KEY = 'qh-collections-v1-migrated'; +const DEFAULT_ROOT_NAME = 'My projects'; + +interface LegacyLocalCollection { + id: string; + name: string; + projectIds: string[]; +} + +/** + * Convert legacy localStorage collections into real collection documents. + * One-way and idempotent: the original JSON is preserved under a + * `-migrated` key, never re-imported. Entries are matched against the + * root set; ids that no longer resolve are skipped. + */ +async function migrateLocalCollections(rootServer: string): Promise { + const raw = localStorage.getItem(LEGACY_LOCAL_KEY); + if (!raw) return; + + let parsed: LegacyLocalCollection[]; + try { + parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) throw new Error('not an array'); + } catch { + localStorage.setItem(LEGACY_LOCAL_MIGRATED_KEY, raw); + localStorage.removeItem(LEGACY_LOCAL_KEY); + return; + } + + const rootEntries = projectSetService.listProjects(); + const byKey = new Map(rootEntries.map((e) => [projectSetKey(e.indexDocId), e])); + + for (const local of parsed) { + if (typeof local?.name !== 'string' || !Array.isArray(local?.projectIds)) continue; + const docId = await projectSetService.createCollection(rootServer, local.name); + const entries = local.projectIds + .map((id) => byKey.get(projectSetKey(id))) + .filter((e): e is ProjectSetEntry => !!e); + if (entries.length > 0) { + projectSetService.addProjectsBulk( + entries.map((e) => ({ + indexDocId: e.indexDocId, + syncServer: e.syncServer, + description: e.description, + lastAccessed: e.lastAccessed, + })), + docId, + ); + } + await addCollectionPointer({ projectSetDocId: docId, syncServer: rootServer }); + } + + localStorage.setItem(LEGACY_LOCAL_MIGRATED_KEY, raw); + localStorage.removeItem(LEGACY_LOCAL_KEY); + console.log(`[collections] migrated ${parsed.length} local collection(s) to synced documents`); +} + +// ============================================================================ +// Hook +// ============================================================================ + +export function useCollectionSets(): [CollectionSetsState, CollectionSetsActions] { + const [status, setStatus] = useState('loading'); + const [collections, setCollections] = useState([]); + const [unreachable, setUnreachable] = useState([]); + const [error, setError] = useState(null); + const [legacyProjects, setLegacyProjects] = useState([]); + const syncServerRef = useRef(null); + const initRef = useRef(false); + + // Subscribe to service-side changes (local edits and remote sync) + useEffect(() => { + projectSetService.setProjectSetHandlers({ + onCollectionsChange: (snapshots) => setCollections(snapshots), + }); + }, []); + + // Reconcile share-route IDB writes into the root set once connected + useEffect(() => { + if (status !== 'connected') return; + let cancelled = false; + (async () => { + try { + const added = await reconcileIntoConnectedProjectSet(); + if (!cancelled && added > 0) { + setCollections(projectSetService.listCollections()); + } + } catch (err) { + console.error('[collections] reconciliation failed:', err); + } + })(); + return () => { cancelled = true; }; + }, [status]); + + /** Connect everything from the pointer array; run local migration. */ + const connectAll = useCallback(async (pointers: CollectionPointerEntry[]) => { + setStatus('connecting'); + syncServerRef.current = pointers[0]?.syncServer ?? null; + const { connected, failed } = await projectSetService.connectCollections(pointers); + const rootFailed = failed.some((f) => f.pointer.projectSetDocId === pointers[0]?.projectSetDocId); + if (rootFailed || connected.length === 0) { + setError(failed[0]?.error ?? 'Failed to connect'); + setStatus('error'); + return; + } + setUnreachable(failed); + + // Name the root when it has none (pre-collections document) + const root = connected[0]; + if (root && !root.name) { + projectSetService.renameCollection(root.docId, DEFAULT_ROOT_NAME); + } + + // One-time legacy localStorage collections migration + try { + await migrateLocalCollections(pointers[0].syncServer); + } catch (err) { + console.error('[collections] local migration failed (will retry next load):', err); + } + + setCollections(projectSetService.listCollections()); + setStatus('connected'); + }, []); + + // Initialize on mount + useEffect(() => { + if (initRef.current) return; + initRef.current = true; + + (async () => { + try { + const pointers = await getCollectionPointers(); + if (pointers.length > 0) { + await connectAll(pointers); + return; + } + const legacy = await projectStorage.listProjects(); + if (legacy.length > 0) { + setLegacyProjects(legacy); + setStatus('needs-migration'); + } else { + setStatus('needs-setup'); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setStatus('error'); + } + })(); + }, [connectAll]); + + // ---- setup actions ---- + + /** Establish a brand-new root collection and record both pointers + * (array + legacy singleton, the latter as a safety net). */ + const establishRoot = useCallback(async (docId: string, syncServer: string) => { + await addCollectionPointer({ projectSetDocId: docId, syncServer }); + await setProjectSetPointer(docId, syncServer); + syncServerRef.current = syncServer; + }, []); + + const createProjectSet = useCallback(async (syncServer: string) => { + setStatus('connecting'); + setError(null); + try { + const docId = await projectSetService.createCollection(syncServer, DEFAULT_ROOT_NAME); + await establishRoot(docId, syncServer); + await migrateLocalCollections(syncServer); + setCollections(projectSetService.listCollections()); + setStatus('connected'); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setStatus('error'); + } + }, [establishRoot]); + + const linkProjectSet = useCallback(async (projectSetDocId: string, syncServer: string) => { + setStatus('connecting'); + setError(null); + try { + await projectSetService.connectCollection({ projectSetDocId, syncServer }); + await establishRoot(projectSetDocId, syncServer); + await migrateLocalCollections(syncServer); + setCollections(projectSetService.listCollections()); + setStatus('connected'); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setStatus('error'); + } + }, [establishRoot]); + + const migrateProjects = useCallback(async (syncServer: string) => { + setStatus('connecting'); + setError(null); + try { + const docId = await projectSetService.createCollection(syncServer, DEFAULT_ROOT_NAME); + const legacy = await projectStorage.listProjects(); + if (legacy.length > 0) { + const added = projectSetService.addProjectsBulk( + legacy.map((p) => ({ + indexDocId: p.indexDocId, + syncServer: p.syncServer, + description: p.description, + lastAccessed: p.lastAccessed, + })), + docId, + ); + console.log(`Migrated ${added} project(s) to the root collection`); + } + await establishRoot(docId, syncServer); + await migrateLocalCollections(syncServer); + setCollections(projectSetService.listCollections()); + setLegacyProjects([]); + setStatus('connected'); + } catch (err) { + setError( + 'Could not reach sync server — your projects are safe, migration will retry automatically. ' + + (err instanceof Error ? err.message : String(err)), + ); + setStatus('needs-migration'); + } + }, [establishRoot]); + + const mergeIntoProjectSet = useCallback(async (projectSetDocId: string, syncServer: string) => { + setStatus('connecting'); + setError(null); + try { + await projectSetService.connectCollection({ projectSetDocId, syncServer }); + const legacy = await projectStorage.listProjects(); + if (legacy.length > 0) { + const added = projectSetService.addProjectsBulk( + legacy.map((p) => ({ + indexDocId: p.indexDocId, + syncServer: p.syncServer, + description: p.description, + lastAccessed: p.lastAccessed, + })), + projectSetDocId, + ); + console.log(`Merged ${added} project(s) into the root collection`); + } + await establishRoot(projectSetDocId, syncServer); + await migrateLocalCollections(syncServer); + setCollections(projectSetService.listCollections()); + setLegacyProjects([]); + setStatus('connected'); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setStatus('needs-migration'); + } + }, [establishRoot]); + + // ---- root-set compat operations ---- + + const refresh = useCallback(() => { + setCollections(projectSetService.listCollections()); + }, []); + + const addProject = useCallback((entry: Omit) => { + projectSetService.addProject(entry); + refresh(); + }, [refresh]); + + const removeProject = useCallback((indexDocId: string) => { + // Personal removal: root plus every subscribed collection this browser + // can see. Other members of shared collections are unaffected (their + // own root supersets still hold the project). + for (const c of projectSetService.listCollections()) { + projectSetService.removeProjectFromCollection(c.docId, indexDocId); + } + refresh(); + }, [refresh]); + + const updateProjectDescription = useCallback((indexDocId: string, description: string) => { + projectSetService.updateProjectDescriptionEverywhere(indexDocId, description); + refresh(); + }, [refresh]); + + const updateProjectSummary = useCallback((indexDocId: string, summary: ProjectSetEntrySummary) => { + if (projectSetService.updateProjectSummaryEverywhere(indexDocId, summary)) { + refresh(); + } + }, [refresh]); + + const touchProject = useCallback((indexDocId: string) => { + projectSetService.touchProjectEverywhere(indexDocId); + }, []); + + const getProjectSetDocId = useCallback(() => projectSetService.getProjectSetDocId(), []); + const getSyncServer = useCallback(() => syncServerRef.current, []); + + // ---- collection operations ---- + + const createCollection = useCallback(async (name: string): Promise => { + const server = syncServerRef.current; + if (!server) throw new Error('Not connected'); + const docId = await projectSetService.createCollection(server, name); + await addCollectionPointer({ projectSetDocId: docId, syncServer: server }); + refresh(); + return docId; + }, [refresh]); + + const subscribeCollection = useCallback(async (projectSetDocId: string, syncServer: string) => { + await projectSetService.connectCollection({ projectSetDocId, syncServer }); + await addCollectionPointer({ projectSetDocId, syncServer }); + refresh(); + }, [refresh]); + + const unsubscribeCollection = useCallback(async (collectionDocId: string) => { + projectSetService.disconnectCollection(collectionDocId); + await removeCollectionPointer(collectionDocId); + refresh(); + }, [refresh]); + + const renameCollection = useCallback((collectionDocId: string, name: string) => { + projectSetService.renameCollection(collectionDocId, name); + refresh(); + }, [refresh]); + + const addProjectToCollection = useCallback((collectionDocId: string, entry: Omit) => { + projectSetService.addProjectToCollection(collectionDocId, entry); + refresh(); + }, [refresh]); + + const removeProjectFromCollection = useCallback((collectionDocId: string, indexDocId: string) => { + projectSetService.removeProjectFromCollection(collectionDocId, indexDocId); + refresh(); + }, [refresh]); + + const moveProjectBetweenCollections = useCallback((fromDocId: string, toDocId: string, indexDocId: string) => { + projectSetService.moveProjectBetweenCollections(fromDocId, toDocId, indexDocId); + refresh(); + }, [refresh]); + + const root = collections.find((c) => c.isRoot); + const state: CollectionSetsState = { + status, + collections, + projects: root?.entries ?? [], + unreachable, + error, + legacyProjects, + }; + + const actions: CollectionSetsActions = { + createProjectSet, + linkProjectSet, + migrateProjects, + mergeIntoProjectSet, + addProject, + removeProject, + updateProjectDescription, + updateProjectSummary, + touchProject, + getProjectSetDocId, + getSyncServer, + createCollection, + subscribeCollection, + unsubscribeCollection, + renameCollection, + addProjectToCollection, + removeProjectFromCollection, + moveProjectBetweenCollections, + }; + + return [state, actions]; +} diff --git a/hub-client/src/hooks/useProjectSet.ts b/hub-client/src/hooks/useProjectSet.ts index d24398028..b6bf5fc4a 100644 --- a/hub-client/src/hooks/useProjectSet.ts +++ b/hub-client/src/hooks/useProjectSet.ts @@ -9,7 +9,7 @@ */ import { useState, useEffect, useCallback, useRef } from 'react'; -import type { ProjectSetEntry } from '@quarto/quarto-automerge-schema'; +import type { ProjectSetEntry, ProjectSetEntrySummary } from '@quarto/quarto-automerge-schema'; import { getProjectSetPointer, setProjectSetPointer, @@ -54,6 +54,8 @@ export interface ProjectSetActions { removeProject: (indexDocId: string) => void; /** Update a project's description. */ updateProjectDescription: (indexDocId: string, description: string) => void; + /** Replace a project's cached peek summary. */ + updateProjectSummary: (indexDocId: string, summary: ProjectSetEntrySummary) => void; /** Touch a project (update lastAccessed). */ touchProject: (indexDocId: string) => void; /** Get the connected project set document ID. */ @@ -264,6 +266,12 @@ export function useProjectSet(): [ProjectSetState, ProjectSetActions] { setProjects(projectSetService.listProjects()); }, []); + const updateProjectSummary = useCallback((indexDocId: string, summary: ProjectSetEntrySummary) => { + if (projectSetService.updateProjectSummary(indexDocId, summary)) { + setProjects(projectSetService.listProjects()); + } + }, []); + const touchProject = useCallback((indexDocId: string) => { projectSetService.touchProject(indexDocId); // Don't update projects list for touch — it's a minor metadata update @@ -286,6 +294,7 @@ export function useProjectSet(): [ProjectSetState, ProjectSetActions] { addProject, removeProject, updateProjectDescription, + updateProjectSummary, touchProject, getProjectSetDocId, getSyncServer, diff --git a/hub-client/src/services/authService.test.ts b/hub-client/src/services/authService.test.ts index a7c13734f..dfd99ddf2 100644 --- a/hub-client/src/services/authService.test.ts +++ b/hub-client/src/services/authService.test.ts @@ -268,6 +268,41 @@ describe('authService', () => { expect(onSessionExpired).not.toHaveBeenCalled(); }); + it('returns the fallback actor id (not the network) when auth is disabled', async () => { + // Auth-less deployments (local-prod) have no /auth/actor to call, but a + // stable local actor id lets identity stamping still work. The fallback + // is returned verbatim; the network is never touched. + const onSessionExpired = vi.fn(); + const result = await resolveActorId( + 'automerge:abc', + false, + onSessionExpired, + '6d914340d834489b934c58390f9b3301', + ); + + expect(result).toBe('6d914340d834489b934c58390f9b3301'); + expect(fetch).not.toHaveBeenCalled(); + expect(onSessionExpired).not.toHaveBeenCalled(); + }); + + it('ignores the fallback when auth is enabled (server actor wins)', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ actor_id: 'serveractor' }), + } as Response); + const onSessionExpired = vi.fn(); + + const result = await resolveActorId( + 'automerge:abc', + true, + onSessionExpired, + '6d914340d834489b934c58390f9b3301', + ); + + expect(result).toBe('serveractor'); + }); + it('returns the actor ID on success without triggering refresh', async () => { vi.mocked(fetch).mockResolvedValue({ ok: true, diff --git a/hub-client/src/services/authService.ts b/hub-client/src/services/authService.ts index 52ba6b980..5d78c1641 100644 --- a/hub-client/src/services/authService.ts +++ b/hub-client/src/services/authService.ts @@ -71,9 +71,14 @@ export async function fetchActorId(projectId: string): Promise { * Resolve the per-project actor ID for a document open. Three-valued contract * the callers depend on: * - string → actor ID resolved; open with it - * - undefined → auth disabled; open with no (random) actor ID + * - undefined → auth disabled and no fallback; open with a random actor ID * - null → auth failure (401/403); abandon the open * + * When auth is disabled, `fallbackActorId` (if provided) is returned so the + * open uses a *stable* local actor — this is how auth-less deployments + * (local-prod / `--allow-insecure-auth`) still stamp a consistent identity into + * documents. The network is never touched in the auth-disabled branch. + * * On auth failure we fire `onSessionExpired` (a silent refresh) and return * `null` so callers' `=== null` guard abandons this attempt; One Tap either * restores the session in place or eventually clears auth via its onError path. @@ -83,8 +88,9 @@ export async function resolveActorId( indexDocId: string, authEnabled: boolean, onSessionExpired: () => void, + fallbackActorId?: string, ): Promise { - if (!authEnabled) return undefined; + if (!authEnabled) return fallbackActorId; const id = await fetchActorId(indexDocId); if (id === null) { onSessionExpired(); diff --git a/hub-client/src/services/projectSetService.ts b/hub-client/src/services/projectSetService.ts index 7c2a3f7af..5acfe5c09 100644 --- a/hub-client/src/services/projectSetService.ts +++ b/hub-client/src/services/projectSetService.ts @@ -1,12 +1,19 @@ /** - * Project Set Service + * Project Set / Collections Service * - * Manages the Automerge-backed project set document that stores a user's - * project list. This replaces the per-browser IndexedDB project list, - * enabling cross-browser sync of the project collection. + * Manages the Automerge-backed ProjectSetDocuments that store a user's + * project lists. Historically this service managed exactly one document + * (the project set); with collections, each collection IS a + * ProjectSetDocument and this service manages a MAP of connections — + * one per collection the browser is subscribed to. * - * The service manages its own Automerge Repo connection, independent from - * the per-project SyncClient used for collaborative editing. + * The first connection (insertion order) is the personal root collection: + * the migrated legacy project set that acts as the superset of the user's + * projects. The legacy singleton API (connect, listProjects, addProject, + * …) delegates to it, so pre-collections callers keep working unchanged. + * + * Repos are shared per sync server: many collection docs on one server + * ride a single websocket connection. */ import { Repo } from '@automerge/automerge-repo'; @@ -19,35 +26,81 @@ import { resolveSyncServerUrl } from '../utils/routing'; import type { ProjectSetDocument, ProjectSetEntry, + ProjectSetEntrySummary, } from '@quarto/quarto-automerge-schema'; import { CURRENT_PROJECT_SET_SCHEMA_VERSION, addProjectToSet, removeProjectFromSet, touchProjectInSet, + updateProjectSummaryInSet, + setProjectSetName, projectSetKey, } from '@quarto/quarto-automerge-schema'; +import type { CollectionPointerEntry } from './storage/types'; // ============================================================================ // Types // ============================================================================ -/** Callback fired when the project list changes (local or remote). */ +/** Callback fired when the ROOT collection's project list changes. */ export type ProjectsChangeHandler = (projects: ProjectSetEntry[]) => void; +/** Callback fired when any collection's contents (or the set of + * collections) changes. */ +export type CollectionsChangeHandler = (collections: CollectionSnapshot[]) => void; + /** Callback fired when the connection state changes. */ export type ConnectionChangeHandler = (connected: boolean) => void; +/** Immutable view of one connected collection. */ +export interface CollectionSnapshot { + /** Automerge document id of the collection's ProjectSetDocument. */ + docId: string; + /** Sync server the collection lives on. */ + syncServer: string; + /** Collection display name (absent on pre-collections documents). */ + name?: string; + /** Entries sorted by lastAccessed, most recent first. */ + entries: ProjectSetEntry[]; + /** True for the personal root collection (first connection). */ + isRoot: boolean; +} + +interface CollectionConnection { + docId: string; + syncServer: string; + handle: DocHandle; + cleanup: () => void; +} + +interface ServerConnection { + repo: Repo; + wsAdapter: BrowserWebSocketClientAdapter; + /** Number of collection connections using this server. */ + refCount: number; + /** + * Resolves once on the first peer connection and stays resolved. The + * repo's 'peer' event fires only on the initial connect, so we latch it + * here — otherwise the second collection sharing this server would wait + * forever for an event that never fires again. + */ + ready: Promise; +} + // ============================================================================ // Internal State // ============================================================================ -let repo: Repo | null = null; -let wsAdapter: BrowserWebSocketClientAdapter | null = null; -let handle: DocHandle | null = null; -let cleanupFn: (() => void) | null = null; +/** Server connections keyed by resolved websocket URL. */ +const servers = new Map(); + +/** Collection connections keyed by doc id; insertion order matters — + * the first entry is the personal root collection. */ +const connections = new Map(); let onProjectsChange: ProjectsChangeHandler | null = null; +let onCollectionsChange: CollectionsChangeHandler | null = null; let onConnectionChange: ConnectionChangeHandler | null = null; // ============================================================================ @@ -55,13 +108,15 @@ let onConnectionChange: ConnectionChangeHandler | null = null; // ============================================================================ /** - * Set callbacks for project set events. + * Set callbacks for project set / collection events. */ export function setProjectSetHandlers(handlers: { onProjectsChange?: ProjectsChangeHandler; + onCollectionsChange?: CollectionsChangeHandler; onConnectionChange?: ConnectionChangeHandler; }): void { if (handlers.onProjectsChange) onProjectsChange = handlers.onProjectsChange; + if (handlers.onCollectionsChange) onCollectionsChange = handlers.onCollectionsChange; if (handlers.onConnectionChange) onConnectionChange = handlers.onConnectionChange; } @@ -76,33 +131,89 @@ function getProjectsList(doc: ProjectSetDocument): ProjectSetEntry[] { ); } -function notifyProjectsChange(): void { - if (!handle || !onProjectsChange) return; - const doc = handle.doc(); - if (doc) { - onProjectsChange(getProjectsList(doc)); +function rootConnection(): CollectionConnection | null { + const first = connections.values().next(); + return first.done ? null : first.value; +} + +function connectionOrThrow(collectionDocId?: string): CollectionConnection { + const conn = collectionDocId ? connections.get(collectionDocId) : rootConnection(); + if (!conn) { + throw new Error( + collectionDocId + ? `Not connected to collection ${collectionDocId}` + : 'Not connected to a project set', + ); } + return conn; } -function waitForPeer(r: Repo, timeoutMs: number = 5000): Promise { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - cleanup(); - reject(new Error('Timeout waiting for peer connection')); - }, timeoutMs); +function snapshotOf(conn: CollectionConnection): CollectionSnapshot { + const doc = conn.handle.doc(); + return { + docId: conn.docId, + syncServer: conn.syncServer, + name: doc?.name, + entries: doc ? getProjectsList(doc) : [], + isRoot: rootConnection()?.docId === conn.docId, + }; +} - const onPeer = () => { - cleanup(); - resolve(); - }; +function notifyChange(): void { + if (onProjectsChange) { + const root = rootConnection(); + const doc = root?.handle.doc(); + if (doc) onProjectsChange(getProjectsList(doc)); + } + if (onCollectionsChange) { + onCollectionsChange(listCollections()); + } +} - const cleanup = () => { - clearTimeout(timeoutId); - r.networkSubsystem.off('peer', onPeer); - }; +/** + * Race a server's latched readiness against a timeout. + * @returns true if a peer connected within the timeout, false otherwise. + */ +function awaitServerReady(server: ServerConnection, timeoutMs: number): Promise { + return Promise.race([ + server.ready.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + ]); +} - r.networkSubsystem.on('peer', onPeer); - }); +/** + * Get or create the shared Repo for a sync server. Creation latches the + * first 'peer' event into `server.ready`, so every collection on the + * server can await readiness regardless of connection order. + */ +function acquireServer(syncServerUrl: string): ServerConnection { + const resolved = resolveSyncServerUrl(syncServerUrl); + let server = servers.get(resolved); + if (!server) { + const wsAdapter = new BrowserWebSocketClientAdapter(resolved); + const repo = new Repo({ + network: [wsAdapter], + storage: new IndexedDBStorageAdapter(), + }); + const ready = new Promise((resolve) => { + repo.networkSubsystem.on('peer', () => resolve()); + }); + server = { repo, wsAdapter, refCount: 0, ready }; + servers.set(resolved, server); + } + server.refCount++; + return server; +} + +function releaseServer(syncServerUrl: string): void { + const resolved = resolveSyncServerUrl(syncServerUrl); + const server = servers.get(resolved); + if (!server) return; + server.refCount--; + if (server.refCount <= 0) { + server.wsAdapter.disconnect(); + servers.delete(resolved); + } } // ============================================================================ @@ -110,231 +221,370 @@ function waitForPeer(r: Repo, timeoutMs: number = 5000): Promise { // ============================================================================ /** - * Connect to an existing project set document. - * - * Resolves immediately if the document is available in local storage, - * then syncs with the server in the background. - * - * @returns The current list of projects, sorted by lastAccessed (most recent first). - * @throws If the document cannot be loaded (e.g., first load on a new browser - * while offline — the document hasn't been cached locally yet). + * Connect to one collection document. Resolves from local cache when + * available (background-syncing after), otherwise waits for the server. + * Idempotent per doc id. */ -export async function connect( - syncServerUrl: string, - projectSetDocId: string, -): Promise { - await disconnect(); - - wsAdapter = new BrowserWebSocketClientAdapter(resolveSyncServerUrl(syncServerUrl)); - repo = new Repo({ - network: [wsAdapter], - storage: new IndexedDBStorageAdapter(), - }); +export async function connectCollection( + pointer: CollectionPointerEntry, +): Promise { + const existing = connections.get(pointer.projectSetDocId); + if (existing) return snapshotOf(existing); - const docId = projectSetDocId as DocumentId; - const foundHandle = await repo.find(docId); - handle = foundHandle; + const server = acquireServer(pointer.syncServer); + try { + const docId = pointer.projectSetDocId as DocumentId; + const handle = await server.repo.find(docId); + + if (!handle.doc()) { + // No local cache — wait for the network before declaring the doc. + const isOnline = await awaitServerReady(server, 5000); + onConnectionChange?.(isOnline); + await handle.whenReady(); + if (!handle.doc()) { + throw new Error( + isOnline + ? 'Failed to load collection document' + : 'Collection not found in local storage. Connect online first to sync.', + ); + } + } else { + awaitServerReady(server, 5000).then((online) => onConnectionChange?.(online)); + } - // Check if we have a cached document locally - const doc = handle.doc(); - if (doc) { - // Have local data — resolve immediately - // Subscribe to changes (from remote browsers) - const onChange = () => notifyProjectsChange(); + const onChange = () => notifyChange(); handle.on('change', onChange); - cleanupFn = () => handle?.off('change', onChange); - - // Start background sync (but don't wait for it) - waitForPeer(repo, 5000) - .then(() => onConnectionChange?.(true)) - .catch(() => onConnectionChange?.(false)); - - return getProjectsList(doc); - } - - // No local data — need to sync from server first - let isOnline = false; - try { - await waitForPeer(repo, 5000); - isOnline = true; - } catch { - isOnline = false; + const conn: CollectionConnection = { + docId: pointer.projectSetDocId, + syncServer: pointer.syncServer, + handle, + cleanup: () => handle.off('change', onChange), + }; + connections.set(conn.docId, conn); + return snapshotOf(conn); + } catch (err) { + releaseServer(pointer.syncServer); + throw err; } +} - onConnectionChange?.(isOnline); - - await foundHandle.whenReady(); - - const syncedDoc = handle.doc(); - if (!syncedDoc) { - throw new Error( - isOnline - ? 'Failed to load project set document' - : 'Project set not found in local storage. Connect online first to sync.', - ); +/** + * Connect to many collections. Individual failures don't abort the rest; + * they're reported in the result so the UI can surface unreachable + * collections without losing the reachable ones. + */ +export async function connectCollections( + pointers: CollectionPointerEntry[], +): Promise<{ connected: CollectionSnapshot[]; failed: Array<{ pointer: CollectionPointerEntry; error: string }> }> { + const connected: CollectionSnapshot[] = []; + const failed: Array<{ pointer: CollectionPointerEntry; error: string }> = []; + for (const pointer of pointers) { + try { + connected.push(await connectCollection(pointer)); + } catch (err) { + failed.push({ pointer, error: err instanceof Error ? err.message : String(err) }); + } } - - // Subscribe to changes (from remote browsers) - const onChange = () => notifyProjectsChange(); - handle.on('change', onChange); - cleanupFn = () => handle?.off('change', onChange); - - return getProjectsList(syncedDoc); + notifyChange(); + return { connected, failed }; } /** - * Create a new project set document on the sync server. + * Create a new collection document on a sync server. * - * @returns The document ID of the newly created ProjectSetDocument. + * @returns The document ID of the new ProjectSetDocument. * @throws If the sync server is unreachable. */ -export async function createProjectSet( +export async function createCollection( syncServerUrl: string, + name?: string, ): Promise { - await disconnect(); - - wsAdapter = new BrowserWebSocketClientAdapter(resolveSyncServerUrl(syncServerUrl)); - repo = new Repo({ - network: [wsAdapter], - storage: new IndexedDBStorageAdapter(), - }); - - // For creation, we need the server to be reachable so the document - // gets synced. Without this, the document would only exist locally. - try { - await waitForPeer(repo, 10000); - } catch { - await disconnect(); + const server = acquireServer(syncServerUrl); + // Creation requires the server so the document actually syncs. + if (!(await awaitServerReady(server, 10000))) { + releaseServer(syncServerUrl); throw new Error( 'Could not reach sync server. Please check your connection and try again.', ); } - onConnectionChange?.(true); - // Create the document const initial = { projects: {}, version: CURRENT_PROJECT_SET_SCHEMA_VERSION, + ...(name !== undefined ? { name } : {}), } as Record; const doc = automergeFrom(initial); - handle = repo.import(automergeSerialize(doc)); + const handle = server.repo.import(automergeSerialize(doc)); - // Subscribe to changes - const onChange = () => notifyProjectsChange(); + const onChange = () => notifyChange(); handle.on('change', onChange); - cleanupFn = () => handle?.off('change', onChange); - + const conn: CollectionConnection = { + docId: handle.documentId, + syncServer: syncServerUrl, + handle, + cleanup: () => handle.off('change', onChange), + }; + connections.set(conn.docId, conn); + notifyChange(); return handle.documentId; } /** - * Disconnect from the project set sync. + * Disconnect one collection (unsubscribe). The document is untouched. + */ +export function disconnectCollection(collectionDocId: string): void { + const conn = connections.get(collectionDocId); + if (!conn) return; + conn.cleanup(); + connections.delete(collectionDocId); + releaseServer(conn.syncServer); + notifyChange(); +} + +/** + * Disconnect everything (all collections, all servers). */ export async function disconnect(): Promise { - if (cleanupFn) { - cleanupFn(); - cleanupFn = null; + for (const conn of connections.values()) { + conn.cleanup(); } - handle = null; - if (wsAdapter) { - wsAdapter.disconnect(); - wsAdapter = null; + connections.clear(); + for (const server of servers.values()) { + server.wsAdapter.disconnect(); } - repo = null; + servers.clear(); } /** - * Check if currently connected to a project set. + * Check if connected to at least the root collection. */ export function isConnected(): boolean { - return handle !== null; + return rootConnection() !== null; +} + +// ============================================================================ +// Collection Queries and Operations +// ============================================================================ + +/** Snapshots of all connected collections, root first. */ +export function listCollections(): CollectionSnapshot[] { + return [...connections.values()].map(snapshotOf); +} + +/** Snapshot of one collection, or undefined when not connected. */ +export function getCollection(collectionDocId: string): CollectionSnapshot | undefined { + const conn = connections.get(collectionDocId); + return conn ? snapshotOf(conn) : undefined; +} + +/** Rename a collection (for everyone subscribed — it's the shared doc). */ +export function renameCollection(collectionDocId: string, name: string): void { + const conn = connectionOrThrow(collectionDocId); + conn.handle.change(doc => { + setProjectSetName(doc, name); + }); + notifyChange(); +} + +/** Add a project entry to a collection (deduped by indexDocId). */ +export function addProjectToCollection( + collectionDocId: string, + entry: Omit, +): void { + const conn = connectionOrThrow(collectionDocId); + conn.handle.change(doc => { + addProjectToSet(doc, entry); + }); + notifyChange(); +} + +/** Remove a project entry from a collection. */ +export function removeProjectFromCollection( + collectionDocId: string, + indexDocId: string, +): void { + const conn = connectionOrThrow(collectionDocId); + conn.handle.change(doc => { + removeProjectFromSet(doc, indexDocId); + }); + notifyChange(); +} + +/** + * Move a project between collections: copy the entry (preserving its + * metadata) into the target, then remove it from the source. + */ +export function moveProjectBetweenCollections( + fromDocId: string, + toDocId: string, + indexDocId: string, +): void { + if (fromDocId === toDocId) return; + const from = connectionOrThrow(fromDocId); + const to = connectionOrThrow(toDocId); + const sourceDoc = from.handle.doc(); + const entry = sourceDoc?.projects[projectSetKey(indexDocId)]; + if (!entry) return; + const copy: ProjectSetEntry = JSON.parse(JSON.stringify(entry)); + to.handle.change(doc => { + const key = projectSetKey(indexDocId); + if (!doc.projects[key]) { + doc.projects[key] = copy; + } + }); + from.handle.change(doc => { + removeProjectFromSet(doc, indexDocId); + }); + notifyChange(); +} + +/** Update a project's description wherever the entry appears. */ +export function updateProjectDescriptionEverywhere( + indexDocId: string, + description: string, +): void { + const key = projectSetKey(indexDocId); + for (const conn of connections.values()) { + if (conn.handle.doc()?.projects[key]) { + conn.handle.change(doc => { + const entry = doc.projects[key]; + if (entry) entry.description = description; + }); + } + } + notifyChange(); +} + +/** Touch lastAccessed wherever the entry appears. */ +export function touchProjectEverywhere(indexDocId: string): void { + const key = projectSetKey(indexDocId); + for (const conn of connections.values()) { + if (conn.handle.doc()?.projects[key]) { + conn.handle.change(doc => { + touchProjectInSet(doc, indexDocId); + }); + } + } + // No notify: minor metadata update, caller knows what it selected. +} + +/** Update the cached peek summary wherever the entry appears. */ +export function updateProjectSummaryEverywhere( + indexDocId: string, + summary: ProjectSetEntrySummary, +): boolean { + const key = projectSetKey(indexDocId); + let updated = false; + for (const conn of connections.values()) { + if (conn.handle.doc()?.projects[key]) { + conn.handle.change(doc => { + updated = updateProjectSummaryInSet(doc, indexDocId, summary) || updated; + }); + } + } + if (updated) notifyChange(); + return updated; } // ============================================================================ -// Project CRUD Operations +// Legacy singleton API (delegates to the personal root collection) // ============================================================================ /** - * List all projects in the set, sorted by lastAccessed (most recent first). + * Connect to an existing project set document as the personal root + * collection, tearing down any previous connections. + * + * @returns The current list of projects, most recently accessed first. + */ +export async function connect( + syncServerUrl: string, + projectSetDocId: string, +): Promise { + await disconnect(); + const snapshot = await connectCollection({ projectSetDocId, syncServer: syncServerUrl }); + return snapshot.entries; +} + +/** + * Create a new project set document as the personal root collection. + * + * @returns The document ID of the newly created ProjectSetDocument. + */ +export async function createProjectSet(syncServerUrl: string): Promise { + await disconnect(); + return createCollection(syncServerUrl); +} + +/** + * List all projects in the root set, sorted by lastAccessed. */ export function listProjects(): ProjectSetEntry[] { - if (!handle) return []; - const doc = handle.doc(); - if (!doc) return []; - return getProjectsList(doc); + const root = rootConnection(); + const doc = root?.handle.doc(); + return doc ? getProjectsList(doc) : []; } /** - * Get a single project by its indexDocId. + * Get a single project from the root set by its indexDocId. */ export function getProject(indexDocId: string): ProjectSetEntry | undefined { - if (!handle) return undefined; - const doc = handle.doc(); - if (!doc) return undefined; - const key = projectSetKey(indexDocId); - return doc.projects[key]; + const doc = rootConnection()?.handle.doc(); + return doc?.projects[projectSetKey(indexDocId)]; } /** - * Add a project to the set. + * Add a project to the root set. */ export function addProject( entry: Omit, ): void { - if (!handle) throw new Error('Not connected to a project set'); - handle.change(doc => { - addProjectToSet(doc, entry); - }); - notifyProjectsChange(); + addProjectToCollection(connectionOrThrow().docId, entry); } /** - * Remove a project from the set. + * Remove a project from the root set. */ export function removeProject(indexDocId: string): void { - if (!handle) throw new Error('Not connected to a project set'); - handle.change(doc => { - removeProjectFromSet(doc, indexDocId); - }); - notifyProjectsChange(); + removeProjectFromCollection(connectionOrThrow().docId, indexDocId); } /** - * Update the description of a project. + * Update the description of a project (in every collection it appears in). */ export function updateProjectDescription( indexDocId: string, description: string, ): void { - if (!handle) throw new Error('Not connected to a project set'); - handle.change(doc => { - const key = projectSetKey(indexDocId); - const entry = doc.projects[key]; - if (entry) { - entry.description = description; - } - }); - notifyProjectsChange(); + connectionOrThrow(); + updateProjectDescriptionEverywhere(indexDocId, description); } /** - * Update the lastAccessed timestamp for a project. + * Update the lastAccessed timestamp (in every collection it appears in). */ export function touchProject(indexDocId: string): void { - if (!handle) throw new Error('Not connected to a project set'); - handle.change(doc => { - touchProjectInSet(doc, indexDocId); - }); - // Don't notify for touch — it's a minor metadata update and the caller - // already knows which project was selected. + connectionOrThrow(); + touchProjectEverywhere(indexDocId); } /** - * Get the document ID of the connected project set, or null if not connected. + * Replace the cached peek summary (in every collection it appears in). + * No-op (returns false) when not connected or the entry is missing. + */ +export function updateProjectSummary( + indexDocId: string, + summary: ProjectSetEntrySummary, +): boolean { + if (!rootConnection()) return false; + return updateProjectSummaryEverywhere(indexDocId, summary); +} + +/** + * Get the document ID of the root project set, or null if not connected. */ export function getProjectSetDocId(): string | null { - return handle?.documentId ?? null; + return rootConnection()?.docId ?? null; } // ============================================================================ @@ -342,8 +592,8 @@ export function getProjectSetDocId(): string | null { // ============================================================================ /** - * Add multiple projects to the set in a single Automerge change. - * Used during migration from IndexedDB to avoid N individual changes. + * Add multiple projects to a collection in a single Automerge change. + * Targets the root set when no collection id is given. * * @returns The number of projects actually added (excludes duplicates). */ @@ -354,11 +604,12 @@ export function addProjectsBulk( description: string; lastAccessed?: string; }>, + collectionDocId?: string, ): number { - if (!handle) throw new Error('Not connected to a project set'); + const conn = connectionOrThrow(collectionDocId); let added = 0; - handle.change(doc => { + conn.handle.change(doc => { for (const entry of entries) { const key = projectSetKey(entry.indexDocId); if (!doc.projects[key]) { @@ -374,7 +625,7 @@ export function addProjectsBulk( } } }); - notifyProjectsChange(); + notifyChange(); return added; } @@ -383,8 +634,7 @@ export function addProjectsBulk( // ============================================================================ /** - * Export the project set as a JSON-serializable array. - * Used by the export/backup functionality. + * Export the root project set as a JSON-serializable array. */ export function exportProjects(): ProjectSetEntry[] { return listProjects(); @@ -399,28 +649,36 @@ export function exportProjects(): ProjectSetEntry[] { * @internal For testing only. */ export function _resetForTesting(): void { - handle = null; - wsAdapter = null; - repo = null; - cleanupFn = null; + for (const conn of connections.values()) conn.cleanup(); + connections.clear(); + servers.clear(); onProjectsChange = null; + onCollectionsChange = null; onConnectionChange = null; } /** - * Get the internal handle for testing. + * Get the root handle for testing. * @internal For testing only. */ export function _getHandleForTesting(): DocHandle | null { - return handle; + return rootConnection()?.handle ?? null; } /** - * Set an internal handle for testing (mock injection). + * Inject a root handle for testing (mock injection). * @internal For testing only. */ export function _setHandleForTesting( mockHandle: DocHandle | null, ): void { - handle = mockHandle; + connections.clear(); + if (mockHandle) { + connections.set('_test-root', { + docId: '_test-root', + syncServer: 'wss://test.invalid', + handle: mockHandle, + cleanup: () => {}, + }); + } } diff --git a/hub-client/src/services/projectSetStorage.test.ts b/hub-client/src/services/projectSetStorage.test.ts index b6c8ed34d..490abe8c6 100644 --- a/hub-client/src/services/projectSetStorage.test.ts +++ b/hub-client/src/services/projectSetStorage.test.ts @@ -12,6 +12,10 @@ import { getProjectSetPointer, setProjectSetPointer, clearProjectSetPointer, + getCollectionPointers, + setCollectionPointers, + addCollectionPointer, + removeCollectionPointer, } from './projectSetStorage'; import { closeDatabase } from './projectStorage'; @@ -68,4 +72,98 @@ describe('projectSetStorage', () => { const pointer = await getProjectSetPointer(); expect(pointer).toBeNull(); }); + + describe('collection pointers', () => { + it('should return [] for a fresh browser', async () => { + expect(await getCollectionPointers()).toEqual([]); + }); + + it('should self-heal from a legacy singleton pointer', async () => { + await setProjectSetPointer('automerge:legacy1', 'wss://sync.example.com'); + const collections = await getCollectionPointers(); + expect(collections).toEqual([ + { projectSetDocId: 'automerge:legacy1', syncServer: 'wss://sync.example.com' }, + ]); + // Legacy pointer is preserved as a safety net + expect((await getProjectSetPointer())!.projectSetDocId).toBe('automerge:legacy1'); + // Conversion is stable across reads (idempotent) + expect(await getCollectionPointers()).toEqual(collections); + }); + + it('should not re-convert once the collections record exists', async () => { + await setProjectSetPointer('automerge:legacy1', 'wss://sync.example.com'); + await getCollectionPointers(); + // A later legacy-pointer change must not clobber the collections array + await setProjectSetPointer('automerge:legacy2', 'wss://sync.example.com'); + const collections = await getCollectionPointers(); + expect(collections.map((c) => c.projectSetDocId)).toEqual(['automerge:legacy1']); + }); + + it('should add with dedupe and remove by doc id', async () => { + await addCollectionPointer({ projectSetDocId: 'automerge:a', syncServer: 'wss://s1' }); + await addCollectionPointer({ projectSetDocId: 'automerge:b', syncServer: 'wss://s2' }); + await addCollectionPointer({ projectSetDocId: 'automerge:a', syncServer: 'wss://s1' }); + expect((await getCollectionPointers()).map((c) => c.projectSetDocId)).toEqual([ + 'automerge:a', + 'automerge:b', + ]); + + await removeCollectionPointer('automerge:a'); + expect((await getCollectionPointers()).map((c) => c.projectSetDocId)).toEqual([ + 'automerge:b', + ]); + }); + + it('pins the legacy-singleton (root) to the front when present but out of order', async () => { + // Simulate a browser whose collections record was built by another path + // first (e.g. the localStorage-collections migration), leaving the real + // root — the legacy singleton — at a non-zero index. Root identity is + // positional (collections[0]) elsewhere, so it must be normalized to front. + await setProjectSetPointer('automerge:root', 'wss://s'); + await setCollectionPointers([ + { projectSetDocId: 'automerge:other1', syncServer: 'wss://s' }, + { projectSetDocId: 'automerge:root', syncServer: 'wss://s' }, + { projectSetDocId: 'automerge:other2', syncServer: 'wss://s' }, + ]); + expect((await getCollectionPointers()).map((c) => c.projectSetDocId)).toEqual([ + 'automerge:root', + 'automerge:other1', + 'automerge:other2', + ]); + }); + + it('matches the root regardless of the automerge: prefix', async () => { + // Real browsers store bare doc ids in the collections array but the + // singleton may carry the prefix (or vice versa); matching normalizes it. + await setProjectSetPointer('automerge:root', 'wss://s'); + await setCollectionPointers([ + { projectSetDocId: 'other1', syncServer: 'wss://s' }, + { projectSetDocId: 'root', syncServer: 'wss://s' }, + ]); + expect((await getCollectionPointers()).map((c) => c.projectSetDocId)).toEqual([ + 'root', + 'other1', + ]); + }); + + it('leaves order unchanged when there is no legacy singleton', async () => { + await setCollectionPointers([ + { projectSetDocId: 'automerge:a', syncServer: 'wss://s' }, + { projectSetDocId: 'automerge:b', syncServer: 'wss://s' }, + ]); + expect((await getCollectionPointers()).map((c) => c.projectSetDocId)).toEqual([ + 'automerge:a', + 'automerge:b', + ]); + }); + + it('should replace the full array with setCollectionPointers', async () => { + await setCollectionPointers([ + { projectSetDocId: 'automerge:x', syncServer: 'wss://s' }, + ]); + expect((await getCollectionPointers()).length).toBe(1); + await setCollectionPointers([]); + expect(await getCollectionPointers()).toEqual([]); + }); + }); }); diff --git a/hub-client/src/services/projectSetStorage.ts b/hub-client/src/services/projectSetStorage.ts index c98d9921f..d20fb3e89 100644 --- a/hub-client/src/services/projectSetStorage.ts +++ b/hub-client/src/services/projectSetStorage.ts @@ -5,8 +5,9 @@ * Automerge-backed ProjectSetDocument. The actual project list lives * in the Automerge document, synced across browsers. */ -import type { ProjectSetPointer } from './storage/types'; +import type { ProjectSetPointer, CollectionPointerEntry, CollectionsPointer } from './storage/types'; import { STORES, getDb } from './storage'; +import { migratePointerToCollections } from './storage/migrations'; /** * Get the stored project set pointer, or null if not yet configured. @@ -48,3 +49,91 @@ export async function clearProjectSetPointer(): Promise { await db.delete(STORES.PROJECT_SET, 'projectSet'); } } + +// ============================================================================ +// Collections pointer (array of collection ProjectSetDocuments) +// ============================================================================ + +/** + * Get the collections this browser is subscribed to. + * + * Self-healing: when the collections record is missing but a legacy + * singleton pointer exists (e.g. written by an older code path after the + * v5 migration ran), it is converted on the spot. Returns [] for a fresh + * browser. + */ +export async function getCollectionPointers(): Promise { + const db = await getDb(); + if (!db.objectStoreNames.contains(STORES.PROJECT_SET)) { + return []; + } + let collections: CollectionPointerEntry[]; + const record: CollectionsPointer | undefined = await db.get(STORES.PROJECT_SET, 'collections'); + if (record) { + collections = record.collections; + } else { + await migratePointerToCollections(db); + const migrated: CollectionsPointer | undefined = await db.get(STORES.PROJECT_SET, 'collections'); + collections = migrated?.collections ?? []; + } + const singleton: ProjectSetPointer | undefined = await db.get(STORES.PROJECT_SET, 'projectSet'); + return pinRootFirst(collections, singleton); +} + +/** + * Ensure the root — the doc named by the legacy singleton pointer — is first. + * + * Root identity is positional elsewhere (`collections[0]` is treated as the + * personal superset). But the array can be built out of order when the + * collections record is first created by a path *other* than the + * singleton→collections migration (notably the `qh-collections-v1` + * localStorage-collections migration, which creates collection pointers before + * the singleton self-heal can prepend the root). Reorder on read so the root + * is always index 0, regardless of how the array was assembled. No-op when + * there is no singleton, the array is trivial, or the root is already first. + */ +function pinRootFirst( + collections: CollectionPointerEntry[], + singleton: ProjectSetPointer | undefined, +): CollectionPointerEntry[] { + if (!singleton || collections.length < 2) return collections; + const norm = (id: string) => id.replace(/^automerge:/, ''); + const rootId = norm(singleton.projectSetDocId); + const idx = collections.findIndex((c) => norm(c.projectSetDocId) === rootId); + if (idx <= 0) return collections; + const reordered = [...collections]; + const [root] = reordered.splice(idx, 1); + reordered.unshift(root); + return reordered; +} + +/** Replace the full collections array. */ +export async function setCollectionPointers( + collections: CollectionPointerEntry[], +): Promise { + const db = await getDb(); + const record: CollectionsPointer = { key: 'collections', collections }; + await db.put(STORES.PROJECT_SET, record); +} + +/** Subscribe to a collection (no-op if the doc id is already present). */ +export async function addCollectionPointer( + entry: CollectionPointerEntry, +): Promise { + const existing = await getCollectionPointers(); + if (existing.some((c) => c.projectSetDocId === entry.projectSetDocId)) { + return; + } + await setCollectionPointers([...existing, entry]); +} + +/** Unsubscribe from a collection. The document itself is untouched. */ +export async function removeCollectionPointer( + projectSetDocId: string, +): Promise { + const existing = await getCollectionPointers(); + const filtered = existing.filter((c) => c.projectSetDocId !== projectSetDocId); + if (filtered.length !== existing.length) { + await setCollectionPointers(filtered); + } +} diff --git a/hub-client/src/services/storage/migrations.test.ts b/hub-client/src/services/storage/migrations.test.ts new file mode 100644 index 000000000..f892d2aab --- /dev/null +++ b/hub-client/src/services/storage/migrations.test.ts @@ -0,0 +1,92 @@ +/** + * Tests for the v5 migration: the singleton project-set pointer becomes a + * collections array (old non-collection project list → collection-driven). + * + * See claude-notes/instructions/hub-client-storage.md and + * claude-notes/plans/2026-07-10-collections-as-project-sets.md. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import { IDBFactory } from 'fake-indexeddb'; +import { openDB, type IDBPDatabase } from 'idb'; +import { + migrations, + getMigrationsFrom, + migratePointerToCollections, + CURRENT_SCHEMA_VERSION, +} from './migrations'; +import { STORES } from './types'; + +async function openWithProjectSetStore(): Promise { + return openDB('migration-test', 1, { + upgrade(db) { + db.createObjectStore(STORES.PROJECT_SET, { keyPath: 'key' }); + }, + }); +} + +describe('v5 migration: pointer → collections', () => { + beforeEach(() => { + Object.defineProperty(globalThis, 'indexedDB', { value: new IDBFactory(), writable: true }); + }); + afterEach(() => { + // fresh factory each test + }); + + it('registers v5 as a transform-only migration', () => { + expect(CURRENT_SCHEMA_VERSION).toBe(5); + const v5 = migrations.find((m) => m.version === 5); + expect(v5).toBeDefined(); + expect(typeof v5!.transform).toBe('function'); + // Transform-only: no structural change (DB version does not bump for v5) + expect(v5!.structural).toBeUndefined(); + // getMigrationsFrom(4) must include v5 so an existing v4 browser runs it + expect(getMigrationsFrom(4).map((m) => m.version)).toContain(5); + }); + + it('converts a singleton pointer into a one-element collections array', async () => { + const db = await openWithProjectSetStore(); + await db.put(STORES.PROJECT_SET, { + key: 'projectSet', + projectSetDocId: 'automerge:abc123', + syncServer: 'wss://sync.example.com', + }); + + await migratePointerToCollections(db); + + const collections = await db.get(STORES.PROJECT_SET, 'collections'); + expect(collections).toEqual({ + key: 'collections', + collections: [{ projectSetDocId: 'automerge:abc123', syncServer: 'wss://sync.example.com' }], + }); + // Legacy singleton is retained as a safety net + expect(await db.get(STORES.PROJECT_SET, 'projectSet')).not.toBeUndefined(); + db.close(); + }); + + it('is idempotent — does not clobber an existing collections array', async () => { + const db = await openWithProjectSetStore(); + await db.put(STORES.PROJECT_SET, { key: 'projectSet', projectSetDocId: 'automerge:abc', syncServer: 'wss://s' }); + await migratePointerToCollections(db); + // A later change to the collections array must survive a re-run + await db.put(STORES.PROJECT_SET, { + key: 'collections', + collections: [ + { projectSetDocId: 'automerge:abc', syncServer: 'wss://s' }, + { projectSetDocId: 'automerge:joined', syncServer: 'wss://s' }, + ], + }); + await migratePointerToCollections(db); + const collections = await db.get(STORES.PROJECT_SET, 'collections'); + expect(collections.collections).toHaveLength(2); + db.close(); + }); + + it('no-ops for a fresh browser with no pointer', async () => { + const db = await openWithProjectSetStore(); + await migratePointerToCollections(db); + expect(await db.get(STORES.PROJECT_SET, 'collections')).toBeUndefined(); + db.close(); + }); +}); diff --git a/hub-client/src/services/storage/migrations.ts b/hub-client/src/services/storage/migrations.ts index 6241edb84..f50681c96 100644 --- a/hub-client/src/services/storage/migrations.ts +++ b/hub-client/src/services/storage/migrations.ts @@ -35,7 +35,7 @@ export const CURRENT_DB_VERSION = 4; * Current application schema version. * This is the version number after all migrations have been applied. */ -export const CURRENT_SCHEMA_VERSION = 4; +export const CURRENT_SCHEMA_VERSION = 5; /** * Baseline schema version for databases that existed before the migration system. @@ -107,8 +107,45 @@ export const migrations: Migration[] = [ } }, }, + // Migration 4→5: Collections. The root pointer goes from a single project + // set to an array of collection pointers (each collection is its own + // ProjectSetDocument). Transform-only: the array lives in the existing + // projectSet store under the 'collections' key, and the legacy singleton + // pointer is kept untouched as a safety net. + { + version: 5, + description: 'Convert singleton project set pointer to collections array', + transform: async (db) => { + await migratePointerToCollections(db); + }, + }, ]; +/** + * Convert the legacy singleton project set pointer into a one-element + * collections array. Idempotent: a no-op when the collections record + * already exists or there is nothing to migrate. The legacy pointer is + * never deleted here. + * + * Exported for direct unit testing and for lazy self-healing reads + * (see projectSetStorage.getCollectionPointers). + */ +export async function migratePointerToCollections( + db: import('idb').IDBPDatabase, +): Promise { + if (!db.objectStoreNames.contains(STORES.PROJECT_SET)) return; + const existing = await db.get(STORES.PROJECT_SET, 'collections'); + if (existing) return; + const legacy = await db.get(STORES.PROJECT_SET, 'projectSet'); + if (!legacy) return; + await db.put(STORES.PROJECT_SET, { + key: 'collections', + collections: [ + { projectSetDocId: legacy.projectSetDocId, syncServer: legacy.syncServer }, + ], + }); +} + /** * Get migrations that need to be applied to upgrade from a given version. * Returns migrations in order, from lowest to highest version. diff --git a/hub-client/src/services/storage/types.ts b/hub-client/src/services/storage/types.ts index 2cfad4a86..ccb2fc472 100644 --- a/hub-client/src/services/storage/types.ts +++ b/hub-client/src/services/storage/types.ts @@ -60,6 +60,31 @@ export interface ProjectSetPointer { syncServer: string; } +/** + * One collection the user is subscribed to: a pointer to a + * ProjectSetDocument acting as a collection. + */ +export interface CollectionPointerEntry { + /** Automerge document ID for the collection's ProjectSetDocument. */ + projectSetDocId: string; + /** Sync server URL where the document is hosted. */ + syncServer: string; +} + +/** + * The root of the user's QuartoHub state: the set of collections this + * browser is subscribed to. Stored as a singleton in the projectSet store + * (key: 'collections'), alongside the legacy singleton pointer. + * + * The first entry is the personal root collection (the migrated legacy + * project set); later entries are additional/shared collections in + * subscription order. + */ +export interface CollectionsPointer { + key: 'collections'; + collections: CollectionPointerEntry[]; +} + /** * User identity settings for presence features. * Stored as a singleton in the userSettings store. diff --git a/hub-client/src/services/userSettings.test.ts b/hub-client/src/services/userSettings.test.ts new file mode 100644 index 000000000..3ccceb973 --- /dev/null +++ b/hub-client/src/services/userSettings.test.ts @@ -0,0 +1,42 @@ +/** + * Unit tests for the pure identity helpers in userSettings. + * + * `actorIdFromUserId` lets auth-less deployments (local-prod / + * `--allow-insecure-auth`) derive a *stable* Automerge actor id from the + * local user id, so opening/editing a document still stamps a consistent + * identity instead of a fresh random actor each session. + */ + +import { describe, it, expect } from 'vitest'; +import { actorIdFromUserId } from './userSettings'; + +describe('actorIdFromUserId', () => { + it('strips dashes from a UUID to form a valid hex actor id', () => { + expect(actorIdFromUserId('6d914340-d834-489b-934c-58390f9b3301')).toBe( + '6d914340d834489b934c58390f9b3301', + ); + }); + + it('always yields an even-length lowercase hex string (a valid Automerge actor)', () => { + for (const id of [ + '6d914340-d834-489b-934c-58390f9b3301', + '00000000-0000-0000-0000-000000000000', + 'ABCDEF01-2345-6789-ABCD-EF0123456789', + ]) { + const actor = actorIdFromUserId(id); + expect(actor).toMatch(/^[0-9a-f]+$/); + expect(actor.length % 2).toBe(0); + } + }); + + it('is deterministic — same userId maps to the same actor id', () => { + const id = '6d914340-d834-489b-934c-58390f9b3301'; + expect(actorIdFromUserId(id)).toBe(actorIdFromUserId(id)); + }); + + it('hex-encodes a non-UUID userId defensively rather than emitting invalid hex', () => { + const actor = actorIdFromUserId('not-a-uuid!'); + expect(actor).toMatch(/^[0-9a-f]+$/); + expect(actor.length % 2).toBe(0); + }); +}); diff --git a/hub-client/src/services/userSettings.ts b/hub-client/src/services/userSettings.ts index 917a7de92..37a6fd059 100644 --- a/hub-client/src/services/userSettings.ts +++ b/hub-client/src/services/userSettings.ts @@ -15,6 +15,30 @@ import { isValidUserName, } from './storage'; +/** + * Derive a stable Automerge actor id from the local user id. + * + * Automerge actor ids must be even-length hex strings. A `userId` produced by + * `crypto.randomUUID()` is 32 hex digits plus dashes, so stripping the dashes + * yields a valid actor id. This lets auth-less deployments (local-prod / + * `--allow-insecure-auth`, where the server exposes no `/auth/actor`) stamp a + * *stable* identity into documents instead of getting a fresh random Automerge + * actor each session — which is why `identities` stayed empty in local testing. + * + * Defensive fallback: any userId that isn't already clean hex is hex-encoded + * from its UTF-8 bytes, so the result is always a valid actor id. In practice + * the app only ever passes `randomUUID()` ids. + */ +export function actorIdFromUserId(userId: string): string { + const stripped = userId.replace(/-/g, '').toLowerCase(); + if (/^[0-9a-f]+$/.test(stripped) && stripped.length % 2 === 0) { + return stripped; + } + return Array.from(new TextEncoder().encode(userId)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + /** * Get the current user identity. * diff --git a/hub-client/src/utils/facepile.ts b/hub-client/src/utils/facepile.ts new file mode 100644 index 000000000..3c4117e88 --- /dev/null +++ b/hub-client/src/utils/facepile.ts @@ -0,0 +1,13 @@ +/** + * A person shown in a facepile — a colored initials disk. + * + * Populated from real identities: the current user (from user settings) and + * the contributors cached on a project's summary (which come from the index + * document's `identities` map as people open/edit projects). + */ +export interface Face { + name: string; + initials: string; + /** Hex color from the palette, e.g. "#E91E63". */ + color: string; +} diff --git a/hub-client/src/utils/routing.ts b/hub-client/src/utils/routing.ts index 67c239861..b25407a05 100644 --- a/hub-client/src/utils/routing.ts +++ b/hub-client/src/utils/routing.ts @@ -124,6 +124,35 @@ export interface LinkProjectSetRoute { syncServer: string; } +/** + * Route from a collection invite link (explore/projects-collections-ui exploration). + * + * The invite carries the collection identity plus the collection's project entries + * inline, so joining delivers real, syncable projects; only collection + * membership itself is mock data until shared collections become synced docs. + * + * SECURITY: Like ShareRoute, the entries contain bearer document IDs and + * the route should only exist transiently. + * + * URL format: #/join-collection/?name=&from=&entries= + */ +export interface JoinCollectionRoute { + type: 'join-collection'; + /** Automerge doc id of the collection's ProjectSetDocument (no prefix). */ + collectionId: string; + collectionName: string; + /** Display name of the person who sent the invite. */ + inviter: string; + /** Sync server hosting the collection document. */ + syncServer: string; + /** + * Legacy payload from pre-architecture invites (projects inlined in the + * URL). Still parsed for backward compatibility; the join flow now + * subscribes to the collection document instead. + */ + entries: Array<{ indexDocId: string; syncServer: string; description: string }>; +} + /** * Route for dev-only harness pages (component previews, visual testing). * Only parsed in development builds; in production, #/dev/... falls through to project-selector. @@ -137,7 +166,7 @@ export interface DevRoute { /** * Union of all possible routes. */ -export type Route = ProjectSelectorRoute | ProjectRoute | FileRoute | ShareRoute | LinkProjectSetRoute | DevRoute; +export type Route = ProjectSelectorRoute | ProjectRoute | FileRoute | ShareRoute | LinkProjectSetRoute | JoinCollectionRoute | DevRoute; // ============================================================================ // URL Parsing @@ -221,6 +250,29 @@ export function parseHashRoute(hash: string): Route { }; } + // Parse join-collection route: /join-collection/?name=&from=&entries= + if (segments[0] === 'join-collection' && segments[1]) { + let entries: JoinCollectionRoute['entries'] = []; + try { + const raw = JSON.parse(queryParams.get('entries') ?? '[]'); + if (Array.isArray(raw)) { + entries = raw + .filter((e) => typeof e?.d === 'string' && typeof e?.s === 'string') + .map((e) => ({ indexDocId: e.d, syncServer: e.s, description: String(e.n ?? '') })); + } + } catch { + // Malformed entries — join proceeds with an empty collection + } + return { + type: 'join-collection', + collectionId: decodeURIComponent(segments[1]), + collectionName: queryParams.get('name') ?? 'Shared collection', + inviter: queryParams.get('from') ?? 'A collaborator', + syncServer: queryParams.get('server') ?? '', + entries, + }; + } + // Parse route based on segments if (segments[0] === 'p' && segments[1]) { const projectId = segments[1]; @@ -307,6 +359,19 @@ export function buildHashRoute(route: Route): string { return `#/link-project-set/${encodeURIComponent(route.projectSetDocId)}?${params.toString()}`; } + case 'join-collection': { + const params = new URLSearchParams(); + params.set('name', route.collectionName); + params.set('from', route.inviter); + if (route.syncServer) params.set('server', route.syncServer); + if (route.entries.length > 0) { + params.set('entries', JSON.stringify( + route.entries.map((e) => ({ d: e.indexDocId, s: e.syncServer, n: e.description })), + )); + } + return `#/join-collection/${encodeURIComponent(route.collectionId)}?${params.toString()}`; + } + case 'dev': return `#/dev/${encodeURIComponent(route.page)}`; } @@ -465,6 +530,9 @@ export function routesEqual(a: Route, b: Route): boolean { ); } + case 'join-collection': + return a.collectionId === (b as JoinCollectionRoute).collectionId; + case 'dev': return a.page === (b as DevRoute).page; } diff --git a/ts-packages/quarto-automerge-schema/src/index.ts b/ts-packages/quarto-automerge-schema/src/index.ts index b037dd6f8..72a50e649 100644 --- a/ts-packages/quarto-automerge-schema/src/index.ts +++ b/ts-packages/quarto-automerge-schema/src/index.ts @@ -125,6 +125,27 @@ export function setIdentity(doc: IndexDocument, actorId: string, screenName: str * prefix, which serves as a natural deduplication key — two browsers adding * the same project converge automatically via Automerge CRDT merge. */ +/** + * Cached at-a-glance summary of a project, denormalized onto the owner's + * project-set entry. + * + * This is a per-user cache, not a source of truth: it reflects the project + * as of the last time THIS user's client opened it (`asOf`), and is written + * by the client while it has the project's documents in hand. It exists so + * list surfaces (cards, peek popovers) can show file counts and contributors + * without opening a sync connection per project. + */ +export interface ProjectSetEntrySummary { + /** Number of files in the project's index at `asOf`. */ + fileCount: number; + /** First few file paths (capped by the writer; typically 5). */ + topFiles: string[]; + /** Identities seen on the project at `asOf` (capped by the writer). */ + contributors: ActorIdentity[]; + /** ISO timestamp when this summary was captured. */ + asOf: string; +} + export interface ProjectSetEntry { /** Automerge document ID for the project's IndexDocument (with 'automerge:' prefix). */ indexDocId: string; @@ -136,6 +157,8 @@ export interface ProjectSetEntry { addedAt: string; /** ISO timestamp of last access from any browser. Updated on project open. */ lastAccessed: string; + /** Cached peek data; absent until the project is first opened by this user. */ + summary?: ProjectSetEntrySummary; } /** @@ -151,6 +174,12 @@ export interface ProjectSetDocument { projects: Record; /** Schema version for forward compatibility. */ version: number; + /** + * Display name when this set is used as a collection (a user-visible, + * shareable grouping of projects). Absent on sets created before + * collections existed; the UI supplies a default until the user names it. + */ + name?: string; } /** @@ -242,6 +271,60 @@ export function touchProjectInSet( return true; } +/** + * Set the collection display name on a ProjectSetDocument. + * Must be called inside an Automerge `change()` callback. + * + * @returns true if the name changed + */ +export function setProjectSetName( + doc: ProjectSetDocument, + name: string, +): boolean { + if (doc.name === name) return false; + doc.name = name; + return true; +} + +/** + * Update the cached peek summary for a project. + * + * The summary lives on a shared collection entry, so multiple collaborators + * write it. The file-shape fields (fileCount, topFiles, asOf) are "last + * writer's view" and replaced wholesale, but `contributors` are **unioned** + * with whatever is already stored: a naive replace made each editor clobber + * the others' identities, so the last person to edit appeared as the sole + * author. Union (dedup by name, incoming color/order preferred) makes + * authorship accumulate the way collaborators expect. + * + * Must be called inside an Automerge `change()` callback. + * + * @returns true if the entry existed and the summary was written + */ +export function updateProjectSummaryInSet( + doc: ProjectSetDocument, + indexDocId: string, + summary: ProjectSetEntrySummary, +): boolean { + const key = projectSetKey(indexDocId); + const entry = doc.projects[key]; + if (!entry) return false; + + const merged = new Map(); + for (const c of summary.contributors) merged.set(c.name, { name: c.name, color: c.color }); + for (const c of entry.summary?.contributors ?? []) { + if (!merged.has(c.name)) merged.set(c.name, { name: c.name, color: c.color }); + } + + entry.summary = { + fileCount: summary.fileCount, + topFiles: summary.topFiles, + asOf: summary.asOf, + contributors: [...merged.values()], + }; + return true; +} + // ============================================================================ // File Document Content Types // ============================================================================ diff --git a/ts-packages/quarto-automerge-schema/src/projectSet.test.ts b/ts-packages/quarto-automerge-schema/src/projectSet.test.ts index c87f7ab6d..eb63683a7 100644 --- a/ts-packages/quarto-automerge-schema/src/projectSet.test.ts +++ b/ts-packages/quarto-automerge-schema/src/projectSet.test.ts @@ -14,6 +14,8 @@ import { addProjectToSet, removeProjectFromSet, touchProjectInSet, + updateProjectSummaryInSet, + setProjectSetName, } from './index.js'; import type { ProjectSetDocument } from './index.js'; @@ -191,4 +193,88 @@ describe('ProjectSetDocument schema helpers', () => { expect(result).toBe(false); }); }); + + describe('setProjectSetName', () => { + it('should set and change the collection name', () => { + const doc = emptyDoc(); + expect(setProjectSetName(doc, 'Lab papers')).toBe(true); + expect(doc.name).toBe('Lab papers'); + expect(setProjectSetName(doc, 'Lab papers')).toBe(false); + expect(setProjectSetName(doc, 'Lab notebooks')).toBe(true); + expect(doc.name).toBe('Lab notebooks'); + }); + }); + + describe('updateProjectSummaryInSet', () => { + const summary = { + fileCount: 3, + topFiles: ['index.qmd', 'notes.qmd', '_quarto.yml'], + contributors: [{ name: 'Charlotte Wu', color: '#E8368F' }], + asOf: '2026-06-15T12:00:00.000Z', + }; + + it('should write the summary onto an existing entry', () => { + const doc = emptyDoc(); + addProjectToSet(doc, { + indexDocId: 'automerge:proj1', + syncServer: 'wss://sync.example.com', + description: 'Project', + }, '2026-01-01T00:00:00.000Z'); + + const result = updateProjectSummaryInSet(doc, 'automerge:proj1', summary); + expect(result).toBe(true); + expect(doc.projects['proj1'].summary).toEqual(summary); + // Other fields untouched + expect(doc.projects['proj1'].lastAccessed).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('should replace file-shape fields but union contributors', () => { + const doc = emptyDoc(); + addProjectToSet(doc, { + indexDocId: 'automerge:proj1', + syncServer: 'wss://sync.example.com', + description: 'Project', + }); + updateProjectSummaryInSet(doc, 'automerge:proj1', summary); + // A different collaborator writes their own view later + const newer = { + fileCount: 5, + topFiles: ['index.qmd'], + contributors: [{ name: 'Saima Khan', color: '#00BCD4' }], + asOf: '2026-06-16T12:00:00.000Z', + }; + updateProjectSummaryInSet(doc, 'automerge:proj1', newer); + const stored = doc.projects['proj1'].summary!; + // File-shape fields take the newer writer's view + expect(stored.fileCount).toBe(5); + expect(stored.topFiles).toEqual(['index.qmd']); + expect(stored.asOf).toBe('2026-06-16T12:00:00.000Z'); + // Contributors accumulate — neither author clobbers the other + expect(stored.contributors.map((c) => c.name).sort()).toEqual(['Charlotte Wu', 'Saima Khan']); + }); + + it('should not duplicate a contributor already present', () => { + const doc = emptyDoc(); + addProjectToSet(doc, { + indexDocId: 'automerge:proj1', + syncServer: 'wss://sync.example.com', + description: 'Project', + }); + updateProjectSummaryInSet(doc, 'automerge:proj1', summary); + // Same author edits again with an updated color + updateProjectSummaryInSet(doc, 'automerge:proj1', { + ...summary, + contributors: [{ name: 'Charlotte Wu', color: '#FF0000' }], + }); + const stored = doc.projects['proj1'].summary!; + expect(stored.contributors).toHaveLength(1); + expect(stored.contributors[0]).toEqual({ name: 'Charlotte Wu', color: '#FF0000' }); + }); + + it('should return false for non-existent project', () => { + const doc = emptyDoc(); + const result = updateProjectSummaryInSet(doc, 'automerge:nonexistent', summary); + expect(result).toBe(false); + }); + }); });