diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index 518c1f03..24a1e88b 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -71,10 +71,6 @@ function App() { const [cursorColor, setCursorColor] = useState(); const [identities, setIdentities] = useState>({}); - // Per-project actor ID fetched from /auth/actor (HMAC-based, project-scoped). - // Undefined when not connected; null would indicate a failed fetch (handled inline). - const [actorId, setActorId] = useState(); - // Fetch per-project actor ID; calls logout and returns null on session expiry. const resolveActorId = useCallback(async (indexDocId: string): Promise => { if (!AUTH_ENABLED) return undefined; @@ -150,7 +146,6 @@ function App() { setFiles([]); setFileContents(new Map()); setConnectionError(null); - setActorId(undefined); } else if (route.type === 'project' || route.type === 'file') { // Navigating to a project (possibly different from current) const currentProjectId = project?.id; @@ -167,7 +162,6 @@ function App() { setProject(targetProject); setFiles(loadedFiles); setFileContents(contents); - setActorId(newActorId); } catch (err) { setConnectionError(err instanceof Error ? err.message : String(err)); navigateToProjectSelector({ replace: true }); @@ -218,7 +212,7 @@ function App() { setProject(existingProject); setFiles(loadedFiles); setFileContents(contents); - setActorId(newActorId); + if (shareRoute.filePath) { navigateToFile(existingProject.id, shareRoute.filePath, { replace: true }); @@ -254,7 +248,7 @@ function App() { setProject(targetProject); setFiles(loadedFiles); setFileContents(contents); - setActorId(newActorId); + } catch (err) { setConnectionError(err instanceof Error ? err.message : String(err)); navigateToProjectSelector({ replace: true }); @@ -283,7 +277,7 @@ function App() { setFiles([]); setFileContents(new Map()); setConnectionError(null); - setActorId(undefined); + } }, [auth, authLoading, project]); @@ -354,7 +348,7 @@ function App() { setProject(selectedProject); setFiles(loadedFiles); setFileContents(contents); - setActorId(newActorId); + if (filePathOverride) { navigateToFile(selectedProject.id, filePathOverride, { replace: true }); @@ -374,7 +368,6 @@ function App() { setFiles([]); setFileContents(new Map()); setConnectionError(null); - setActorId(undefined); // Update URL to show project selector navigateToProjectSelector({ replace: true }); }, [navigateToProjectSelector]); @@ -406,12 +399,13 @@ function App() { mimeType: f.mime_type, })); - // Create the Automerge documents (initial creation without actorId; - // the per-project HMAC actor ID is fetched after we have the indexDocId). + // Create the Automerge documents. The resolveActorId callback is + // called after the index doc is created (to derive the HMAC actor + // ID from the indexDocId) but before any file docs are written. const result = await createNewProject({ syncServer, files, - }, undefined, screenName, cursorColor); + }, undefined, screenName, cursorColor, resolveActorId); // Store the project in IndexedDB const projectEntry = await projectStorage.addProject( @@ -420,10 +414,6 @@ function App() { title ); - // Fetch the per-project actor ID now that we have the indexDocId - const newActorId = await resolveActorId(result.indexDocId); - if (newActorId === null) return; - setActorId(newActorId); // Set up the project state setProject(projectEntry); @@ -504,7 +494,6 @@ function App() { onNavigateToFile={(filePath, options) => { navigateToFile(project.id, filePath, options); }} - actorId={actorId} identities={identities} /> diff --git a/hub-client/src/components/Editor.tsx b/hub-client/src/components/Editor.tsx index f3ade487..096e2d95 100644 --- a/hub-client/src/components/Editor.tsx +++ b/hub-client/src/components/Editor.tsx @@ -50,8 +50,6 @@ interface Props { route: Route; /** Callback to update URL when file changes */ onNavigateToFile: (filePath: string, options?: { anchor?: string; replace?: boolean }) => void; - /** Current user's Automerge actor ID (for "Me" label in replay) */ - actorId?: string | null; /** Actor ID -> identity mapping from the IndexDocument */ identities?: Record; } @@ -101,7 +99,7 @@ function selectDefaultFile(files: FileEntry[]): FileEntry | null { return files[0]; } -export default function Editor({ project, files, fileContents, onDisconnect, onContentChange, route, onNavigateToFile, actorId, identities }: Props) { +export default function Editor({ project, files, fileContents, onDisconnect, onContentChange, route, onNavigateToFile, identities }: Props) { // View mode for pane sizing const { viewMode } = useViewMode(); @@ -1089,7 +1087,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC {/* Replay mode drawer */} {!isFullscreenPreview && ( - + )} {/* New file dialog */} diff --git a/hub-client/src/components/ReplayDrawer.test.tsx b/hub-client/src/components/ReplayDrawer.test.tsx index 8b7a935a..e796eddc 100644 --- a/hub-client/src/components/ReplayDrawer.test.tsx +++ b/hub-client/src/components/ReplayDrawer.test.tsx @@ -9,6 +9,12 @@ import { render, screen, fireEvent, cleanup } from '@testing-library/react'; import ReplayDrawer from './ReplayDrawer'; import type { ReplayState, ReplayControls } from '../hooks/useReplayMode'; +// Mock getActorId from automergeSync +let mockActorId: string | null = null; +vi.mock('../services/automergeSync', () => ({ + getActorId: () => mockActorId, +})); + function makeState(overrides: Partial = {}): ReplayState { return { isActive: false, @@ -47,6 +53,7 @@ describe('ReplayDrawer', () => { beforeEach(() => { controls = makeControls(); + mockActorId = null; }); afterEach(() => { @@ -127,32 +134,36 @@ describe('ReplayDrawer', () => { }); it('applies --me CSS class when currentActorId matches', () => { + mockActorId = 'abcdef0123456789abcdef0123456789'; const identities = { 'abcdef0123456789abcdef0123456789': { name: 'Alice', color: '#E91E63' } }; - render(); + render(); const actorEl = screen.getByText('Alice'); expect(actorEl.className).toContain('replay-drawer__actor--me'); }); it('does not apply --me CSS class when actor is not current user', () => { + mockActorId = 'different0123456789abcdef01234567'; const identities = { 'abcdef0123456789abcdef0123456789': { name: 'Alice', color: '#E91E63' } }; - render(); + render(); const actorEl = screen.getByText('Alice'); expect(actorEl.className).not.toContain('replay-drawer__actor--me'); }); it('applies --me CSS class with truncated hex when no identity', () => { - render(); + mockActorId = 'abcdef0123456789abcdef0123456789'; + render(); const actorEl = screen.getByText('abcdef01'); expect(actorEl.className).toContain('replay-drawer__actor--me'); }); it('renders short hash when currentActorId does not match and no identities', () => { - render(); + mockActorId = 'different0123456789abcdef01234567'; + render(); expect(screen.getByText('abcdef01')).toBeDefined(); }); it('renders short hash when currentActorId is null', () => { - render(); + render(); expect(screen.getByText('abcdef01')).toBeDefined(); }); diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index 54ad1f82..34770a0d 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -2,13 +2,13 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import type { ReplayState, ReplayControls } from '../hooks/useReplayMode'; import { actorColor } from '../hooks/useReplayMode'; import type { ActorIdentity } from '../services/automergeSync'; +import { getActorId } from '../services/automergeSync'; import './ReplayDrawer.css'; interface Props { state: ReplayState; controls: ReplayControls; disabled?: boolean; - currentActorId?: string | null; identities?: Record; } @@ -39,7 +39,8 @@ function formatFullTimestamp(ts: number | null): string { return date.toLocaleString(); } -export default function ReplayDrawer({ state, controls, disabled, currentActorId, identities }: Props) { +export default function ReplayDrawer({ state, controls, disabled, identities }: Props) { + const currentActorId = getActorId(); const drawerRef = useRef(null); // Auto-focus the drawer when replay mode activates so keyboard shortcuts work immediately diff --git a/hub-client/src/services/automergeSync.ts b/hub-client/src/services/automergeSync.ts index a137260f..786e9641 100644 --- a/hub-client/src/services/automergeSync.ts +++ b/hub-client/src/services/automergeSync.ts @@ -203,11 +203,24 @@ export function isConnected(): boolean { /** * Create a new project with the given files. */ -export async function createNewProject(options: CreateProjectOptions, actorId?: string, screenName?: string, color?: string): Promise { +export async function createNewProject( + options: CreateProjectOptions, + actorId?: string, + screenName?: string, + color?: string, + resolveActorId?: (indexDocId: string) => Promise, +): Promise { await initWasm(); vfsClear(); - return ensureClient().createNewProject(options, actorId, screenName, color); + return ensureClient().createNewProject(options, actorId, screenName, color, resolveActorId); +} + +/** + * Get the current actor ID, or null if not set. + */ +export function getActorId(): string | null { + return client?.getActorId() ?? null; } /** diff --git a/ts-packages/quarto-sync-client/src/client.ts b/ts-packages/quarto-sync-client/src/client.ts index 2e40d885..392023b2 100644 --- a/ts-packages/quarto-sync-client/src/client.ts +++ b/ts-packages/quarto-sync-client/src/client.ts @@ -6,9 +6,9 @@ * them to provide their own storage/VFS implementation. */ -import { Repo, DocHandle, updateText } from '@automerge/automerge-repo'; +import { Repo, DocHandle, updateText, generateAutomergeUrl, parseAutomergeUrl } from '@automerge/automerge-repo'; import type { DocumentId, Patch } from '@automerge/automerge-repo'; -import { clone as automergeClone } from '@automerge/automerge'; +import { clone as automergeClone, from as automergeFrom, save as automergeSerialize } from '@automerge/automerge'; import { BrowserWebSocketClientAdapter } from '@automerge/automerge-repo-network-websocket'; import type { @@ -169,11 +169,20 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS handle.update(doc => automergeClone(doc, { actor: actorId })); } - // Helper: create a document with actor ID applied. - function createDoc(): DocHandle { - const handle = state.repo!.create(); - applyActorId(handle, state.actorId); - return handle; + // Helper: create a new document with the correct actor ID from the + // very first change. Uses Automerge.from() + repo.import() so the + // initial data is attributed to the HMAC actor (not a random one). + // applyActorId is still needed after import because repo.import() + // does not preserve the actor for future handle.change() calls. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function createDoc(initialValue?: any, docId?: DocumentId): DocHandle { + if (state.actorId) { + const doc = automergeFrom(initialValue ?? {}, { actor: state.actorId }); + const handle = state.repo!.import(automergeSerialize(doc), docId ? { docId } : undefined); + applyActorId(handle, state.actorId); + return handle; + } + return state.repo!.create(); } // Helper: find a document by ID, wait for it to be ready, and apply actor ID. @@ -626,32 +635,52 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS /** * Create a new project with the given files. */ - async function createNewProject(options: CreateProjectOptions, actorId?: string, screenName?: string, color?: string): Promise { + async function createNewProject( + options: CreateProjectOptions, + actorId?: string, + screenName?: string, + color?: string, + resolveActorId?: (indexDocId: string) => Promise, + ): Promise { await disconnect(); try { state.wsAdapter = new BrowserWebSocketClientAdapter(options.syncServer); state.repo = new Repo({ network: [state.wsAdapter] }); - state.actorId = actorId ?? null; await waitForPeer(state.repo, 30000); - const indexHandle = createDoc(); - indexHandle.change(doc => { - doc.files = {}; - doc.version = 1; - doc.identities = {}; - if (actorId && screenName) { - setIdentity(doc, actorId, screenName, color || ''); - } - }); + // Phase 1: Generate a document ID and resolve the actor ID before + // creating any documents. This avoids the chicken-and-egg problem + // where repo.create() writes an initial change with a random actor. + const indexUrl = generateAutomergeUrl(); + const { documentId: indexDocId } = parseAutomergeUrl(indexUrl); + + const resolvedActorId = resolveActorId + ? (await resolveActorId(indexDocId)) ?? undefined + : actorId; + state.actorId = resolvedActorId ?? null; + + // Phase 2: Create the index document via createDoc with the + // pre-generated ID so the first change uses the correct actor. + const indexHandle = createDoc( + { files: {}, version: 1, identities: {} }, + indexDocId, + ); state.indexHandle = indexHandle; + // Write identity (separate change so the schema init is clean). + if (resolvedActorId && screenName) { + indexHandle.change(doc => { + setIdentity(doc, resolvedActorId, screenName, color || ''); + }); + } + // Fire initial identities lastIdentities = getIdentitiesFromIndex(indexHandle.doc()!); callbacks.onIdentitiesChange?.(lastIdentities); - const indexDocId = indexHandle.documentId; + // Phase 3: Create file documents (now using the correct actor). const createdFiles: FileEntry[] = []; for (const file of options.files) { @@ -752,6 +781,13 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS return astCache.get(path)?.ast ?? null; } + /** + * Get the current actor ID, or null if not set. + */ + function getActorId(): string | null { + return state.actorId; + } + // Return the public API return { connect, @@ -770,6 +806,7 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS getFileHandle, getFilePaths, createNewProject, + getActorId, }; }