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
6 changes: 4 additions & 2 deletions .plans/editor-workflows-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,9 @@ It follows the original rule: identify the smallest reusable workflows first, th
- Log out from the signed-in account surface.

#### A64 — Resolve Cloud vs Device Workspace Conflict
- When prompted, compare cloud vs device YAML summaries.
- Only for ambiguous cloud recovery cases such as offline divergence, compare cloud vs device YAML summaries.
- Export both snapshots, choose cloud, or keep device.
- For linked online projects, startup keeps the device workspace active and older cloud versions are recovered through Manage/History instead of a startup conflict prompt.

#### A65 — Connect / Switch / Disconnect GitHub
- In the Cloud pane, connect GitHub, switch the linked GitHub account, or disconnect it from PhaserForge.
Expand Down Expand Up @@ -369,7 +370,8 @@ It follows the original rule: identify the smallest reusable workflows first, th
- A63 sign in → A65 connect GitHub → A66 precheck and publish → verify the Pages URL.

### W17 — Workspace Conflict Recovery
- A63 sign in → A64 compare cloud/device snapshots → keep the desired workspace → continue editing or publish.
- A63 sign in → if an offline/ambiguous divergence is detected, A64 compare cloud/device snapshots → keep the desired workspace → continue editing or publish.
- For normal linked online startup, continue editing immediately and use Manage/History to restore or copy an older cloud-backed revision when needed.

