From 389af46bd53383b0dba7b9edcc587d00f055bd4b Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 7 Jul 2026 17:30:39 -0400 Subject: [PATCH 01/48] feat(hub-client): shelves-based projects home (UI exploration) Implements the "Short term" section of the QH-ProjectManagement-July26 Figma design as an alternate projects view, replacing the modal ProjectSelector with a full-page home: - header bar: logo, search (Cmd+K), Connect/Import and + New menus, avatar menu holding identity, cursor color, device linking, JSON backup, and theme (relocated from the modal) - personal shelves (localStorage-only for the exploration) with project cards, paging at 8+ per shelf, move-to-shelf via the per-project menu - "Everything else" list with sort control, inline Rename/Peek for unnamed projects, and plumbing (doc ID, share link) behind the menu - New project / Connect / Import flows reworked as dialogs A persisted toggle (qh-ui-variant) switches between this and the classic UI so both can be UX-tested side by side. Co-Authored-By: Claude Fable 5 --- hub-client/src/App.css | 19 + hub-client/src/App.tsx | 81 +- hub-client/src/components/ProjectsHome.css | 737 ++++++++++++ hub-client/src/components/ProjectsHome.tsx | 1189 ++++++++++++++++++++ hub-client/src/hooks/useShelves.ts | 134 +++ 5 files changed, 2141 insertions(+), 19 deletions(-) create mode 100644 hub-client/src/components/ProjectsHome.css create mode 100644 hub-client/src/components/ProjectsHome.tsx create mode 100644 hub-client/src/hooks/useShelves.ts diff --git a/hub-client/src/App.css b/hub-client/src/App.css index 59cd6b24b..0d45e2471 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 shelves UI exploration (explore/projects-shelves-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..77f315c98 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -1,6 +1,7 @@ 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 ProjectSetSetup from './components/ProjectSetSetup'; // Lazy-loaded dev harness — only fetched when navigating to #/dev/... routes. @@ -173,6 +174,17 @@ function App() { // Track if we've done the initial URL-based navigation const initialLoadRef = useRef(false); + // UI exploration (explore/projects-shelves-ui): choose between the new + // shelves-based projects home and the classic modal selector. Persisted so + // UX testing can flip back and forth across reloads. + const [uiVariant, setUiVariant] = useState<'shelves' | 'classic'>(() => + localStorage.getItem('qh-ui-variant') === 'classic' ? 'classic' : 'shelves', + ); + const switchUiVariant = useCallback((variant: 'shelves' | 'classic') => { + localStorage.setItem('qh-ui-variant', variant); + setUiVariant(variant); + }, []); + // URL-based routing const { route, @@ -667,25 +679,56 @@ function App() { return ( <> {!project ? ( - + uiVariant === 'shelves' ? ( + switchUiVariant('classic')} + /> + ) : ( + <> + + + + ) ) : ( diff --git a/hub-client/src/components/ProjectsHome.css b/hub-client/src/components/ProjectsHome.css new file mode 100644 index 000000000..60f2a55de --- /dev/null +++ b/hub-client/src/components/ProjectsHome.css @@ -0,0 +1,737 @@ +/* ProjectsHome — shelves-based projects view (explore/projects-shelves-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.ghost-accent { + border-color: 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; +} + +/* ---- shelves ---- */ + +.ph-main { + padding: 24px 30px 40px; + max-width: 1060px; + width: 100%; + margin: 0 auto; + box-sizing: border-box; + flex: 1; +} + +.ph-shelf { margin-bottom: 26px; } + +.ph-shelf-header { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 10px; +} + +.ph-shelf-name { font-size: 13px; font-weight: 700; } +.ph-shelf-count { font-size: 11.5px; color: var(--text-secondary); } + +.ph-shelf-empty, +.ph-rest-empty { + font-size: 12.5px; + color: var(--text-secondary); + padding: 14px 2px; +} + +.ph-shelf-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-menu-btn { + position: absolute; + top: 8px; + right: 8px; + background: none; + border: none; + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; + border-radius: 5px; + padding: 1px 6px; + opacity: 0; +} + +.ph-card:hover .ph-card-menu-btn { opacity: 1; } +.ph-card-menu-btn:hover { background: var(--input-bg-alpha); color: var(--text-primary); } + +.ph-card .ph-menu { top: 34px; left: auto; right: 8px; } + +.ph-new-shelf-row { margin: 2px 0 24px; } + +/* ---- 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% + 4px); + left: 140px; + 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; +} + +.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-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-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..35c1ad922 --- /dev/null +++ b/hub-client/src/components/ProjectsHome.tsx @@ -0,0 +1,1189 @@ +/** + * ProjectsHome — full-page projects view (explore/projects-shelves-ui). + * + * Implements the "Short term" design from QH-ProjectManagement-July26.fig: + * shelves + 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 shelves 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 } from '@quarto/quarto-automerge-schema'; +import type { ProjectSetStatus } from '../hooks/useProjectSet'; +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, + type ProjectChoice, + type ProjectFile, +} from '@quarto/preview-runtime'; +import { + DEFAULT_SYNC_SERVER, + buildProjectSetLinkUrl, + buildShareableUrl, +} from '../utils/routing'; +import ShareDialog from './ShareDialog'; +import { useShelves, setPendingShelfAssignment, type Shelf } from '../hooks/useShelves'; +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; + onSwitchToClassicUi?: () => void; +} + +/** 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; +} + +const COLOR_PALETTE = [ + '#E91E63', '#9C27B0', '#3F51B5', '#2196F3', + '#00BCD4', '#009688', '#4CAF50', '#FF9800', + '#FF5722', '#795548', +]; + +const SHELF_PAGE_SIZE = 8; // two rows of four cards + +const UNNAMED_RE = /^Project \d{4}-\d{2}-\d{2}T/; + +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, + 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 + // `shelf:`; 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); + 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 [newShelfId, setNewShelfId] = 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(); + const { shelves, createShelf, renameShelf, deleteShelf, moveProject, reconcilePending } = useShelves(); + const [shelfPages, setShelfPages] = useState>({}); + 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, + })); + } + 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 shelf on create" once the new entry appears. + useEffect(() => { + reconcilePending(items); + }, [items, reconcilePending]); + + // ---- global listeners ---- + + const closeAllMenus = useCallback(() => { + setOpenMenu(null); + setMoveSubmenuOpen(false); + setNewMenuOpen(false); + setAvatarMenuOpen(false); + setPeekFor(null); + setSortMenuOpen(false); + }, []); + + 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 ---- + + 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]); + + const handleRemove = useCallback((item: ProjectItem) => { + if (confirm(`Remove "${item.description}" from this device?\n\nThis doesn't delete the project for others.`)) { + moveProject(item.indexDocId, null); + onRemoveProjectFromSet?.(item.indexDocId); + } + closeAllMenus(); + }, [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]); + + const handleNewShelf = useCallback((): string | null => { + const name = prompt('Shelf name'); + if (!name?.trim()) return null; + return createShelf(name.trim()); + }, [createShelf]); + + const openNewDialog = useCallback((choice: ProjectChoice) => { + setNewDialogChoice(choice); + setNewTitle(''); + setNewShelfId(''); + 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 (newShelfId) { + setPendingShelfAssignment(newTitle.trim(), newShelfId); + } + 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, newShelfId, 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(() => { + const s = new Set(); + for (const shelf of shelves) for (const id of shelf.projectIds) s.add(id); + return s; + }, [shelves]); + + 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'; + + // ---- rendering ---- + + if (loading || projectSetConnecting) { + return ( +
+
+ {projectSetConnecting ? 'Connecting to project set…' : 'Loading projects…'} +
+
+ ); + } + + const renderProjectMenu = (item: ProjectItem) => ( +
+ +
+ + {moveSubmenuOpen && ( +
+ {shelves.map((shelf) => ( + + ))} + {shelvedIds.has(item.indexDocId) && ( + + )} + +
+ )} +
+ + + +
+ +
+ ); + + const renderPeek = (item: ProjectItem) => ( +
e.stopPropagation()}> +
+ ADDED {formatOpened(item.addedAt).toUpperCase()} · OPENED {formatOpened(item.lastAccessed).toUpperCase()} +
+
{serverHost(item.syncServer)}
+
{shortId(item.indexDocId)}
+
+ File-list preview isn't wired up in this exploration yet — it needs a + lightweight index-doc connection. +
+
+
+ + + +
+
Peeking doesn't count as opening the project.
+
+ ); + + const renderCard = (item: ProjectItem) => ( +
+ + + {openMenu === item.indexDocId && renderProjectMenu(item)} +
+ ); + + const renderShelf = (shelf: Shelf) => { + const shelfItems = shelf.projectIds + .map((id) => byId.get(id)) + .filter((it): it is ProjectItem => !!it) + .filter(matches); + if (query && shelfItems.length === 0) return null; + const pageCount = Math.max(1, Math.ceil(shelfItems.length / SHELF_PAGE_SIZE)); + const page = Math.min(shelfPages[shelf.id] ?? 0, pageCount - 1); + const pageItems = shelfItems.slice(page * SHELF_PAGE_SIZE, (page + 1) * SHELF_PAGE_SIZE); + const menuKey = `shelf:${shelf.id}`; + return ( +
+
+ {shelf.name} + {shelfItems.length} + + + {openMenu === menuKey && ( +
+ + +
+ )} +
+ {shelfItems.length === 0 ? ( +
Empty shelf — use a project's ⋯ menu to move it here.
+ ) : ( +
+ {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 ? ( +
+

No projects yet

+

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

+
+ + +
+
+ ) : ( + <> + {shelves.map(renderShelf)} + +
+ +
+ +
+
+ Everything else + {everythingElse.length} · {sortLabel} + + + {sortMenuOpen && ( +
+ {(['newest', 'oldest', 'name'] as SortOrder[]).map((o) => ( + + ))} +
+ )} +
+ {everythingElse.length === 0 ? ( +
+ {query ? 'No projects match your search.' : 'Everything is on a shelf.'} +
+ ) : ( +
+ {everythingElse.map((item) => ( +
+ + {isUnnamed(item.description) && ( + <> + + + + )} + opened {formatOpened(item.lastAccessed)} + + {openMenu === item.indexDocId && renderProjectMenu(item)} + {peekFor === item.indexDocId && renderPeek(item)} +
+ ))} +
+ )} +
+ + )} +
+ + {/* 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__} + + shelves UI exploration +
+
+ ); +} diff --git a/hub-client/src/hooks/useShelves.ts b/hub-client/src/hooks/useShelves.ts new file mode 100644 index 000000000..df810b25c --- /dev/null +++ b/hub-client/src/hooks/useShelves.ts @@ -0,0 +1,134 @@ +/** + * Shelf state for the projects-home UI exploration (explore/projects-shelves-ui). + * + * Shelves are a purely local, per-browser grouping of projects: a named list + * of indexDocIds persisted to localStorage. They deliberately do NOT sync — + * the short-term design ("Shelves + streamlined entry") is buildable on + * today's metadata, and shared shelves are a later phase that would make a + * shelf its own synced document. Keeping the storage local lets us test the + * UX without touching the project-set schema. + */ + +import { useState, useCallback, useEffect } from 'react'; + +export interface Shelf { + id: string; + name: string; + projectIds: string[]; +} + +const SHELVES_KEY = 'qh-shelves-v1'; +const PENDING_KEY = 'qh-shelf-pending-v1'; + +interface PendingAssignment { + title: string; + shelfId: string; + ts: number; +} + +function loadShelves(): Shelf[] { + try { + const raw = localStorage.getItem(SHELVES_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (s): s is Shelf => + typeof s?.id === 'string' && + typeof s?.name === 'string' && + Array.isArray(s?.projectIds), + ); + } catch { + return []; + } +} + +/** + * Record that the next project created with this title should land on a + * shelf. The indexDocId of a new project isn't known until the parent app + * finishes creating the Automerge docs, so we reconcile by title when the + * entry shows up in the project set. + */ +export function setPendingShelfAssignment(title: string, shelfId: string): void { + const pending: PendingAssignment = { title, shelfId, ts: Date.now() }; + localStorage.setItem(PENDING_KEY, JSON.stringify(pending)); +} + +export function useShelves() { + const [shelves, setShelves] = useState(loadShelves); + + useEffect(() => { + localStorage.setItem(SHELVES_KEY, JSON.stringify(shelves)); + }, [shelves]); + + const createShelf = useCallback((name: string): string => { + const id = `shelf-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + setShelves((prev) => [...prev, { id, name, projectIds: [] }]); + return id; + }, []); + + const renameShelf = useCallback((id: string, name: string) => { + setShelves((prev) => prev.map((s) => (s.id === id ? { ...s, name } : s))); + }, []); + + const deleteShelf = useCallback((id: string) => { + setShelves((prev) => prev.filter((s) => s.id !== id)); + }, []); + + /** + * Put a project on a shelf (or none). A project sits on at most one + * personal shelf, so it's removed from all others first. + */ + const moveProject = useCallback((indexDocId: string, shelfId: string | null) => { + setShelves((prev) => + prev.map((s) => { + const without = s.projectIds.filter((p) => p !== indexDocId); + if (s.id === shelfId && !without.includes(indexDocId)) { + return { ...s, projectIds: [...without, indexDocId] }; + } + return without.length === s.projectIds.length ? s : { ...s, projectIds: without }; + }), + ); + }, []); + + const shelfFor = useCallback( + (indexDocId: string): Shelf | undefined => + shelves.find((s) => s.projectIds.includes(indexDocId)), + [shelves], + ); + + /** + * Reconcile a pending "add to shelf on create" against the current + * project list. Called whenever the entries change; a no-op when there is + * nothing pending. Stale pendings (>1 day) are dropped. + */ + const reconcilePending = useCallback( + (entries: Array<{ indexDocId: string; description: string; addedAt: string }>) => { + const raw = localStorage.getItem(PENDING_KEY); + if (!raw) return; + let pending: PendingAssignment; + try { + pending = JSON.parse(raw); + } catch { + localStorage.removeItem(PENDING_KEY); + return; + } + if (Date.now() - pending.ts > 24 * 3600 * 1000) { + localStorage.removeItem(PENDING_KEY); + return; + } + const match = entries.find( + (e) => + e.description === pending.title && + new Date(e.addedAt).getTime() >= pending.ts - 60_000, + ); + if (match) { + moveProject(match.indexDocId, pending.shelfId); + localStorage.removeItem(PENDING_KEY); + } + }, + [moveProject], + ); + + return { shelves, createShelf, renameShelf, deleteShelf, moveProject, shelfFor, reconcilePending }; +} From 9dbc89c4444c9a6ce44c954d8ff1fe1d5e4108af Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 7 Jul 2026 17:33:58 -0400 Subject: [PATCH 02/48] docs(hub-client): changelog for shelves projects-home exploration Co-Authored-By: Claude Fable 5 --- hub-client/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 5d56aba9d..eac408a52 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -25,6 +25,7 @@ WASM rebuild is needed for a changelog-only edit. ### 2026-07-07 +- [`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. From 1a37f9ebf5938f1dd4f089b0a9b4d1598ffda43a Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 7 Jul 2026 18:05:47 -0400 Subject: [PATCH 03/48] feat(hub-client): drag projects between shelves and the unshelved list Project cards and rows are draggable; shelf sections and the "Everything else" list are drop zones with a dashed teal highlight. Drop zones key off a custom dataTransfer MIME type rather than React state so dragover recognition doesn't race the state flush. Also makes the "+ New shelf" button dashed to match the Figma design. Co-Authored-By: Claude Fable 5 --- hub-client/src/components/ProjectsHome.css | 22 ++++++- hub-client/src/components/ProjectsHome.tsx | 70 ++++++++++++++++++++-- 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/hub-client/src/components/ProjectsHome.css b/hub-client/src/components/ProjectsHome.css index 60f2a55de..a25fbdd4f 100644 --- a/hub-client/src/components/ProjectsHome.css +++ b/hub-client/src/components/ProjectsHome.css @@ -120,7 +120,7 @@ .ph-btn.outline:disabled { opacity: 0.5; cursor: default; } .ph-btn.ghost-accent { - border-color: var(--posit-teal); + border: 1px dashed var(--posit-teal); color: var(--posit-teal); font-size: 12.5px; padding: 7px 14px; @@ -419,6 +419,26 @@ .ph-new-shelf-row { margin: 2px 0 24px; } +/* ---- drag and drop ---- */ + +.ph-card[draggable="true"], +.ph-row[draggable="true"] { + cursor: grab; +} + +.ph-card.dragging, +.ph-row.dragging { + opacity: 0.4; +} + +.ph-shelf.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 { diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx index 35c1ad922..ca8bc6c1c 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -210,6 +210,10 @@ export default function ProjectsHome({ const { colorScheme, cycleColorScheme } = useTheme(); const { shelves, createShelf, renameShelf, deleteShelf, moveProject, reconcilePending } = useShelves(); const [shelfPages, setShelfPages] = useState>({}); + // Drag-and-drop between shelves and the unshelved list. dropTarget is a + // shelf id or 'unshelved'. + const [draggingId, setDraggingId] = useState(null); + const [dropTarget, setDropTarget] = useState(null); const [sortOrder, setSortOrder] = useState('newest'); const [sortMenuOpen, setSortMenuOpen] = useState(false); @@ -311,6 +315,45 @@ export default function ProjectsHome({ // ---- 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 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 shelf 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) moveProject(docId, target === 'unshelved' ? null : target); + handleDragEnd(); + }, + }), [draggingId, dropTarget, moveProject, 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); @@ -655,7 +698,13 @@ export default function ProjectsHome({ ); const renderCard = (item: ProjectItem) => ( -
+
-
+
Everything else {everythingElse.length} · {sortLabel} @@ -949,7 +1005,13 @@ export default function ProjectsHome({ ) : (
{everythingElse.map((item) => ( -
+
{shelfItems.length === 0 ? ( -
Empty shelf — use a project's ⋯ menu to move it here.
+
Empty shelf — drag a project here, or use its ⋯ menu.
) : (
{page > 0 && ( From 1092bc7bd5c4802e2384f148ac2dd7b827564216 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 7 Jul 2026 19:16:50 -0400 Subject: [PATCH 06/48] feat(hub-client): order shelf cards by recency, newest first Shelf display order is lastAccessed descending regardless of when a project was added to the shelf, so paging always walks toward older projects. Last-opened stands in for "recent edits" until per-project edit attribution is exposed from automerge history. Co-Authored-By: Claude Fable 5 --- hub-client/src/components/ProjectsHome.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx index 0d18d3ff3..7d9e8bbe2 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -727,10 +727,15 @@ export default function ProjectsHome({ ); const renderShelf = (shelf: Shelf) => { + // Shelf 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 shelfItems = shelf.projectIds .map((id) => byId.get(id)) .filter((it): it is ProjectItem => !!it) - .filter(matches); + .filter(matches) + .sort((a, b) => (a.lastAccessed < b.lastAccessed ? 1 : -1)); if (query && shelfItems.length === 0) return null; const pageCount = Math.max(1, Math.ceil(shelfItems.length / SHELF_PAGE_SIZE)); const page = Math.min(shelfPages[shelf.id] ?? 0, pageCount - 1); From 0f338475ccc88422c9bcf8aae9c7d75aa82d3113 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 7 Jul 2026 19:29:37 -0400 Subject: [PATCH 07/48] feat(hub-client): mock collaborator facepiles on cards, shelves, and peek Colored initial disks per the design's shared-shelf and peek sections. Contributors are spoofed for the exploration: a deterministic fake crew seeded from each project's doc id (stable across reloads), with the real user always first in their cursor color. collaboratorsFor() is the seam where real attribution data plugs in later (automerge history or a recent-editors summary on the index doc). Co-Authored-By: Claude Fable 5 --- hub-client/src/components/ProjectsHome.css | 51 +++++++++++++++++++ hub-client/src/components/ProjectsHome.tsx | 43 +++++++++++++++- hub-client/src/utils/mockCollaborators.ts | 58 ++++++++++++++++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 hub-client/src/utils/mockCollaborators.ts diff --git a/hub-client/src/components/ProjectsHome.css b/hub-client/src/components/ProjectsHome.css index a25fbdd4f..6227594a3 100644 --- a/hub-client/src/components/ProjectsHome.css +++ b/hub-client/src/components/ProjectsHome.css @@ -419,6 +419,57 @@ .ph-new-shelf-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-shelf-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); +} + /* ---- drag and drop ---- */ .ph-card[draggable="true"], diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx index 7d9e8bbe2..fd8df9c41 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -33,6 +33,7 @@ import { buildShareableUrl, } from '../utils/routing'; import ShareDialog from './ShareDialog'; +import { mockCollaborators, unionCollaborators, type MockUser } from '../utils/mockCollaborators'; import { useShelves, setPendingShelfAssignment, type Shelf } from '../hooks/useShelves'; import './ProjectsHome.css'; @@ -595,6 +596,32 @@ export default function ProjectsHome({ const sortLabel = sortOrder === 'newest' ? 'newest first' : sortOrder === 'oldest' ? 'oldest first' : 'A to Z'; + // Facepiles are mock data for the exploration (see utils/mockCollaborators). + // The real user is always the first face, in their cursor color. + const selfUser: MockUser | undefined = userSettings + ? { name: `${userSettings.userName} (you)`, initials: initialsFor(userSettings.userName), color: userSettings.userColor } + : undefined; + const collaboratorsFor = useCallback( + (indexDocId: string) => mockCollaborators(indexDocId, selfUser), + // eslint-disable-next-line react-hooks/exhaustive-deps + [userSettings?.userName, userSettings?.userColor], + ); + + const renderFacepile = (users: MockUser[], size: 'sm' | 'md' | 'lg', max = 3) => { + const shown = users.slice(0, max); + const extra = users.length - shown.length; + return ( + + {shown.map((u) => ( + + {u.initials} + + ))} + {extra > 0 && +{extra}} + + ); + }; + // ---- rendering ---- if (loading || projectSetConnecting) { @@ -681,6 +708,12 @@ export default function ProjectsHome({
ADDED {formatOpened(item.addedAt).toUpperCase()} · OPENED {formatOpened(item.lastAccessed).toUpperCase()}
+
+ {renderFacepile(collaboratorsFor(item.indexDocId), 'lg')} + + {collaboratorsFor(item.indexDocId).map((u) => u.name.replace(/ .*$/, '')).join(', ')} have joined + +
{serverHost(item.syncServer)}
{shortId(item.indexDocId)}
@@ -709,7 +742,10 @@ export default function ProjectsHome({ {item.description} - opened {formatOpened(item.lastAccessed)} + + opened {formatOpened(item.lastAccessed)} + {renderFacepile(collaboratorsFor(item.indexDocId), 'sm')} +
+
+ +
+ +
+

+ Joining adds these projects to your list. This invite flow is part of a UI + exploration — shelf membership isn't synced to other members yet. +

+
+
+
+ ); +} diff --git a/hub-client/src/components/ProjectsHome.css b/hub-client/src/components/ProjectsHome.css index 6227594a3..d6b545cce 100644 --- a/hub-client/src/components/ProjectsHome.css +++ b/hub-client/src/components/ProjectsHome.css @@ -470,6 +470,189 @@ color: var(--text-primary); } +/* ---- shelf sharing: people button, members popover, join landing ---- */ + +.ph-shelf-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-shelf-people:hover { + background: var(--input-bg-alpha); + color: var(--text-primary); +} + +.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-shelf-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"], diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx index fd8df9c41..1727038b0 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -31,10 +31,11 @@ import { DEFAULT_SYNC_SERVER, buildProjectSetLinkUrl, buildShareableUrl, + buildFullUrl, } from '../utils/routing'; import ShareDialog from './ShareDialog'; import { mockCollaborators, unionCollaborators, type MockUser } from '../utils/mockCollaborators'; -import { useShelves, setPendingShelfAssignment, type Shelf } from '../hooks/useShelves'; +import { useShelves, setPendingShelfAssignment, type Shelf, type ShelfMember } from '../hooks/useShelves'; import './ProjectsHome.css'; interface Props { @@ -209,7 +210,9 @@ export default function ProjectsHome({ const [editNameValue, setEditNameValue] = useState(''); const { colorScheme, cycleColorScheme } = useTheme(); - const { shelves, createShelf, renameShelf, deleteShelf, moveProject, reconcilePending } = useShelves(); + const { shelves, createShelf, renameShelf, deleteShelf, moveProject, reconcilePending, shareShelf, removeMember } = useShelves(); + // Which shelf's members-and-invite popover is open + const [membersFor, setMembersFor] = useState(null); const [shelfPages, setShelfPages] = useState>({}); // Drag-and-drop between shelves and the unshelved list. dropTarget is a // shelf id or 'unshelved'. @@ -291,6 +294,7 @@ export default function ProjectsHome({ setAvatarMenuOpen(false); setPeekFor(null); setSortMenuOpen(false); + setMembersFor(null); }, []); useEffect(() => { @@ -576,8 +580,15 @@ export default function ProjectsHome({ ); const shelvedIds = useMemo(() => { + // Shelf 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 shelf of shelves) for (const id of shelf.projectIds) s.add(id); + for (const shelf of shelves) { + for (const id of shelf.projectIds) { + s.add(id); + s.add(id.startsWith('automerge:') ? id.replace(/^automerge:/, '') : `automerge:${id}`); + } + } return s; }, [shelves]); @@ -612,8 +623,8 @@ export default function ProjectsHome({ const extra = users.length - shown.length; return ( - {shown.map((u) => ( - + {shown.map((u, i) => ( + {u.initials} ))} @@ -730,6 +741,102 @@ export default function ProjectsHome({
); + // ---- shelf sharing (membership is mock; see utils/mockCollaborators) ---- + + const shelfItemsOf = (shelf: Shelf): ProjectItem[] => + shelf.projectIds + .map((id) => byId.get(id) ?? byId.get(`automerge:${id}`)) + .filter((it): it is ProjectItem => !!it); + + const buildInviteUrl = (shelf: Shelf): string => + buildFullUrl({ + type: 'join-shelf', + shelfId: shelf.id, + shelfName: shelf.name, + inviter: userSettings?.userName ?? 'A collaborator', + entries: shelfItemsOf(shelf).map((it) => ({ + indexDocId: it.indexDocId.replace(/^automerge:/, ''), + syncServer: it.syncServer, + description: it.description, + })), + }); + + const handleShareShelf = (shelf: Shelf) => { + if (!shelf.shared) { + const now = new Date().toISOString(); + const seeded: ShelfMember[] = selfUser + ? [{ ...selfUser, name: selfUser.name.replace(/ \(you\)$/, ''), joinedAt: now, isOwner: true, isYou: true }] + : []; + // Seed with the mock collaborators already shown on this shelf's + // project cards — the "people with access" story stays consistent. + const others = unionCollaborators(shelfItemsOf(shelf).map((it) => collaboratorsFor(it.indexDocId))) + .filter((u) => !seeded.some((m) => m.initials === u.initials)) + .map((u) => ({ ...u, joinedAt: now })); + shareShelf(shelf.id, [...seeded, ...others]); + } + setOpenMenu(null); + setMembersFor(shelf.id); + }; + + const renderMembersPopover = (shelf: Shelf) => { + const members = shelf.shared?.members ?? []; + const you = members.find((m) => m.isYou); + const inviteUrl = buildInviteUrl(shelf); + return ( +
+
SHARED WITH {members.length} {members.length === 1 ? 'PERSON' : 'PEOPLE'}
+
+ {members.map((m, i) => ( +
+ {m.initials} + + {m.name} + {m.isYou && (you)} + + {m.isOwner ? ( + Owner + ) : !m.isYou ? ( + + ) : null} +
+ ))} +
+ {you && !you.isOwner && ( + + )} +
+
INVITE BY LINK
+
+ {inviteUrl.replace(/^https?:\/\//, '').slice(0, 34)}… + +
+
+ Anyone with this link can join this shelf and add or remove projects. +
+
+ ); + }; + const renderCard = (item: ProjectItem) => (
byId.get(id)) - .filter((it): it is ProjectItem => !!it) + const shelfItems = shelfItemsOf(shelf) .filter(matches) .sort((a, b) => (a.lastAccessed < b.lastAccessed ? 1 : -1)); if (query && shelfItems.length === 0) return null; @@ -786,24 +891,50 @@ export default function ProjectsHome({
{shelf.name} {shelfItems.length} - {shelfItems.length > 0 && - renderFacepile( - unionCollaborators(shelfItems.map((it) => collaboratorsFor(it.indexDocId))), - 'md', - )} + {shelf.shared && ( + + )} + {membersFor === shelf.id && shelf.shared && renderMembersPopover(shelf)} {openMenu === menuKey && (
+ {shelf.shared ? ( + + ) : ( + + )} - + {shelf.shared ? ( + <> +
+ + + + ) : ( + + )}
)}
diff --git a/hub-client/src/hooks/useShelves.ts b/hub-client/src/hooks/useShelves.ts index df810b25c..03fdf619b 100644 --- a/hub-client/src/hooks/useShelves.ts +++ b/hub-client/src/hooks/useShelves.ts @@ -11,10 +11,25 @@ import { useState, useCallback, useEffect } from 'react'; +/** A member of a shared shelf. Mock data until shelves become synced docs. */ +export interface ShelfMember { + name: string; + initials: string; + color: string; + joinedAt: string; + isOwner?: boolean; + isYou?: boolean; +} + export interface Shelf { id: string; name: string; projectIds: string[]; + /** Present once the shelf has been explicitly shared. */ + shared?: { + sharedAt: string; + members: ShelfMember[]; + }; } const SHELVES_KEY = 'qh-shelves-v1'; @@ -97,6 +112,27 @@ export function useShelves() { [shelves], ); + /** Convert a personal shelf to shared, seeding the member list. */ + const shareShelf = useCallback((id: string, members: ShelfMember[]) => { + setShelves((prev) => + prev.map((s) => + s.id === id && !s.shared + ? { ...s, shared: { sharedAt: new Date().toISOString(), members } } + : s, + ), + ); + }, []); + + const removeMember = useCallback((shelfId: string, initials: string) => { + setShelves((prev) => + prev.map((s) => + s.id === shelfId && s.shared + ? { ...s, shared: { ...s.shared, members: s.shared.members.filter((m) => m.initials !== initials) } } + : s, + ), + ); + }, []); + /** * Reconcile a pending "add to shelf on create" against the current * project list. Called whenever the entries change; a no-op when there is @@ -130,5 +166,39 @@ export function useShelves() { [moveProject], ); - return { shelves, createShelf, renameShelf, deleteShelf, moveProject, shelfFor, reconcilePending }; + return { + shelves, + createShelf, + renameShelf, + deleteShelf, + moveProject, + shelfFor, + reconcilePending, + shareShelf, + removeMember, + }; +} + +/** + * Create a shared shelf from an invite, writing localStorage directly. + * Used by the join-shelf landing screen, which renders before ProjectsHome + * mounts (so there is no live hook instance to go through). Returns false + * if the shelf already exists in this browser. + */ +export function createSharedShelfFromInvite(args: { + shelfId: string; + name: string; + projectIds: string[]; + members: ShelfMember[]; +}): boolean { + const shelves = loadShelves(); + if (shelves.some((s) => s.id === args.shelfId)) return false; + shelves.push({ + id: args.shelfId, + name: args.name, + projectIds: args.projectIds, + shared: { sharedAt: new Date().toISOString(), members: args.members }, + }); + localStorage.setItem(SHELVES_KEY, JSON.stringify(shelves)); + return true; } diff --git a/hub-client/src/utils/routing.ts b/hub-client/src/utils/routing.ts index 67c239861..ea7d2de91 100644 --- a/hub-client/src/utils/routing.ts +++ b/hub-client/src/utils/routing.ts @@ -124,6 +124,28 @@ export interface LinkProjectSetRoute { syncServer: string; } +/** + * Route from a shelf invite link (explore/projects-shelves-ui exploration). + * + * The invite carries the shelf identity plus the shelf's project entries + * inline, so joining delivers real, syncable projects; only shelf + * membership itself is mock data until shared shelves become synced docs. + * + * SECURITY: Like ShareRoute, the entries contain bearer document IDs and + * the route should only exist transiently. + * + * URL format: #/join-shelf/?name=&from=&entries= + */ +export interface JoinShelfRoute { + type: 'join-shelf'; + shelfId: string; + shelfName: string; + /** Display name of the person who sent the invite. */ + inviter: string; + /** Projects on the shelf: real doc ids + servers, joinable immediately. */ + 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 +159,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 | JoinShelfRoute | DevRoute; // ============================================================================ // URL Parsing @@ -221,6 +243,28 @@ export function parseHashRoute(hash: string): Route { }; } + // Parse join-shelf route: /join-shelf/?name=&from=&entries= + if (segments[0] === 'join-shelf' && segments[1]) { + let entries: JoinShelfRoute['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 shelf + } + return { + type: 'join-shelf', + shelfId: decodeURIComponent(segments[1]), + shelfName: queryParams.get('name') ?? 'Shared shelf', + inviter: queryParams.get('from') ?? 'A collaborator', + entries, + }; + } + // Parse route based on segments if (segments[0] === 'p' && segments[1]) { const projectId = segments[1]; @@ -307,6 +351,16 @@ export function buildHashRoute(route: Route): string { return `#/link-project-set/${encodeURIComponent(route.projectSetDocId)}?${params.toString()}`; } + case 'join-shelf': { + const params = new URLSearchParams(); + params.set('name', route.shelfName); + params.set('from', route.inviter); + params.set('entries', JSON.stringify( + route.entries.map((e) => ({ d: e.indexDocId, s: e.syncServer, n: e.description })), + )); + return `#/join-shelf/${encodeURIComponent(route.shelfId)}?${params.toString()}`; + } + case 'dev': return `#/dev/${encodeURIComponent(route.page)}`; } @@ -465,6 +519,9 @@ export function routesEqual(a: Route, b: Route): boolean { ); } + case 'join-shelf': + return a.shelfId === (b as JoinShelfRoute).shelfId; + case 'dev': return a.page === (b as DevRoute).page; } From 8997ea1aa90095b5ddfc7e3daf5469325ea7821c Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Thu, 9 Jul 2026 15:48:53 -0400 Subject: [PATCH 10/48] docs(hub-client): changelog for shelf sharing and invite flow Co-Authored-By: Claude Fable 5 --- hub-client/changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 9a9a1c03f..3607dcd4e 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -23,6 +23,10 @@ WASM rebuild is needed for a changelog-only edit. --> +### 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. From b379c89de3062390bd41bf15da6677e84fed44ac Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Thu, 9 Jul 2026 16:39:18 -0400 Subject: [PATCH 11/48] feat(hub-client): rename "shelf" to "collection" throughout Product-language change across the exploration UI: all user-facing copy, the invite route (#/join-collection/), and code identifiers (useCollections, Collection, CollectionMember, CSS classes). Existing data survives via a one-time localStorage fallback that reads the old qh-shelves-v1 key when qh-collections-v1 is absent. Old join-shelf invite links are not honored (none in circulation beyond tests). Co-Authored-By: Claude Fable 5 --- hub-client/src/App.css | 2 +- hub-client/src/App.tsx | 28 +-- ...fLanding.tsx => JoinCollectionLanding.tsx} | 32 +-- hub-client/src/components/ProjectsHome.css | 30 +-- hub-client/src/components/ProjectsHome.tsx | 230 +++++++++--------- hub-client/src/hooks/useCollections.ts | 207 ++++++++++++++++ hub-client/src/hooks/useShelves.ts | 204 ---------------- hub-client/src/utils/mockCollaborators.ts | 6 +- hub-client/src/utils/routing.ts | 46 ++-- 9 files changed, 394 insertions(+), 391 deletions(-) rename hub-client/src/components/{JoinShelfLanding.tsx => JoinCollectionLanding.tsx} (83%) create mode 100644 hub-client/src/hooks/useCollections.ts delete mode 100644 hub-client/src/hooks/useShelves.ts diff --git a/hub-client/src/App.css b/hub-client/src/App.css index 0d45e2471..ea7cfb1cd 100644 --- a/hub-client/src/App.css +++ b/hub-client/src/App.css @@ -1,6 +1,6 @@ /* Minimal app styles - component-specific styles in their own CSS files */ -/* Floating switch back to the shelves UI exploration (explore/projects-shelves-ui) */ +/* Floating switch back to the collections UI exploration (explore/projects-collections-ui) */ .ui-variant-toggle { position: fixed; bottom: 16px; diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index 2fdfcf19b..cc00c4667 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -2,7 +2,7 @@ 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 JoinShelfLanding from './components/JoinShelfLanding'; +import JoinCollectionLanding from './components/JoinCollectionLanding'; import ProjectSetSetup from './components/ProjectSetSetup'; // Lazy-loaded dev harness — only fetched when navigating to #/dev/... routes. @@ -175,13 +175,13 @@ function App() { // Track if we've done the initial URL-based navigation const initialLoadRef = useRef(false); - // UI exploration (explore/projects-shelves-ui): choose between the new - // shelves-based projects home and the classic modal selector. Persisted so + // 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<'shelves' | 'classic'>(() => - localStorage.getItem('qh-ui-variant') === 'classic' ? 'classic' : 'shelves', + const [uiVariant, setUiVariant] = useState<'collections' | 'classic'>(() => + localStorage.getItem('qh-ui-variant') === 'classic' ? 'classic' : 'collections', ); - const switchUiVariant = useCallback((variant: 'shelves' | 'classic') => { + const switchUiVariant = useCallback((variant: 'collections' | 'classic') => { localStorage.setItem('qh-ui-variant', variant); setUiVariant(variant); }, []); @@ -194,11 +194,11 @@ function App() { navigateToFile, } = useRouting(); - // Invite-first onboarding: a fresh browser opening a shelf invite gets a + // Invite-first onboarding: a fresh browser opening a collection invite gets a // project set created silently instead of the setup screen. The landing // shows "Connecting…" until the set is ready. useEffect(() => { - if (route.type === 'join-shelf' && projectSetState.status === 'needs-setup') { + if (route.type === 'join-collection' && projectSetState.status === 'needs-setup') { projectSetActions.createProjectSet(DEFAULT_SYNC_SERVER); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -652,14 +652,14 @@ function App() { return ; } - // Shelf invite landing (explore/projects-shelves-ui). Rendered before the + // Collection invite landing (explore/projects-collections-ui). Rendered before the // setup screens: a brand-new browser clicking an invite never sees // project-set setup — the set is auto-created below while the landing asks // for identity. The needs-migration case (legacy local projects) still // falls through to the setup screen rather than migrating silently. - if (route.type === 'join-shelf' && projectSetState.status !== 'needs-migration') { + if (route.type === 'join-collection' && projectSetState.status !== 'needs-migration') { return ( - {!project ? ( - uiVariant === 'shelves' ? ( + uiVariant === 'collections' ? ( diff --git a/hub-client/src/components/JoinShelfLanding.tsx b/hub-client/src/components/JoinCollectionLanding.tsx similarity index 83% rename from hub-client/src/components/JoinShelfLanding.tsx rename to hub-client/src/components/JoinCollectionLanding.tsx index 4299248ac..f515a19fc 100644 --- a/hub-client/src/components/JoinShelfLanding.tsx +++ b/hub-client/src/components/JoinCollectionLanding.tsx @@ -1,12 +1,12 @@ /** - * JoinShelfLanding — invite-first onboarding for a shared shelf - * (explore/projects-shelves-ui exploration). + * JoinCollectionLanding — invite-first onboarding for a shared collection + * (explore/projects-collections-ui exploration). * - * Rendered for #/join-shelf/ links. A fresh browser never sees the + * Rendered for #/join-collection/ links. A fresh browser never sees the * project-set setup screen: App auto-creates a set silently while this * screen asks the one question that matters — who you are. Joining adds * the invite's project entries (real doc ids, so they sync for real) and - * creates the shelf locally with mock membership. + * creates the collection locally with mock membership. */ import { useState, useEffect } from 'react'; @@ -14,8 +14,8 @@ import type { ProjectSetEntry } from '@quarto/quarto-automerge-schema'; import type { ProjectSetStatus } from '../hooks/useProjectSet'; import type { UserSettings } from '../services/storage/types'; import * as userSettingsService from '../services/userSettings'; -import type { JoinShelfRoute } from '../utils/routing'; -import { createSharedShelfFromInvite, type ShelfMember } from '../hooks/useShelves'; +import type { JoinCollectionRoute } from '../utils/routing'; +import { createSharedCollectionFromInvite, type CollectionMember } from '../hooks/useCollections'; import './ProjectsHome.css'; const COLOR_PALETTE = [ @@ -32,14 +32,14 @@ function initialsFor(name: string): string { } interface Props { - route: JoinShelfRoute; + route: JoinCollectionRoute; projectSetStatus: ProjectSetStatus; onAddProjectToSet: (entry: Omit) => void; /** Navigate home once the join completes. */ onDone: () => void; } -export default function JoinShelfLanding({ route, projectSetStatus, onAddProjectToSet, onDone }: Props) { +export default function JoinCollectionLanding({ route, projectSetStatus, onAddProjectToSet, onDone }: Props) { const [userSettings, setUserSettings] = useState(null); const [name, setName] = useState(''); const [color, setColor] = useState(COLOR_PALETTE[0]); @@ -71,7 +71,7 @@ export default function JoinShelfLanding({ route, projectSetStatus, onAddProject : `automerge:${entry.indexDocId}`; onAddProjectToSet({ indexDocId, syncServer: entry.syncServer, description: entry.description }); } - const members: ShelfMember[] = [ + const members: CollectionMember[] = [ { name: route.inviter, initials: initialsFor(route.inviter), @@ -87,9 +87,9 @@ export default function JoinShelfLanding({ route, projectSetStatus, onAddProject isYou: true, }, ]; - createSharedShelfFromInvite({ - shelfId: route.shelfId, - name: route.shelfName, + createSharedCollectionFromInvite({ + collectionId: route.collectionId, + name: route.collectionName, // Store ids in the same un-prefixed form the invite carries; the // home view matches them against project-set entries either way. projectIds: route.entries.map((e) => e.indexDocId.replace(/^automerge:/, '')), @@ -105,9 +105,9 @@ export default function JoinShelfLanding({ route, projectSetStatus, onAddProject
-
SHELF INVITATION
+
COLLECTION INVITATION

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

{route.entries.length === 0 @@ -152,12 +152,12 @@ export default function JoinShelfLanding({ route, projectSetStatus, onAddProject

Joining adds these projects to your list. This invite flow is part of a UI - exploration — shelf membership isn't synced to other members yet. + exploration — collection membership isn't synced to other members yet.

diff --git a/hub-client/src/components/ProjectsHome.css b/hub-client/src/components/ProjectsHome.css index d6b545cce..31d9c41a3 100644 --- a/hub-client/src/components/ProjectsHome.css +++ b/hub-client/src/components/ProjectsHome.css @@ -1,4 +1,4 @@ -/* ProjectsHome — shelves-based projects view (explore/projects-shelves-ui). +/* 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. */ @@ -286,7 +286,7 @@ min-width: 180px; } -/* ---- shelves ---- */ +/* ---- collections ---- */ .ph-main { padding: 24px 30px 40px; @@ -297,26 +297,26 @@ flex: 1; } -.ph-shelf { margin-bottom: 26px; } +.ph-collection { margin-bottom: 26px; } -.ph-shelf-header { +.ph-collection-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; } -.ph-shelf-name { font-size: 13px; font-weight: 700; } -.ph-shelf-count { font-size: 11.5px; color: var(--text-secondary); } +.ph-collection-name { font-size: 13px; font-weight: 700; } +.ph-collection-count { font-size: 11.5px; color: var(--text-secondary); } -.ph-shelf-empty, +.ph-collection-empty, .ph-rest-empty { font-size: 12.5px; color: var(--text-secondary); padding: 14px 2px; } -.ph-shelf-row { +.ph-collection-row { display: flex; align-items: stretch; gap: 8px; @@ -417,7 +417,7 @@ .ph-card .ph-menu { top: 34px; left: auto; right: 8px; } -.ph-new-shelf-row { margin: 2px 0 24px; } +.ph-new-collection-row { margin: 2px 0 24px; } /* ---- facepiles (mock collaborators for the exploration) ---- */ @@ -448,7 +448,7 @@ .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-shelf-header .ph-facepile { margin-left: 6px; align-self: center; } +.ph-collection-header .ph-facepile { margin-left: 6px; align-self: center; } .ph-card-footer { display: flex; @@ -470,9 +470,9 @@ color: var(--text-primary); } -/* ---- shelf sharing: people button, members popover, join landing ---- */ +/* ---- collection sharing: people button, members popover, join landing ---- */ -.ph-shelf-people { +.ph-collection-people { display: inline-flex; align-items: center; gap: 6px; @@ -486,7 +486,7 @@ align-self: center; } -.ph-shelf-people:hover { +.ph-collection-people:hover { background: var(--input-bg-alpha); color: var(--text-primary); } @@ -611,7 +611,7 @@ line-height: 1.3; } -.ph-join-shelf-name { color: var(--posit-teal); } +.ph-join-collection-name { color: var(--posit-teal); } .ph-join-sub { font-size: 13.5px; @@ -665,7 +665,7 @@ opacity: 0.4; } -.ph-shelf.drop-target, +.ph-collection.drop-target, .ph-rest.drop-target { outline: 2px dashed var(--posit-teal); outline-offset: 8px; diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx index 1727038b0..e80eadeff 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -1,11 +1,11 @@ /** - * ProjectsHome — full-page projects view (explore/projects-shelves-ui). + * ProjectsHome — full-page projects view (explore/projects-collections-ui). * * Implements the "Short term" design from QH-ProjectManagement-July26.fig: - * shelves + streamlined entry, buildable on today's metadata. Replaces the + * 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 shelves with project cards (paged at 6+) + * - 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 @@ -35,7 +35,7 @@ import { } from '../utils/routing'; import ShareDialog from './ShareDialog'; import { mockCollaborators, unionCollaborators, type MockUser } from '../utils/mockCollaborators'; -import { useShelves, setPendingShelfAssignment, type Shelf, type ShelfMember } from '../hooks/useShelves'; +import { useCollections, setPendingCollectionAssignment, type Collection, type CollectionMember } from '../hooks/useCollections'; import './ProjectsHome.css'; interface Props { @@ -73,7 +73,7 @@ const COLOR_PALETTE = [ '#FF5722', '#795548', ]; -const SHELF_PAGE_SIZE = 8; // two rows of four cards +const COLLECTION_PAGE_SIZE = 8; // two rows of four cards const UNNAMED_RE = /^Project \d{4}-\d{2}-\d{2}T/; @@ -169,7 +169,7 @@ export default function ProjectsHome({ const searchRef = useRef(null); // Menus / popovers. openMenu identifies the ⋯ menu by project id or - // `shelf:`; submenus and the peek popover are tracked separately. + // `collection:`; submenus and the peek popover are tracked separately. const [openMenu, setOpenMenu] = useState(null); const [moveSubmenuOpen, setMoveSubmenuOpen] = useState(false); const [newMenuOpen, setNewMenuOpen] = useState(false); @@ -188,7 +188,7 @@ export default function ProjectsHome({ // + New dialog state const [newTitle, setNewTitle] = useState(''); - const [newShelfId, setNewShelfId] = useState(''); + const [newCollectionId, setNewCollectionId] = useState(''); const [newServer, setNewServer] = useState(DEFAULT_SYNC_SERVER); const [showServerField, setShowServerField] = useState(false); const [isCreating, setIsCreating] = useState(false); @@ -210,12 +210,12 @@ export default function ProjectsHome({ const [editNameValue, setEditNameValue] = useState(''); const { colorScheme, cycleColorScheme } = useTheme(); - const { shelves, createShelf, renameShelf, deleteShelf, moveProject, reconcilePending, shareShelf, removeMember } = useShelves(); - // Which shelf's members-and-invite popover is open + const { collections, createCollection, renameCollection, deleteCollection, moveProject, reconcilePending, shareCollection, removeMember } = useCollections(); + // Which collection's members-and-invite popover is open const [membersFor, setMembersFor] = useState(null); - const [shelfPages, setShelfPages] = useState>({}); - // Drag-and-drop between shelves and the unshelved list. dropTarget is a - // shelf id or 'unshelved'. + 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'); @@ -280,7 +280,7 @@ export default function ProjectsHome({ return m; }, [items]); - // Apply any pending "add to shelf on create" once the new entry appears. + // Apply any pending "add to collection on create" once the new entry appears. useEffect(() => { reconcilePending(items); }, [items, reconcilePending]); @@ -338,7 +338,7 @@ export default function ProjectsHome({ setDropTarget(null); }, []); - /** Drop-zone props for a shelf section (or 'unshelved' for the bottom list). */ + /** 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; @@ -402,16 +402,16 @@ export default function ProjectsHome({ setRenameValue(''); }, [renameFor, renameValue, onRenameProject]); - const handleNewShelf = useCallback((): string | null => { - const name = prompt('Shelf name'); + const handleNewCollection = useCallback((): string | null => { + const name = prompt('Collection name'); if (!name?.trim()) return null; - return createShelf(name.trim()); - }, [createShelf]); + return createCollection(name.trim()); + }, [createCollection]); const openNewDialog = useCallback((choice: ProjectChoice) => { setNewDialogChoice(choice); setNewTitle(''); - setNewShelfId(''); + setNewCollectionId(''); setShowServerField(false); setFormError(null); setNewMenuOpen(false); @@ -428,8 +428,8 @@ export default function ProjectsHome({ setFormError(result.error || 'Failed to create project'); return; } - if (newShelfId) { - setPendingShelfAssignment(newTitle.trim(), newShelfId); + if (newCollectionId) { + setPendingCollectionAssignment(newTitle.trim(), newCollectionId); } onProjectCreated?.(result.files, newTitle.trim(), newDialogChoice.id, newServer.trim()); setNewDialogChoice(null); @@ -438,7 +438,7 @@ export default function ProjectsHome({ } finally { setIsCreating(false); } - }, [newDialogChoice, newTitle, newServer, newShelfId, onProjectCreated]); + }, [newDialogChoice, newTitle, newServer, newCollectionId, onProjectCreated]); const handleConnect = useCallback(async (e: React.FormEvent) => { e.preventDefault(); @@ -580,17 +580,17 @@ export default function ProjectsHome({ ); const shelvedIds = useMemo(() => { - // Shelf project ids may be stored with or without the 'automerge:' + // 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 shelf of shelves) { - for (const id of shelf.projectIds) { + 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; - }, [shelves]); + }, [collections]); const everythingElse = useMemo(() => { const rest = items.filter((it) => !shelvedIds.has(it.indexDocId)).filter(matches); @@ -655,17 +655,17 @@ export default function ProjectsHome({ className="ph-menu-item-inner" onClick={(e) => { e.stopPropagation(); setMoveSubmenuOpen((v) => !v); }} > - Move to shelf + Move to collection {moveSubmenuOpen && (
- {shelves.map((shelf) => ( + {collections.map((collection) => ( ))} {shelvedIds.has(item.indexDocId) && ( @@ -673,18 +673,18 @@ export default function ProjectsHome({ className="ph-menu-item" onClick={() => { moveProject(item.indexDocId, null); closeAllMenus(); }} > - No shelf + No collection )}
)} @@ -741,49 +741,49 @@ export default function ProjectsHome({
); - // ---- shelf sharing (membership is mock; see utils/mockCollaborators) ---- + // ---- collection sharing (membership is mock; see utils/mockCollaborators) ---- - const shelfItemsOf = (shelf: Shelf): ProjectItem[] => - shelf.projectIds + const collectionItemsOf = (collection: Collection): ProjectItem[] => + collection.projectIds .map((id) => byId.get(id) ?? byId.get(`automerge:${id}`)) .filter((it): it is ProjectItem => !!it); - const buildInviteUrl = (shelf: Shelf): string => + const buildInviteUrl = (collection: Collection): string => buildFullUrl({ - type: 'join-shelf', - shelfId: shelf.id, - shelfName: shelf.name, + type: 'join-collection', + collectionId: collection.id, + collectionName: collection.name, inviter: userSettings?.userName ?? 'A collaborator', - entries: shelfItemsOf(shelf).map((it) => ({ + entries: collectionItemsOf(collection).map((it) => ({ indexDocId: it.indexDocId.replace(/^automerge:/, ''), syncServer: it.syncServer, description: it.description, })), }); - const handleShareShelf = (shelf: Shelf) => { - if (!shelf.shared) { + const handleShareCollection = (collection: Collection) => { + if (!collection.shared) { const now = new Date().toISOString(); - const seeded: ShelfMember[] = selfUser + const seeded: CollectionMember[] = selfUser ? [{ ...selfUser, name: selfUser.name.replace(/ \(you\)$/, ''), joinedAt: now, isOwner: true, isYou: true }] : []; - // Seed with the mock collaborators already shown on this shelf's + // Seed with the mock collaborators already shown on this collection's // project cards — the "people with access" story stays consistent. - const others = unionCollaborators(shelfItemsOf(shelf).map((it) => collaboratorsFor(it.indexDocId))) + const others = unionCollaborators(collectionItemsOf(collection).map((it) => collaboratorsFor(it.indexDocId))) .filter((u) => !seeded.some((m) => m.initials === u.initials)) .map((u) => ({ ...u, joinedAt: now })); - shareShelf(shelf.id, [...seeded, ...others]); + shareCollection(collection.id, [...seeded, ...others]); } setOpenMenu(null); - setMembersFor(shelf.id); + setMembersFor(collection.id); }; - const renderMembersPopover = (shelf: Shelf) => { - const members = shelf.shared?.members ?? []; + const renderMembersPopover = (collection: Collection) => { + const members = collection.shared?.members ?? []; const you = members.find((m) => m.isYou); - const inviteUrl = buildInviteUrl(shelf); + const inviteUrl = buildInviteUrl(collection); return ( -
+
SHARED WITH {members.length} {members.length === 1 ? 'PERSON' : 'PEOPLE'}
{members.map((m, i) => ( @@ -798,7 +798,7 @@ export default function ProjectsHome({ ) : !m.isYou ? ( @@ -810,13 +810,13 @@ export default function ProjectsHome({ )}
@@ -825,13 +825,13 @@ export default function ProjectsHome({ {inviteUrl.replace(/^https?:\/\//, '').slice(0, 34)}…
- Anyone with this link can join this shelf and add or remove projects. + Anyone with this link can join this collection and add or remove projects.
); @@ -869,36 +869,36 @@ export default function ProjectsHome({
); - const renderShelf = (shelf: Shelf) => { - // Shelf order is by recency (lastAccessed, newest first), not by stored + const renderCollection = (collection: Collection) => { + // 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 shelfItems = shelfItemsOf(shelf) + const collectionItems = collectionItemsOf(collection) .filter(matches) .sort((a, b) => (a.lastAccessed < b.lastAccessed ? 1 : -1)); - if (query && shelfItems.length === 0) return null; - const pageCount = Math.max(1, Math.ceil(shelfItems.length / SHELF_PAGE_SIZE)); - const page = Math.min(shelfPages[shelf.id] ?? 0, pageCount - 1); - const pageItems = shelfItems.slice(page * SHELF_PAGE_SIZE, (page + 1) * SHELF_PAGE_SIZE); - const menuKey = `shelf:${shelf.id}`; + 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 (
-
- {shelf.name} - {shelfItems.length} - {shelf.shared && ( +
+ {collection.name} + {collectionItems.length} + {collection.shared && ( )} - {membersFor === shelf.id && shelf.shared && renderMembersPopover(shelf)} + {membersFor === collection.id && collection.shared && renderMembersPopover(collection)} {openMenu === menuKey && (
- {shelf.shared ? ( - ) : ( - )} - {shelf.shared ? ( + {collection.shared ? ( <>
@@ -978,28 +978,28 @@ export default function ProjectsHome({ )}
)}
- {shelfItems.length === 0 ? ( -
Empty shelf — drag a project here, or use its ⋯ menu.
+ {collectionItems.length === 0 ? ( +
Empty collection — drag a project here, or use its ⋯ menu.
) : ( -
+
{page > 0 && ( @@ -1009,7 +1009,7 @@ export default function ProjectsHome({
) : ( <> - {shelves.map(renderShelf)} + {collections.map(renderCollection)} -
- +
+
{everythingElse.length === 0 ? (
- {query ? 'No projects match your search.' : 'Everything is on a shelf.'} + {query ? 'No projects match your search.' : 'Everything is on a collection.'}
) : (
@@ -1305,15 +1305,15 @@ export default function ProjectsHome({ placeholder="Q3 all-hands deck" autoFocus /> - + @@ -1452,7 +1452,7 @@ export default function ProjectsHome({ {__GIT_COMMIT_HASH__} - shelves UI exploration + collections UI exploration
); diff --git a/hub-client/src/hooks/useCollections.ts b/hub-client/src/hooks/useCollections.ts new file mode 100644 index 000000000..403dbdeb2 --- /dev/null +++ b/hub-client/src/hooks/useCollections.ts @@ -0,0 +1,207 @@ +/** + * Collection state for the projects-home UI exploration (explore/projects-collections-ui). + * + * Collections are a purely local, per-browser grouping of projects: a named list + * of indexDocIds persisted to localStorage. They deliberately do NOT sync — + * the short-term design ("Collections + streamlined entry") is buildable on + * today's metadata, and shared collections are a later phase that would make a + * collection its own synced document. Keeping the storage local lets us test the + * UX without touching the project-set schema. + */ + +import { useState, useCallback, useEffect } from 'react'; + +/** A member of a shared collection. Mock data until collections become synced docs. */ +export interface CollectionMember { + name: string; + initials: string; + color: string; + joinedAt: string; + isOwner?: boolean; + isYou?: boolean; +} + +export interface Collection { + id: string; + name: string; + projectIds: string[]; + /** Present once the collection has been explicitly shared. */ + shared?: { + sharedAt: string; + members: CollectionMember[]; + }; +} + +const COLLECTIONS_KEY = 'qh-collections-v1'; +// Pre-rename storage key ("shelf" era); read once as a migration fallback so +// existing exploration data survives the shelf → collection rename. +const LEGACY_SHELVES_KEY = 'qh-shelves-v1'; +const PENDING_KEY = 'qh-collection-pending-v1'; + +interface PendingAssignment { + title: string; + collectionId: string; + ts: number; +} + +function loadCollections(): Collection[] { + try { + const raw = localStorage.getItem(COLLECTIONS_KEY) ?? localStorage.getItem(LEGACY_SHELVES_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (s): s is Collection => + typeof s?.id === 'string' && + typeof s?.name === 'string' && + Array.isArray(s?.projectIds), + ); + } catch { + return []; + } +} + +/** + * Record that the next project created with this title should land on a + * collection. The indexDocId of a new project isn't known until the parent app + * finishes creating the Automerge docs, so we reconcile by title when the + * entry shows up in the project set. + */ +export function setPendingCollectionAssignment(title: string, collectionId: string): void { + const pending: PendingAssignment = { title, collectionId, ts: Date.now() }; + localStorage.setItem(PENDING_KEY, JSON.stringify(pending)); +} + +export function useCollections() { + const [collections, setCollections] = useState(loadCollections); + + useEffect(() => { + localStorage.setItem(COLLECTIONS_KEY, JSON.stringify(collections)); + }, [collections]); + + const createCollection = useCallback((name: string): string => { + const id = `collection-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + setCollections((prev) => [...prev, { id, name, projectIds: [] }]); + return id; + }, []); + + const renameCollection = useCallback((id: string, name: string) => { + setCollections((prev) => prev.map((s) => (s.id === id ? { ...s, name } : s))); + }, []); + + const deleteCollection = useCallback((id: string) => { + setCollections((prev) => prev.filter((s) => s.id !== id)); + }, []); + + /** + * Put a project on a collection (or none). A project sits on at most one + * personal collection, so it's removed from all others first. + */ + const moveProject = useCallback((indexDocId: string, collectionId: string | null) => { + setCollections((prev) => + prev.map((s) => { + const without = s.projectIds.filter((p) => p !== indexDocId); + if (s.id === collectionId && !without.includes(indexDocId)) { + return { ...s, projectIds: [...without, indexDocId] }; + } + return without.length === s.projectIds.length ? s : { ...s, projectIds: without }; + }), + ); + }, []); + + const collectionFor = useCallback( + (indexDocId: string): Collection | undefined => + collections.find((s) => s.projectIds.includes(indexDocId)), + [collections], + ); + + /** Convert a personal collection to shared, seeding the member list. */ + const shareCollection = useCallback((id: string, members: CollectionMember[]) => { + setCollections((prev) => + prev.map((s) => + s.id === id && !s.shared + ? { ...s, shared: { sharedAt: new Date().toISOString(), members } } + : s, + ), + ); + }, []); + + const removeMember = useCallback((collectionId: string, initials: string) => { + setCollections((prev) => + prev.map((s) => + s.id === collectionId && s.shared + ? { ...s, shared: { ...s.shared, members: s.shared.members.filter((m) => m.initials !== initials) } } + : s, + ), + ); + }, []); + + /** + * Reconcile a pending "add to collection on create" against the current + * project list. Called whenever the entries change; a no-op when there is + * nothing pending. Stale pendings (>1 day) are dropped. + */ + const reconcilePending = useCallback( + (entries: Array<{ indexDocId: string; description: string; addedAt: string }>) => { + const raw = localStorage.getItem(PENDING_KEY); + if (!raw) return; + let pending: PendingAssignment; + try { + pending = JSON.parse(raw); + } catch { + localStorage.removeItem(PENDING_KEY); + return; + } + if (Date.now() - pending.ts > 24 * 3600 * 1000) { + localStorage.removeItem(PENDING_KEY); + return; + } + const match = entries.find( + (e) => + e.description === pending.title && + new Date(e.addedAt).getTime() >= pending.ts - 60_000, + ); + if (match) { + moveProject(match.indexDocId, pending.collectionId); + localStorage.removeItem(PENDING_KEY); + } + }, + [moveProject], + ); + + return { + collections, + createCollection, + renameCollection, + deleteCollection, + moveProject, + collectionFor, + reconcilePending, + shareCollection, + removeMember, + }; +} + +/** + * Create a shared collection from an invite, writing localStorage directly. + * Used by the join-collection landing screen, which renders before ProjectsHome + * mounts (so there is no live hook instance to go through). Returns false + * if the collection already exists in this browser. + */ +export function createSharedCollectionFromInvite(args: { + collectionId: string; + name: string; + projectIds: string[]; + members: CollectionMember[]; +}): boolean { + const collections = loadCollections(); + if (collections.some((s) => s.id === args.collectionId)) return false; + collections.push({ + id: args.collectionId, + name: args.name, + projectIds: args.projectIds, + shared: { sharedAt: new Date().toISOString(), members: args.members }, + }); + localStorage.setItem(COLLECTIONS_KEY, JSON.stringify(collections)); + return true; +} diff --git a/hub-client/src/hooks/useShelves.ts b/hub-client/src/hooks/useShelves.ts deleted file mode 100644 index 03fdf619b..000000000 --- a/hub-client/src/hooks/useShelves.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Shelf state for the projects-home UI exploration (explore/projects-shelves-ui). - * - * Shelves are a purely local, per-browser grouping of projects: a named list - * of indexDocIds persisted to localStorage. They deliberately do NOT sync — - * the short-term design ("Shelves + streamlined entry") is buildable on - * today's metadata, and shared shelves are a later phase that would make a - * shelf its own synced document. Keeping the storage local lets us test the - * UX without touching the project-set schema. - */ - -import { useState, useCallback, useEffect } from 'react'; - -/** A member of a shared shelf. Mock data until shelves become synced docs. */ -export interface ShelfMember { - name: string; - initials: string; - color: string; - joinedAt: string; - isOwner?: boolean; - isYou?: boolean; -} - -export interface Shelf { - id: string; - name: string; - projectIds: string[]; - /** Present once the shelf has been explicitly shared. */ - shared?: { - sharedAt: string; - members: ShelfMember[]; - }; -} - -const SHELVES_KEY = 'qh-shelves-v1'; -const PENDING_KEY = 'qh-shelf-pending-v1'; - -interface PendingAssignment { - title: string; - shelfId: string; - ts: number; -} - -function loadShelves(): Shelf[] { - try { - const raw = localStorage.getItem(SHELVES_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - return parsed.filter( - (s): s is Shelf => - typeof s?.id === 'string' && - typeof s?.name === 'string' && - Array.isArray(s?.projectIds), - ); - } catch { - return []; - } -} - -/** - * Record that the next project created with this title should land on a - * shelf. The indexDocId of a new project isn't known until the parent app - * finishes creating the Automerge docs, so we reconcile by title when the - * entry shows up in the project set. - */ -export function setPendingShelfAssignment(title: string, shelfId: string): void { - const pending: PendingAssignment = { title, shelfId, ts: Date.now() }; - localStorage.setItem(PENDING_KEY, JSON.stringify(pending)); -} - -export function useShelves() { - const [shelves, setShelves] = useState(loadShelves); - - useEffect(() => { - localStorage.setItem(SHELVES_KEY, JSON.stringify(shelves)); - }, [shelves]); - - const createShelf = useCallback((name: string): string => { - const id = `shelf-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - setShelves((prev) => [...prev, { id, name, projectIds: [] }]); - return id; - }, []); - - const renameShelf = useCallback((id: string, name: string) => { - setShelves((prev) => prev.map((s) => (s.id === id ? { ...s, name } : s))); - }, []); - - const deleteShelf = useCallback((id: string) => { - setShelves((prev) => prev.filter((s) => s.id !== id)); - }, []); - - /** - * Put a project on a shelf (or none). A project sits on at most one - * personal shelf, so it's removed from all others first. - */ - const moveProject = useCallback((indexDocId: string, shelfId: string | null) => { - setShelves((prev) => - prev.map((s) => { - const without = s.projectIds.filter((p) => p !== indexDocId); - if (s.id === shelfId && !without.includes(indexDocId)) { - return { ...s, projectIds: [...without, indexDocId] }; - } - return without.length === s.projectIds.length ? s : { ...s, projectIds: without }; - }), - ); - }, []); - - const shelfFor = useCallback( - (indexDocId: string): Shelf | undefined => - shelves.find((s) => s.projectIds.includes(indexDocId)), - [shelves], - ); - - /** Convert a personal shelf to shared, seeding the member list. */ - const shareShelf = useCallback((id: string, members: ShelfMember[]) => { - setShelves((prev) => - prev.map((s) => - s.id === id && !s.shared - ? { ...s, shared: { sharedAt: new Date().toISOString(), members } } - : s, - ), - ); - }, []); - - const removeMember = useCallback((shelfId: string, initials: string) => { - setShelves((prev) => - prev.map((s) => - s.id === shelfId && s.shared - ? { ...s, shared: { ...s.shared, members: s.shared.members.filter((m) => m.initials !== initials) } } - : s, - ), - ); - }, []); - - /** - * Reconcile a pending "add to shelf on create" against the current - * project list. Called whenever the entries change; a no-op when there is - * nothing pending. Stale pendings (>1 day) are dropped. - */ - const reconcilePending = useCallback( - (entries: Array<{ indexDocId: string; description: string; addedAt: string }>) => { - const raw = localStorage.getItem(PENDING_KEY); - if (!raw) return; - let pending: PendingAssignment; - try { - pending = JSON.parse(raw); - } catch { - localStorage.removeItem(PENDING_KEY); - return; - } - if (Date.now() - pending.ts > 24 * 3600 * 1000) { - localStorage.removeItem(PENDING_KEY); - return; - } - const match = entries.find( - (e) => - e.description === pending.title && - new Date(e.addedAt).getTime() >= pending.ts - 60_000, - ); - if (match) { - moveProject(match.indexDocId, pending.shelfId); - localStorage.removeItem(PENDING_KEY); - } - }, - [moveProject], - ); - - return { - shelves, - createShelf, - renameShelf, - deleteShelf, - moveProject, - shelfFor, - reconcilePending, - shareShelf, - removeMember, - }; -} - -/** - * Create a shared shelf from an invite, writing localStorage directly. - * Used by the join-shelf landing screen, which renders before ProjectsHome - * mounts (so there is no live hook instance to go through). Returns false - * if the shelf already exists in this browser. - */ -export function createSharedShelfFromInvite(args: { - shelfId: string; - name: string; - projectIds: string[]; - members: ShelfMember[]; -}): boolean { - const shelves = loadShelves(); - if (shelves.some((s) => s.id === args.shelfId)) return false; - shelves.push({ - id: args.shelfId, - name: args.name, - projectIds: args.projectIds, - shared: { sharedAt: new Date().toISOString(), members: args.members }, - }); - localStorage.setItem(SHELVES_KEY, JSON.stringify(shelves)); - return true; -} diff --git a/hub-client/src/utils/mockCollaborators.ts b/hub-client/src/utils/mockCollaborators.ts index 467c86fcf..cb39656c0 100644 --- a/hub-client/src/utils/mockCollaborators.ts +++ b/hub-client/src/utils/mockCollaborators.ts @@ -1,9 +1,9 @@ /** * Mock collaborators for the projects-home UI exploration - * (explore/projects-shelves-ui). + * (explore/projects-collections-ui). * * The Figma design shows per-project facepiles (colored disks with initials) - * on cards, shelf headers, and the Peek popover. Real contributor data needs + * on cards, collection headers, and the Peek popover. Real contributor data needs * automerge-history attribution (a later design phase), so the exploration * spoofs a stable fake crew per project: seeded from the indexDocId, so the * same project always shows the same faces across reloads. @@ -46,7 +46,7 @@ export function mockCollaborators(indexDocId: string, self?: MockUser): MockUser return self ? [self, ...others.values()] : [...others.values()]; } -/** Union of collaborators across several projects, capped for shelf headers. */ +/** Union of collaborators across several projects, capped for collection headers. */ export function unionCollaborators(lists: MockUser[][]): MockUser[] { const m = new Map(); for (const list of lists) { diff --git a/hub-client/src/utils/routing.ts b/hub-client/src/utils/routing.ts index ea7d2de91..cfa46e189 100644 --- a/hub-client/src/utils/routing.ts +++ b/hub-client/src/utils/routing.ts @@ -125,24 +125,24 @@ export interface LinkProjectSetRoute { } /** - * Route from a shelf invite link (explore/projects-shelves-ui exploration). + * Route from a collection invite link (explore/projects-collections-ui exploration). * - * The invite carries the shelf identity plus the shelf's project entries - * inline, so joining delivers real, syncable projects; only shelf - * membership itself is mock data until shared shelves become synced docs. + * 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-shelf/?name=&from=&entries= + * URL format: #/join-collection/?name=&from=&entries= */ -export interface JoinShelfRoute { - type: 'join-shelf'; - shelfId: string; - shelfName: string; +export interface JoinCollectionRoute { + type: 'join-collection'; + collectionId: string; + collectionName: string; /** Display name of the person who sent the invite. */ inviter: string; - /** Projects on the shelf: real doc ids + servers, joinable immediately. */ + /** Projects on the collection: real doc ids + servers, joinable immediately. */ entries: Array<{ indexDocId: string; syncServer: string; description: string }>; } @@ -159,7 +159,7 @@ export interface DevRoute { /** * Union of all possible routes. */ -export type Route = ProjectSelectorRoute | ProjectRoute | FileRoute | ShareRoute | LinkProjectSetRoute | JoinShelfRoute | DevRoute; +export type Route = ProjectSelectorRoute | ProjectRoute | FileRoute | ShareRoute | LinkProjectSetRoute | JoinCollectionRoute | DevRoute; // ============================================================================ // URL Parsing @@ -243,9 +243,9 @@ export function parseHashRoute(hash: string): Route { }; } - // Parse join-shelf route: /join-shelf/?name=&from=&entries= - if (segments[0] === 'join-shelf' && segments[1]) { - let entries: JoinShelfRoute['entries'] = []; + // 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)) { @@ -254,12 +254,12 @@ export function parseHashRoute(hash: string): Route { .map((e) => ({ indexDocId: e.d, syncServer: e.s, description: String(e.n ?? '') })); } } catch { - // Malformed entries — join proceeds with an empty shelf + // Malformed entries — join proceeds with an empty collection } return { - type: 'join-shelf', - shelfId: decodeURIComponent(segments[1]), - shelfName: queryParams.get('name') ?? 'Shared shelf', + type: 'join-collection', + collectionId: decodeURIComponent(segments[1]), + collectionName: queryParams.get('name') ?? 'Shared collection', inviter: queryParams.get('from') ?? 'A collaborator', entries, }; @@ -351,14 +351,14 @@ export function buildHashRoute(route: Route): string { return `#/link-project-set/${encodeURIComponent(route.projectSetDocId)}?${params.toString()}`; } - case 'join-shelf': { + case 'join-collection': { const params = new URLSearchParams(); - params.set('name', route.shelfName); + params.set('name', route.collectionName); params.set('from', route.inviter); params.set('entries', JSON.stringify( route.entries.map((e) => ({ d: e.indexDocId, s: e.syncServer, n: e.description })), )); - return `#/join-shelf/${encodeURIComponent(route.shelfId)}?${params.toString()}`; + return `#/join-collection/${encodeURIComponent(route.collectionId)}?${params.toString()}`; } case 'dev': @@ -519,8 +519,8 @@ export function routesEqual(a: Route, b: Route): boolean { ); } - case 'join-shelf': - return a.shelfId === (b as JoinShelfRoute).shelfId; + case 'join-collection': + return a.collectionId === (b as JoinCollectionRoute).collectionId; case 'dev': return a.page === (b as DevRoute).page; From 4faebe72a8945898664a304221ea36242d04881c Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Thu, 9 Jul 2026 16:39:49 -0400 Subject: [PATCH 12/48] docs(hub-client): changelog for shelf-to-collection rename Co-Authored-By: Claude Fable 5 --- hub-client/changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 3607dcd4e..a6169f072 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -23,6 +23,10 @@ WASM rebuild is needed for a changelog-only edit. --> +### 2026-07-09 + +- [`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. From dbd1bc44b6a035ba19f30015848c1beef6db534f Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Thu, 9 Jul 2026 16:47:17 -0400 Subject: [PATCH 13/48] feat(hub-client): avatar chips on every collection open the members popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Private collections show a single dimmed chip (you); shared ones keep the people glyph and full-strength member facepile. Clicking the chips always opens the members-and-share popover: shared collections list who has access, private ones read "Private — only you" and copying the invite link is the act that turns sharing on (seeding mock membership, as before). Co-Authored-By: Claude Fable 5 --- hub-client/src/components/ProjectsHome.css | 5 ++ hub-client/src/components/ProjectsHome.tsx | 62 +++++++++++++++------- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/hub-client/src/components/ProjectsHome.css b/hub-client/src/components/ProjectsHome.css index 31d9c41a3..ca3c4c372 100644 --- a/hub-client/src/components/ProjectsHome.css +++ b/hub-client/src/components/ProjectsHome.css @@ -491,6 +491,11 @@ 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; diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx index e80eadeff..a621c2c1f 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -779,12 +779,22 @@ export default function ProjectsHome({ }; const renderMembersPopover = (collection: Collection) => { - const members = collection.shared?.members ?? []; + // A private collection shows the same popover with just you in it, plus + // the way to share — copying the link is what turns sharing on. + const members: CollectionMember[] = collection.shared?.members + ?? (selfUser + ? [{ ...selfUser, name: selfUser.name.replace(/ \(you\)$/, ''), joinedAt: '', isOwner: true, isYou: true }] + : []); const you = members.find((m) => m.isYou); const inviteUrl = buildInviteUrl(collection); + const copyKey = `collection:${collection.id}:invite`; return (
-
SHARED WITH {members.length} {members.length === 1 ? 'PERSON' : 'PEOPLE'}
+
+ {collection.shared + ? `SHARED WITH ${members.length} ${members.length === 1 ? 'PERSON' : 'PEOPLE'}` + : 'PRIVATE — ONLY YOU'} +
{members.map((m, i) => (
@@ -820,18 +830,25 @@ export default function ProjectsHome({ )}
-
INVITE BY LINK
+
{collection.shared ? 'INVITE BY LINK' : 'SHARE BY LINK'}
{inviteUrl.replace(/^https?:\/\//, '').slice(0, 34)}…
- Anyone with this link can join this collection and add or remove projects. + {collection.shared + ? 'Anyone with this link can join this collection and add or remove projects.' + : 'Copying turns on sharing — anyone with the link can join and add or remove projects.'}
); @@ -891,25 +908,30 @@ export default function ProjectsHome({
{collection.name} {collectionItems.length} - {collection.shared && ( - - )} + )} + {renderFacepile( + collection.shared?.members ?? (selfUser ? [selfUser] : []), + 'md', + )} + - {membersFor === collection.id && collection.shared && renderMembersPopover(collection)} + {membersFor === collection.id && renderMembersPopover(collection)} {openMenu === menuKey && (
{collection.shared ? ( From c8a53940effdc52635c36e0d604873fa9e278aa9 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Thu, 9 Jul 2026 16:48:02 -0400 Subject: [PATCH 14/48] docs(hub-client): changelog for collection avatar chips Co-Authored-By: Claude Fable 5 --- hub-client/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index a6169f072..6fdf27725 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -25,6 +25,7 @@ WASM rebuild is needed for a changelog-only edit. ### 2026-07-09 +- [`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 From 1c447eeae292961dd8e8ffc8e4d9fe671bfc35fb Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Thu, 9 Jul 2026 16:55:08 -0400 Subject: [PATCH 15/48] feat(hub-client): warn before moving a project out of a shared collection Every move path (drag-and-drop and the Move-to-collection menu) now routes through a gate: when the source collection is shared with other people, a dialog explains that the move changes their view of the collection and asks to confirm, with a persisted "Don't show this again" opt-out (qh-collection-move-warning-dismissed). Private collections, same-collection drops, and moves into a collection pass straight through. Co-Authored-By: Claude Fable 5 --- hub-client/src/components/ProjectsHome.css | 13 +++ hub-client/src/components/ProjectsHome.tsx | 93 ++++++++++++++++++++-- 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/hub-client/src/components/ProjectsHome.css b/hub-client/src/components/ProjectsHome.css index ca3c4c372..653b35db3 100644 --- a/hub-client/src/components/ProjectsHome.css +++ b/hub-client/src/components/ProjectsHome.css @@ -919,6 +919,19 @@ select.ph-input { appearance: auto; } 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); diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx index a621c2c1f..02330caba 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -77,6 +77,9 @@ 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'; + function isUnnamed(description: string): boolean { return UNNAMED_RE.test(description); } @@ -213,6 +216,15 @@ export default function ProjectsHome({ const { collections, createCollection, renameCollection, deleteCollection, moveProject, reconcilePending, shareCollection, removeMember } = useCollections(); // 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); const [collectionPages, setCollectionPages] = useState>({}); // Drag-and-drop between collections and the unshelved list. dropTarget is a // collection id or 'unshelved'. @@ -325,6 +337,36 @@ export default function ProjectsHome({ // the payload) rather than React state, which lags the native events. const DRAG_TYPE = 'application/x-qh-project'; + // Moving a project out of a shared collection changes what its other + // members see. Warn once (suppressible) before such moves; private + // collections and moves within the same collection pass straight through. + const collectionOf = useCallback((indexDocId: string): Collection | undefined => { + const short = indexDocId.replace(/^automerge:/, ''); + return collections.find((c) => + c.projectIds.some((id) => id === indexDocId || id === short || `automerge:${id}` === indexDocId), + ); + }, [collections]); + + const requestMove = useCallback((indexDocId: string, target: string | null) => { + const from = collectionOf(indexDocId); + const othersCount = from?.shared?.members.filter((m) => !m.isYou).length ?? 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, 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); @@ -354,10 +396,10 @@ export default function ProjectsHome({ onDrop: (e: React.DragEvent) => { e.preventDefault(); const docId = e.dataTransfer.getData(DRAG_TYPE) || draggingId; - if (docId) moveProject(docId, target === 'unshelved' ? null : target); + if (docId) requestMove(docId, target === 'unshelved' ? null : target); handleDragEnd(); }, - }), [draggingId, dropTarget, moveProject, handleDragEnd]); + }), [draggingId, dropTarget, requestMove, handleDragEnd]); const handleOpen = useCallback(async (item: ProjectItem) => { // Ensure a local IDB entry exists (URL routing uses local ids) @@ -663,7 +705,7 @@ export default function ProjectsHome({ @@ -671,7 +713,7 @@ export default function ProjectsHome({ {shelvedIds.has(item.indexDocId) && ( @@ -680,7 +722,7 @@ export default function ProjectsHome({ className="ph-menu-item accent" onClick={() => { const id = handleNewCollection(); - if (id) moveProject(item.indexDocId, id); + if (id) requestMove(item.indexDocId, id); closeAllMenus(); }} > @@ -1286,6 +1328,47 @@ export default function ProjectsHome({ )} + {/* 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)}> From 3839162534356da704bbbbaf65dd29c8f78aeebe Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Thu, 9 Jul 2026 16:55:44 -0400 Subject: [PATCH 16/48] docs(hub-client): changelog for shared-collection move warning Co-Authored-By: Claude Fable 5 --- hub-client/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 6fdf27725..2eb458f0a 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -25,6 +25,7 @@ WASM rebuild is needed for a changelog-only edit. ### 2026-07-09 +- [`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. From 52c92f94c146a7b810d93b29ce3635b3303056c0 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Thu, 9 Jul 2026 17:05:13 -0400 Subject: [PATCH 17/48] feat(hub-client): download a project as ZIP from the projects home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Download as ZIP" in the project menu connects to the project in the background, exports its files via the existing exportProjectAsZip runtime (previously reachable only inside the editor), triggers the download, and disconnects. The menu shows "Preparing ZIP…" while the background sync runs. Also renames the avatar-menu backup items to "Export/Import project list (JSON)" for findability. Co-Authored-By: Claude Fable 5 --- hub-client/src/components/ProjectsHome.tsx | 48 +++++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx index 02330caba..87a486806 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -24,6 +24,9 @@ import { getProjectChoices, createProject as wasmCreateProject, importProjectFromZip, + exportProjectAsZip, + connect, + disconnect, type ProjectChoice, type ProjectFile, } from '@quarto/preview-runtime'; @@ -32,6 +35,7 @@ import { buildProjectSetLinkUrl, buildShareableUrl, buildFullUrl, + resolveSyncServerUrl, } from '../utils/routing'; import ShareDialog from './ShareDialog'; import { mockCollaborators, unionCollaborators, type MockUser } from '../utils/mockCollaborators'; @@ -412,6 +416,39 @@ export default function ProjectsHome({ 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]); + const handleRemove = useCallback((item: ProjectItem) => { if (confirm(`Remove "${item.description}" from this device?\n\nThis doesn't delete the project for others.`)) { moveProject(item.indexDocId, null); @@ -747,6 +784,13 @@ export default function ProjectsHome({ {copied === item.indexDocId + ':id' ? 'ID copied!' : 'Copy project ID'} {shortId(item.indexDocId)} +
)} - - + + @@ -906,10 +932,13 @@ export default function ProjectsHome({ +
+ {/* 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

+ {renameCollectionTarget.shared && ( +

Renames it for everyone it's shared with.

+ )} +
{ + e.preventDefault(); + if (renameCollectionValue.trim()) { + renameCollection(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)}> From 2fec8b5da70dfe629ae0c1710c94e8c141426f1f Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Fri, 10 Jul 2026 16:53:11 -0400 Subject: [PATCH 20/48] docs(hub-client): changelog for dialog fix Co-Authored-By: Claude Fable 5 --- hub-client/changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index a0f7aa138..1710640fc 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -23,6 +23,10 @@ WASM rebuild is needed for a changelog-only edit. --> +### 2026-07-10 + +- [`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)". From a239108932916544f40d0813b332c10e72f40498 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Mon, 13 Jul 2026 09:32:16 -0400 Subject: [PATCH 21/48] feat(hub-client): duplicate a project from the projects home "Duplicate" in the project menu background-connects to the source, reads every file (text and binary, base64 for the latter), and feeds them through the standard creation path as " (copy)", landing in the editor like any new project. The copy keeps the source's collection placement via the pending-assignment mechanism. First leg of the recurring-documents workflow (user templates are the follow-on). Co-Authored-By: Claude Fable 5 --- hub-client/src/components/ProjectsHome.tsx | 72 ++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx index f3a35ba1a..17e90bbaf 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -27,6 +27,9 @@ import { exportProjectAsZip, connect, disconnect, + getFileContent, + getBinaryFileContent, + isFileBinary, type ProjectChoice, type ProjectFile, } from '@quarto/preview-runtime'; @@ -84,6 +87,16 @@ 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'; +/** 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); } @@ -462,6 +475,57 @@ export default function ProjectsHome({ } }, [exportingId, closeAllMenus]); + // Which project is being duplicated (background connect + re-create) + const [duplicatingId, setDuplicatingId] = useState(null); + + /** + * Duplicate a project: background-connect to the source, read every file + * (text and binary), and feed them through the same creation path new + * projects use. The copy keeps the source's collection placement via the + * pending-assignment mechanism (the new doc id isn't known until the + * parent finishes creating it). + */ + const handleDuplicate = useCallback(async (item: ProjectItem) => { + if (duplicatingId || exportingId) return; + setDuplicatingId(item.indexDocId); + setFormError(null); + const copyTitle = `${item.description} (copy)`; + 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(); + const sourceCollection = collectionOf(item.indexDocId); + if (sourceCollection) { + setPendingCollectionAssignment(copyTitle, sourceCollection.id); + } + onProjectCreated?.(projectFiles, copyTitle, '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); + closeAllMenus(); + } + }, [duplicatingId, exportingId, collectionOf, onProjectCreated, closeAllMenus]); + const handleRemove = useCallback((item: ProjectItem) => { closeAllMenus(); setConfirmState({ @@ -794,6 +858,14 @@ export default function ProjectsHome({
)}
+
)}
+ - - + /** 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, false, + )} + + {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 doesn't count as opening the project.
-
Peeking doesn't count as opening the project.
-
- ); + ); + }; // ---- collection sharing (membership is mock; see utils/mockCollaborators) ---- @@ -1054,8 +1123,16 @@ export default function ProjectsHome({ {item.description} - opened {formatOpened(item.lastAccessed)} - {renderFacepile(collaboratorsFor(item.indexDocId), 'sm')} + + {item.summary ? `${item.summary.fileCount} ${item.summary.fileCount === 1 ? 'file' : 'files'} · ` : ''} + opened {formatOpened(item.lastAccessed)} + + {item.summary?.contributors.length + ? renderFacepile( + item.summary.contributors.map((c) => ({ name: c.name, color: c.color, initials: initialsFor(c.name) })), + 'sm', 3, false, + ) + : renderFacepile(collaboratorsFor(item.indexDocId), 'sm')}
); @@ -1459,7 +1537,10 @@ export default function ProjectsHome({ )} - opened {formatOpened(item.lastAccessed)} + + {item.summary ? `${item.summary.fileCount} ${item.summary.fileCount === 1 ? 'file' : 'files'} · ` : ''} + opened {formatOpened(item.lastAccessed)} + - + + + + {openMenu === item.indexDocId && renderProjectMenu(item)} {peekFor === item.indexDocId && renderPeek(item)}
@@ -1541,6 +1575,14 @@ export default function ProjectsHome({ {item.summary ? `${item.summary.fileCount} ${item.summary.fileCount === 1 ? 'file' : 'files'} · ` : ''} opened {formatOpened(item.lastAccessed)} + + +
+ +
+
+ )} + {/* New collection */} {newCollectionDialog && (
setNewCollectionDialog(null)}> From e8c855ab2ee1fac3172cb3c1741ad77ab523aeeb Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Mon, 13 Jul 2026 13:51:31 -0400 Subject: [PATCH 26/48] docs(hub-client): changelog for duplicate dialog and fork button Co-Authored-By: Claude Fable 5 --- hub-client/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index aa4a456c8..5d7054fd9 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -26,6 +26,7 @@ WASM rebuild is needed for a changelog-only edit. ### 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. +- [`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. From ea5bea8d11c2f97745c449bc4232214da30e6ef7 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Mon, 13 Jul 2026 13:52:45 -0400 Subject: [PATCH 27/48] docs(plans): collections-as-project-sets migration plan Co-Authored-By: Claude Fable 5 --- .../2026-07-10-collections-as-project-sets.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 claude-notes/plans/2026-07-10-collections-as-project-sets.md 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..182c84d93 --- /dev/null +++ b/claude-notes/plans/2026-07-10-collections-as-project-sets.md @@ -0,0 +1,106 @@ +# 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 +- [ ] `ProjectSetDocument` gains optional `name?: string` (collection display + name) with helper `setProjectSetName` + tests +- [ ] New IDB shape: `CollectionsPointer { key: 'collections', collections: + Array<{ projectSetDocId, syncServer }> }` in storage types +- [ ] 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) +- [ ] Migration tests: fresh install, existing pointer, re-run idempotence + +### Phase 2 — service layer +- [ ] `projectSetService` → manages a MAP of doc handles keyed by docId + (connect-all on startup; add/remove collection connections at runtime) +- [ ] Operations gain a collection-doc parameter: addProject(collectionId, + entry), removeProject, updateSummary, touch, rename collection +- [ ] Create-collection (new empty set doc), subscribe-collection (existing + doc id), unsubscribe (drop from array; doc untouched — "leave") +- [ ] `useProjectSet` → `useCollectionSets`: exposes + `Array<{ docId, name, entries, syncServer }>` + actions + +### Phase 3 — localStorage collections migration +- [ ] 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 +- [ ] The original set becomes the personal root collection (name it + "My projects" when the name field is empty; user-editable) +- [ ] 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 +- [ ] 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) +- [ ] 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) +- [ ] Share/members popover backed by the real doc: invite link carries the + collection doc id + server only (entries no longer inlined in the URL) +- [ ] JoinCollectionLanding: real subscribe (append docId to pointer array), + auto-create personal root for fresh browsers as today +- [ ] 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 +- [ ] `npm run build:all` + full `npm run test:ci` +- [ ] 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. From 53e9e48466d42df84f33942961f30832f588dbd2 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Mon, 13 Jul 2026 14:13:03 -0400 Subject: [PATCH 28/48] feat(hub-client): collections pointer storage + schema name (phase 1) Groundwork for collections-as-project-sets (see claude-notes/plans/2026-07-10-collections-as-project-sets.md): - ProjectSetDocument gains an optional collection display name with a setProjectSetName helper (schema package, tested) - New CollectionsPointer IDB record: the browser root becomes an array of collection pointers, stored under the 'collections' key in the existing projectSet store (no DB version bump needed) - Schema migration v5 (transform-only) converts the legacy singleton pointer into a one-element array; the legacy pointer is preserved as a safety net, and getCollectionPointers() self-heals lazily for robustness against ordering - Pointer accessors: get/set/add (deduped)/remove, with tests covering fresh install, legacy conversion, idempotence, and no-clobber No user-visible behavior change yet; the UI still runs on the singleton set until phase 2-4 land. Co-Authored-By: Claude Fable 5 --- .../2026-07-10-collections-as-project-sets.md | 8 +-- .../src/services/projectSetStorage.test.ts | 55 +++++++++++++++++ hub-client/src/services/projectSetStorage.ts | 60 ++++++++++++++++++- hub-client/src/services/storage/migrations.ts | 39 +++++++++++- hub-client/src/services/storage/types.ts | 25 ++++++++ .../quarto-automerge-schema/src/index.ts | 21 +++++++ .../src/projectSet.test.ts | 12 ++++ 7 files changed, 214 insertions(+), 6 deletions(-) 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 index 182c84d93..1a2e90b70 100644 --- a/claude-notes/plans/2026-07-10-collections-as-project-sets.md +++ b/claude-notes/plans/2026-07-10-collections-as-project-sets.md @@ -27,14 +27,14 @@ untouched. "Minimum blast surface." ## Checklist ### Phase 1 — schema + tests -- [ ] `ProjectSetDocument` gains optional `name?: string` (collection display +- [x] `ProjectSetDocument` gains optional `name?: string` (collection display name) with helper `setProjectSetName` + tests -- [ ] New IDB shape: `CollectionsPointer { key: 'collections', collections: +- [x] New IDB shape: `CollectionsPointer { key: 'collections', collections: Array<{ projectSetDocId, syncServer }> }` in storage types -- [ ] IDB migration 4→5 (structural no-op, transform converts the singleton +- [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) -- [ ] Migration tests: fresh install, existing pointer, re-run idempotence +- [x] Migration tests: fresh install, existing pointer, re-run idempotence ### Phase 2 — service layer - [ ] `projectSetService` → manages a MAP of doc handles keyed by docId diff --git a/hub-client/src/services/projectSetStorage.test.ts b/hub-client/src/services/projectSetStorage.test.ts index b6c8ed34d..d51047cce 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,55 @@ 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('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..4c519a5f6 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,60 @@ 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 []; + } + const record: CollectionsPointer | undefined = await db.get(STORES.PROJECT_SET, 'collections'); + if (record) { + return record.collections; + } + await migratePointerToCollections(db); + const migrated: CollectionsPointer | undefined = await db.get(STORES.PROJECT_SET, 'collections'); + return migrated?.collections ?? []; +} + +/** 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.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/ts-packages/quarto-automerge-schema/src/index.ts b/ts-packages/quarto-automerge-schema/src/index.ts index 09f136b4c..a2b00f1de 100644 --- a/ts-packages/quarto-automerge-schema/src/index.ts +++ b/ts-packages/quarto-automerge-schema/src/index.ts @@ -174,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; } /** @@ -265,6 +271,21 @@ 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; +} + /** * Replace the cached peek summary for a project. * Must be called inside an Automerge `change()` callback. diff --git a/ts-packages/quarto-automerge-schema/src/projectSet.test.ts b/ts-packages/quarto-automerge-schema/src/projectSet.test.ts index 7930ebe51..bd82d4fd8 100644 --- a/ts-packages/quarto-automerge-schema/src/projectSet.test.ts +++ b/ts-packages/quarto-automerge-schema/src/projectSet.test.ts @@ -15,6 +15,7 @@ import { removeProjectFromSet, touchProjectInSet, updateProjectSummaryInSet, + setProjectSetName, } from './index.js'; import type { ProjectSetDocument } from './index.js'; @@ -193,6 +194,17 @@ describe('ProjectSetDocument schema helpers', () => { }); }); + 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, From 81be0a7f55ab9e6ea9538929d02324a06340e470 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Mon, 13 Jul 2026 15:14:52 -0400 Subject: [PATCH 29/48] refactor(hub-client): multi-collection projectSetService (phase 2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service now manages a map of ProjectSetDocument connections (one per collection) over per-server shared Repos, instead of a single document on a dedicated Repo. New API: connectCollection(s), createCollection(name), disconnectCollection, listCollections, renameCollection, add/remove/move project per collection, and description/touch/summary updates applied wherever an entry appears. The legacy singleton API delegates to the first (personal root) connection, so every existing caller — useProjectSet, the reconciler, App — works unchanged; all 706 unit tests pass. Also fixes the init-vs-link connection race class noted in the plan (appending a collection no longer contends with startup). Part of claude-notes/plans/2026-07-10-collections-as-project-sets.md. Co-Authored-By: Claude Fable 5 --- .../2026-07-10-collections-as-project-sets.md | 6 +- hub-client/src/services/projectSetService.ts | 606 ++++++++++++------ 2 files changed, 430 insertions(+), 182 deletions(-) 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 index 1a2e90b70..9279e45c8 100644 --- a/claude-notes/plans/2026-07-10-collections-as-project-sets.md +++ b/claude-notes/plans/2026-07-10-collections-as-project-sets.md @@ -37,11 +37,11 @@ untouched. "Minimum blast surface." - [x] Migration tests: fresh install, existing pointer, re-run idempotence ### Phase 2 — service layer -- [ ] `projectSetService` → manages a MAP of doc handles keyed by docId +- [x] `projectSetService` → manages a MAP of doc handles keyed by docId (connect-all on startup; add/remove collection connections at runtime) -- [ ] Operations gain a collection-doc parameter: addProject(collectionId, +- [x] Operations gain a collection-doc parameter: addProject(collectionId, entry), removeProject, updateSummary, touch, rename collection -- [ ] Create-collection (new empty set doc), subscribe-collection (existing +- [x] Create-collection (new empty set doc), subscribe-collection (existing doc id), unsubscribe (drop from array; doc untouched — "leave") - [ ] `useProjectSet` → `useCollectionSets`: exposes `Array<{ docId, name, entries, syncServer }>` + actions diff --git a/hub-client/src/services/projectSetService.ts b/hub-client/src/services/projectSetService.ts index abc6b78c6..90096e9cf 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'; @@ -27,29 +34,66 @@ import { 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; +} + // ============================================================================ // 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; // ============================================================================ @@ -57,13 +101,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; } @@ -78,11 +124,42 @@ 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 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, + }; +} + +function notifyChange(): void { + if (onProjectsChange) { + const root = rootConnection(); + const doc = root?.handle.doc(); + if (doc) onProjectsChange(getProjectsList(doc)); + } + if (onCollectionsChange) { + onCollectionsChange(listCollections()); } } @@ -107,253 +184,416 @@ function waitForPeer(r: Repo, timeoutMs: number = 5000): Promise { }); } +/** + * Get or create the shared Repo for a sync server. + * When created, kicks off a peer wait purely for the connection indicator. + */ +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(), + }); + server = { repo, wsAdapter, refCount: 0 }; + 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); + } +} + // ============================================================================ // Connection Management // ============================================================================ /** - * 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(); +export async function connectCollection( + pointer: CollectionPointerEntry, +): Promise { + const existing = connections.get(pointer.projectSetDocId); + if (existing) return snapshotOf(existing); - wsAdapter = new BrowserWebSocketClientAdapter(resolveSyncServerUrl(syncServerUrl)); - repo = new Repo({ - network: [wsAdapter], - storage: new IndexedDBStorageAdapter(), - }); - - 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. + let isOnline = false; + try { + await waitForPeer(server.repo, 5000); + isOnline = true; + } catch { + isOnline = false; + } + 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 { + waitForPeer(server.repo, 5000) + .then(() => onConnectionChange?.(true)) + .catch(() => onConnectionChange?.(false)); + } - // 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. + const server = acquireServer(syncServerUrl); try { - await waitForPeer(repo, 10000); + // Creation requires the server so the document actually syncs. + await waitForPeer(server.repo, 10000); } catch { - await disconnect(); + 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; } // ============================================================================ -// Project CRUD Operations +// 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(); +} + /** - * List all projects in the set, sorted by lastAccessed (most recent first). + * 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; +} + +// ============================================================================ +// Legacy singleton API (delegates to the personal root collection) +// ============================================================================ + +/** + * 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); } /** - * Replace the cached peek summary for a project. + * 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 (!handle) return false; - let updated = false; - handle.change(doc => { - updated = updateProjectSummaryInSet(doc, indexDocId, summary); - }); - if (updated) notifyProjectsChange(); - return updated; + if (!rootConnection()) return false; + return updateProjectSummaryEverywhere(indexDocId, summary); } /** - * Get the document ID of the connected project set, or null if not connected. + * 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; } // ============================================================================ @@ -361,8 +601,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). */ @@ -373,11 +613,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]) { @@ -393,7 +634,7 @@ export function addProjectsBulk( } } }); - notifyProjectsChange(); + notifyChange(); return added; } @@ -402,8 +643,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(); @@ -418,28 +658,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: () => {}, + }); + } } From c0f09841f1af8165fafafea738596426262ad3b7 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Mon, 13 Jul 2026 15:29:09 -0400 Subject: [PATCH 30/48] feat(hub-client): useCollectionSets hook + local collections migration (phases 2b/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full replacement for useProjectSet: reads the collections pointer array (self-healing from the legacy singleton), connects every collection document with per-collection failure reporting, names an unnamed root "My projects", and mirrors the setup/migration actions so the setup screens can swap over unchanged. Includes the one-time phase 3 migration: legacy localStorage collections (qh-collections-v1) become real synced ProjectSetDocuments named after them, entries copied from the root superset; the original JSON is preserved under a -migrated key, never re-imported. Not yet wired into App — phase 4 swaps App/ProjectsHome/JoinCollection onto this hook in one move (two hooks sharing the service would fight over connections). Co-Authored-By: Claude Fable 5 --- .../2026-07-10-collections-as-project-sets.md | 8 +- hub-client/src/hooks/useCollectionSets.ts | 459 ++++++++++++++++++ 2 files changed, 463 insertions(+), 4 deletions(-) create mode 100644 hub-client/src/hooks/useCollectionSets.ts 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 index 9279e45c8..3e40fe0a1 100644 --- a/claude-notes/plans/2026-07-10-collections-as-project-sets.md +++ b/claude-notes/plans/2026-07-10-collections-as-project-sets.md @@ -43,16 +43,16 @@ untouched. "Minimum blast surface." 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") -- [ ] `useProjectSet` → `useCollectionSets`: exposes +- [x] `useProjectSet` → `useCollectionSets`: exposes `Array<{ docId, name, entries, syncServer }>` + actions ### Phase 3 — localStorage collections migration -- [ ] On first load with the new code: for each entry in `qh-collections-v1`, +- [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 -- [ ] The original set becomes the personal root collection (name it +- [x] The original set becomes the personal root collection (name it "My projects" when the name field is empty; user-editable) -- [ ] Mark `qh-collections-v1` migrated (rename key, don't delete) — one-way, +- [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 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]; +} From 19c69ff34bbabd2f10d0cbe49af661465d05332c Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Mon, 13 Jul 2026 16:01:04 -0400 Subject: [PATCH 31/48] feat(hub-client): wire projects home onto real collection documents (phase 4) ProjectsHome, App, and the join flow now run on useCollectionSets instead of localStorage collections: - Collections come from connected ProjectSetDocuments (props), root excluded; "Everything else" = root entries not in any collection - Move across collections uses the service's cross-doc move; new "Add to collection" submenu adds without removing (multi-membership) - People & invite popover derives people from the projects' cached index-doc contributor summaries; the invite link now carries only the collection doc id + server (no inlined entries) - JoinCollectionLanding subscribes to the shared collection document - New/rename/leave collection act on the real docs; leaving just unsubscribes (the document is untouched, matching "nothing is destructive") - Fixes a shared-server readiness bug surfaced by the migration: the repo 'peer' event fires once, so collections after the first timed out; server readiness is now a latched promise Removes the dead localStorage useCollections hook. Verified end to end in the browser: legacy pointer + localStorage collections migrate into synced docs, survive a fresh reload with no localStorage, and create/move/share/leave all work; 706 tests pass, build clean. Closes the implementation in claude-notes/plans/2026-07-10-collections-as-project-sets.md. Co-Authored-By: Claude Fable 5 --- .../2026-07-10-collections-as-project-sets.md | 12 +- hub-client/src/App.tsx | 15 +- .../src/components/JoinCollectionLanding.tsx | 81 +-- hub-client/src/components/ProjectsHome.tsx | 490 +++++++++++------- hub-client/src/hooks/useCollections.ts | 207 -------- hub-client/src/services/projectSetService.ts | 63 +-- hub-client/src/utils/routing.ts | 19 +- 7 files changed, 372 insertions(+), 515 deletions(-) delete mode 100644 hub-client/src/hooks/useCollections.ts 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 index 3e40fe0a1..9ae7d2edc 100644 --- a/claude-notes/plans/2026-07-10-collections-as-project-sets.md +++ b/claude-notes/plans/2026-07-10-collections-as-project-sets.md @@ -56,23 +56,23 @@ untouched. "Minimum blast surface." idempotent, safe to interrupt (commit point = pointer-array write) ### Phase 4 — UI rewiring -- [ ] ProjectsHome reads collections from the hook instead of localStorage; +- [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) -- [ ] Move keeps current semantics (remove+add across docs); new "Add to +- [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) -- [ ] Share/members popover backed by the real doc: invite link carries the +- [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) -- [ ] JoinCollectionLanding: real subscribe (append docId to pointer array), +- [x] JoinCollectionLanding: real subscribe (append docId to pointer array), auto-create personal root for fresh browsers as today -- [ ] Facepiles/peek contributors read from project index-doc `identities` +- [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 -- [ ] `npm run build:all` + full `npm run test:ci` +- [x] `npm run build:all` + full `npm run test:ci` - [ ] Manual: fresh browser, upgraded browser (with and without localStorage collections), two-browser share/join round trip, debug.html inspection of resulting docs diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index f8e980215..6476ced91 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -36,7 +36,7 @@ import * as projectStorage from './services/projectStorage'; import { installDebugApi } from './services/debugApi'; import { getUserIdentity, updateUserName } 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'; @@ -132,7 +132,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. @@ -683,8 +683,8 @@ function App() { return ( navigateToProjectSelector({ replace: true })} /> ); @@ -747,6 +747,13 @@ function App() { onAddProjectToSet={projectSetActions.addProject} onRenameProject={projectSetActions.updateProjectDescription} onUpdateProjectSummary={projectSetActions.updateProjectSummary} + collections={projectSetState.collections} + onCreateCollection={projectSetActions.createCollection} + onUnsubscribeCollection={projectSetActions.unsubscribeCollection} + onRenameCollection={projectSetActions.renameCollection} + onAddProjectToCollection={projectSetActions.addProjectToCollection} + onRemoveProjectFromCollection={projectSetActions.removeProjectFromCollection} + onMoveProjectBetweenCollections={projectSetActions.moveProjectBetweenCollections} onSwitchToClassicUi={() => switchUiVariant('classic')} /> ) : ( diff --git a/hub-client/src/components/JoinCollectionLanding.tsx b/hub-client/src/components/JoinCollectionLanding.tsx index f515a19fc..46930185d 100644 --- a/hub-client/src/components/JoinCollectionLanding.tsx +++ b/hub-client/src/components/JoinCollectionLanding.tsx @@ -1,21 +1,20 @@ /** - * JoinCollectionLanding — invite-first onboarding for a shared collection - * (explore/projects-collections-ui exploration). + * JoinCollectionLanding — invite-first onboarding for a shared collection. * * Rendered for #/join-collection/ links. A fresh browser never sees the - * project-set setup screen: App auto-creates a set silently while this - * screen asks the one question that matters — who you are. Joining adds - * the invite's project entries (real doc ids, so they sync for real) and - * creates the collection locally with mock membership. + * 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 { ProjectSetEntry } from '@quarto/quarto-automerge-schema'; -import type { ProjectSetStatus } from '../hooks/useProjectSet'; +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 { createSharedCollectionFromInvite, type CollectionMember } from '../hooks/useCollections'; +import { DEFAULT_SYNC_SERVER } from '../utils/routing'; import './ProjectsHome.css'; const COLOR_PALETTE = [ @@ -33,17 +32,19 @@ function initialsFor(name: string): string { interface Props { route: JoinCollectionRoute; - projectSetStatus: ProjectSetStatus; - onAddProjectToSet: (entry: Omit) => void; + 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, projectSetStatus, onAddProjectToSet, onDone }: Props) { +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) => { @@ -53,11 +54,12 @@ export default function JoinCollectionLanding({ route, projectSetStatus, onAddPr }).catch((err) => console.error('Failed to load identity:', err)); }, []); - const ready = projectSetStatus === 'connected'; + const ready = status === 'connected'; const handleJoin = async () => { - if (!name.trim() || !ready) return; + if (!name.trim() || !ready || joining) return; setJoining(true); + setError(null); try { if (userSettings && name.trim() !== userSettings.userName) { await userSettingsService.updateUserName(name.trim()); @@ -65,37 +67,11 @@ export default function JoinCollectionLanding({ route, projectSetStatus, onAddPr if (userSettings && color !== userSettings.userColor) { await userSettingsService.updateUserColor(color); } - for (const entry of route.entries) { - const indexDocId = entry.indexDocId.startsWith('automerge:') - ? entry.indexDocId - : `automerge:${entry.indexDocId}`; - onAddProjectToSet({ indexDocId, syncServer: entry.syncServer, description: entry.description }); - } - const members: CollectionMember[] = [ - { - name: route.inviter, - initials: initialsFor(route.inviter), - color: '#7C3AED', - joinedAt: new Date().toISOString(), - isOwner: true, - }, - { - name: name.trim(), - initials: initialsFor(name.trim()), - color, - joinedAt: new Date().toISOString(), - isYou: true, - }, - ]; - createSharedCollectionFromInvite({ - collectionId: route.collectionId, - name: route.collectionName, - // Store ids in the same un-prefixed form the invite carries; the - // home view matches them against project-set entries either way. - projectIds: route.entries.map((e) => e.indexDocId.replace(/^automerge:/, '')), - members, - }); + 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); } @@ -110,18 +86,9 @@ export default function JoinCollectionLanding({ route, projectSetStatus, onAddPr {route.inviter} invited you to {route.collectionName}

- {route.entries.length === 0 - ? 'A shared collection of Quarto projects.' - : `A shared collection of ${route.entries.length} Quarto project${route.entries.length === 1 ? '' : 's'}:`} + A shared collection of Quarto projects — its contents sync to you when you join.

- {route.entries.length > 0 && ( -
    - {route.entries.slice(0, 5).map((e) => ( -
  • {e.description || 'Untitled project'}
  • - ))} - {route.entries.length > 5 &&
  • and {route.entries.length - 5} more…
  • } -
- )} + {error &&
{error}
}
How you'll appear to the team
@@ -156,8 +123,8 @@ export default function JoinCollectionLanding({ route, projectSetStatus, onAddPr

- Joining adds these projects to your list. This invite flow is part of a UI - exploration — collection membership isn't synced to other members yet. + 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.tsx b/hub-client/src/components/ProjectsHome.tsx index 56278ce09..b94b1a533 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -16,7 +16,7 @@ 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 { ProjectSetStatus } from '../hooks/useProjectSet'; +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'; @@ -41,8 +41,8 @@ import { resolveSyncServerUrl, } from '../utils/routing'; import ShareDialog from './ShareDialog'; -import { mockCollaborators, unionCollaborators, type MockUser } from '../utils/mockCollaborators'; -import { useCollections, setPendingCollectionAssignment, type Collection, type CollectionMember } from '../hooks/useCollections'; +import { mockCollaborators, type MockUser } from '../utils/mockCollaborators'; +import type { CollectionSnapshot } from '../services/projectSetService'; import './ProjectsHome.css'; interface Props { @@ -64,9 +64,28 @@ interface Props { 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; @@ -90,6 +109,20 @@ 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 = (
- {moveSubmenuOpen && ( + {moveSubmenuOpen === 'move' && (
- {collections.map((collection) => ( - - ))} - {shelvedIds.has(item.indexDocId) && ( + {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 +
+ )} +
+ )} +
- ) : null}
))}
- {you && !you.isOwner && ( - - )}
-
{collection.shared ? 'INVITE BY LINK' : 'SHARE BY LINK'}
+
INVITE BY LINK
{inviteUrl.replace(/^https?:\/\//, '').slice(0, 34)}…
- {collection.shared - ? 'Anyone with this link can join this collection and add or remove projects.' - : 'Copying turns on sharing — anyone with the link can join and add or remove projects.'} + Anyone with this link can join this collection and add or remove projects. + Its contents sync to them for real.
); @@ -1185,7 +1311,7 @@ export default function ProjectsHome({
); - const renderCollection = (collection: Collection) => { + 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 @@ -1207,30 +1333,33 @@ export default function ProjectsHome({
{collection.name} {collectionItems.length} - + {(() => { + const people = peopleOn(collection); + const hasOthers = people.length > 1; + return ( + + ); + })()} - ) : ( - - )} + +
+ - {collection.shared ? ( - <> -
- - - - ) : ( - - )}
)}
@@ -1682,14 +1772,12 @@ export default function ProjectsHome({
setRenameCollectionTarget(null)}>
e.stopPropagation()}>

Rename collection

- {renameCollectionTarget.shared && ( -

Renames it for everyone it's shared with.

- )} +

Renames it for everyone subscribed to it.

{ e.preventDefault(); if (renameCollectionValue.trim()) { - renameCollection(renameCollectionTarget.id, renameCollectionValue.trim()); + onRenameCollection?.(renameCollectionTarget.id, renameCollectionValue.trim()); } setRenameCollectionTarget(null); }} diff --git a/hub-client/src/hooks/useCollections.ts b/hub-client/src/hooks/useCollections.ts deleted file mode 100644 index 403dbdeb2..000000000 --- a/hub-client/src/hooks/useCollections.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Collection state for the projects-home UI exploration (explore/projects-collections-ui). - * - * Collections are a purely local, per-browser grouping of projects: a named list - * of indexDocIds persisted to localStorage. They deliberately do NOT sync — - * the short-term design ("Collections + streamlined entry") is buildable on - * today's metadata, and shared collections are a later phase that would make a - * collection its own synced document. Keeping the storage local lets us test the - * UX without touching the project-set schema. - */ - -import { useState, useCallback, useEffect } from 'react'; - -/** A member of a shared collection. Mock data until collections become synced docs. */ -export interface CollectionMember { - name: string; - initials: string; - color: string; - joinedAt: string; - isOwner?: boolean; - isYou?: boolean; -} - -export interface Collection { - id: string; - name: string; - projectIds: string[]; - /** Present once the collection has been explicitly shared. */ - shared?: { - sharedAt: string; - members: CollectionMember[]; - }; -} - -const COLLECTIONS_KEY = 'qh-collections-v1'; -// Pre-rename storage key ("shelf" era); read once as a migration fallback so -// existing exploration data survives the shelf → collection rename. -const LEGACY_SHELVES_KEY = 'qh-shelves-v1'; -const PENDING_KEY = 'qh-collection-pending-v1'; - -interface PendingAssignment { - title: string; - collectionId: string; - ts: number; -} - -function loadCollections(): Collection[] { - try { - const raw = localStorage.getItem(COLLECTIONS_KEY) ?? localStorage.getItem(LEGACY_SHELVES_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - return parsed.filter( - (s): s is Collection => - typeof s?.id === 'string' && - typeof s?.name === 'string' && - Array.isArray(s?.projectIds), - ); - } catch { - return []; - } -} - -/** - * Record that the next project created with this title should land on a - * collection. The indexDocId of a new project isn't known until the parent app - * finishes creating the Automerge docs, so we reconcile by title when the - * entry shows up in the project set. - */ -export function setPendingCollectionAssignment(title: string, collectionId: string): void { - const pending: PendingAssignment = { title, collectionId, ts: Date.now() }; - localStorage.setItem(PENDING_KEY, JSON.stringify(pending)); -} - -export function useCollections() { - const [collections, setCollections] = useState(loadCollections); - - useEffect(() => { - localStorage.setItem(COLLECTIONS_KEY, JSON.stringify(collections)); - }, [collections]); - - const createCollection = useCallback((name: string): string => { - const id = `collection-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - setCollections((prev) => [...prev, { id, name, projectIds: [] }]); - return id; - }, []); - - const renameCollection = useCallback((id: string, name: string) => { - setCollections((prev) => prev.map((s) => (s.id === id ? { ...s, name } : s))); - }, []); - - const deleteCollection = useCallback((id: string) => { - setCollections((prev) => prev.filter((s) => s.id !== id)); - }, []); - - /** - * Put a project on a collection (or none). A project sits on at most one - * personal collection, so it's removed from all others first. - */ - const moveProject = useCallback((indexDocId: string, collectionId: string | null) => { - setCollections((prev) => - prev.map((s) => { - const without = s.projectIds.filter((p) => p !== indexDocId); - if (s.id === collectionId && !without.includes(indexDocId)) { - return { ...s, projectIds: [...without, indexDocId] }; - } - return without.length === s.projectIds.length ? s : { ...s, projectIds: without }; - }), - ); - }, []); - - const collectionFor = useCallback( - (indexDocId: string): Collection | undefined => - collections.find((s) => s.projectIds.includes(indexDocId)), - [collections], - ); - - /** Convert a personal collection to shared, seeding the member list. */ - const shareCollection = useCallback((id: string, members: CollectionMember[]) => { - setCollections((prev) => - prev.map((s) => - s.id === id && !s.shared - ? { ...s, shared: { sharedAt: new Date().toISOString(), members } } - : s, - ), - ); - }, []); - - const removeMember = useCallback((collectionId: string, initials: string) => { - setCollections((prev) => - prev.map((s) => - s.id === collectionId && s.shared - ? { ...s, shared: { ...s.shared, members: s.shared.members.filter((m) => m.initials !== initials) } } - : s, - ), - ); - }, []); - - /** - * Reconcile a pending "add to collection on create" against the current - * project list. Called whenever the entries change; a no-op when there is - * nothing pending. Stale pendings (>1 day) are dropped. - */ - const reconcilePending = useCallback( - (entries: Array<{ indexDocId: string; description: string; addedAt: string }>) => { - const raw = localStorage.getItem(PENDING_KEY); - if (!raw) return; - let pending: PendingAssignment; - try { - pending = JSON.parse(raw); - } catch { - localStorage.removeItem(PENDING_KEY); - return; - } - if (Date.now() - pending.ts > 24 * 3600 * 1000) { - localStorage.removeItem(PENDING_KEY); - return; - } - const match = entries.find( - (e) => - e.description === pending.title && - new Date(e.addedAt).getTime() >= pending.ts - 60_000, - ); - if (match) { - moveProject(match.indexDocId, pending.collectionId); - localStorage.removeItem(PENDING_KEY); - } - }, - [moveProject], - ); - - return { - collections, - createCollection, - renameCollection, - deleteCollection, - moveProject, - collectionFor, - reconcilePending, - shareCollection, - removeMember, - }; -} - -/** - * Create a shared collection from an invite, writing localStorage directly. - * Used by the join-collection landing screen, which renders before ProjectsHome - * mounts (so there is no live hook instance to go through). Returns false - * if the collection already exists in this browser. - */ -export function createSharedCollectionFromInvite(args: { - collectionId: string; - name: string; - projectIds: string[]; - members: CollectionMember[]; -}): boolean { - const collections = loadCollections(); - if (collections.some((s) => s.id === args.collectionId)) return false; - collections.push({ - id: args.collectionId, - name: args.name, - projectIds: args.projectIds, - shared: { sharedAt: new Date().toISOString(), members: args.members }, - }); - localStorage.setItem(COLLECTIONS_KEY, JSON.stringify(collections)); - return true; -} diff --git a/hub-client/src/services/projectSetService.ts b/hub-client/src/services/projectSetService.ts index 90096e9cf..5acfe5c09 100644 --- a/hub-client/src/services/projectSetService.ts +++ b/hub-client/src/services/projectSetService.ts @@ -79,6 +79,13 @@ interface ServerConnection { 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; } // ============================================================================ @@ -163,30 +170,21 @@ function notifyChange(): void { } } -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); - - const onPeer = () => { - cleanup(); - resolve(); - }; - - const cleanup = () => { - clearTimeout(timeoutId); - r.networkSubsystem.off('peer', onPeer); - }; - - r.networkSubsystem.on('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)), + ]); } /** - * Get or create the shared Repo for a sync server. - * When created, kicks off a peer wait purely for the connection indicator. + * 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); @@ -197,7 +195,10 @@ function acquireServer(syncServerUrl: string): ServerConnection { network: [wsAdapter], storage: new IndexedDBStorageAdapter(), }); - server = { repo, wsAdapter, refCount: 0 }; + const ready = new Promise((resolve) => { + repo.networkSubsystem.on('peer', () => resolve()); + }); + server = { repo, wsAdapter, refCount: 0, ready }; servers.set(resolved, server); } server.refCount++; @@ -237,13 +238,7 @@ export async function connectCollection( if (!handle.doc()) { // No local cache — wait for the network before declaring the doc. - let isOnline = false; - try { - await waitForPeer(server.repo, 5000); - isOnline = true; - } catch { - isOnline = false; - } + const isOnline = await awaitServerReady(server, 5000); onConnectionChange?.(isOnline); await handle.whenReady(); if (!handle.doc()) { @@ -254,9 +249,7 @@ export async function connectCollection( ); } } else { - waitForPeer(server.repo, 5000) - .then(() => onConnectionChange?.(true)) - .catch(() => onConnectionChange?.(false)); + awaitServerReady(server, 5000).then((online) => onConnectionChange?.(online)); } const onChange = () => notifyChange(); @@ -307,10 +300,8 @@ export async function createCollection( name?: string, ): Promise { const server = acquireServer(syncServerUrl); - try { - // Creation requires the server so the document actually syncs. - await waitForPeer(server.repo, 10000); - } catch { + // 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.', diff --git a/hub-client/src/utils/routing.ts b/hub-client/src/utils/routing.ts index cfa46e189..b25407a05 100644 --- a/hub-client/src/utils/routing.ts +++ b/hub-client/src/utils/routing.ts @@ -138,11 +138,18 @@ export interface LinkProjectSetRoute { */ 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; - /** Projects on the collection: real doc ids + servers, joinable immediately. */ + /** 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 }>; } @@ -261,6 +268,7 @@ export function parseHashRoute(hash: string): Route { collectionId: decodeURIComponent(segments[1]), collectionName: queryParams.get('name') ?? 'Shared collection', inviter: queryParams.get('from') ?? 'A collaborator', + syncServer: queryParams.get('server') ?? '', entries, }; } @@ -355,9 +363,12 @@ export function buildHashRoute(route: Route): string { const params = new URLSearchParams(); params.set('name', route.collectionName); params.set('from', route.inviter); - params.set('entries', JSON.stringify( - route.entries.map((e) => ({ d: e.indexDocId, s: e.syncServer, n: e.description })), - )); + 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()}`; } From 3f2d1991616e675f04dba94f89763fc0c779770a Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Mon, 13 Jul 2026 16:03:32 -0400 Subject: [PATCH 32/48] docs(hub-client): changelog for collections-as-documents Co-Authored-By: Claude Fable 5 --- hub-client/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 5d7054fd9..895877cfb 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -26,6 +26,7 @@ WASM rebuild is needed for a changelog-only edit. ### 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. +- [`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. From 85108dde0eba004c6fe228db923603d5019de0ae Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 14 Jul 2026 14:35:56 -0400 Subject: [PATCH 33/48] fix(hub-client): show subscribed collections when the root is empty The projects-home empty-state guard keyed off root entries only, so a browser that had just joined a shared collection (empty personal root, populated subscribed collection) rendered "No projects yet" and hid the collection entirely. Guard now also checks collection entries. Found during local-prod two-browser join testing. Co-Authored-By: Claude Fable 5 --- hub-client/src/components/ProjectsHome.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx index b94b1a533..ba78099dd 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -1582,7 +1582,7 @@ export default function ProjectsHome({ {isConnecting &&
Connecting to sync server…
}
- {items.length === 0 ? ( + {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.

From c16f89e8ce8af4359c49b96b4e46ac7f7db489b4 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 14 Jul 2026 14:36:21 -0400 Subject: [PATCH 34/48] docs(hub-client): changelog for empty-root collection fix Co-Authored-By: Claude Fable 5 --- hub-client/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 895877cfb..88280a00b 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -26,6 +26,7 @@ WASM rebuild is needed for a changelog-only edit. ### 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. +- [`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. From 77820e2c330987719fda54340fdc6dd2df0f8485 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 14 Jul 2026 14:36:56 -0400 Subject: [PATCH 35/48] docs(plans): record local-prod verification for collections migration Co-Authored-By: Claude Fable 5 --- .../2026-07-10-collections-as-project-sets.md | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) 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 index 9ae7d2edc..96da43788 100644 --- a/claude-notes/plans/2026-07-10-collections-as-project-sets.md +++ b/claude-notes/plans/2026-07-10-collections-as-project-sets.md @@ -73,7 +73,7 @@ untouched. "Minimum blast surface." ### Phase 5 — verification - [x] `npm run build:all` + full `npm run test:ci` -- [ ] Manual: fresh browser, upgraded browser (with and without localStorage +- [x] Manual: fresh browser, upgraded browser (with and without localStorage collections), two-browser share/join round trip, debug.html inspection of resulting docs @@ -104,3 +104,22 @@ collection doc afterward. Charlie's history view is prior art. 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). + From 645d5b536180b25907dbc987f5780de9f32e3756 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 14 Jul 2026 14:55:09 -0400 Subject: [PATCH 36/48] fix(hub-client): collection invite handles a browser with a legacy project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The join-collection landing was shown only for needs-setup; a browser with a pre-existing project (needs-migration) fell through to the "Upgrade: Synced Project List / Migrate Projects" screen, so an invitee saw a confusing migration prompt with no mention of the collection they were joining. (Easy to hit: opening a document share link first creates exactly one such project.) The invite route now always renders the landing, and the onboarding effect establishes the personal root for both cases — creating an empty root (needs-setup) or silently migrating the stray project into a new root (needs-migration; non-destructive, the legacy store is retained). A once-per-mount guard prevents a retry loop, since migrateProjects resets to needs-migration on failure. Verified on the dev server against the local hub: a needs-migration browser opening the invite now shows "Quick Hawk invited you to Team docs", joins, and lands home with the collection's documents; the migrated project coexists without orphaning or duplication. Co-Authored-By: Claude Fable 5 --- hub-client/src/App.tsx | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index 6476ced91..6940e997b 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -194,12 +194,24 @@ function App() { navigateToFile, } = useRouting(); - // Invite-first onboarding: a fresh browser opening a collection invite gets a - // project set created silently instead of the setup screen. The landing - // shows "Connecting…" until the set is ready. + // 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' && projectSetState.status === 'needs-setup') { + 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]); @@ -674,12 +686,11 @@ function App() { return ; } - // Collection invite landing (explore/projects-collections-ui). Rendered before the - // setup screens: a brand-new browser clicking an invite never sees - // project-set setup — the set is auto-created below while the landing asks - // for identity. The needs-migration case (legacy local projects) still - // falls through to the setup screen rather than migrating silently. - if (route.type === 'join-collection' && projectSetState.status !== 'needs-migration') { + // 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 ( Date: Tue, 14 Jul 2026 14:56:08 -0400 Subject: [PATCH 37/48] docs(hub-client): changelog for invite-with-legacy-project fix Co-Authored-By: Claude Fable 5 --- hub-client/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 88280a00b..e01b84c6d 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -26,6 +26,7 @@ WASM rebuild is needed for a changelog-only edit. ### 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. +- [`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. From 7a7dafdf0c52c25d7c9acf24b483a6f078811d8e Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 14 Jul 2026 15:38:09 -0400 Subject: [PATCH 38/48] fix(hub-client): stop showing fabricated authors on project cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card facepiles fell back to mockCollaborators — a deterministic fake crew seeded from the doc id — whenever a project had no cached contributors yet, so a brand-new file appeared to already have other authors. Now that collections carry real contributor summaries (from index-doc identities), the mock fallback is misleading. Facepiles are real-only: show the project's cached contributors, and when there are none yet, just the current user — never invented people. Removed the now-dead mockCollaborators module; its face shape moves to utils/facepile.ts as `Face` (no longer a misnomer). Verified on the dev server against the local hub: a freshly created "Todolist" card shows only its creator; existing cards show their real contributors. Co-Authored-By: Claude Fable 5 --- hub-client/src/components/ProjectsHome.tsx | 44 +++++++++------- hub-client/src/utils/facepile.ts | 13 +++++ hub-client/src/utils/mockCollaborators.ts | 58 ---------------------- 3 files changed, 38 insertions(+), 77 deletions(-) create mode 100644 hub-client/src/utils/facepile.ts delete mode 100644 hub-client/src/utils/mockCollaborators.ts diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx index ba78099dd..456ab9f63 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -41,7 +41,7 @@ import { resolveSyncServerUrl, } from '../utils/routing'; import ShareDialog from './ShareDialog'; -import { mockCollaborators, type MockUser } from '../utils/mockCollaborators'; +import type { Face } from '../utils/facepile'; import type { CollectionSnapshot } from '../services/projectSetService'; import './ProjectsHome.css'; @@ -940,28 +940,39 @@ export default function ProjectsHome({ const sortLabel = sortOrder === 'newest' ? 'newest first' : sortOrder === 'oldest' ? 'oldest first' : 'A to Z'; - // Facepiles are mock data for the exploration (see utils/mockCollaborators). - // The real user is always the first face, in their cursor color. - const selfUser: MockUser | undefined = userSettings + // 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; - const collaboratorsFor = useCallback( - (indexDocId: string) => mockCollaborators(indexDocId, selfUser), + + // 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: MockUser[], size: 'sm' | 'md' | 'lg', max = 3, mock = true) => { + 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}} + {extra > 0 && +{extra}} ); }; @@ -1135,7 +1146,7 @@ export default function ProjectsHome({
{renderFacepile( s.contributors.map((c) => ({ name: c.name, color: c.color, initials: initialsFor(c.name) })), - 'lg', 3, false, + 'lg', 3, )} {s.contributors.length === 1 @@ -1205,8 +1216,8 @@ export default function ProjectsHome({ /** 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): MockUser[] => { - const people: MockUser[] = selfUser + const peopleOn = (collection: CollectionView): Face[] => { + const people: Face[] = selfUser ? [{ ...selfUser, name: selfUser.name.replace(/ \(you\)$/, '') }] : []; for (const e of collection.entries) { @@ -1277,12 +1288,7 @@ export default function ProjectsHome({ {item.summary ? `${item.summary.fileCount} ${item.summary.fileCount === 1 ? 'file' : 'files'} · ` : ''} opened {formatOpened(item.lastAccessed)} - {item.summary?.contributors.length - ? renderFacepile( - item.summary.contributors.map((c) => ({ name: c.name, color: c.color, initials: initialsFor(c.name) })), - 'sm', 3, false, - ) - : renderFacepile(collaboratorsFor(item.indexDocId), 'sm')} + {renderFacepile(contributorsFor(item), 'sm')} @@ -1356,7 +1362,7 @@ export default function ProjectsHome({ )} - {renderFacepile(people, 'md', 3, false)} + {renderFacepile(people, 'md', 3)} ); })()} 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/mockCollaborators.ts b/hub-client/src/utils/mockCollaborators.ts deleted file mode 100644 index cb39656c0..000000000 --- a/hub-client/src/utils/mockCollaborators.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Mock collaborators for the projects-home UI exploration - * (explore/projects-collections-ui). - * - * The Figma design shows per-project facepiles (colored disks with initials) - * on cards, collection headers, and the Peek popover. Real contributor data needs - * automerge-history attribution (a later design phase), so the exploration - * spoofs a stable fake crew per project: seeded from the indexDocId, so the - * same project always shows the same faces across reloads. - */ - -export interface MockUser { - name: string; - initials: string; - color: string; -} - -const POOL: MockUser[] = [ - { name: 'Charlotte Wu', initials: 'CW', color: '#E8368F' }, - { name: 'Saima Khan', initials: 'SK', color: '#00BCD4' }, - { name: 'Gordon West', initials: 'GW', color: '#FF9800' }, - { name: 'Maya Patel', initials: 'MP', color: '#4CAF50' }, - { name: 'Leo Ferreira', initials: 'LF', color: '#3F51B5' }, -]; - -function hashString(s: string): number { - let h = 0; - for (let i = 0; i < s.length; i++) { - h = (h * 31 + s.charCodeAt(i)) | 0; - } - return Math.abs(h); -} - -/** - * Deterministic fake collaborators for a project. `self` (the real user) is - * always first; 1–3 mock users follow, chosen by hashing the doc id. - */ -export function mockCollaborators(indexDocId: string, self?: MockUser): MockUser[] { - const h = hashString(indexDocId); - const count = 1 + (h % 3); - const others = new Map(); - for (let i = 0; i < count; i++) { - const pick = POOL[(h >> (i * 4)) % POOL.length]; - others.set(pick.initials, pick); - } - return self ? [self, ...others.values()] : [...others.values()]; -} - -/** Union of collaborators across several projects, capped for collection headers. */ -export function unionCollaborators(lists: MockUser[][]): MockUser[] { - const m = new Map(); - for (const list of lists) { - for (const u of list) { - if (!m.has(u.initials)) m.set(u.initials, u); - } - } - return [...m.values()]; -} From 55133c6ce1782da4bcb1b279bb8a43f8474ed9df Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 14 Jul 2026 15:52:55 -0400 Subject: [PATCH 39/48] docs(hub-client): changelog for real-only facepiles Co-Authored-By: Claude Fable 5 --- hub-client/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index e01b84c6d..1d90ebc42 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -26,6 +26,7 @@ WASM rebuild is needed for a changelog-only edit. ### 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. +- [`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. From 214d62e4d844229b33cf09600047acdb03c473c5 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 14 Jul 2026 16:28:24 -0400 Subject: [PATCH 40/48] fix(schema): union contributors in project summary instead of replacing Author chips on shared collection cards clobbered each other: the peek summary is stored on a shared collection entry, and updateProjectSummaryInSet replaced the whole summary object, so whoever edited last became the sole recorded contributor. Now the file-shape fields (fileCount, topFiles, asOf) still take the latest writer's view, but `contributors` are unioned (dedup by name) with whatever is already stored. Authorship accumulates as collaborators open a project, matching expectations. Verified two-browser against the local hub: two users opening the same shared project both appear as authors. Sequential edits union correctly; a truly-concurrent first open can momentarily show one author but self-heals on the next edit by either party (confirmed: reopening as the first user restored both). Full per-actor-map contributors (zero-window, mirroring the index-doc identities map) is the follow-up for complete concurrency safety. Co-Authored-By: Claude Fable 5 --- .../quarto-automerge-schema/src/index.ts | 25 +++++++++++-- .../src/projectSet.test.ts | 36 +++++++++++++++++-- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/ts-packages/quarto-automerge-schema/src/index.ts b/ts-packages/quarto-automerge-schema/src/index.ts index a2b00f1de..72a50e649 100644 --- a/ts-packages/quarto-automerge-schema/src/index.ts +++ b/ts-packages/quarto-automerge-schema/src/index.ts @@ -287,7 +287,16 @@ export function setProjectSetName( } /** - * Replace the cached peek summary for a project. + * 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 @@ -300,7 +309,19 @@ export function updateProjectSummaryInSet( const key = projectSetKey(indexDocId); const entry = doc.projects[key]; if (!entry) return false; - entry.summary = summary; + + 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; } diff --git a/ts-packages/quarto-automerge-schema/src/projectSet.test.ts b/ts-packages/quarto-automerge-schema/src/projectSet.test.ts index bd82d4fd8..eb63683a7 100644 --- a/ts-packages/quarto-automerge-schema/src/projectSet.test.ts +++ b/ts-packages/quarto-automerge-schema/src/projectSet.test.ts @@ -228,7 +228,7 @@ describe('ProjectSetDocument schema helpers', () => { expect(doc.projects['proj1'].lastAccessed).toBe('2026-01-01T00:00:00.000Z'); }); - it('should replace an existing summary', () => { + it('should replace file-shape fields but union contributors', () => { const doc = emptyDoc(); addProjectToSet(doc, { indexDocId: 'automerge:proj1', @@ -236,9 +236,39 @@ describe('ProjectSetDocument schema helpers', () => { description: 'Project', }); updateProjectSummaryInSet(doc, 'automerge:proj1', summary); - const newer = { ...summary, fileCount: 5, asOf: '2026-06-16T12:00:00.000Z' }; + // 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); - expect(doc.projects['proj1'].summary).toEqual(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', () => { From 90009fba8de27a7d31896fc50690625077fe21ef Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 14 Jul 2026 16:28:50 -0400 Subject: [PATCH 41/48] docs(hub-client): changelog for contributor-union fix Co-Authored-By: Claude Fable 5 --- hub-client/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 1d90ebc42..2eaabb48e 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -26,6 +26,7 @@ WASM rebuild is needed for a changelog-only edit. ### 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. +- [`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. From f0ce3a31cfa40f7f222dfec5727c25bb733f8ca5 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 14 Jul 2026 16:48:19 -0400 Subject: [PATCH 42/48] feat(hub-client): peek as a hover-card on a magnifying-glass icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved Peek out of the project ⋯ menu (and off the inline row link) onto a dedicated magnifying-glass icon next to the fork/branch icon on every card and row. Hovering the icon opens the peek as a floating card and it stays open while the pointer is over the icon or the popover (short close delay bridges the gap so the popover's actions stay reachable); leaving closes it. The popover anchors to the card/row's left edge so it stays on-screen in every column, and the card's action row is pinned visible while peek is open so moving onto the popover doesn't dismiss it. Verified with real pointer hover on the dev server: hovering the icon opens the peek (files, contributors, actions), and moving away closes it. Co-Authored-By: Claude Fable 5 --- hub-client/src/components/ProjectsHome.css | 20 ++++-- hub-client/src/components/ProjectsHome.tsx | 78 ++++++++++++++-------- 2 files changed, 67 insertions(+), 31 deletions(-) diff --git a/hub-client/src/components/ProjectsHome.css b/hub-client/src/components/ProjectsHome.css index ede6db3d9..87f99ca3d 100644 --- a/hub-client/src/components/ProjectsHome.css +++ b/hub-client/src/components/ProjectsHome.css @@ -415,7 +415,8 @@ opacity: 0; } -.ph-card:hover .ph-card-actions { opacity: 1; } +.ph-card:hover .ph-card-actions, +.ph-card.peek-open .ph-card-actions { opacity: 1; } .ph-card-menu-btn { background: none; @@ -429,7 +430,8 @@ .ph-card-menu-btn:hover { background: var(--input-bg-alpha); color: var(--text-primary); } -.ph-card-actions .ph-fork-btn { +.ph-card-actions .ph-fork-btn, +.ph-card-actions .ph-peek-btn { background: none; border: none; color: var(--text-secondary); @@ -441,6 +443,14 @@ .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; } @@ -761,8 +771,9 @@ .ph-peek { position: absolute; - top: calc(100% + 4px); - left: 140px; + top: calc(100% + 6px); + left: 0; + right: auto; width: 320px; background: var(--context-menu-bg); border: 1px solid var(--context-menu-border); @@ -770,6 +781,7 @@ box-shadow: 0 10px 32px var(--context-menu-shadow); padding: 17px 19px; z-index: 70; + cursor: default; } .ph-peek-header { diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx index 456ab9f63..3e88240dd 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -133,6 +133,14 @@ 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 = ''; @@ -249,6 +257,19 @@ export default function ProjectsHome({ 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); @@ -1059,18 +1080,6 @@ export default function ProjectsHome({
)}
- + openPeekHover(item.indexDocId)} + onMouseOut={closePeekHoverSoon} + > + + {peekFor === item.indexDocId && renderPeek(item)} +
); @@ -1654,23 +1676,26 @@ export default function ProjectsHome({ {item.description} {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)} +
))}
From 16856df6a77f463e3422999316519fd40b0b0520 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 14 Jul 2026 16:49:13 -0400 Subject: [PATCH 43/48] docs(hub-client): changelog for hover-peek Co-Authored-By: Claude Fable 5 --- hub-client/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 2eaabb48e..567f30bc5 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -26,6 +26,7 @@ WASM rebuild is needed for a changelog-only edit. ### 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. +- [`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. From 02095b9a652c3b81282e1ab25f5975affe17221e Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 14 Jul 2026 16:56:42 -0400 Subject: [PATCH 44/48] feat(hub-client): trim redundant actions from the peek card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Peek's footer duplicated the ⋯ menu (Rename, Open, Remove). Since Peek is a read-only preview, its footer now keeps only Refresh/Load details (the one peek-specific action); the footnote points to the ⋯ menu for acting on the project. Co-Authored-By: Claude Fable 5 --- hub-client/src/components/ProjectsHome.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx index 3e88240dd..93f86e61e 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -1186,14 +1186,11 @@ export default function ProjectsHome({
- - -
-
Peeking doesn't count as opening the project.
+
Peeking is read-only — use the ⋯ menu to act on the project.
); }; From b47d545e360f2a0349d7d460a609b34a3c316fd4 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 14 Jul 2026 16:57:54 -0400 Subject: [PATCH 45/48] docs(hub-client): changelog for peek footer trim Co-Authored-By: Claude Fable 5 --- hub-client/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 567f30bc5..53d31aca8 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -26,6 +26,7 @@ WASM rebuild is needed for a changelog-only edit. ### 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. From f52d87e34d74f55adbc6a017dc9b4c3e065dcc94 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Tue, 14 Jul 2026 18:00:56 -0400 Subject: [PATCH 46/48] test(hub-client)+docs: cover and document the v5 collections migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds migrations.test.ts covering the pointer→collections conversion: v5 is registered as transform-only, a singleton projectSet pointer becomes a one-element collections array (legacy pointer retained), re-running is idempotent (no clobber of a grown array), and a fresh browser with no pointer is a no-op. Documents the migration in claude-notes/instructions/hub-client-storage.md: a Migration History table (v2–v5), the projectSet store and its two pointer keys, why v5 bumps schema-version-only (not DB version) and how the runner still applies it, plus the app-level localStorage-collections migration. Verified live on the dev server: a genuine v4 pre-collections browser (singleton pointer + a project set with three projects, no collections) upgrades on reload to a one-entry collections pointer with all projects preserved in "Everything else"; _meta bumps to v5, migration recorded, legacy pointer retained. Co-Authored-By: Claude Fable 5 --- .../instructions/hub-client-storage.md | 16 +++- .../src/services/storage/migrations.test.ts | 92 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 hub-client/src/services/storage/migrations.test.ts 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/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(); + }); +}); From 3992e45db24f7d258b37dbd3f7038edeeaf72baa Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Wed, 15 Jul 2026 18:29:54 -0400 Subject: [PATCH 47/48] hub-client: identities, root ordering, and debug-view fixes for collections Local testing of the collections UI surfaced several issues; this batch addresses them (all TDD): - Debug auth gate: on a loopback host, treat /auth/me 401 as "proceed read-only" instead of the sign-in dead-end, so the inspector opens against an auth-less local hub. Real deployments stay gated. - Auth-less identity stamping: derive a stable Automerge actor id from the local userId when auth is disabled, so setIdentity fires and a document's `identities` populate (also fixes local editor attribution). The server actor still wins when auth is enabled. - Root-identity hardening: getCollectionPointers pins the legacy-singleton root to index 0, so root is no longer identified by accidental array position; self-heals scrambled pointer orders. - Debug view: list every collection ProjectSetDocument (not just the legacy singleton), each labeled with its live name, so all synced collection docs are inspectable. - ProjectsHome polish: fade card hover-actions beneath long titles so the peek/fork/menu icons no longer collide with the title; fix "Everything is in a collection" copy. Co-Authored-By: Claude Opus 4.8 --- hub-client/src/App.tsx | 9 +- hub-client/src/components/ProjectsHome.css | 7 + hub-client/src/components/ProjectsHome.tsx | 2 +- .../src/debug/components/QuickPick.test.ts | 40 ++++++ hub-client/src/debug/components/QuickPick.tsx | 127 +++++++++++++++--- .../src/debug/hooks/useDebugAuthGate.test.ts | 42 +++++- .../src/debug/hooks/useDebugAuthGate.ts | 48 +++++-- .../src/debug/hooks/useLocalProjects.test.ts | 26 +++- .../src/debug/hooks/useLocalProjects.ts | 16 ++- .../src/debug/services/localProjects.test.ts | 34 ++++- .../src/debug/services/localProjects.ts | 31 ++++- hub-client/src/services/authService.test.ts | 35 +++++ hub-client/src/services/authService.ts | 10 +- .../src/services/projectSetStorage.test.ts | 43 ++++++ hub-client/src/services/projectSetStorage.ts | 39 +++++- hub-client/src/services/userSettings.test.ts | 42 ++++++ hub-client/src/services/userSettings.ts | 24 ++++ 17 files changed, 529 insertions(+), 46 deletions(-) create mode 100644 hub-client/src/debug/components/QuickPick.test.ts create mode 100644 hub-client/src/services/userSettings.test.ts diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index 6940e997b..58c60932d 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -34,7 +34,7 @@ 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 { useCollectionSets } from './hooks/useCollectionSets'; import { useAuth } from './hooks/useAuth'; @@ -104,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 @@ -143,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). @@ -160,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); diff --git a/hub-client/src/components/ProjectsHome.css b/hub-client/src/components/ProjectsHome.css index 87f99ca3d..b38538304 100644 --- a/hub-client/src/components/ProjectsHome.css +++ b/hub-client/src/components/ProjectsHome.css @@ -413,6 +413,13 @@ 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, diff --git a/hub-client/src/components/ProjectsHome.tsx b/hub-client/src/components/ProjectsHome.tsx index 93f86e61e..40e5f6f77 100644 --- a/hub-client/src/components/ProjectsHome.tsx +++ b/hub-client/src/components/ProjectsHome.tsx @@ -1653,7 +1653,7 @@ export default function ProjectsHome({
{everythingElse.length === 0 ? (
- {query ? 'No projects match your search.' : 'Everything is on a collection.'} + {query ? 'No projects match your search.' : 'Everything is in a collection.'}
) : (
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/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/projectSetStorage.test.ts b/hub-client/src/services/projectSetStorage.test.ts index d51047cce..490abe8c6 100644 --- a/hub-client/src/services/projectSetStorage.test.ts +++ b/hub-client/src/services/projectSetStorage.test.ts @@ -114,6 +114,49 @@ describe('projectSetStorage', () => { ]); }); + 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' }, diff --git a/hub-client/src/services/projectSetStorage.ts b/hub-client/src/services/projectSetStorage.ts index 4c519a5f6..d20fb3e89 100644 --- a/hub-client/src/services/projectSetStorage.ts +++ b/hub-client/src/services/projectSetStorage.ts @@ -67,13 +67,44 @@ export async function getCollectionPointers(): Promise if (!db.objectStoreNames.contains(STORES.PROJECT_SET)) { return []; } + let collections: CollectionPointerEntry[]; const record: CollectionsPointer | undefined = await db.get(STORES.PROJECT_SET, 'collections'); if (record) { - return record.collections; + collections = record.collections; + } else { + await migratePointerToCollections(db); + const migrated: CollectionsPointer | undefined = await db.get(STORES.PROJECT_SET, 'collections'); + collections = migrated?.collections ?? []; } - await migratePointerToCollections(db); - const migrated: CollectionsPointer | undefined = await db.get(STORES.PROJECT_SET, 'collections'); - return 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. */ 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. * From ccdce7e01bc95f02f03c5bfeb3659884a4c6c9c6 Mon Sep 17 00:00:00 2001 From: Andrew Holz Date: Thu, 16 Jul 2026 09:12:03 -0400 Subject: [PATCH 48/48] hub-client: changelog for collections identities/root/debug-view fixes Documents 3992e45d. Co-Authored-By: Claude Opus 4.8 --- hub-client/changelog.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 53d31aca8..068fee8c5 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -23,6 +23,12 @@ 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.