Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 8 additions & 19 deletions hub-client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,6 @@ function App() {
const [cursorColor, setCursorColor] = useState<string | undefined>();
const [identities, setIdentities] = useState<Record<string, ActorIdentity>>({});

// 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<string | undefined>();

// Fetch per-project actor ID; calls logout and returns null on session expiry.
const resolveActorId = useCallback(async (indexDocId: string): Promise<string | undefined | null> => {
if (!AUTH_ENABLED) return undefined;
Expand Down Expand Up @@ -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;
Expand All @@ -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 });
Expand Down Expand Up @@ -218,7 +212,7 @@ function App() {
setProject(existingProject);
setFiles(loadedFiles);
setFileContents(contents);
setActorId(newActorId);


if (shareRoute.filePath) {
navigateToFile(existingProject.id, shareRoute.filePath, { replace: true });
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -283,7 +277,7 @@ function App() {
setFiles([]);
setFileContents(new Map());
setConnectionError(null);
setActorId(undefined);

}
}, [auth, authLoading, project]);

Expand Down Expand Up @@ -354,7 +348,7 @@ function App() {
setProject(selectedProject);
setFiles(loadedFiles);
setFileContents(contents);
setActorId(newActorId);


if (filePathOverride) {
navigateToFile(selectedProject.id, filePathOverride, { replace: true });
Expand All @@ -374,7 +368,6 @@ function App() {
setFiles([]);
setFileContents(new Map());
setConnectionError(null);
setActorId(undefined);
// Update URL to show project selector
navigateToProjectSelector({ replace: true });
}, [navigateToProjectSelector]);
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand Down Expand Up @@ -504,7 +494,6 @@ function App() {
onNavigateToFile={(filePath, options) => {
navigateToFile(project.id, filePath, options);
}}
actorId={actorId}
identities={identities}
/>
</ViewModeProvider>
Expand Down
6 changes: 2 additions & 4 deletions hub-client/src/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, import('../services/automergeSync').ActorIdentity>;
}
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -1089,7 +1087,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC

{/* Replay mode drawer */}
{!isFullscreenPreview && (
<ReplayDrawer state={replayState} controls={replayControls} disabled={!!currentFile && isBinaryExtension(currentFile.path)} currentActorId={actorId} identities={identities} />
<ReplayDrawer state={replayState} controls={replayControls} disabled={!!currentFile && isBinaryExtension(currentFile.path)} identities={identities} />
)}

{/* New file dialog */}
Expand Down
21 changes: 16 additions & 5 deletions hub-client/src/components/ReplayDrawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): ReplayState {
return {
isActive: false,
Expand Down Expand Up @@ -47,6 +53,7 @@ describe('ReplayDrawer', () => {

beforeEach(() => {
controls = makeControls();
mockActorId = null;
});

afterEach(() => {
Expand Down Expand Up @@ -127,32 +134,36 @@ describe('ReplayDrawer', () => {
});

it('applies --me CSS class when currentActorId matches', () => {
mockActorId = 'abcdef0123456789abcdef0123456789';
const identities = { 'abcdef0123456789abcdef0123456789': { name: 'Alice', color: '#E91E63' } };
render(<ReplayDrawer state={activeState} controls={controls} currentActorId="abcdef0123456789abcdef0123456789" identities={identities} />);
render(<ReplayDrawer state={activeState} controls={controls} identities={identities} />);
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(<ReplayDrawer state={activeState} controls={controls} currentActorId="different0123456789abcdef01234567" identities={identities} />);
render(<ReplayDrawer state={activeState} controls={controls} identities={identities} />);
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(<ReplayDrawer state={activeState} controls={controls} currentActorId="abcdef0123456789abcdef0123456789" />);
mockActorId = 'abcdef0123456789abcdef0123456789';
render(<ReplayDrawer state={activeState} controls={controls} />);
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(<ReplayDrawer state={activeState} controls={controls} currentActorId="different0123456789abcdef01234567" />);
mockActorId = 'different0123456789abcdef01234567';
render(<ReplayDrawer state={activeState} controls={controls} />);
expect(screen.getByText('abcdef01')).toBeDefined();
});

it('renders short hash when currentActorId is null', () => {
render(<ReplayDrawer state={activeState} controls={controls} currentActorId={null} />);
render(<ReplayDrawer state={activeState} controls={controls} />);
expect(screen.getByText('abcdef01')).toBeDefined();
});

Expand Down
5 changes: 3 additions & 2 deletions hub-client/src/components/ReplayDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ActorIdentity>;
}

Expand Down Expand Up @@ -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<HTMLDivElement>(null);

// Auto-focus the drawer when replay mode activates so keyboard shortcuts work immediately
Expand Down
17 changes: 15 additions & 2 deletions hub-client/src/services/automergeSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CreateProjectResult> {
export async function createNewProject(
options: CreateProjectOptions,
actorId?: string,
screenName?: string,
color?: string,
resolveActorId?: (indexDocId: string) => Promise<string | null | undefined>,
): Promise<CreateProjectResult> {
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;
}

/**
Expand Down
75 changes: 56 additions & 19 deletions ts-packages/quarto-sync-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<T>(): DocHandle<T> {
const handle = state.repo!.create<T>();
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<T>(initialValue?: any, docId?: DocumentId): DocHandle<T> {
if (state.actorId) {
const doc = automergeFrom(initialValue ?? {}, { actor: state.actorId });
const handle = state.repo!.import<T>(automergeSerialize(doc), docId ? { docId } : undefined);
applyActorId(handle, state.actorId);
return handle;
}
return state.repo!.create<T>();
}

// Helper: find a document by ID, wait for it to be ready, and apply actor ID.
Expand Down Expand Up @@ -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<CreateProjectResult> {
async function createNewProject(
options: CreateProjectOptions,
actorId?: string,
screenName?: string,
color?: string,
resolveActorId?: (indexDocId: string) => Promise<string | null | undefined>,
): Promise<CreateProjectResult> {
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<IndexDocument>();
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<IndexDocument>(
{ 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) {
Expand Down Expand Up @@ -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,
Expand All @@ -770,6 +806,7 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS
getFileHandle,
getFilePaths,
createNewProject,
getActorId,
};
}

Expand Down
Loading