### W18 — Multi-scene Authoring
- A29 create scene → A30 switch scene → use W1-W13 inside that scene → A31 duplicate/base/clear/delete scene as needed.
Expand Down
72 changes: 33 additions & 39 deletions src/editor/CloudAccountPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type Dispatch, useEffect, useMemo, useRef, useState } from 'react';
import { checkGithubPagesTarget, createGame, disconnectGithub, fetchCsrfToken, getGame, getGithubPagesPublishInfo, listGames, login, logout, me, publishToGithubPages, signup, updateGame, uploadEmbeddedAsset } from '../cloud/api';
import { checkGithubPagesTarget, createGame, disconnectGithub, fetchCsrfToken, getGame, getGithubPagesPublishInfo, login, logout, me, publishToGithubPages, signup, updateGame, uploadEmbeddedAsset } from '../cloud/api';
import { canonicalizeProjectForComparison, projectsSemanticallyEqual } from '../model/projectCanonical';
import { serializeProjectToYaml } from '../model/serialization';
import type { AssetFileSource, ProjectSpec } from '../model/types';
Expand Down Expand Up @@ -157,15 +157,17 @@ export function CloudAccountPanel({
onLoadYaml,
onLoadProject,
onCloudGameLinked,
onWorkspaceConflictChange,
onStatus,
onError,
}: {
state: Pick<EditorState, 'project'>;
state: Pick<EditorState, 'project' | 'syncMode'>;
activeCloudGameId?: string | null;
dispatch: Dispatch<EditorAction>;
onLoadYaml: (yaml: string, sourceLabel: string) => void;
onLoadProject?: (project: ProjectSpec, sourceLabel: string) => void;
onCloudGameLinked?: (gameId: string) => void | Promise<void>;
onWorkspaceConflictChange?: (hasConflict: boolean) => void;
onStatus: (message: string) => void;
onError: (message: string) => void;
}) {
Expand Down Expand Up @@ -292,6 +294,10 @@ export function CloudAccountPanel({
cloudGameIdRef.current = cloudGameId;
}, [cloudGameId]);

useEffect(() => {
onWorkspaceConflictChange?.(workspaceConflict != null);
}, [onWorkspaceConflictChange, workspaceConflict]);

useEffect(() => {
let cancelled = false;
if (!user) return;
Expand Down Expand Up @@ -330,47 +336,35 @@ export function CloudAccountPanel({
savedAtMs: null as number | null,
};
const mappedCloudGameId = activeCloudGameId ?? cloudGameIdRef.current;
if (!mappedCloudGameId) {
appendPersistenceDebugEntry('cloud:conflict-check-skipped-unlinked-project', {
stateProjectId: state.project.id,
});
return;
}
if (state.syncMode === 'online') {
appendPersistenceDebugEntry('cloud:conflict-check-skipped-online-autosync', {
stateProjectId: state.project.id,
cloudGameId: mappedCloudGameId,
});
return;
}
let cloudLabel = 'Cloud';
let cloudUpdatedAt = '';
let cloudProject: ProjectSpec | null = null;

if (mappedCloudGameId) {
const full = await getGame(mappedCloudGameId);
if (!full?.game?.project) return;
cloudProject = full.game.project;
cloudUpdatedAt = full.game.updated_at;
cloudLabel = `Cloud (current project: ${full.game.title})`;
appendPersistenceDebugEntry('restore:cloud-project-fetched', {
source: 'active-cloud-game',
cloudGameId: mappedCloudGameId,
updatedAt: full.game.updated_at,
title: full.game.title,
...summarizeYamlForDebug(serializeProjectToYaml(full.game.project)),
});
} else {
const res = await listGames();
const candidates = res.games ?? [];
if (candidates.length === 0) return;
const latest = candidates.reduce((best, cur) => {
const bestMs = Date.parse(best.updated_at);
const curMs = Date.parse(cur.updated_at);
if (!Number.isFinite(bestMs)) return cur;
if (!Number.isFinite(curMs)) return best;
return curMs > bestMs ? cur : best;
});
const full = await getGame(latest.id);
if (!full?.game?.project) return;
cloudProject = full.game.project;
cloudUpdatedAt = latest.updated_at;
cloudLabel = `Cloud (last game: ${latest.title})`;
appendPersistenceDebugEntry('restore:cloud-project-fetched', {
source: 'latest-cloud-game',
cloudGameId: latest.id,
updatedAt: latest.updated_at,
title: latest.title,
...summarizeYamlForDebug(serializeProjectToYaml(full.game.project)),
});
}
const full = await getGame(mappedCloudGameId);
if (!full?.game?.project) return;
cloudProject = full.game.project;
cloudUpdatedAt = full.game.updated_at;
cloudLabel = `Cloud (current project: ${full.game.title})`;
appendPersistenceDebugEntry('restore:cloud-project-fetched', {
source: 'active-cloud-game',
cloudGameId: mappedCloudGameId,
updatedAt: full.game.updated_at,
title: full.game.title,
...summarizeYamlForDebug(serializeProjectToYaml(full.game.project)),
});

if (!cloudProject) return;
const isEquivalent = projectsSemanticallyEqual(device.project, cloudProject);
Expand Down
8 changes: 8 additions & 0 deletions src/editor/InspectorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export function InspectorPane() {
const cachedUser = getCachedCloudAccountUserSnapshot();
return cachedUser ? 'inspector' : 'cloud';
});
const [hasWorkspaceConflict, setHasWorkspaceConflict] = useState(false);
const userSelectedTabRef = useRef(false);
const stabilityDebugKeyRef = useRef<string | null>(null);

Expand Down Expand Up @@ -136,6 +137,12 @@ export function InspectorPane() {
setTab(nextTab);
};

useEffect(() => {
if (!cloudEnabled) return;
if (!hasWorkspaceConflict) return;
setTab('cloud');
}, [cloudEnabled, hasWorkspaceConflict]);

useEffect(() => {
if (!state.initialized) return;
const projectId = state.project?.id;
Expand Down Expand Up @@ -164,6 +171,7 @@ export function InspectorPane() {
state={state}
activeCloudGameId={activeCloudGameId}
dispatch={dispatch}
onWorkspaceConflictChange={setHasWorkspaceConflict}
onLoadYaml={(yaml, sourceLabel) => {
appendPersistenceDebugEntry('inspector-pane:on-load-yaml-dispatch', {
sourceLabel,
Expand Down
80 changes: 26 additions & 54 deletions tests/e2e/cloud-workspace-conflict.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { test, expect } from '@playwright/test';
import { enablePersistenceDebug, expectPersistenceDebugEvents, expectProjectRestoreState, gotoStudio, waitForEmptyScene } from './helpers';
import { enablePersistenceDebug, gotoStudio } from './helpers';
import { sampleProject } from '../../src/model/sampleProject';
import { createEmptyProject } from '../../src/model/emptyProject';
import { buildStoredProjectRecord } from '../../src/editor/projectPersistence';

test('Cloud login shows conflict picker when cloud and device diverge @smoke', async ({ page }) => {
test('Signed-in linked online projects stay local at startup and autosave to cloud without a conflict modal @smoke', async ({ page }) => {
test.setTimeout(120000);
const cloudProject = createEmptyProject();
await enablePersistenceDebug(page);
const deviceRecord = buildStoredProjectRecord(sampleProject, {
id: sampleProject.id,
updatedAt: new Date(Date.now() - 60_000).toISOString(),
origin: 'local-only',
syncStatus: 'local',
origin: 'cloud-cache',
syncStatus: 'cloud',
cloudProjectId: 'g1',
});

await page.addInitScript(async ({ record }) => {
Expand Down Expand Up @@ -48,9 +49,6 @@ test('Cloud login shows conflict picker when cloud and device diverge @smoke', a
await route.fulfill({ status: 200, body: JSON.stringify({ csrfToken: 'csrf' }), contentType: 'application/json' });
});
await page.route('**/api/v1/auth/me', async (route) => {
await route.fulfill({ status: 401, body: JSON.stringify({ error: 'not_logged_in' }), contentType: 'application/json' });
});
await page.route('**/api/v1/auth/login', async (route) => {
await route.fulfill({ status: 200, body: JSON.stringify({ user: { id: 'u1', email: 'a@b.c' } }), contentType: 'application/json' });
});
await page.route('**/api/v1/games', async (route) => {
Expand All @@ -61,6 +59,14 @@ test('Cloud login shows conflict picker when cloud and device diverge @smoke', a
});
});
await page.route('**/api/v1/games/g1', async (route) => {
if (route.request().method() === 'PUT') {
await route.fulfill({
status: 200,
body: JSON.stringify({ game: { id: 'g1', title: 'Workspace', created_at: '2026-05-28T10:00:00.000Z', updated_at: '2026-05-28T10:15:00.000Z', project: sampleProject } }),
contentType: 'application/json',
});
return;
}
await route.fulfill({
status: 200,
body: JSON.stringify({ game: { id: 'g1', title: 'Workspace', created_at: '2026-05-28T10:00:00.000Z', updated_at: '2026-05-28T10:14:00.000Z', project: cloudProject } }),
Expand All @@ -76,54 +82,20 @@ test('Cloud login shows conflict picker when cloud and device diverge @smoke', a
return;
}

await cloudTab.click();
await expect(page.getByTestId('cloud-panel')).toBeVisible();

await page.getByLabel('Email').fill('a@b.c');
await page.locator('input[autocomplete="current-password"]').fill('pw');
await page.getByRole('button', { name: 'Log in' }).click();

await expect(page.getByTestId('workspace-conflict-modal')).toBeVisible();
await expect(page.getByTestId('workspace-conflict-cloud-card')).toContainText('Cloud');
await expect(page.getByTestId('workspace-conflict-device-card')).toContainText('This device');

const dl1 = page.waitForEvent('download');
const dl2 = page.waitForEvent('download');
await page.getByTestId('workspace-conflict-export-both').click();
await Promise.all([dl1, dl2]);
await expect(page.getByTestId('workspace-conflict-modal')).toHaveCount(0);
await expect(cloudTab).toHaveAttribute('aria-selected', 'false');

await page.getByTestId('workspace-conflict-use-cloud').click();
await waitForEmptyScene(page);
await expectProjectRestoreState(page, {
projectId: cloudProject.id,
title: cloudProject.title ?? 'Untitled Project',
currentSceneId: cloudProject.initialSceneId,
entityCount: 0,
groupCount: 0,
});
await expectPersistenceDebugEvents(page, [
'cloud:workspace-conflict-detected',
'cloud:workspace-conflict-choice-applied',
'restore:project-dispatched',
'restore:inspector-entity-list-stable',
]);

const backup = await page.evaluate(async () => {
const openDb = () => new Promise<IDBDatabase>((resolve, reject) => {
const request = window.indexedDB.open('phaserforge.persistence.v1', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
await expect.poll(async () => {
return await page.evaluate(() => {
const entries = window.__PHASER_FORGE_TEST__?.persistenceDebugEntries ?? [];
return entries.filter((entry: { event?: string }) => entry.event === 'cloud:autosave-flush-start').length;
});
const db = await openDb();
const saved = await new Promise<any>((resolve, reject) => {
const tx = db.transaction('workspaceState', 'readonly');
const request = tx.objectStore('workspaceState').get('workspaceBackup');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
}).toBeGreaterThan(0);

await expect.poll(async () => {
return await page.evaluate(() => {
const entries = window.__PHASER_FORGE_TEST__?.persistenceDebugEntries ?? [];
return entries.filter((entry: { event?: string }) => entry.event === 'cloud:workspace-conflict-detected').length;
});
return saved ?? null;
});
expect(backup?.source).toBe('device');
expect(backup?.project?.id).toBe(sampleProject.id);
expect(Object.keys(backup?.project?.scenes ?? {})).not.toHaveLength(0);
}).toBe(0);
});
Loading
Loading