From 0c74328933a2970d9d8c0ead178f6b1963ca92e4 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 5 Nov 2026 20:47:30 +0800 Subject: [PATCH 1/5] Polish the editor's UX and visual design to IDE standard Co-Authored-By: Claude Opus 5.5 --- apps/web/src/app/[locale]/(public)/page.tsx | 18 +- apps/web/src/app/[locale]/app/page.test.tsx | 48 +- apps/web/src/app/[locale]/app/page.tsx | 1444 +++++++++++------ .../src/app/components/DiagnosticsPanel.tsx | 40 - apps/web/src/app/components/Dialog.test.tsx | 51 + apps/web/src/app/components/Dialog.tsx | 21 +- .../app/components/MonacoPseudocodeEditor.tsx | 275 +++- apps/web/src/app/components/ProblemsList.tsx | 75 + apps/web/src/app/components/PublicHeader.tsx | 8 +- apps/web/src/app/components/PublicUi.tsx | 2 +- .../web/src/app/components/SettingsSelect.tsx | 4 +- apps/web/src/app/components/StatusBar.tsx | 125 ++ .../app/components/WorkspaceSidebar.test.tsx | 56 +- .../src/app/components/WorkspaceSidebar.tsx | 292 +++- .../components/flowchart/FlowchartEditor.tsx | 4 +- .../components/flowchart/FlowchartNodes.tsx | 62 +- .../flowchart/FlowchartRunControls.tsx | 2 +- apps/web/src/app/globals.css | 476 +++++- apps/web/src/app/hooks/useWorkspaceSession.ts | 356 +++- apps/web/src/i18n/messages/editor.en.ts | 59 +- apps/web/src/i18n/messages/editor.zh.ts | 10 +- apps/web/src/lib/analytics.ts | 9 +- apps/web/src/lib/appleTouch.test.ts | 14 +- apps/web/src/lib/appleTouch.ts | 17 +- apps/web/src/lib/editorCursorStore.ts | 37 + apps/web/src/lib/editorModels.ts | 28 + apps/web/src/lib/pseudocodeEditorOptions.ts | 22 +- apps/web/src/lib/shortcuts.test.ts | 53 + apps/web/src/lib/shortcuts.ts | 117 ++ apps/web/src/lib/storage.test.ts | 481 +++++- apps/web/src/lib/storage.ts | 310 +++- apps/web/src/lib/syntaxColorSchemes.test.ts | 5 +- apps/web/src/lib/syntaxColorSchemes.ts | 27 +- .../runtime/compilePseudocodeInWorker.test.ts | 21 + .../src/runtime/compilePseudocodeInWorker.ts | 18 +- 35 files changed, 3703 insertions(+), 884 deletions(-) delete mode 100644 apps/web/src/app/components/DiagnosticsPanel.tsx create mode 100644 apps/web/src/app/components/Dialog.test.tsx create mode 100644 apps/web/src/app/components/ProblemsList.tsx create mode 100644 apps/web/src/app/components/StatusBar.tsx create mode 100644 apps/web/src/lib/editorCursorStore.ts create mode 100644 apps/web/src/lib/editorModels.ts create mode 100644 apps/web/src/lib/shortcuts.test.ts create mode 100644 apps/web/src/lib/shortcuts.ts diff --git a/apps/web/src/app/[locale]/(public)/page.tsx b/apps/web/src/app/[locale]/(public)/page.tsx index ba5dcee..af9bc9a 100644 --- a/apps/web/src/app/[locale]/(public)/page.tsx +++ b/apps/web/src/app/[locale]/(public)/page.tsx @@ -55,7 +55,7 @@ function HeroWindow({ workspace, compiled, terminal }: { workspace: string; comp main.pseudo - {compiled} + {compiled}
diff --git a/apps/web/src/app/[locale]/app/page.test.tsx b/apps/web/src/app/[locale]/app/page.test.tsx index f90653a..4210cf7 100644 --- a/apps/web/src/app/[locale]/app/page.test.tsx +++ b/apps/web/src/app/[locale]/app/page.test.tsx @@ -110,6 +110,10 @@ vi.mock("@/lib/auth-components", () => ({ vi.mock("@/lib/storage", () => ({ loadWorkspace: loadWorkspaceMock, saveWorkspace: saveWorkspaceMock, + saveDeviceCheckpoint: vi.fn(async () => {}), + takeAccountSyncPending: vi.fn(() => false), + takeRecoveredCopies: vi.fn(() => false), + isAccountUnreachable: vi.fn(() => false), })); /** Makes loadWorkspace resolve to `workspace`. */ @@ -386,8 +390,8 @@ describe("HomePage workspace flow", () => { render(); await screen.findByRole("textbox", { name: "Mock editor" }); - fireEvent.click(getExplorerHeaderButton("Create File")); - const createDialog = await screen.findByRole("dialog", { name: "Create New File" }); + fireEvent.click(getExplorerHeaderButton("New file")); + const createDialog = await screen.findByRole("dialog", { name: "New file" }); const nameInput = within(createDialog).getByLabelText("File name"); expect(nameInput).toHaveValue("Untitled"); @@ -395,7 +399,7 @@ describe("HomePage workspace flow", () => { fireEvent.change(nameInput, { target: { value: "Renamed Doc.pseudo" } }); expect(nameInput).toHaveValue("Renamed Doc"); - fireEvent.click(within(createDialog).getByRole("button", { name: "Create File" })); + fireEvent.click(within(createDialog).getByRole("button", { name: "Create" })); await waitFor(() => { expect(getExplorerButton("Renamed Doc.pseudo")).toBeInTheDocument(); }); @@ -410,14 +414,18 @@ describe("HomePage workspace flow", () => { mockLoadedWorkspace(createEmptyWorkspace("2026-03-15T00:00:00.000Z")); render(); - expect(await screen.findByText("Welcome to PseudoEditor")).toBeInTheDocument(); + expect(await screen.findByText("Start writing pseudocode")).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Create New File" })); - const dialog = await screen.findByRole("dialog", { name: "Create New File" }); + fireEvent.click( + within(screen.getByText("Start writing pseudocode").closest("section") as HTMLElement).getByRole("button", { + name: "New file", + }), + ); + const dialog = await screen.findByRole("dialog", { name: "New file" }); fireEvent.change(within(dialog).getByLabelText("File name"), { target: { value: "main.pseudo" }, }); - fireEvent.click(within(dialog).getByRole("button", { name: "Create File" })); + fireEvent.click(within(dialog).getByRole("button", { name: "Create" })); await waitFor(() => { expect(screen.getByRole("textbox", { name: "Mock editor" })).toHaveValue(""); @@ -528,13 +536,13 @@ describe("HomePage workspace flow", () => { mockLoadedWorkspace(createWorkspaceFixture()); const { unmount } = render(); const editor = await screen.findByRole("textbox", { name: "Mock editor" }); - expect(editor).toHaveAttribute("data-color-scheme", "classic"); + expect(editor).toHaveAttribute("data-color-scheme", "pseudoeditor"); fireEvent.click(screen.getByRole("button", { name: "Open settings" })); const dialog = await screen.findByRole("dialog", { name: "Settings" }); const picker = within(dialog).getByRole("radiogroup", { name: "Syntax colours" }); - expect(within(picker).getAllByRole("radio")).toHaveLength(6); - expect(within(picker).getByRole("radio", { name: "Classic" })).toBeChecked(); + expect(within(picker).getAllByRole("radio")).toHaveLength(7); + expect(within(picker).getByRole("radio", { name: "PseudoEditor" })).toBeChecked(); fireEvent.click(within(picker).getByRole("radio", { name: "Ocean" })); @@ -556,7 +564,7 @@ describe("HomePage workspace flow", () => { await screen.findByRole("textbox", { name: "Mock editor" }); fireEvent.contextMenu(getExplorerButton("Helper.pseudo")); - fireEvent.click(await screen.findByRole("menuitem", { name: "Move Up" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Move up" })); await waitFor(() => { const savedState = lastLocallySavedWorkspace(); @@ -614,7 +622,7 @@ describe("HomePage workspace flow", () => { fireEvent.contextMenu(getExplorerButton("Helper.pseudo")); fireEvent.click(await screen.findByRole("menuitem", { name: "Delete" })); - expect(screen.getByText('Delete "Helper.pseudo"?')).toBeInTheDocument(); + expect(screen.getByRole("dialog", { name: 'Delete "Helper.pseudo"?' })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { @@ -635,7 +643,7 @@ describe("HomePage workspace flow", () => { fireEvent.click(helperRow); fireEvent.keyDown(helperRow, { key: "Delete" }); - const dialog = await screen.findByRole("dialog", { name: "Confirm Delete" }); + const dialog = await screen.findByRole("dialog", { name: 'Delete "Helper.pseudo"?' }); expect(within(dialog).getByRole("button", { name: "Cancel" })).toHaveFocus(); fireEvent.keyDown(dialog, { key: "Escape" }); @@ -643,7 +651,7 @@ describe("HomePage workspace flow", () => { await waitFor(() => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); - expect(helperRow).toHaveFocus(); + expect(screen.getByRole("tree", { name: "Explorer" })).toHaveFocus(); expect(getExplorerRow("Helper.pseudo")).toBeInTheDocument(); }); @@ -656,8 +664,8 @@ describe("HomePage workspace flow", () => { fireEvent.contextMenu(helperRow); fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" })); - const dialog = await screen.findByRole("dialog", { name: "Rename Item" }); - const nameInput = within(dialog).getByRole("textbox", { name: "Item name" }); + const dialog = await screen.findByRole("dialog", { name: "Rename" }); + const nameInput = within(dialog).getByRole("textbox", { name: "Name" }); expect(nameInput).toHaveFocus(); fireEvent.keyDown(nameInput, { key: "Escape" }); @@ -665,7 +673,7 @@ describe("HomePage workspace flow", () => { await waitFor(() => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); - expect(helperRow).toHaveFocus(); + expect(screen.getByRole("tree", { name: "Explorer" })).toHaveFocus(); }); it("uses the phone layout on narrow windows regardless of user agent and hides the flowchart switch", async () => { @@ -677,7 +685,7 @@ describe("HomePage workspace flow", () => { render(); await screen.findByRole("textbox", { name: "Mock editor" }); - fireEvent.click(screen.getByRole("button", { name: "SETTINGS" })); + fireEvent.click(within(screen.getByRole("navigation", { name: "Workspace sections" })).getByRole("button", { name: "Settings" })); expect(screen.getByText("Theme")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Switch to flowchart view" })).not.toBeInTheDocument(); } finally { @@ -946,7 +954,7 @@ describe("HomePage workspace flow", () => { loadWorkspaceMock.mockResolvedValue(createEmptyWorkspace("2026-03-15T00:00:00.000Z")); render(); - expect(await screen.findByText("Welcome to PseudoEditor")).toBeInTheDocument(); + expect(await screen.findByText("Start writing pseudocode")).toBeInTheDocument(); const event = new Event("beforeunload", { cancelable: true }); Object.defineProperty(event, "returnValue", { @@ -967,7 +975,7 @@ describe("HomePage workspace flow", () => { await screen.findByRole("textbox", { name: "Mock editor" }); expect(loadWorkspaceMock).toHaveBeenCalledWith(expect.any(String), { mode: "memory" }); const signInButton = screen.getByRole("button", { name: "Log In" }); - expect(signInButton).toHaveClass("bg-[var(--accent)]", "text-[var(--on-accent)]"); + expect(signInButton).toHaveClass("ui-btn", "ui-btn-primary"); expect(screen.queryByText(/Need an account/i)).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Save workspace" })); diff --git a/apps/web/src/app/[locale]/app/page.tsx b/apps/web/src/app/[locale]/app/page.tsx index 5b4b80d..44fe359 100644 --- a/apps/web/src/app/[locale]/app/page.tsx +++ b/apps/web/src/app/[locale]/app/page.tsx @@ -1,7 +1,6 @@ "use client"; import dynamic from "next/dynamic"; -import Image from "next/image"; import Link from "next/link"; import { BrandMark } from "@/app/components/BrandMark"; import { @@ -9,6 +8,7 @@ import { FormEvent, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, + type ReactNode, UIEvent as ReactUIEvent, memo, useCallback, @@ -25,13 +25,13 @@ import { CircleAlert, Code, CloudOff, - Ellipsis, FileCode, FilePlus, Folder, - GitBranch, + Workflow, LoaderCircle, LogIn, + PanelBottom, PanelLeft, Palette, Play, @@ -53,6 +53,18 @@ import { PracticeQuestionBanner } from "@/app/components/PracticeQuestionBanner" import { SettingsSelect } from "@/app/components/SettingsSelect"; import { SyntaxColorSchemePicker } from "@/app/components/SyntaxColorSchemePicker"; import { WorkspaceSidebar } from "@/app/components/WorkspaceSidebar"; +import { ProblemsList, countProblems } from "@/app/components/ProblemsList"; +import { StatusBar, type SaveIndicatorState } from "@/app/components/StatusBar"; +import type { PseudocodeEditorApi } from "@/app/components/MonacoPseudocodeEditor"; +import { disposeWorkspaceModels } from "@/lib/editorModels"; +import { + SHORTCUTS, + formatShortcut, + isApplePlatform, + matchesShortcut, + withShortcut, + type WorkspaceCommand, +} from "@/lib/shortcuts"; import { usePracticeQuestion } from "@/app/hooks/usePracticeQuestion"; import { useWorkspaceSession } from "@/app/hooks/useWorkspaceSession"; import { @@ -108,6 +120,8 @@ const MAX_TERMINAL_HEIGHT = 460; const SIDEBAR_WIDTH_STORAGE_KEY = "pseudocode-compiler-sidebar-width"; const TERMINAL_HEIGHT_STORAGE_KEY = "pseudocode-compiler-terminal-height"; const AUTO_SAVE_INTERVAL_STORAGE_KEY = "pseudocode-compiler-autosave-minutes"; +const EXPLORER_VISIBLE_STORAGE_KEY = "pseudocode-compiler-explorer-visible"; +const PANEL_VISIBLE_STORAGE_KEY = "pseudocode-compiler-panel-visible"; const DEFAULT_AUTO_SAVE_INTERVAL_MINUTES = 5; const MIN_AUTO_SAVE_INTERVAL_MINUTES = 1; const MAX_AUTO_SAVE_INTERVAL_MINUTES = 60; @@ -115,10 +129,27 @@ const MANUAL_SAVE_STATUS_TIMEOUT_MS = 2200; const PSEUDO_EXTENSION = ".pseudo"; // Shared so editors without compile results don't get a new array (and reset markers) every render. const EMPTY_DIAGNOSTICS: Diagnostic[] = []; -const DIALOG_CARD_CLASS = - "w-[calc(100%-2rem)] border border-[var(--separator)] bg-[var(--surface)] p-6 text-[var(--text)] shadow-[var(--shadow-modal)]"; type TouchTab = "editor" | "files" | "output" | "settings"; +type PanelView = "output" | "problems"; + +function loadStoredFlag(key: string, fallback: boolean): boolean { + if (typeof window === "undefined") return fallback; + try { + const stored = window.localStorage.getItem(key); + return stored === null ? fallback : stored === "1"; + } catch { + return fallback; + } +} + +function saveStoredFlag(key: string, value: boolean): void { + try { + window.localStorage.setItem(key, value ? "1" : "0"); + } catch { + /* storage can be blocked; the layout still works for this session */ + } +} type ManualSaveStatus = "idle" | "saving" | "saved" | "error"; function EditorLoadingFallback() { @@ -186,7 +217,9 @@ function normalizeDocumentEditableName(name: string): string { /** The focusable explorer button for a workspace node, if it is still rendered. */ function findExplorerItem(nodeId: string | undefined): HTMLElement | null { if (!nodeId) return null; - return document.querySelector(`[data-workspace-row-id="${CSS.escape(nodeId)}"] [data-workspace-item]`); + // The tree owns keyboard focus (rows are its active descendants), so focus returns to the tree itself. + const row = document.querySelector(`[data-workspace-row-id="${CSS.escape(nodeId)}"]`); + return row?.closest('[role="tree"]') ?? null; } function getSubmittedRenameValue(renameDialog: RenameDialogState, value: string): string { @@ -275,7 +308,7 @@ export default function HomePage() { { href: localePath(locale, "/security"), label: t.legal.security }, { href: "https://github.com/The1UNeed/pseudoeditor.dev", label: t.legal.github }, ]; - const { getToken, isLoaded: authLoaded, isSignedIn } = useAuth(); + const { getToken, isLoaded: authLoaded, isSignedIn, userId } = useAuth(); const authLoading = !authLoaded; const [appPlatform] = useState(() => getClientAppPlatform()); const cloudSavingRequired = platformUsesCloudSaving(appPlatform); @@ -306,9 +339,13 @@ export default function HomePage() { pendingInput, isRunning, lastRunTrace, + lastRunError, runningTerminalPanelId, saveError, hasPendingSave, + unsyncedEditsSafeOnDevice, + syncNotice, + dismissSyncNotice, isSaving, appNotice, dismissNotice, @@ -338,6 +375,8 @@ export default function HomePage() { getCloudAuthToken: workspaceAuthTokenProvider, persistenceMode: workspacePersistenceMode, cloudSyncLoading: cloudSavingRequired ? authLoading : false, + checkpointOwner: userId ?? null, + recoveredFolderName: t.sync.recoveredFolder, }); /* ── local state ── */ @@ -361,7 +400,26 @@ export default function HomePage() { MAX_TERMINAL_HEIGHT, ), ); - const [showTerminal, setShowTerminal] = useState(true); + const [showTerminal, setShowTerminalState] = useState(() => loadStoredFlag(PANEL_VISIBLE_STORAGE_KEY, true)); + const setShowTerminal = useCallback((next: boolean | ((current: boolean) => boolean)) => { + setShowTerminalState((current) => { + const value = typeof next === "function" ? next(current) : next; + saveStoredFlag(PANEL_VISIBLE_STORAGE_KEY, value); + return value; + }); + }, []); + const [showExplorer, setShowExplorerState] = useState(() => loadStoredFlag(EXPLORER_VISIBLE_STORAGE_KEY, true)); + const setShowExplorer = useCallback((next: boolean | ((current: boolean) => boolean)) => { + setShowExplorerState((current) => { + const value = typeof next === "function" ? next(current) : next; + saveStoredFlag(EXPLORER_VISIBLE_STORAGE_KEY, value); + return value; + }); + }, []); + const [panelView, setPanelView] = useState("output"); + const [touchOutputView, setTouchOutputView] = useState("output"); + const editorApiRef = useRef(null); + const [apple] = useState(() => isApplePlatform()); const [touchTab, setTouchTab] = useState("editor"); const [touchSidebarVisible, setTouchSidebarVisible] = useState(true); const [touchOutputVisible, setTouchOutputVisible] = useState(true); @@ -481,6 +539,13 @@ export default function HomePage() { /> ) : null; + const titlePath = useMemo(() => { + if (!workspace || !editorActiveDoc) return []; + return getNodePath(workspace, editorActiveDoc.id) + .filter((node) => node.parentId !== null) + .map((node) => node.name); + }, [workspace, editorActiveDoc]); + const breadcrumbs = useMemo(() => { if (!workspace || !currentDocument) return []; return getNodePath(workspace, currentDocument.id); @@ -490,7 +555,15 @@ export default function HomePage() { ? (terminalOutputs[terminalPanelId] ?? "") : ""; const resolvedTheme = resolveTheme(themeMode, systemTheme); + // Warn while the account copy is behind, even when edits are safe in this device's checkpoint: a student who + // closes the tab and then works on another device would otherwise not see these edits there. const shouldWarnBeforeUnload = hasPendingSave || saveError !== null; + // The hook reports failures in English; show the dictionary's wording (manual Save vs. autosave) instead. + const localizedSaveError = saveError?.startsWith("Save failed") + ? t.save.failedKeptManual + : saveError?.startsWith("Autosave failed") + ? t.save.failedKeptAuto + : saveError; const flowchartVisible = showFlowchart; const currentSource = currentDocument?.source ?? ""; // The flowchart stays mounted while hidden; only feed it source updates while it is visible. @@ -777,9 +850,16 @@ export default function HomePage() { const handleRun = useCallback(() => { shouldAutoScrollOutputRef.current = true; setShowTerminal(true); + setPanelView("output"); preloadRunRuntime(); - runNow(); - }, [preloadRunRuntime, runNow]); + void runNow().then((outcome) => { + // A program that doesn't compile has nothing to show in Output; the fix list is what the student needs. + if (outcome === "compile_error") { + setPanelView("problems"); + setTouchOutputView("problems"); + } + }); + }, [preloadRunRuntime, runNow, setShowTerminal]); const handleSaveWorkspace = useCallback(() => { if ((cloudSavingRequired && authLoading) || isSaving) { @@ -881,19 +961,23 @@ export default function HomePage() { [syncFlowchartCodeToWorkspace], ); // Browser-only values; the workspace loading gate renders first, so this can't cause a hydration mismatch. - const touchLayout = getTouchLayout(viewportSize.width, coarsePointer); + const touchLayout = getTouchLayout(viewportSize.width, coarsePointer, viewportSize.height); const isTouchTablet = touchLayout === "tablet"; const isTouchPhone = touchLayout === "phone"; const handleTouchRun = useCallback(() => { + setTouchOutputView("output"); if (isTouchTablet) { setTouchOutputVisible(true); + // Give output a useful share of a tall tablet screen instead of a thin strip under a mostly empty editor. + const comfortableHeight = Math.round(viewportSize.height * 0.35); + setTerminalHeight((current) => clampNumber(Math.max(current, comfortableHeight), MIN_TERMINAL_HEIGHT, maxTerminalHeight)); } if (isTouchPhone) { setTouchTab("output"); } handleRun(); - }, [handleRun, isTouchPhone, isTouchTablet]); + }, [handleRun, isTouchPhone, isTouchTablet, maxTerminalHeight, viewportSize.height]); const handlePhoneBack = useCallback(() => { setTouchTab((current) => (current === "editor" ? "files" : "editor")); @@ -923,54 +1007,61 @@ export default function HomePage() { setSyntaxId(nextSyntaxId); }; - const renderSyntaxSettings = (compact = false) => ( -
-
-

- {t.settings.examBoard} -

-

{t.settings.syntax}

-

- {t.settings.syntaxDescription} -

-
+ // Settings read like an IDE preferences pane: one card of rows, each with the label and what it does on the left + // and the control on the right (stacked on phones so descriptions keep a readable measure). + const settingsRowClass = (compact: boolean) => + compact + ? "flex flex-col items-stretch gap-3 px-4 py-3.5" + : "flex flex-wrap items-center justify-between gap-x-6 gap-y-2 px-4 py-3.5"; + const settingsLabel = (label: string, description: ReactNode) => ( + + {label} + {description} + + ); -
- -

- {currentSyntax.board} {currentSyntax.syllabus}. {currentSyntax.description} -

-
-
+ const renderSyntaxSettings = (compact = false) => ( + ); const renderThemeSettings = (compact = false) => ( -
-
-

- {t.settings.appearance} -

-

{t.settings.theme}

-

- {t.settings.themeDescription} -

-
- -
+
+ {settingsLabel( + t.settings.theme, + <> + {t.settings.themeDescription}{" "} + {themeMode === "system" + ? `${t.settings.activeAppearance} ${resolvedTheme === "dark" ? t.settings.dark : t.settings.light}` + : null} + , + )} +
{themeOptions.map((option) => { const selected = themeMode === option.value; return ( @@ -979,39 +1070,24 @@ export default function HomePage() { type="button" onClick={() => handleThemeModeChange(option.value)} aria-pressed={selected} - className={`relative flex-1 rounded-lg px-3 py-2 text-sm font-medium transition ${ + className={`rounded-[4px] px-3 text-[13px] font-medium transition ${compact ? "h-11 flex-1" : "h-7 pointer-coarse:min-h-11"} ${ selected - ? "bg-[var(--accent)] text-[var(--on-accent)] shadow-sm" + ? "bg-[var(--bg)] text-[var(--text)] shadow-[var(--shadow-sm)]" : "text-[var(--text2)] hover:text-[var(--text)]" }`} > - {option.label} + {option.label} ); })}
- -

- {t.settings.activeAppearance}{" "} - - {resolvedTheme === "dark" ? t.settings.dark : t.settings.light} - -

); - const renderSyntaxColorSettings = (compact = false) => ( -
-
-

- {t.settings.appearance} -

-

{t.settings.syntaxColors}

-

- {t.settings.syntaxColorsDescription} -

-
- + // The scheme previews need the full width, so this row stacks its picker under the label. + const renderSyntaxColorSettings = () => ( +
+ {settingsLabel(t.settings.syntaxColors, t.settings.syntaxColorsDescription)} ); - const renderSaveSettings = (compact = false) => ( -
-
-

- {t.settings.saving} -

-

{t.settings.autosave}

+ const renderSaveSettings = (compact = false) => + workspacePersistenceMode !== "cloud" ? ( +
+ {settingsLabel( + t.settings.autosave, + workspacePersistenceMode === "local" ? t.settings.autosaveLocal : t.settings.autosaveMemory, + )}
+ ) : ( + + ); -
-
- {t.settings.autosaveInterval} - handleAutoSaveIntervalChange(Number(event.target.value))} - className="w-[220px] max-w-[60%]" - > - {[1, 2, 3, 5, 10, 15, 30, 60].map((minutes) => )} - -
-

- {t.settings.autosaveEvery(autoSaveIntervalMinutes)} -

-
+ const renderSettingsRows = (compact = false) => ( +
+ {renderSyntaxSettings(compact)} + {renderThemeSettings(compact)} + {renderSyntaxColorSettings()} + {renderSaveSettings(compact)}
); @@ -1059,7 +1142,7 @@ export default function HomePage() { return ( ); }; @@ -1218,26 +1298,13 @@ export default function HomePage() { const renderFlowchartPromptDialog = () => showFlowchartPrompt ? ( - -
- - {t.flowchart.name} -
-

+ +

{t.flowchart.createFirst}

-

- {t.flowchart.createFirstDescription} -

+

{t.flowchart.createFirstDescription}

{ event.preventDefault(); const name = flowchartFileName.trim(); @@ -1249,29 +1316,21 @@ export default function HomePage() { }} > -
- -
@@ -1281,25 +1340,14 @@ export default function HomePage() { const renderCreateFileDialog = () => showCreateFileDialog ? ( - -
- - {t.files.explorer} -
-

+ +

{t.files.createNewFile}

- + -
- -
@@ -1336,40 +1376,26 @@ export default function HomePage() { const renderSignInPromptDialog = () => showSignInPrompt ? ( - setShowSignInPrompt(false)} - className={`${DIALOG_CARD_CLASS} max-w-sm rounded-xl`} - > -
- - {t.auth.signedOut} + setShowSignInPrompt(false)} className="ui-dialog max-w-sm"> +
+ + + +
+

+ {t.auth.signInToSave} +

+

{t.auth.signInDescription}

+
-

- {t.auth.signInToSave} -

-

- {t.auth.signInDescription} -

-
- {/* Close this modal first: Clerk's sign-in modal would otherwise sit under the inert page. */} - @@ -1383,33 +1409,18 @@ export default function HomePage() { labelledBy="rename-dialog-title" returnFocusTo={() => findExplorerItem(renameDialog.nodeId)} onClose={() => setRenameDialog(null)} - className={`${DIALOG_CARD_CLASS} max-w-md rounded-xl`} + className="ui-dialog max-w-sm" > -
-
-

{t.files.explorer}

-

- {t.files.renameItem} -

-

- {t.files.currentName} {renameDialog.currentName} -

-
- -
-
+

+ {t.files.renameItem} +

+

+ {t.files.currentName} {renameDialog.currentName} +

+ -
- -
@@ -1457,29 +1460,17 @@ export default function HomePage() { labelledBy="delete-dialog-title" onClose={() => setDeleteDialog(null)} returnFocusTo={() => findExplorerItem(deleteDialog.nodeIds[0])} - className={`${DIALOG_CARD_CLASS} max-w-md rounded-xl`} + className="ui-dialog max-w-sm" > -

{t.files.explorer}

-

- {t.files.confirmDelete} +

+ {deleteDialog.message}

-

{deleteDialog.message}

-
- -
@@ -1491,125 +1482,130 @@ export default function HomePage() { setShowSettingsPanel(false)} - className={`${DIALOG_CARD_CLASS} max-w-lg rounded-[var(--radius-3xl)]`} + className="ui-dialog max-w-xl overflow-y-auto p-0" > -
-
-

{t.toolbar.settings}

-

- {t.toolbar.settings} -

-
+
+

+ {t.toolbar.settings} +

-
- {renderSyntaxSettings()} - {renderThemeSettings()} - {renderSyntaxColorSettings()} - {renderSaveSettings()} -
-
-

- {t.legal.notice(new Date().getFullYear())} -

-
+
{renderSettingsRows()}
+
+ {t.legal.notice(new Date().getFullYear())} + {appLegalLinks.map((link) => ( {link.label} ))} -
-
+ +
) : null; - const renderStarterPanel = (compact = false) => ( -
-
-
-
- PseudoEditor -
-

- {t.empty.welcome} -

-

- {t.empty.description} -

-
- - + // The empty editor works like an IDE start page: the actions that begin work, and the shortcuts worth learning. + const renderStarterPanel = (compact = false) => { + const startActions = [ + { icon: FilePlus, label: t.files.createNewFile, onClick: () => openCreateFileDialog() }, + { icon: Folder, label: t.files.createFolder, onClick: () => createFolderInWorkspace() }, + { icon: BookOpen, label: t.empty.openDocs, onClick: openDocsPanel }, + ]; + const shortcutRows: Array<{ label: string; id: keyof typeof SHORTCUTS }> = [ + { label: t.toolbar.run, id: "run" }, + { label: t.save.saveWorkspace, id: "save" }, + { label: t.workbench.commandPalette, id: "commandPalette" }, + { label: t.workbench.toggleExplorer, id: "toggleExplorer" }, + ]; + return ( +
+
+ +

{t.empty.welcome}

+

{t.empty.description}

+
+ {startActions.map(({ icon: Icon, label, onClick }, index) => ( + + ))}
-
-
-
- ); + {!compact ? ( +
+

{t.empty.shortcuts}

+
+ {shortcutRows.map((row) => ( +
+
{row.label}
+
+ + {shortcutHint(row.id)} + +
+
+ ))} +
+
+ ) : null} + +
+ ); + }; const renderTouchOutputSurface = (title: string, onClose?: () => void) => ( -
-
- {title} +
+
+ {renderPanelTabs(touchOutputView, setTouchOutputView, true)}
- + {touchOutputView === "output" ? ( + + ) : null} {onClose ? ( ) : null}
+ {touchOutputView === "problems" ? ( +
+ +
+ ) : ( + <>
{terminalOutput ? ( {terminalOutput} - ) : ( + ) : lastRunError ? null : ( {t.terminal.ready} )} + {renderRunErrorRow(true)}
{pendingInput.panelId === terminalPanelId && pendingInput.prompt ? ( @@ -1647,18 +1644,18 @@ export default function HomePage() { autoFocus aria-label={t.terminal.input} aria-describedby="terminal-input-prompt" - className="h-8 min-w-[140px] flex-1 rounded-lg border border-[var(--separator)] bg-[var(--bg)] px-2 font-mono text-xs text-[var(--text)] outline-none focus:border-[var(--accent)]" + className="ui-input h-10 min-w-[140px] flex-1 font-mono text-[14px]" placeholder={t.terminal.inputPlaceholder} />
)} + + )}
); @@ -1707,6 +1706,22 @@ export default function HomePage() { [t, workspace], ); + function focusEditorWhenReady() { + let attempts = 0; + let previousApi: PseudocodeEditorApi | null = null; + const tryFocus = () => { + const api = editorApiRef.current; + // Two consecutive frames with the same editor means any remount for the new file has finished. + if (api && api === previousApi) { + api.focus(); + return; + } + previousApi = api; + if (attempts++ < 120) window.requestAnimationFrame(tryFocus); + }; + window.requestAnimationFrame(tryFocus); + } + const openCreateFileDialog = (options?: { parentId?: string; initialName?: string }) => { setCreateFileParentId(options?.parentId); setCreateFileName(getDocumentEditableName(options?.initialName ?? "main.pseudo")); @@ -1732,6 +1747,9 @@ export default function HomePage() { const name = `${baseName}${PSEUDO_EXTENSION}`; createDocumentInWorkspace(createFileParentId, { name }); closeCreateFileDialog(); + // Put the caret in the new file so the first keystrokes land in the code, as in an IDE. This runs after the + // dialog's focus restoration, and waits for a freshly mounted editor if this was the first file. + focusEditorWhenReady(); }; const submitRename = (event: FormEvent) => { @@ -1768,6 +1786,329 @@ export default function HomePage() { } }; + const renderPanelTabs = (view: PanelView, onChange: (view: PanelView) => void, touch = false) => { + const tabs: Array<{ id: PanelView; label: string; count?: number }> = [ + { id: "output", label: t.terminal.output }, + { id: "problems", label: t.workbench.problems, count: problemTotal }, + ]; + return ( +
{ + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + const next = view === "output" ? "problems" : "output"; + const tabList = event.currentTarget; + onChange(next); + window.requestAnimationFrame(() => { + tabList.querySelector(`[data-panel-view="${next}"]`)?.focus(); + }); + }} + > + {tabs.map((tab) => { + const selected = view === tab.id; + return ( + + ); + })} +
+ ); + }; + + /** Explains account-sync situations in the notice bar, with the action that resolves them. */ + const renderSyncNotice = () => { + if (!syncNotice) return null; + const unreachable = syncNotice === "account-unreachable"; + return ( +
+ + {unreachable ? t.sync.accountUnreachable : t.sync.recoveredCopies} + + +
+ ); + }; + + /** The error that stopped the last run, as a row that jumps to the line (like a linked stack frame). */ + const renderRunErrorRow = (touch = false) => { + if (!lastRunError) return null; + const message = lastRunError.code === "RUN408" ? t.workbench.runawayLoop : lastRunError.message; + const errorDocument = workspace?.nodes[lastRunError.documentId]; + if (!errorDocument) return null; + const inOtherFile = lastRunError.documentId !== currentDocument?.id; + return ( + + ); + }; + + /** Closes a tab and, like IDE tab strips, activates its right-hand neighbour (else the left one). */ + const closeTabSelectingNeighbour = (documentId: string): string | undefined => { + if (!editorPanel) return undefined; + const ids = editorPanel.openDocumentIds; + const index = ids.indexOf(documentId); + const neighbourId = ids[index + 1] ?? ids[index - 1]; + const wasActive = editorPanel.activeDocumentId === documentId; + closeEditorDocumentTab(editorPanel.id, documentId); + if (wasActive && neighbourId) setEditorActiveDocument(editorPanel.id, neighbourId); + return wasActive ? neighbourId : editorPanel.activeDocumentId ?? neighbourId; + }; + + /** Arrow keys move between editor tabs, as in IDE tab strips; Home/End jump to the ends. */ + const handleTabListKeyDown = (event: ReactKeyboardEvent) => { + if (!editorPanel) return; + const ids = editorPanel.openDocumentIds; + const currentIndex = ids.indexOf(editorPanel.activeDocumentId ?? ""); + let nextIndex = -1; + if (event.key === "ArrowRight") nextIndex = (currentIndex + 1) % ids.length; + else if (event.key === "ArrowLeft") nextIndex = (currentIndex - 1 + ids.length) % ids.length; + else if (event.key === "Home") nextIndex = 0; + else if (event.key === "End") nextIndex = ids.length - 1; + else if ((event.key === "Delete" || event.key === "Backspace") && currentIndex >= 0 && ids.length > 1) { + // Close the focused tab from the keyboard, then keep focus in the strip on its neighbour. + event.preventDefault(); + const neighbourId = closeTabSelectingNeighbour(ids[currentIndex]); + const tabList = event.currentTarget; + window.requestAnimationFrame(() => { + const next = neighbourId + ? tabList.querySelector(`[data-document-id="${CSS.escape(neighbourId)}"]`) + : null; + (next ?? document.querySelector('[role="tree"]'))?.focus(); + }); + return; + } + if (nextIndex < 0 || ids.length === 0) return; + event.preventDefault(); + const nextId = ids[nextIndex]; + setEditorActiveDocument(editorPanel.id, nextId); + const tabList = event.currentTarget; + window.requestAnimationFrame(() => { + tabList.querySelector(`[data-document-id="${CSS.escape(nextId)}"]`)?.focus(); + }); + }; + + /* ── workspace commands & keyboard shortcuts ── */ + + // Free the editor models of deleted documents, and all of them when leaving the workspace. + const documentIdsKey = workspace + ? Object.values(workspace.nodes) + .filter((node) => node.type === "document") + .map((node) => node.id) + .sort() + .join("\n") + : ""; + useEffect(() => { + const keep = new Set(documentIdsKey ? documentIdsKey.split("\n") : []); + // Wait a tick so an editor that is unmounting with the last document has detached its model first. + const timer = window.setTimeout(() => disposeWorkspaceModels(keep), 0); + return () => window.clearTimeout(timer); + }, [documentIdsKey]); + useEffect(() => () => void window.setTimeout(() => disposeWorkspaceModels(), 0), []); + + const revealDiagnostic = useCallback( + (diagnostic: Diagnostic, documentId?: string) => { + if (isTouchPhone) { + setTouchTab("editor"); + } + // On phones the editor remounts when its tab opens, and opening another file swaps the model a frame later, + // so wait until the editor is showing the right document. + let attempts = 0; + const tryReveal = () => { + const api = editorApiRef.current; + if (api && (!documentId || api.showsDocument(documentId))) { + api.revealPosition(diagnostic.line, diagnostic.column); + return; + } + if (attempts++ < 120) window.requestAnimationFrame(tryReveal); + }; + tryReveal(); + }, + [isTouchPhone], + ); + + const showProblems = useCallback(() => { + if (touchLayout === "phone") { + setTouchTab("output"); + setTouchOutputView("problems"); + return; + } + if (touchLayout === "tablet") { + setTouchOutputVisible(true); + setTouchOutputView("problems"); + return; + } + setShowTerminal(true); + setPanelView("problems"); + }, [setShowTerminal, touchLayout]); + + const togglePanel = useCallback(() => { + if (touchLayout === "tablet") { + setTouchOutputVisible((current) => !current); + return; + } + setShowTerminal((current) => !current); + }, [setShowTerminal, touchLayout]); + + const toggleExplorer = useCallback(() => { + if (touchLayout === "tablet") { + setTouchSidebarVisible((current) => !current); + return; + } + setShowExplorer((current) => !current); + }, [setShowExplorer, touchLayout]); + + // Commands call through a ref so the list (and the editor actions built from it) stays stable across renders. + const commandHandlersRef = useRef void>>({}); + useEffect(() => { + commandHandlersRef.current = { + run: () => { + if (!isRunning && currentDocument) (touchLayout ? handleTouchRun : handleRun)(); + }, + save: handleSaveWorkspace, + toggleExplorer, + togglePanel, + problems: showProblems, + settings: () => (isTouchPhone ? setTouchTab("settings") : setShowSettingsPanel(true)), + newFile: () => openCreateFileDialog(), + newFolder: () => createFolderInWorkspace(), + commandPalette: () => editorApiRef.current?.openCommandPalette(), + }; + }); + + const workspaceCommands = useMemo(() => [ + { id: "run", label: t.workbench.commands.run, shortcuts: SHORTCUTS.run, run: () => commandHandlersRef.current.run?.() }, + { id: "save", label: t.workbench.commands.save, shortcuts: SHORTCUTS.save, run: () => commandHandlersRef.current.save?.() }, + { + id: "toggleExplorer", + label: t.workbench.commands.toggleExplorer, + shortcuts: SHORTCUTS.toggleExplorer, + run: () => commandHandlersRef.current.toggleExplorer?.(), + }, + { + id: "togglePanel", + label: t.workbench.commands.togglePanel, + shortcuts: SHORTCUTS.togglePanel, + run: () => commandHandlersRef.current.togglePanel?.(), + }, + { + id: "problems", + label: t.workbench.commands.problems, + shortcuts: SHORTCUTS.problems, + run: () => commandHandlersRef.current.problems?.(), + }, + { + id: "settings", + label: t.workbench.commands.settings, + shortcuts: SHORTCUTS.settings, + run: () => commandHandlersRef.current.settings?.(), + }, + { id: "newFile", label: t.workbench.commands.newFile, shortcuts: [], run: () => commandHandlersRef.current.newFile?.() }, + { id: "newFolder", label: t.workbench.commands.newFolder, shortcuts: [], run: () => commandHandlersRef.current.newFolder?.() }, + { + id: "commandPalette", + label: t.workbench.commands.commandPalette, + shortcuts: SHORTCUTS.commandPalette, + run: () => commandHandlersRef.current.commandPalette?.(), + }, + ], [t]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // The editor handles its own keybindings (and marks the event handled); everything else lands here. + if (event.defaultPrevented || event.isComposing) return; + const command = workspaceCommands.find((candidate) => + candidate.shortcuts.some((shortcut) => matchesShortcut(event, shortcut, apple)), + ); + if (!command) return; + event.preventDefault(); + // Leave the page behind an open dialog alone; still swallow Save so the browser's save-page dialog never opens. + if (document.querySelector("dialog[open]") && command.id !== "save") return; + command.run(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [apple, workspaceCommands]); + + const phoneSaveLabel = { + "saved-local": t.workbench.savedLocal, + "saved-cloud": t.workbench.savedCloud, + "saved-device-syncing": t.workbench.savedDeviceSyncingShort, + unsaved: t.workbench.unsaved, + saving: t.workbench.savingNow, + "not-saved": t.workbench.notSaved, + failed: t.workbench.saveFailed, + }[saveIndicatorStateFor()]; + function saveIndicatorStateFor(): SaveIndicatorState { + return saveError + ? "failed" + : isSaving || manualSaveStatus === "saving" + ? "saving" + : !canSaveWorkspace + ? "not-saved" + : hasPendingSave + ? workspacePersistenceMode === "cloud" && unsyncedEditsSafeOnDevice + ? "saved-device-syncing" + : "unsaved" + : workspacePersistenceMode === "cloud" + ? "saved-cloud" + : "saved-local"; + } + const shortcutHint = (id: keyof typeof SHORTCUTS) => formatShortcut(SHORTCUTS[id][0], apple); + const tip = (label: string, id: keyof typeof SHORTCUTS) => withShortcut(label, SHORTCUTS[id][0], apple); + const problemCounts = countProblems(compileDiagnostics); + const problemTotal = problemCounts.errors + problemCounts.warnings; + const saveIndicatorState = saveIndicatorStateFor(); + /* ── loading state ── */ if (!workspace) { @@ -1794,10 +2135,11 @@ export default function HomePage() { style={{ height: "100dvh", minHeight: "100svh" }} >
- {(saveError || appNotice) && ( + {(saveError || appNotice || syncNotice) && (
- {saveError && {saveError}} + {renderSyncNotice()} + {saveError && syncNotice !== "account-unreachable" && {localizedSaveError}} {appNotice && (
- + {currentDocument?.name ?? t.files.noFileSelected}
-
-

{t.toolbar.editor}

-
-
- {renderSaveControl()} + {renderSaveControl(true)} {renderAccountControl(true)}
@@ -1894,21 +2234,23 @@ export default function HomePage() { aria-label={t.terminal.resizeSidebar} aria-orientation="vertical" tabIndex={0} - className="relative w-3 shrink-0 cursor-col-resize touch-none" + className="relative z-10 w-px shrink-0 cursor-col-resize touch-none bg-[var(--separator)] before:absolute before:inset-y-0 before:-left-3 before:-right-3 before:content-[''] focus-visible:bg-[var(--accent)] focus-visible:outline-none" onPointerDown={handleSidebarResize} onKeyDown={handleSidebarResizeKeyDown} - > -
-
+ /> ) : null}
-
- -
+ {breadcrumbs.filter((node) => node.parentId !== null).length > 1 ? ( + <> +
+ +
+
+ + ) : null} {practiceBanner} -
{currentDocument ? ( @@ -1922,26 +2264,27 @@ export default function HomePage() { theme={resolvedTheme} syntaxId={syntaxId} colorScheme={syntaxColorScheme} + commands={workspaceCommands} + apiRef={editorApiRef} /> ) : ( renderStarterPanel(true) )}
-
- {touchOutputVisible ? ( <> + {/* A finger-sized drag handle with a visible grip, on a single divider. */}
-
+
setTouchOutputVisible(true)} > {t.terminal.output}
- + )}
+ setShowSettingsPanel(true)} + saveState={saveIndicatorState} + onSave={handleSaveWorkspace} + showCursor={Boolean(currentDocument)} + onGoToLine={() => editorApiRef.current?.goToLine()} + /> ) : ( <>
- - {currentDocument?.name ?? t.files.createAFile} - -
- {renderSaveControl()} +
+ + {currentDocument?.name ?? t.files.createAFile} + + + {phoneSaveLabel} + +
+ {renderSaveControl(true)} + + + + {renderAccountControl(true)}
@@ -2025,6 +2395,8 @@ export default function HomePage() { theme={resolvedTheme} syntaxId={syntaxId} colorScheme={syntaxColorScheme} + commands={workspaceCommands} + apiRef={editorApiRef} /> ) : ( renderStarterPanel(true) @@ -2048,25 +2420,15 @@ export default function HomePage() { {touchTab === "output" ? renderTouchOutputSurface(t.terminal.output) : null} {touchTab === "settings" ? ( -
-

- {t.settings.heading} -

-

- PseudoEditor -

-

- {t.settings.touchDescription} -

- {renderSyntaxSettings(true)} - {renderThemeSettings(true)} - {renderSyntaxColorSettings(true)} - {renderSaveSettings(true)} +
+

{t.toolbar.settings}

+
{renderSettingsRows(true)}
@@ -2077,7 +2439,10 @@ export default function HomePage() { className="shrink-0 bg-[var(--bg)] px-4 pt-3" style={{ paddingBottom: "calc(env(safe-area-inset-bottom, 0px) + 21px)" }} > -
+
+
@@ -2121,13 +2501,14 @@ export default function HomePage() { return (
{/* ════════════ Title Bar ════════════ */} + {/* Three columns so the file name sits in the true centre of the window, never over the brand or toolbar. */}
{/* Spacer for native traffic lights (desktop) / brand label (web) */} -
+
{isDesktopShell ? null : ( - + PseudoEditor )}
-
-

{t.toolbar.editor}

-
+

+ {titlePath.length > 1 ? ( + {titlePath.slice(0, -1).join(" / ")} / + ) : null} + {titlePath.at(-1) ?? ""} +

- {/* Toolbar */} -
+ {/* Toolbar: layout toggles | file and learning tools | Run (the primary action) */} +
+ + +
{renderSaveControl()} - + +
{renderAccountControl()}
- {/* ════════════ Title Divider ════════════ */} -
- {/* ════════════ Notice Bar ════════════ */} - {(saveError || appNotice) && ( + {(saveError || appNotice || syncNotice) && (
- {saveError && {saveError}} + {renderSyncNotice()} + {saveError && syncNotice !== "account-unreachable" && {localizedSaveError}} {appNotice && (
+ ) : null}
- {/* Terminal Content */} + {panelView === "problems" ? ( +
+ +
+ ) : ( + <> + {/* Output Content */}
                   {terminalOutput ? (
-                    <>
-                      $ 
-                      {terminalOutput}
-                    
-                  ) : (
+                    {terminalOutput}
+                  ) : lastRunError ? null : (
                     {t.terminal.ready}
                   )}
                 
+ {renderRunErrorRow()}
{/* Pending Input */} @@ -2475,18 +2919,18 @@ export default function HomePage() { autoFocus aria-label={t.terminal.input} aria-describedby="terminal-input-prompt" - className="h-7 flex-1 rounded-lg border border-[var(--separator)] bg-[var(--bg)] px-2 font-mono text-xs text-[var(--text)] outline-none focus:border-[var(--accent)]" + className="ui-input h-8 flex-1 font-mono text-[12.5px]" placeholder={t.terminal.inputPlaceholder} />
)} + + )}
)}
+ setShowSettingsPanel(true)} + saveState={saveIndicatorState} + onSave={handleSaveWorkspace} + saveShortcut={shortcutHint("save")} + showCursor={Boolean(editorActiveDoc) && !flowchartVisible} + onGoToLine={() => editorApiRef.current?.goToLine()} + /> + {renderSettingsDialog()} {renderRenameDialog()} {renderDeleteDialog()} diff --git a/apps/web/src/app/components/DiagnosticsPanel.tsx b/apps/web/src/app/components/DiagnosticsPanel.tsx deleted file mode 100644 index 2c7d97b..0000000 --- a/apps/web/src/app/components/DiagnosticsPanel.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import { Diagnostic } from "@/compiler/types"; -import { useDictionary } from "@/i18n/context"; - -interface DiagnosticsPanelProps { - diagnostics: Diagnostic[]; -} - -export function DiagnosticsPanel({ diagnostics }: DiagnosticsPanelProps) { - const t = useDictionary().editor; - if (diagnostics.length === 0) { - return ( -
-

No diagnostics.

-

Compile the active file to see errors, warnings, and hints.

-
- ); - } - - return ( -
- {diagnostics.map((diagnostic, index) => ( -
-

- - {diagnostic.severity.toUpperCase()} - - {" "}· {diagnostic.code} · L{diagnostic.line}:C{diagnostic.column} -

-

{diagnostic.message}

- {diagnostic.hint ?

{t.diagnostics.hint} {diagnostic.hint}

: null} -
- ))} -
- ); -} diff --git a/apps/web/src/app/components/Dialog.test.tsx b/apps/web/src/app/components/Dialog.test.tsx new file mode 100644 index 0000000..adc865b --- /dev/null +++ b/apps/web/src/app/components/Dialog.test.tsx @@ -0,0 +1,51 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { Dialog } from "./Dialog"; + +beforeAll(() => { + // jsdom has no modal support. + HTMLDialogElement.prototype.showModal ??= function showModal(this: HTMLDialogElement) { + this.setAttribute("open", ""); + }; + HTMLDialogElement.prototype.close ??= function close(this: HTMLDialogElement) { + this.removeAttribute("open"); + }; +}); + +afterEach(cleanup); + +function renderDialog(onClose = vi.fn()) { + render( + +

Settings

+
, + ); + const dialog = screen.getByRole("dialog", { hidden: true }); + vi.spyOn(dialog, "getBoundingClientRect").mockReturnValue({ + left: 100, top: 100, right: 500, bottom: 500, width: 400, height: 400, x: 100, y: 100, toJSON: () => ({}), + }); + return { dialog, onClose }; +} + +describe("Dialog", () => { + it("stays open when the card's own padding is clicked", () => { + const { dialog, onClose } = renderDialog(); + fireEvent.pointerDown(dialog, { clientX: 110, clientY: 110 }); + fireEvent.click(dialog, { clientX: 110, clientY: 110 }); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("closes on a click on the backdrop outside the card", () => { + const { dialog, onClose } = renderDialog(); + fireEvent.pointerDown(dialog, { clientX: 20, clientY: 20 }); + fireEvent.click(dialog, { clientX: 20, clientY: 20 }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("stays open when a drag starts inside the card and ends on the backdrop", () => { + const { dialog, onClose } = renderDialog(); + fireEvent.pointerDown(dialog, { clientX: 300, clientY: 300 }); + fireEvent.click(dialog, { clientX: 20, clientY: 20 }); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/components/Dialog.tsx b/apps/web/src/app/components/Dialog.tsx index cf57b51..23ba1ac 100644 --- a/apps/web/src/app/components/Dialog.tsx +++ b/apps/web/src/app/components/Dialog.tsx @@ -14,6 +14,11 @@ type DialogProps = { children: ReactNode; }; +function isOutsideDialogBox(dialog: HTMLElement, x: number, y: number): boolean { + const box = dialog.getBoundingClientRect(); + return x < box.left || x > box.right || y < box.top || y > box.bottom; +} + /** * Modal built on the native element, so focus is trapped and the rest of the page is inert. * Escape and backdrop clicks call onClose; the parent owns the open state by mounting or unmounting this. @@ -24,6 +29,7 @@ export function Dialog({ labelledBy, onClose, className = "", returnFocusTo, chi const dialogRef = useRef(null); const onCloseRef = useRef(onClose); const returnFocusToRef = useRef(returnFocusTo); + const pointerDownOnBackdropRef = useRef(false); useEffect(() => { onCloseRef.current = onClose; @@ -62,8 +68,21 @@ export function Dialog({ labelledBy, onClose, className = "", returnFocusTo, chi event.stopPropagation(); onCloseRef.current(); }} + onPointerDown={(event) => { + pointerDownOnBackdropRef.current = isOutsideDialogBox(event.currentTarget, event.clientX, event.clientY); + }} onClick={(event) => { - if (event.target === event.currentTarget) onCloseRef.current(); + // The element is also the target for clicks on its own padding, so only a press that both + // starts and ends outside the card's box counts as a backdrop click (a drag out of an input does not). + const startedOnBackdrop = pointerDownOnBackdropRef.current; + pointerDownOnBackdropRef.current = false; + if ( + event.target === event.currentTarget && + startedOnBackdrop && + isOutsideDialogBox(event.currentTarget, event.clientX, event.clientY) + ) { + onCloseRef.current(); + } }} className={`m-auto max-h-[calc(100%-2rem)] outline-none backdrop:bg-[var(--overlay)] ${className}`} > diff --git a/apps/web/src/app/components/MonacoPseudocodeEditor.tsx b/apps/web/src/app/components/MonacoPseudocodeEditor.tsx index 518f5a4..d60476a 100644 --- a/apps/web/src/app/components/MonacoPseudocodeEditor.tsx +++ b/apps/web/src/app/components/MonacoPseudocodeEditor.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useEffect, useMemo, useRef } from "react"; +import { memo, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; import Editor, { BeforeMount, OnMount } from "@monaco-editor/react"; import "@/lib/monacoLocalLoader"; import type * as Monaco from "monaco-editor"; @@ -26,8 +26,21 @@ import { } from "@/lib/syntaxColorSchemes"; import type { ResolvedTheme } from "@/lib/theme"; import { useDictionary } from "@/i18n/context"; +import { setEditorCursor } from "@/lib/editorCursorStore"; +import { toMonacoKeybinding, type WorkspaceCommand } from "@/lib/shortcuts"; +import { modelPathForDocument, registerLoadedMonaco } from "@/lib/editorModels"; import { editorEn } from "@/i18n/messages/editor.en"; +/** What the workspace can ask of the mounted editor (focus, reveal a problem, open the command palette). */ +export interface PseudocodeEditorApi { + focus: () => void; + revealPosition: (line: number, column: number) => void; + /** The document whose model the editor is showing right now. */ + showsDocument: (documentKey: string) => boolean; + openCommandPalette: () => void; + goToLine: () => void; +} + interface MonacoPseudocodeEditorProps { value: string; onChange: (value: string) => void; @@ -36,8 +49,13 @@ interface MonacoPseudocodeEditorProps { documentKey?: string; syntaxId?: SyntaxId | string; colorScheme?: SyntaxColorSchemeId; + /** Workspace actions to list in the editor's command palette with their keybindings. */ + commands?: WorkspaceCommand[]; + apiRef?: MutableRefObject; } +const DATA_TYPE_NAMES = new Set(["INTEGER", "REAL", "CHAR", "STRING", "BOOLEAN", "DATE"]); + const TYPE_KEYWORDS = [ "INTEGER", "REAL", @@ -154,38 +172,146 @@ let pseudocodeCompletionProviderRegistered = false; if (typeof window !== "undefined") { void ensureMonacoNullCaretHitTestGuard(); + // Switching models cancels Monaco's in-flight work (hover, occurrences) with a `Canceled` rejection by design. + window.addEventListener("unhandledrejection", (event) => { + const reason = event.reason as { name?: unknown; message?: unknown } | null; + if (reason?.name === "Canceled" && reason.message === "Canceled") event.preventDefault(); + }); } +// Editor surface, gutter, selection and widget colours follow the app theme (see globals.css), so every syntax +// scheme sits on the same workbench. Selected text keeps full contrast whatever its token colour. const EDITOR_SURFACE_COLORS = { dark: { - "editor.background": "#1C1C1E", - "editor.foreground": "#E5E5EA", - "editorLineNumber.foreground": "#48484A", - "editorLineNumber.activeForeground": "#98989D", - "editor.lineHighlightBackground": "#2C2C2E", - "editorCursor.foreground": "#E5E5EA", - "editor.selectionBackground": "#0A84FF30", - "editor.inactiveSelectionBackground": "#38383A", - "editorIndentGuide.background1": "#38383A", - "editorIndentGuide.activeBackground1": "#48484A", - "editorWidget.background": "#2C2C2E", - "editorSuggestWidget.background": "#2C2C2E", - "editorSuggestWidget.selectedBackground": "#0A84FF30", + "editor.background": "#16171B", + "editor.foreground": "#E3E5EC", + "editorLineNumber.foreground": "#7D8293", + "editorLineNumber.activeForeground": "#ECEDF2", + "editor.lineHighlightBackground": "#1D1E24", + "editor.lineHighlightBorder": "#00000000", + "editorCursor.foreground": "#7FD6B0", + "editor.selectionBackground": "#5BC49A3D", + // Selected text keeps full contrast whatever its token colour (comments would otherwise drop below 4.5:1). + "editor.selectionForeground": "#E3E5EC", + "editor.inactiveSelectionBackground": "#5BC49A22", + "editor.selectionHighlightBackground": "#5BC49A1F", + "editor.wordHighlightBackground": "#5BC49A1A", + "editor.findMatchBackground": "#F2B45A55", + "editor.findMatchHighlightBackground": "#F2B45A2A", + "editorBracketMatch.background": "#5BC49A2A", + "editorBracketMatch.border": "#5BC49A80", + "editorIndentGuide.background1": "#2B2D35", + "editorIndentGuide.activeBackground1": "#3A3D48", + "editorWhitespace.foreground": "#3A3D48", + "editorError.foreground": "#FF6B63", + "editorWarning.foreground": "#F2B45A", + "editorInfo.foreground": "#5BC49A", + "editorOverviewRuler.border": "#00000000", + "scrollbarSlider.background": "#3537424D", + "scrollbarSlider.hoverBackground": "#353742AA", + "scrollbarSlider.activeBackground": "#3A3D48", + "editorWidget.background": "#202128", + "editorWidget.border": "#3A3D48", + "editorHoverWidget.background": "#202128", + "editorHoverWidget.border": "#3A3D48", + "editorSuggestWidget.background": "#202128", + "editorSuggestWidget.border": "#3A3D48", + "editorSuggestWidget.selectedBackground": "#5BC49A2E", + "editorSuggestWidget.highlightForeground": "#7FD6B0", + "list.hoverBackground": "#292B33", + "quickInput.background": "#202128", + "quickInputList.focusBackground": "#5BC49A2E", + "quickInputList.focusForeground": "#ECEDF2", + "editorSuggestWidget.selectedForeground": "#ECEDF2", + "list.highlightForeground": "#7FD6B0", + "list.focusHighlightForeground": "#7FD6B0", + "editorBracketHighlight.foreground1": "#9DA2B3", + "editorBracketHighlight.foreground2": "#9DA2B3", + "editorBracketHighlight.foreground3": "#9DA2B3", + "editorBracketHighlight.foreground4": "#9DA2B3", + "editorBracketHighlight.foreground5": "#9DA2B3", + "editorBracketHighlight.foreground6": "#9DA2B3", + "input.background": "#16171B", + "input.border": "#3A3D48", + "focusBorder": "#5BC49A", + "widget.shadow": "#00000066", + "foreground": "#ECEDF2", + "descriptionForeground": "#8A8FA0", + "editor.rangeHighlightBackground": "#5BC49A12", + "textLink.foreground": "#5BC49A", + "textLink.activeForeground": "#7FD6B0", + "editorHoverWidget.statusBarBackground": "#1B1C21", + "keybindingLabel.background": "#292B33", + "keybindingLabel.border": "#3A3D48", + "keybindingLabel.bottomBorder": "#3A3D48", + "keybindingLabel.foreground": "#A9ADBB", + "pickerGroup.foreground": "#5BC49A", + "pickerGroup.border": "#2B2D35", }, light: { "editor.background": "#FFFFFF", - "editor.foreground": "#111111", - "editorLineNumber.foreground": "#CFCFCF", - "editorLineNumber.activeForeground": "#6B7280", - "editor.lineHighlightBackground": "#F5F5F5", - "editorCursor.foreground": "#111111", - "editor.selectionBackground": "#0B6E4F2A", - "editor.inactiveSelectionBackground": "#E5E5E5", - "editorIndentGuide.background1": "#E5E5E5", - "editorIndentGuide.activeBackground1": "#D1D5DB", + "editor.foreground": "#1C1F26", + "editorLineNumber.foreground": "#6E7383", + "editorLineNumber.activeForeground": "#15171C", + "editor.lineHighlightBackground": "#F5F6F9", + "editor.lineHighlightBorder": "#00000000", + "editorCursor.foreground": "#085A40", + "editor.selectionBackground": "#0B6E4F2E", + "editor.selectionForeground": "#1C1F26", + "editor.inactiveSelectionBackground": "#0B6E4F1A", + "editor.selectionHighlightBackground": "#0B6E4F1A", + "editor.wordHighlightBackground": "#0B6E4F14", + "editor.findMatchBackground": "#E0A73A66", + "editor.findMatchHighlightBackground": "#E0A73A33", + "editorBracketMatch.background": "#0B6E4F1F", + "editorBracketMatch.border": "#0B6E4F66", + "editorIndentGuide.background1": "#E3E6EC", + "editorIndentGuide.activeBackground1": "#C9CED8", + "editorWhitespace.foreground": "#D2D6DF", + "editorError.foreground": "#C2362F", + "editorWarning.foreground": "#9A5800", + "editorInfo.foreground": "#0B6E4F", + "editorOverviewRuler.border": "#00000000", + "scrollbarSlider.background": "#9AA1B040", + "scrollbarSlider.hoverBackground": "#9AA1B080", + "scrollbarSlider.activeBackground": "#9AA1B0AA", "editorWidget.background": "#FFFFFF", + "editorWidget.border": "#D2D6DF", + "editorHoverWidget.background": "#FFFFFF", + "editorHoverWidget.border": "#D2D6DF", "editorSuggestWidget.background": "#FFFFFF", - "editorSuggestWidget.selectedBackground": "#0B6E4F1F", + "editorSuggestWidget.border": "#D2D6DF", + "editorSuggestWidget.selectedBackground": "#0B6E4F1C", + "editorSuggestWidget.highlightForeground": "#085A40", + "list.hoverBackground": "#EEF0F4", + "quickInput.background": "#FFFFFF", + "quickInputList.focusBackground": "#0B6E4F1C", + "quickInputList.focusForeground": "#15171C", + "editorSuggestWidget.selectedForeground": "#15171C", + "list.highlightForeground": "#085A40", + "list.focusHighlightForeground": "#085A40", + "editorBracketHighlight.foreground1": "#5A6070", + "editorBracketHighlight.foreground2": "#5A6070", + "editorBracketHighlight.foreground3": "#5A6070", + "editorBracketHighlight.foreground4": "#5A6070", + "editorBracketHighlight.foreground5": "#5A6070", + "editorBracketHighlight.foreground6": "#5A6070", + "input.background": "#FFFFFF", + "input.border": "#D2D6DF", + "focusBorder": "#0B6E4F", + "widget.shadow": "#15171C26", + "foreground": "#15171C", + "descriptionForeground": "#626979", + "editor.rangeHighlightBackground": "#0B6E4F0D", + "textLink.foreground": "#0B6E4F", + "textLink.activeForeground": "#085A40", + "editorHoverWidget.statusBarBackground": "#F7F8FA", + "keybindingLabel.background": "#EEF0F4", + "keybindingLabel.border": "#D2D6DF", + "keybindingLabel.bottomBorder": "#D2D6DF", + "keybindingLabel.foreground": "#4A5060", + "pickerGroup.foreground": "#0B6E4F", + "pickerGroup.border": "#E3E6EC", }, } as const; @@ -199,12 +325,14 @@ function defineSyntaxThemes(monaco: typeof import("monaco-editor")) { base: mode === "dark" ? "vs-dark" : "vs", inherit: true, rules: [ + { token: "", foreground: hex(colors.identifier) }, { token: "keyword", foreground: hex(colors.keyword), fontStyle: scheme.boldKeywords ? "bold" : "" }, { token: "type", foreground: hex(colors.type) }, { token: "predefined", foreground: hex(colors.routine) }, { token: "string", foreground: hex(colors.string) }, { token: "comment", foreground: hex(colors.comment), fontStyle: "italic" }, { token: "number", foreground: hex(colors.number) }, + { token: "number.float", foreground: hex(colors.number) }, { token: "operator", foreground: hex(colors.operator) }, { token: "identifier", foreground: hex(colors.identifier) }, { token: "delimiter", foreground: hex(colors.operator) }, @@ -223,7 +351,13 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({ documentKey, syntaxId = "cambridge-igcse", colorScheme = DEFAULT_SYNTAX_COLOR_SCHEME, + commands, + apiRef, }: MonacoPseudocodeEditorProps) { + const commandsRef = useRef(commands); + useEffect(() => { + commandsRef.current = commands; + }, [commands]); const autocomplete = useDictionary().editor.autocomplete; useEffect(() => { autocompleteText = autocomplete; @@ -239,7 +373,13 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({ const themeName = monacoThemeName(colorScheme, theme); const themeNameRef = useRef(themeName); const appleTouchDevice = useMemo(() => isAppleTouchDevice(typeof navigator === "undefined" ? undefined : navigator), []); - const editorOptions = useMemo(() => getPseudocodeEditorOptions(appleTouchDevice), [appleTouchDevice]); + const [touchScreen] = useState( + () => appleTouchDevice || (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches === true), + ); + const editorOptions = useMemo( + () => getPseudocodeEditorOptions(appleTouchDevice, touchScreen), + [appleTouchDevice, touchScreen], + ); const handleBeforeMount: BeforeMount = () => { void ensureMonacoNullCaretHitTestGuard(); @@ -251,7 +391,9 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({ startColumn: diagnostic.column, endLineNumber: diagnostic.endLine, endColumn: Math.max(diagnostic.endColumn + 1, diagnostic.column + 1), - message: `${diagnostic.code}: ${diagnostic.message}${diagnostic.hint ? `\nHint: ${diagnostic.hint}` : ""}`, + // Message first (as in Problems); the code shows as Monaco's secondary label. + message: `${diagnostic.message}${diagnostic.hint ? `\nHint: ${diagnostic.hint}` : ""}`, + code: diagnostic.code, severity: diagnostic.severity === "error" ? 8 @@ -263,6 +405,7 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({ const handleMount: OnMount = (editor, monaco) => { monacoRef.current = monaco; + registerLoadedMonaco(monaco); editorRef.current = editor; touchFocusCleanupRef.current?.(); touchFocusCleanupRef.current = null; @@ -293,7 +436,12 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({ const keywords = syntaxKeywords(option).map((keyword) => option.keywordCase === "lower" ? keyword : keyword, ); - const pattern = keywords.map((keyword) => keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"); + const escape = (keyword: string) => keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // Data types get their own colour, so they are classified before the general keyword rule. + const typeKeywords = keywords.filter((keyword) => DATA_TYPE_NAMES.has(keyword.toUpperCase())); + const otherKeywords = keywords.filter((keyword) => !DATA_TYPE_NAMES.has(keyword.toUpperCase())); + const pattern = otherKeywords.map(escape).join("|"); + const typePattern = typeKeywords.map(escape).join("|"); monaco.languages.register({ id }); monaco.languages.setMonarchTokensProvider(id, { ignoreCase: option.keywordCase === "any", @@ -301,6 +449,7 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({ root: [ [/#.*$/, "comment"], [/\/\/.*$/, "comment"], + ...(typePattern ? [[new RegExp(`\\b(${typePattern})\\b`), "type"] as [RegExp, string]] : []), [new RegExp(`\\b(${pattern})\\b`), "keyword"], [/\b[0-9]+\.[0-9]+\b/, "number.float"], [/\b[0-9]+\b/, "number"], @@ -428,6 +577,55 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({ monaco.editor.setModelLanguage(model, languageId); } + for (const command of commandsRef.current ?? []) { + editor.addAction({ + id: `pseudoeditor.${command.id}`, + label: command.label, + keybindings: command.shortcuts.map((shortcut) => toMonacoKeybinding(monaco, shortcut)), + run: () => { + // Look the handler up at run time so the action always calls the current one. + commandsRef.current?.find((candidate) => candidate.id === command.id)?.run(); + }, + }); + } + + const publishCursor = () => { + const position = editor.getPosition(); + const selection = editor.getSelection(); + const activeModel = editor.getModel(); + if (!position) { + return; + } + setEditorCursor({ + line: position.lineNumber, + column: position.column, + selectedCharacters: selection && activeModel ? activeModel.getValueLengthInRange(selection) : 0, + }); + }; + editor.onDidChangeCursorSelection(publishCursor); + editor.onDidChangeModel(publishCursor); + publishCursor(); + + if (apiRef) { + apiRef.current = { + focus: () => editor.focus(), + showsDocument: (documentKey) => editor.getModel()?.uri.toString() === modelPathForDocument(documentKey), + revealPosition: (line, column) => { + editor.setSelection(new monaco.Selection(line, column, line, column)); + editor.revealPositionInCenterIfOutsideViewport({ lineNumber: line, column }); + editor.focus(); + }, + openCommandPalette: () => { + editor.focus(); + void editor.getAction("editor.action.quickCommand")?.run(); + }, + goToLine: () => { + editor.focus(); + void editor.getAction("editor.action.gotoLine")?.run(); + }, + }; + } + const insertQuotePair = (quote: "\"" | "'") => { const model = editor.getModel(); const selection = editor.getSelection(); @@ -477,14 +675,18 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({ editor.onKeyDown((event) => { const browserKey = event.browserEvent.key; - const browserCode = event.browserEvent.code; if (browserKey === "Tab") { lastTabKeydownAt = Date.now(); } + // Match the character typed, not the physical key: on UK, German and other layouts the key labelled + // "Quote" on a US keyboard types something else entirely. + const browserEvent = event.browserEvent; const isQuoteKey = - browserKey === "\"" || - browserKey === "'" || - (browserCode === "Quote" && browserKey !== "Dead"); + (browserKey === "\"" || browserKey === "'") && + !browserEvent.isComposing && + !browserEvent.ctrlKey && + !browserEvent.metaKey && + !browserEvent.altKey; if (isQuoteKey) { const quote = browserKey === "\"" ? "\"" : "'"; const paired = insertQuotePair(quote); @@ -610,8 +812,10 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({ () => () => { touchFocusCleanupRef.current?.(); touchFocusCleanupRef.current = null; + if (apiRef) apiRef.current = null; + setEditorCursor(null); }, - [], + [apiRef], ); useEffect(() => { @@ -623,7 +827,7 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({ return; } monacoRef.current.editor.setModelMarkers(model, "igcse-compiler", markers); - }, [markers]); + }, [markers, documentKey]); useEffect(() => { themeNameRef.current = themeName; @@ -642,12 +846,13 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({ return; } monaco.editor.setModelLanguage(model, languageId); - }, [languageId]); + }, [languageId, documentKey]); return ( onChange(nextValue ?? "")} diff --git a/apps/web/src/app/components/ProblemsList.tsx b/apps/web/src/app/components/ProblemsList.tsx new file mode 100644 index 0000000..17351c3 --- /dev/null +++ b/apps/web/src/app/components/ProblemsList.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { CircleAlert, CircleX, Info } from "lucide-react"; +import type { Diagnostic } from "@/compiler/types"; +import { useDictionary } from "@/i18n/context"; + +interface ProblemsListProps { + diagnostics: Diagnostic[]; + onSelect: (diagnostic: Diagnostic) => void; + /** Larger rows for touch layouts. */ + touch?: boolean; +} + +export function countProblems(diagnostics: Diagnostic[]) { + let errors = 0; + let warnings = 0; + for (const diagnostic of diagnostics) { + if (diagnostic.severity === "error") errors += 1; + else if (diagnostic.severity === "warning") warnings += 1; + } + return { errors, warnings }; +} + +function SeverityIcon({ severity }: { severity: Diagnostic["severity"] }) { + if (severity === "error") return ; + if (severity === "warning") return ; + return ; +} + +/** The Problems view: one row per diagnostic; choosing a row moves the caret to it in the editor. */ +export function ProblemsList({ diagnostics, onSelect, touch = false }: ProblemsListProps) { + const t = useDictionary().editor; + + if (diagnostics.length === 0) { + return

{t.workbench.noProblems}

; + } + + const sorted = [...diagnostics].sort((a, b) => a.line - b.line || a.column - b.column); + + return ( +
    + {sorted.map((diagnostic, index) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/apps/web/src/app/components/PublicHeader.tsx b/apps/web/src/app/components/PublicHeader.tsx index d9a7918..d88ca8a 100644 --- a/apps/web/src/app/components/PublicHeader.tsx +++ b/apps/web/src/app/components/PublicHeader.tsx @@ -1,6 +1,6 @@ import Image from "next/image"; import Link from "next/link"; -import { Play } from "lucide-react"; +import { ArrowRight } from "lucide-react"; import { localePath, type Locale } from "@/i18n/config"; import { getDictionary } from "@/i18n/messages"; import { LocaleSwitcher } from "@/i18n/LocaleSwitcher"; @@ -25,7 +25,7 @@ export function PublicHeader({ locale, active }: PublicHeaderProps) { return (
-
diff --git a/apps/web/src/app/components/PublicUi.tsx b/apps/web/src/app/components/PublicUi.tsx index b4aca2d..1adfa28 100644 --- a/apps/web/src/app/components/PublicUi.tsx +++ b/apps/web/src/app/components/PublicUi.tsx @@ -10,7 +10,7 @@ export const publicListClassName = "divide-y divide-[var(--separator)] border-y border-[var(--separator)]"; export const publicPrimaryButtonClassName = - "inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-[var(--accent)] px-5 text-sm font-semibold text-white transition hover:brightness-110"; + "inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-[var(--accent)] px-5 text-sm font-semibold text-[var(--on-accent)] transition hover:bg-[var(--accent-strong)]"; export const publicSecondaryButtonClassName = "inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-[var(--separator)] bg-[var(--surface2)] px-5 text-sm font-medium text-[var(--text2)] transition hover:bg-[var(--surface3)] hover:text-[var(--text)]"; diff --git a/apps/web/src/app/components/SettingsSelect.tsx b/apps/web/src/app/components/SettingsSelect.tsx index f22884e..ff381c8 100644 --- a/apps/web/src/app/components/SettingsSelect.tsx +++ b/apps/web/src/app/components/SettingsSelect.tsx @@ -12,14 +12,14 @@ export function SettingsSelect({ className = "", children, ...props }: SettingsS ); diff --git a/apps/web/src/app/components/StatusBar.tsx b/apps/web/src/app/components/StatusBar.tsx new file mode 100644 index 0000000..b92ecb2 --- /dev/null +++ b/apps/web/src/app/components/StatusBar.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { CircleAlert, CircleX, LoaderCircle } from "lucide-react"; +import { useDictionary } from "@/i18n/context"; +import { getEditorCursor, getServerEditorCursor, subscribeEditorCursor } from "@/lib/editorCursorStore"; +import { formatShortcut } from "@/lib/shortcuts"; + +export type SaveIndicatorState = + | "saved-local" + | "saved-cloud" + | "saved-device-syncing" + | "unsaved" + | "saving" + | "not-saved" + | "failed"; + +interface StatusBarProps { + errors: number; + warnings: number; + onShowProblems: () => void; + problemsShortcut?: string; + syntaxLabel: string; + onChangeSyntax: () => void; + saveState: SaveIndicatorState; + onSave: () => void; + saveShortcut?: string; + showCursor: boolean; + onGoToLine?: () => void; +} + +function goToLineShortcut() { + return formatShortcut({ key: "g", ctrl: true }); +} + +const itemClassName = + "flex h-full items-center gap-1 rounded px-1.5 text-[var(--text2)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[var(--accent)]"; + +function CursorPosition({ onGoToLine }: { onGoToLine?: () => void }) { + const t = useDictionary().editor; + const cursor = useSyncExternalStore(subscribeEditorCursor, getEditorCursor, getServerEditorCursor); + if (!cursor) return null; + const label = `${t.workbench.lineColumn(cursor.line, cursor.column)}${ + cursor.selectedCharacters > 0 ? ` ${t.workbench.selectedCharacters(cursor.selectedCharacters)}` : "" + }`; + return onGoToLine ? ( + + ) : ( + {label} + ); +} + +/** A compact strip with the things a student needs at a glance: problems, caret position, exam syntax, save state. */ +export function StatusBar({ + errors, + warnings, + onShowProblems, + problemsShortcut, + syntaxLabel, + onChangeSyntax, + saveState, + onSave, + saveShortcut, + showCursor, + onGoToLine, +}: StatusBarProps) { + const t = useDictionary().editor; + const problemsLabel = t.workbench.problemsLabel(errors, warnings); + const saveLabel = { + "saved-local": t.workbench.savedLocal, + "saved-cloud": t.workbench.savedCloud, + "saved-device-syncing": t.workbench.savedDeviceSyncing, + unsaved: t.workbench.unsaved, + saving: t.workbench.savingNow, + "not-saved": t.workbench.notSaved, + failed: t.workbench.saveFailed, + }[saveState]; + const saveTone = + saveState === "failed" + ? "text-[var(--red)]" + : saveState === "unsaved" || saveState === "not-saved" + ? "text-[var(--text2)]" + : "text-[var(--text3)]"; + + return ( +
+ + +
+ {showCursor ? : null} + +
+ ); +} diff --git a/apps/web/src/app/components/WorkspaceSidebar.test.tsx b/apps/web/src/app/components/WorkspaceSidebar.test.tsx index edc15a4..8f3dd2c 100644 --- a/apps/web/src/app/components/WorkspaceSidebar.test.tsx +++ b/apps/web/src/app/components/WorkspaceSidebar.test.tsx @@ -84,10 +84,10 @@ describe("WorkspaceSidebar", () => { const workspace = createWorkspaceFixture(); const props = renderSidebar(workspace); - fireEvent.click(screen.getByRole("button", { name: "Create File" })); + fireEvent.click(screen.getByRole("button", { name: "New file" })); expect(props.onCreateDocument).toHaveBeenCalledWith(workspace.rootFolderId); - fireEvent.click(screen.getByRole("button", { name: "Create Folder" })); + fireEvent.click(screen.getByRole("button", { name: "New folder" })); expect(props.onCreateFolder).toHaveBeenCalledWith(workspace.rootFolderId); }); @@ -350,4 +350,56 @@ describe("WorkspaceSidebar", () => { expect(getExplorerRow("Archive")).toHaveAttribute("draggable", "true"); }); }); + + it("moves the selection with the arrow keys so F2 renames the row the keyboard is on", () => { + const props = renderSidebar(); + const tree = screen.getByRole("tree", { name: "Explorer" }); + fireEvent.click(getExplorerRow("second.pseudo")); + expect(tree).toHaveFocus(); + + fireEvent.keyDown(tree, { key: "ArrowUp" }); + expect(screen.getByRole("treeitem", { name: "main.pseudo" })).toHaveAttribute("aria-selected", "true"); + fireEvent.keyDown(tree, { key: "F2" }); + const mainId = Object.values(props.workspace.nodes).find((node) => node.name === "main.pseudo")?.id; + expect(props.onRenameNode).toHaveBeenLastCalledWith(mainId); + + fireEvent.keyDown(tree, { key: "End" }); + const archive = screen.getByRole("treeitem", { name: "Archive" }); + expect(archive).toHaveAttribute("aria-selected", "true"); + expect(tree).toHaveAttribute("aria-activedescendant", archive.id); + fireEvent.keyDown(tree, { key: "ArrowRight" }); + expect(props.onExpandFolder).toHaveBeenCalledWith("folder-archive"); + }); + + it("extends the selection with Shift+Arrow from a fixed anchor", () => { + renderSidebar(); + const tree = screen.getByRole("tree", { name: "Explorer" }); + fireEvent.click(getExplorerRow("main.pseudo")); + fireEvent.keyDown(tree, { key: "ArrowDown", shiftKey: true }); + fireEvent.keyDown(tree, { key: "ArrowDown", shiftKey: true }); + const selected = screen.getAllByRole("treeitem").filter((row) => row.getAttribute("aria-selected") === "true"); + expect(selected).toHaveLength(3); + }); + + it("opens the context menu from the keyboard and focuses its first item", async () => { + renderSidebar(); + const tree = screen.getByRole("tree", { name: "Explorer" }); + fireEvent.click(getExplorerRow("second.pseudo")); + fireEvent.keyDown(tree, { key: "F10", shiftKey: true }); + const menu = await screen.findByRole("menu"); + await waitFor(() => expect(menu.contains(document.activeElement)).toBe(true)); + fireEvent.keyDown(window, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument()); + expect(tree).toHaveFocus(); + }); + + it("lets Cmd/Ctrl chords such as Run (Cmd+Enter) through to the workspace keymap", () => { + const props = renderSidebar(); + const tree = screen.getByRole("tree", { name: "Explorer" }); + fireEvent.click(getExplorerRow("Archive")); + const event = new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true, cancelable: true }); + tree.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(props.onToggleFolder).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/app/components/WorkspaceSidebar.tsx b/apps/web/src/app/components/WorkspaceSidebar.tsx index bff5331..f0bfbe7 100644 --- a/apps/web/src/app/components/WorkspaceSidebar.tsx +++ b/apps/web/src/app/components/WorkspaceSidebar.tsx @@ -74,20 +74,21 @@ interface SelectionModifierState { const CONTEXT_MENU_WIDTH = 248; const CONTEXT_MENU_HEIGHT = 320; const CONTEXT_MENU_MARGIN = 14; +const deleteKeyLabel = + typeof navigator !== "undefined" && /mac|iphone|ipad/i.test(navigator.platform ?? "") ? "⌫" : "Del"; const EXPLORER_RELEASE_LABEL = `${packageJson.version}-Preview`; const DRAG_EDGE_SCROLL_ZONE = 34; const DRAG_EDGE_SCROLL_STEP = 14; -const contextMenuButtonClassName = - "block w-full appearance-none rounded-lg border-0 bg-transparent px-3 py-2 text-left text-sm transition hover:bg-[var(--hover)] disabled:cursor-not-allowed"; -const contextMenuDangerButtonClassName = - "block w-full appearance-none rounded-lg border-0 bg-transparent px-3 py-2 text-left text-sm transition hover:bg-[var(--danger-hover)] disabled:cursor-not-allowed"; +const contextMenuButtonClassName = "ui-menu-item"; +const contextMenuDangerButtonClassName = "ui-menu-item !text-[var(--red)] hover:!bg-[var(--danger-hover)]"; +// Icons stay neutral; only the file open in the editor takes the accent, matching its tab. function getFileIcon(name: string, isActive: boolean) { const ext = name.split(".").pop()?.toLowerCase(); const color = isActive ? "text-[var(--accent)]" : "text-[var(--text3)]"; if (ext === "pseudo" || ext === "ps") { - return ; + return ; } if (ext === "md" || ext === "txt") { return ; @@ -169,6 +170,9 @@ export function WorkspaceSidebar({ workspace.activeDocumentId ? [workspace.activeDocumentId] : [], ); const [anchorState, setAnchorState] = useState(workspace.activeDocumentId); + const [focusState, setFocusState] = useState(null); + const menuOpenedByKeyboardRef = useRef(false); + const contextMenuRef = useRef(null); const [dropHint, setDropHint] = useState(null); const [contextMenu, setContextMenu] = useState(null); const [supportsNativeDragAndDrop] = useState(() => getInitialNativeDragSupport()); @@ -209,8 +213,14 @@ export function WorkspaceSidebar({ const handleEscape = (event: globalThis.KeyboardEvent) => { if (event.key === "Escape") { setContextMenu(null); + if (menuOpenedByKeyboardRef.current) treeContainerRef.current?.focus(); } }; + if (menuOpenedByKeyboardRef.current) { + window.requestAnimationFrame(() => { + contextMenuRef.current?.querySelector('[role="menuitem"]:not([disabled])')?.focus(); + }); + } window.addEventListener("pointerdown", handleClose); window.addEventListener("scroll", handleClose, true); @@ -222,6 +232,7 @@ export function WorkspaceSidebar({ window.removeEventListener("scroll", handleClose, true); window.removeEventListener("keydown", handleEscape); window.removeEventListener("blur", handleClose); + menuOpenedByKeyboardRef.current = false; }; }, [contextMenu]); @@ -260,6 +271,7 @@ export function WorkspaceSidebar({ const selectSingleNode = (node: WorkspaceNode) => { setSelectionState([node.id]); setAnchorState(node.id); + setFocusState(node.id); if (node.type === "document") { onSelectDocument(node.id); } @@ -354,6 +366,7 @@ export function WorkspaceSidebar({ const handleNodeSelection = (node: WorkspaceNode, modifiers: SelectionModifierState) => { setContextMenu(null); + setFocusState(node.id); if (modifiers.shiftKey && anchorNodeId) { setSelectionState(getRangeSelection(visibleNodeIds, anchorNodeId, node.id)); @@ -376,6 +389,8 @@ export function WorkspaceSidebar({ const handleNodeClick = (event: MouseEvent, node: WorkspaceNode) => { event.stopPropagation(); + // Keep focus on the tree (rows are its active descendants) so arrow keys continue from the clicked row. + treeContainerRef.current?.focus({ preventScroll: true }); if (suppressClickRef.current) { event.preventDefault(); @@ -693,48 +708,129 @@ export function WorkspaceSidebar({ setContextMenu(null); }; - const handleTreeKeyDown = (event: ReactKeyboardEvent) => { - if ((event.key === "Delete" || event.key === "Backspace") && orderedSelection.length > 0) { - event.preventDefault(); - handleDeleteSelection(); - return; - } - - if (orderedSelection.length !== 1) { - return; - } - - const selectedNode = workspace.nodes[orderedSelection[0]]; - if (!selectedNode) { - return; - } + // The keyboard cursor; Shift+Arrow moves it while the anchor stays put, so the range grows from where it began. + const focusedNodeId = + focusState && visibleNodeIds.includes(focusState) + ? focusState + : anchorNodeId && visibleNodeIds.includes(anchorNodeId) + ? anchorNodeId + : (orderedSelection[0] ?? visibleNodeIds[0] ?? null); + + const revealRow = (nodeId: string) => { + window.requestAnimationFrame(() => { + treeContainerRef.current + ?.querySelector(`[data-workspace-row-id="${CSS.escape(nodeId)}"]`) + ?.scrollIntoView({ block: "nearest" }); + }); + }; - if (event.key === "F2") { - event.preventDefault(); - onRenameNode(selectedNode.id); - return; + /** Moves the keyboard cursor to a row: a plain move selects it (Enter opens it); Shift extends the selection. */ + const moveFocusTo = (nodeId: string, extend: boolean) => { + const node = workspace.nodes[nodeId]; + if (!node) return; + setFocusState(nodeId); + if (extend && anchorNodeId) { + setSelectionState(getRangeSelection(visibleNodeIds, anchorNodeId, nodeId)); + } else { + setSelectionState([nodeId]); + setAnchorState(nodeId); } + revealRow(nodeId); + }; - if (event.key === "Enter") { - event.preventDefault(); - handleNodeDoubleClick(selectedNode); - return; - } + const openContextMenuFromKeyboard = (nodeId: string) => { + const node = workspace.nodes[nodeId]; + const row = treeContainerRef.current?.querySelector(`[data-workspace-row-id="${CSS.escape(nodeId)}"]`); + if (!node || !row) return; + const bounds = row.getBoundingClientRect(); + menuOpenedByKeyboardRef.current = true; + openContextMenuForNode(node, bounds.left + 24, bounds.bottom); + }; - if (selectedNode.type !== "folder") { + const handleTreeKeyDown = (event: ReactKeyboardEvent) => { + // Leave Cmd/Ctrl/Alt chords (Run, Save, panel toggles…) to the workspace keymap. + if (event.metaKey || event.ctrlKey || event.altKey) { return; } - + const currentIndex = focusedNodeId ? visibleNodeIds.indexOf(focusedNodeId) : -1; + const focusedNode = focusedNodeId ? workspace.nodes[focusedNodeId] : null; const expandedFolders = new Set(workspace.expandedFolderIds ?? []); - if (event.key === "ArrowRight" && !expandedFolders.has(selectedNode.id)) { - event.preventDefault(); - onExpandFolder(selectedNode.id); - return; - } - if (event.key === "ArrowLeft" && expandedFolders.has(selectedNode.id)) { - event.preventDefault(); - onToggleFolder(selectedNode.id); + switch (event.key) { + case "ArrowDown": + case "ArrowUp": { + event.preventDefault(); + if (visibleNodeIds.length === 0) return; + const delta = event.key === "ArrowDown" ? 1 : -1; + const nextIndex = currentIndex < 0 ? 0 : Math.min(visibleNodeIds.length - 1, Math.max(0, currentIndex + delta)); + const nextId = visibleNodeIds[nextIndex]; + moveFocusTo(nextId, event.shiftKey); + return; + } + case "Home": + case "End": { + event.preventDefault(); + const nextId = event.key === "Home" ? visibleNodeIds[0] : visibleNodeIds[visibleNodeIds.length - 1]; + if (nextId) moveFocusTo(nextId, event.shiftKey); + return; + } + case "ArrowRight": { + if (!focusedNode || focusedNode.type !== "folder") return; + event.preventDefault(); + if (!expandedFolders.has(focusedNode.id)) { + onExpandFolder(focusedNode.id); + return; + } + const firstChild = getChildNodes(workspace, focusedNode.id)[0]; + if (firstChild) moveFocusTo(firstChild.id, false); + return; + } + case "ArrowLeft": { + if (!focusedNode) return; + event.preventDefault(); + if (focusedNode.type === "folder" && expandedFolders.has(focusedNode.id)) { + onToggleFolder(focusedNode.id); + return; + } + if (focusedNode.parentId && focusedNode.parentId !== workspace.rootFolderId) { + moveFocusTo(focusedNode.parentId, false); + } + return; + } + case "Enter": + case " ": { + if (!focusedNode) return; + event.preventDefault(); + handleNodeDoubleClick(focusedNode); + return; + } + case "F2": { + if (!focusedNode || orderedSelection.length > 1) return; + event.preventDefault(); + onRenameNode(focusedNode.id); + return; + } + case "Delete": + case "Backspace": { + if (orderedSelection.length === 0) return; + event.preventDefault(); + handleDeleteSelection(); + return; + } + case "ContextMenu": { + if (!focusedNode) return; + event.preventDefault(); + openContextMenuFromKeyboard(focusedNode.id); + return; + } + case "F10": { + if (!event.shiftKey || !focusedNode) return; + event.preventDefault(); + openContextMenuFromKeyboard(focusedNode.id); + return; + } + default: + return; } }; @@ -869,31 +965,31 @@ export function WorkspaceSidebar({ onClick={() => setContextMenu(null)} > {/* Sidebar Header */} -
+
- {t.files.explorer} +

{t.files.explorer}

@@ -901,13 +997,17 @@ export function WorkspaceSidebar({ {/* File Tree */}
-
+
{flattened.map(({ node, depth }) => { const isActive = node.type === "document" && node.id === workspace.activeDocumentId; const isSelected = selectedNodeSet.has(node.id); @@ -916,12 +1016,18 @@ export function WorkspaceSidebar({ const isDropTarget = dropHint?.nodeId === node.id; return ( -
+
{isDropTarget && dropHint.position === "before" ? (
) : null}
handleDragOver(event, node)} onDrop={(event) => handleDrop(event, node)} - className={`flex h-7 touch-pan-y select-none items-center gap-1.5 rounded-lg transition ${ + className={`flex h-[26px] touch-pan-y select-none items-center gap-1.5 rounded-[var(--radius-control)] transition-colors pointer-coarse:h-11 ${ + node.id === focusedNodeId + ? "group-focus-visible/tree:outline group-focus-visible/tree:outline-1 group-focus-visible/tree:-outline-offset-1 group-focus-visible/tree:outline-[var(--accent)]" + : "" + } ${ isSelected - ? "bg-[var(--selected)]" + ? isActive + ? "bg-[var(--selected-strong)]" + : "bg-[var(--selected)]" : isDropTarget && dropHint?.position === "inside" ? "bg-[var(--selected)]" : "hover:bg-[var(--hover)]" }`} - style={{ paddingLeft: `${depth * 20 + 8}px`, paddingRight: 8 }} + style={{ paddingLeft: `${depth * 16 + 6}px`, paddingRight: 8 }} > {node.type === "folder" ? (
-
- +
+ {EXPLORER_RELEASE_LABEL}
@@ -1029,33 +1143,44 @@ export function WorkspaceSidebar({ {/* Context Menu */} {contextMenu ? (
{ + const items = Array.from( + event.currentTarget.querySelectorAll('[role="menuitem"]:not([disabled])'), + ); + const index = items.indexOf(document.activeElement as HTMLElement); + let next = -1; + if (event.key === "ArrowDown") next = (index + 1) % items.length; + else if (event.key === "ArrowUp") next = (index - 1 + items.length) % items.length; + else if (event.key === "Home") next = 0; + else if (event.key === "End") next = items.length - 1; + else if (event.key === "Tab") { + event.preventDefault(); + setContextMenu(null); + treeContainerRef.current?.focus(); + return; + } + if (next < 0 || items.length === 0) return; + event.preventDefault(); + items[next]?.focus(); + }} + className="ui-menu fixed z-[var(--z-dropdown)] w-[232px]" style={{ left: contextMenu.x, top: contextMenu.y }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > -
-

- {contextMenu.selection.length > 1 - ? t.files.itemsSelected(contextMenu.selection.length) - : contextNode?.type === "folder" - ? t.files.folderActions - : t.files.fileActions} -

-

- {contextMenu.selection.length > 1 ? t.files.batchActions : contextNode?.name} -

-
-
+

+ {contextMenu.selection.length > 1 ? t.files.itemsSelected(contextMenu.selection.length) : contextNode?.name} +

+
{contextMenu.selection.length === 1 && contextNode?.type === "folder" ? ( <> -
+
) : null} {contextMenu.selection.length === 1 ? ( @@ -1097,10 +1220,6 @@ export function WorkspaceSidebar({ role="menuitem" className={contextMenuButtonClassName} disabled={!canMoveContextNodeUp} - style={{ - color: "var(--text)", - opacity: canMoveContextNodeUp ? 1 : 0.38, - }} onClick={() => { if (!contextNode) return; moveByOffset(contextNode, -1); @@ -1114,10 +1233,6 @@ export function WorkspaceSidebar({ role="menuitem" className={contextMenuButtonClassName} disabled={!canMoveContextNodeDown} - style={{ - color: "var(--text)", - opacity: canMoveContextNodeDown ? 1 : 0.38, - }} onClick={() => { if (!contextNode) return; moveByOffset(contextNode, 1); @@ -1132,7 +1247,6 @@ export function WorkspaceSidebar({ type="button" role="menuitem" className={contextMenuButtonClassName} - style={{ color: "var(--text)" }} onClick={() => moveSelectionToTopLevel(contextMenu.selection)} > {t.files.moveTopLevel} @@ -1142,24 +1256,24 @@ export function WorkspaceSidebar({ type="button" role="menuitem" className={contextMenuButtonClassName} - style={{ color: "var(--text)" }} onClick={() => { onRenameNode(contextMenu.nodeId); setContextMenu(null); }} > {t.files.rename} + F2 ) : null} -
+
diff --git a/apps/web/src/app/components/flowchart/FlowchartEditor.tsx b/apps/web/src/app/components/flowchart/FlowchartEditor.tsx index 3661d9f..609f947 100644 --- a/apps/web/src/app/components/flowchart/FlowchartEditor.tsx +++ b/apps/web/src/app/components/flowchart/FlowchartEditor.tsx @@ -618,7 +618,7 @@ function FlowchartEditorInner({ onClick={generatePseudocode} className=" flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2.5 - bg-[var(--accent)] text-sm font-medium text-white transition-opacity hover:opacity-90 + bg-[var(--accent)] text-sm font-medium text-[var(--on-accent)] transition-opacity hover:opacity-90 " > @@ -773,7 +773,7 @@ function FlowchartEditorInner({ rounded-lg border px-3 py-2 text-sm font-medium transition ${ active - ? 'border-[var(--accent)] bg-[var(--accent)] text-white' + ? 'border-[var(--accent)] bg-[var(--accent)] text-[var(--on-accent)]' : 'border-[var(--separator)] bg-[var(--surface)] text-[var(--text2)] hover:text-[var(--text)]' } `} diff --git a/apps/web/src/app/components/flowchart/FlowchartNodes.tsx b/apps/web/src/app/components/flowchart/FlowchartNodes.tsx index ab38bf3..3783456 100644 --- a/apps/web/src/app/components/flowchart/FlowchartNodes.tsx +++ b/apps/web/src/app/components/flowchart/FlowchartNodes.tsx @@ -57,7 +57,7 @@ export const TerminatorNode = memo((props: NodeProps) => { return (
{ return (
- + {t.flowchart.process} {nodeData.label ? ( - {nodeData.label} + {nodeData.label} ) : null}
{statements.length > 0 ? ( -
+
{statements.map((statement, index) => (
- {statement} + {statement}
))}
) : ( -
- +
+ {t.flowchart.processEmpty}
@@ -175,7 +176,7 @@ export const DecisionNode = memo((props: NodeProps) => { width="140" height="140" viewBox="0 0 140 140" - className="absolute inset-0" + className="flowchart-shape absolute inset-0" > { return ( -
- - {isInput ? 'Input' : 'Output'} - - - {content} - + {/* The glow sits on this unclipped wrapper; the clip-path below would cut it off. */} +
+
+ + {isInput ? 'Input' : 'Output'} + + + {content} + +
); @@ -278,7 +282,7 @@ export const SubroutineNode = memo((props: NodeProps) => { return ( -
+
{/* Main rectangle */}
{playing ? : } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 4ccb361..ce765d7 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -122,54 +122,67 @@ U+FFFD; } +/* + * PseudoEditor palette. One green brand accent in both themes (forest green in light, sea green in dark). Running + * code, success and program output use a brighter, separate green so "interactive" and "execute" stay distinct. + * Neutrals are cool and ink-tinted rather than generic greys. + */ :root, :root[data-theme="dark"] { + color-scheme: dark; --font-sans: "Inter"; --font-mono: "Fira Code"; - --bg: #1C1C1E; - --sidebar: #262628; - --titlebar: #323234; - --surface: #2C2C2E; - --surface2: #3A3A3C; - --surface3: #48484A; - --separator: #38383A; - --text: #FFFFFF; - --text2: #AEAEB2; - --text3: #9C9CA1; - --accent: #0A84FF; - --green: #30D158; - --red: #FF453A; - --on-accent: #111111; - --on-green: #111111; - --on-red: #111111; - --orange: #FF9F0A; - --yellow: #FFD60A; - --selected: rgba(10, 132, 255, 0.19); - --hover: rgba(255, 255, 255, 0.04); - --line-num: #98989D; + --bg: #16171B; + --sidebar: #1B1C21; + --titlebar: #1B1C21; + --surface: #202128; + --surface2: #292B33; + --surface3: #353742; + --separator: #2B2D35; + --separator-strong: #3A3D48; + --text: #ECEDF2; + --text2: #A9ADBB; + --text3: #8A8FA0; + --accent: #5BC49A; + --accent-strong: #7FD6B0; + --accent-soft: rgba(91, 196, 154, 0.14); + --green: #3FCB7E; + --red: #FF6B63; + --on-accent: #08251A; + --on-green: #06210F; + --on-red: #2A0605; + --orange: #F2B45A; + --yellow: #F2C14E; + --selected: rgba(91, 196, 154, 0.15); + --selected-strong: rgba(91, 196, 154, 0.24); + --hover: rgba(236, 237, 242, 0.05); + --pressed: rgba(236, 237, 242, 0.08); + --focus-ring: #5BC49A; + --line-num: #7D8293; --traffic-red: #FF5F57; --traffic-yellow: #FEBC2E; --traffic-green: #28C840; - --code-keyword: #FC5FA3; - --code-variable: #67B7A4; - --code-type: #5DD8FF; - --code-string: #FC6A5D; - --code-number: #D0BF69; - --code-operator: #E5E5EA; - --code-comment: #6C7986; - --code-func: #A167E6; - --code-text: #E5E5EA; + --code-keyword: #6FCF9F; + --code-variable: #E3E5EC; + --code-type: #7FB6F0; + --code-string: #E8A585; + --code-number: #E9C46A; + --code-operator: #B8BCC9; + --code-comment: #7F8698; + --code-func: #E2A6DC; + --code-text: #E3E5EC; /* ── Unified Design Tokens ─────────────────────────────────── */ /* Border Radius */ - --radius-xs: 6px; - --radius-sm: 8px; - --radius-md: 12px; - --radius-lg: 14px; - --radius-xl: 16px; - --radius-2xl: 20px; - --radius-3xl: 24px; + /* These names also drive Tailwind's rounded-* utilities, so they follow the 6 / 10 / 14px scale. */ + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 6px; + --radius-lg: 10px; + --radius-xl: 10px; + --radius-2xl: 14px; + --radius-3xl: 14px; --radius-full: 999px; /* Shadows */ @@ -194,7 +207,7 @@ --z-toast: 90; /* Overlays */ - --overlay: rgba(0, 0, 0, 0.55); + --overlay: rgb(0 0 0 / 0.5); --overlay-strong: rgba(0, 0, 0, 0.6); /* Semantic colors */ @@ -203,57 +216,73 @@ /* Flowchart colors */ --flowchart-terminator: var(--green); - --flowchart-process: #8B5CF6; + --flowchart-process: var(--code-type); --flowchart-decision: var(--orange); --flowchart-io: var(--accent); --flowchart-subroutine: var(--accent); } :root[data-theme="light"] { + color-scheme: light; --bg: #FFFFFF; - --sidebar: #FAFAFA; - --titlebar: #FFFFFF; - --surface: #FAFAFA; - --surface2: #F5F5F5; - --surface3: #EDEDED; - --separator: #E5E5E5; - --text: #111111; - --text2: #4B5563; - --text3: #666B73; + --sidebar: #F7F8FA; + --titlebar: #F7F8FA; + --surface: #F7F8FA; + --surface2: #EEF0F4; + --surface3: #E3E6EC; + --separator: #E3E6EC; + --separator-strong: #D2D6DF; + --text: #15171C; + --text2: #4A5060; + --text3: #626979; --accent: #0B6E4F; - --green: #087A48; - --red: #C03B2B; + --accent-strong: #085A40; + --accent-soft: rgba(11, 110, 79, 0.09); + --green: #137A4B; + --red: #C2362F; --on-accent: #FFFFFF; --on-green: #FFFFFF; --on-red: #FFFFFF; - --orange: #B45309; - --yellow: #CA8A04; - --selected: rgba(11, 110, 79, 0.14); - --hover: rgba(17, 17, 17, 0.04); - --line-num: #6E6E73; - --traffic-red: #D95C52; - --traffic-yellow: #D6A33F; - --traffic-green: #55A66F; - --code-keyword: #9B2D63; - --code-variable: #2D7A6D; - --code-type: #0F7A94; - --code-string: #B45438; - --code-number: #8D6A12; - --code-operator: #111111; - --code-comment: #9CA3AF; - --code-func: #7856B3; - --code-text: #111111; - - /* Light theme overrides for semantic tokens */ + --orange: #9A5800; + --yellow: #8A6A00; + --selected: rgba(11, 110, 79, 0.11); + --selected-strong: rgba(11, 110, 79, 0.18); + --hover: rgba(21, 23, 28, 0.045); + --pressed: rgba(21, 23, 28, 0.08); + --focus-ring: #0B6E4F; + --line-num: #6E7383; + --traffic-red: #E0574F; + --traffic-yellow: #E0A73A; + --traffic-green: #3FA35E; + --code-keyword: #0B6E4F; + --code-variable: #1C1F26; + --code-type: #2F5FA8; + --code-string: #A84A2A; + --code-number: #8A6400; + --code-operator: #4A5060; + --code-comment: #697080; + --code-func: #9233AB; + --code-text: #1C1F26; + + /* Light theme overrides for semantic tokens: softer, ink-tinted elevation instead of dark-theme black shadows */ + --shadow-sm: 0 1px 2px rgb(21 23 28 / 0.06), 0 1px 3px rgb(21 23 28 / 0.05); + --shadow-md: 0 4px 12px rgb(21 23 28 / 0.08); + --shadow-lg: 0 10px 28px rgb(21 23 28 / 0.11); + --shadow-xl: 0 16px 40px rgb(21 23 28 / 0.14); + --shadow-context: 0 8px 24px rgb(21 23 28 / 0.12); + --shadow-dropdown: 0 8px 24px rgb(21 23 28 / 0.12); + --shadow-modal: 0 20px 60px rgb(21 23 28 / 0.18); + --shadow-card: 0 1px 2px rgb(21 23 28 / 0.05), 0 4px 12px rgb(21 23 28 / 0.05); + --shadow-card-hover: 0 2px 4px rgb(21 23 28 / 0.06), 0 10px 24px rgb(21 23 28 / 0.08); --shadow-button: 0 0 0 1px rgba(0, 0, 0, 0.06); - --overlay: rgba(0, 0, 0, 0.55); - --overlay-strong: rgba(0, 0, 0, 0.65); + --overlay: rgb(21 23 28 / 0.28); + --overlay-strong: rgb(21 23 28 / 0.4); --danger-hover: rgba(192, 59, 43, 0.12); --surface-ghost: rgba(0, 0, 0, 0.02); /* Flowchart colors */ --flowchart-terminator: var(--green); - --flowchart-process: #7C3AED; + --flowchart-process: var(--code-type); --flowchart-decision: var(--orange); --flowchart-io: var(--accent); --flowchart-subroutine: var(--accent); @@ -362,23 +391,41 @@ code { box-shadow: none !important; } +/* React Flow's minimap defaults to a light grey mask; tie it to the theme. */ +.flowchart-canvas { + --xy-minimap-mask-background-color: color-mix(in srgb, var(--bg) 70%, transparent); + --xy-minimap-mask-stroke-color: var(--accent); + --xy-minimap-background-color: var(--surface); + /* Branch labels (Yes/No) sit on the canvas colour instead of React Flow's white box. */ + --xy-edge-label-background-color: var(--bg); + --xy-edge-label-color: var(--text2); +} + /* Replaying a run: the current block glows, blocks still ahead fade back */ .flowchart-canvas .react-flow__node { - transition: opacity 200ms ease, filter 200ms ease; + transition: opacity 200ms ease; } .flowchart-canvas .react-flow__node.flowchart-run-pending { opacity: 0.38; } -.flowchart-canvas .react-flow__node.flowchart-run-current { +.flowchart-canvas .react-flow__node.flowchart-run-current, +.flowchart-canvas .react-flow__node.flowchart-run-error { z-index: 10 !important; +} + +/* The glow wraps the block's shape only, so labels beside it (a decision's Yes/No) stay sharp. */ +.flowchart-canvas .flowchart-shape { + transition: filter 200ms ease; +} + +.flowchart-canvas .react-flow__node.flowchart-run-current .flowchart-shape { /* A thin outline in the text colour keeps the glow visible on blocks of any colour */ filter: drop-shadow(0 0 1px var(--text)) drop-shadow(0 0 1px var(--text)) drop-shadow(0 0 12px var(--green)); } -.flowchart-canvas .react-flow__node.flowchart-run-error { - z-index: 10 !important; +.flowchart-canvas .react-flow__node.flowchart-run-error .flowchart-shape { filter: drop-shadow(0 0 1px var(--red)) drop-shadow(0 0 1px var(--red)) drop-shadow(0 0 12px var(--red)); } @@ -397,7 +444,8 @@ code { @media (prefers-reduced-motion: reduce) { .flowchart-canvas .react-flow__node, - .flowchart-canvas .react-flow__edge { + .flowchart-canvas .react-flow__edge, + .flowchart-canvas .flowchart-shape { transition: none; } } @@ -554,7 +602,7 @@ code { --ink-2: var(--text2); --ink-3: var(--text3); --line: var(--separator); - --radius: 12px; + --radius: var(--radius-lg); min-height: 100vh; overflow-x: clip; background: var(--bg); @@ -615,7 +663,7 @@ code { align-items: center; justify-content: center; gap: 0.5rem; - border-radius: 0.75rem; + border-radius: var(--radius-control); border: 1px solid var(--separator); background: var(--surface2); padding: 0 1.25rem; @@ -661,7 +709,7 @@ code { .site-chip { display: inline-flex; align-items: center; - border-radius: 0.5rem; + border-radius: var(--radius-control); background: var(--surface2); padding: 0.125rem 0.5rem; font-size: 0.75rem; @@ -670,7 +718,7 @@ code { } .site-nav-link { - border-radius: 0.5rem; + border-radius: var(--radius-control); padding: 0.375rem 0.75rem; font-size: 13px; font-weight: 500; @@ -678,6 +726,14 @@ code { transition: background-color 150ms, color 150ms; } +@media (pointer: coarse) { + .site-nav-link { + display: inline-flex; + min-height: 44px; + align-items: center; + } +} + .site-nav-link:hover { background: var(--hover); color: var(--text); @@ -1492,3 +1548,261 @@ code { min-width: 2rem; padding: 0 0.25rem; } + +/* ── Workbench primitives ───────────────────────────────────────────────────────────────────────────────────── + * One definition per control so every surface of the editor shares size, radius, colour and state behaviour. + * Radius scale: controls 6px, panels and menus 10px, dialogs 14px. Utilities still override these. + */ +:root { + --radius-control: var(--radius-sm); + --radius-panel: var(--radius-lg); + --radius-dialog: var(--radius-2xl); +} + +@layer base { + :where(button, a, [role="tab"], [role="menuitem"], [tabindex]):focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 1px; + } + + * { + scrollbar-width: thin; + scrollbar-color: var(--surface3) transparent; + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } + } +} + +.monaco-editor .monaco-hover { + min-width: 280px; +} + +@layer components { + .ui-icon-btn { + display: inline-flex; + height: 28px; + width: 28px; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: var(--radius-control); + color: var(--text3); + transition: background-color 120ms ease, color 120ms ease; + } + .ui-icon-btn:hover:not(:disabled) { + background: var(--hover); + color: var(--text); + } + .ui-icon-btn:active:not(:disabled) { + background: var(--pressed); + } + /* A switched-on layout toggle is simply brighter; only mode switches (flowchart) take the accent fill. */ + .ui-icon-btn[aria-pressed="true"] { + color: var(--text); + } + .ui-icon-btn[data-accent-when-on][aria-pressed="true"], + .ui-icon-btn[data-accent-when-on][aria-pressed="true"]:hover { + background: var(--accent-soft); + color: var(--accent); + } + .ui-icon-btn:disabled { + cursor: not-allowed; + opacity: 0.45; + } + + .ui-btn { + display: inline-flex; + height: 32px; + flex-shrink: 0; + align-items: center; + justify-content: center; + gap: 6px; + border-radius: var(--radius-control); + border: 1px solid transparent; + padding: 0 12px; + font-size: 13px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; + } + .ui-btn:disabled { + cursor: not-allowed; + opacity: 0.5; + } + .ui-btn:active:not(:disabled) { + transform: translateY(0.5px); + filter: brightness(0.94); + } + .ui-btn-primary { + background: var(--accent); + color: var(--on-accent); + font-weight: 600; + } + .ui-btn-primary:hover:not(:disabled) { + background: var(--accent-strong); + } + .ui-btn-secondary { + border-color: var(--separator-strong); + background: var(--surface2); + color: var(--text); + } + .ui-btn-secondary:hover:not(:disabled) { + background: var(--surface3); + } + .ui-btn-ghost { + color: var(--text2); + } + .ui-btn-ghost:hover:not(:disabled) { + background: var(--hover); + color: var(--text); + } + .ui-btn-danger { + background: var(--red); + color: var(--on-red); + font-weight: 600; + } + .ui-btn-danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--red) 88%, var(--text)); + } + /* Fingers need 44px targets; desktop keeps its compact sizes. */ + @media (pointer: coarse) { + .ui-btn, + .ui-icon-btn { + min-height: 44px; + } + .ui-icon-btn { + min-width: 44px; + } + } + + /* Running code is the one action in green, everywhere it appears. */ + .ui-btn-run { + background: var(--green); + color: var(--on-green); + font-weight: 600; + } + .ui-btn-run:hover:not(:disabled) { + background: color-mix(in srgb, var(--green) 88%, var(--text)); + } + + .ui-input { + height: 34px; + width: 100%; + border-radius: var(--radius-control); + border: 1px solid var(--separator-strong); + background: var(--bg); + padding: 0 10px; + font-size: 13px; + color: var(--text); + outline: none; + transition: border-color 120ms ease, box-shadow 120ms ease; + } + .ui-input:focus, + .ui-input-group:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + } + .ui-input-group { + display: flex; + height: 34px; + width: 100%; + overflow: hidden; + border-radius: var(--radius-control); + border: 1px solid var(--separator-strong); + background: var(--bg); + transition: border-color 120ms ease, box-shadow 120ms ease; + } + + .ui-dialog { + width: calc(100% - 2rem); + border-radius: var(--radius-dialog); + border: 1px solid var(--separator-strong); + background: var(--surface); + padding: 20px; + color: var(--text); + box-shadow: var(--shadow-modal); + } + .ui-dialog-title { + font-size: 16px; + font-weight: 600; + line-height: 1.35; + color: var(--text); + } + .ui-dialog-body { + margin-top: 6px; + font-size: 13px; + line-height: 1.55; + color: var(--text2); + } + .ui-dialog-actions { + margin-top: 20px; + display: flex; + justify-content: flex-end; + gap: 8px; + } + + .ui-menu { + border-radius: var(--radius-panel); + border: 1px solid var(--separator-strong); + background: var(--surface); + padding: 4px; + box-shadow: var(--shadow-dropdown); + } + .ui-menu-item { + display: flex; + width: 100%; + align-items: center; + gap: 8px; + border-radius: var(--radius-control); + padding: 6px 10px; + font-size: 13px; + color: var(--text); + text-align: left; + } + .ui-menu-item:hover:not(:disabled), + .ui-menu-item:focus-visible { + background: var(--selected); + outline: none; + } + .ui-menu-item:disabled { + cursor: not-allowed; + opacity: 0.4; + } + .ui-menu-separator { + margin: 4px 6px; + height: 1px; + background: var(--separator); + } + + /* IDE tab strip: flat tabs; the active one joins the surface below and carries an accent line. */ + .ui-tab { + position: relative; + display: inline-flex; + height: 100%; + align-items: center; + gap: 6px; + color: var(--text3); + transition: color 120ms ease, background-color 120ms ease; + } + .ui-tab:hover { + color: var(--text2); + } + .ui-tab[data-active="true"] { + color: var(--text); + } + .ui-tab[data-active="true"]::after { + content: ""; + position: absolute; + inset: auto 0 0; + height: 2px; + background: var(--accent); + } +} diff --git a/apps/web/src/app/hooks/useWorkspaceSession.ts b/apps/web/src/app/hooks/useWorkspaceSession.ts index 0d5e960..1855bad 100644 --- a/apps/web/src/app/hooks/useWorkspaceSession.ts +++ b/apps/web/src/app/hooks/useWorkspaceSession.ts @@ -36,7 +36,14 @@ import { } from "@pseudoeditor/workspace"; import type { Diagnostic, RunTraceStep } from "@/compiler/types"; import { track } from "@/lib/analytics"; -import { loadWorkspace, saveWorkspace } from "@/lib/storage"; +import { + isAccountUnreachable, + loadWorkspace, + saveDeviceCheckpoint, + saveWorkspace, + takeAccountSyncPending, + takeRecoveredCopies, +} from "@/lib/storage"; import type { WorkspacePersistenceMode } from "@/lib/platform"; import { pseudocodeRuntimeRunner } from "@/runtime/executeRuntime"; import { @@ -49,6 +56,11 @@ const INPUT_REQUEST_ERROR_TEXT = "INPUT requested but no stdin lines remain"; const MAX_INTERACTIVE_INPUTS = 200; const TERMINAL_PROMPT = ">"; const DEFAULT_AUTO_SAVE_DELAY_MS = 5 * 60 * 1000; +const LIVE_DIAGNOSTICS_DELAY_MS = 450; +const GENERIC_RUNTIME_HINT = "Check pseudocode runtime state and input values."; +// Device-only saves are a cheap IndexedDB write: save shortly after typing stops, and at least this often. +const LOCAL_SAVE_IDLE_MS = 1000; +const LOCAL_SAVE_MAX_WAIT_MS = 5000; /** The steps of the last finished run, which the flowchart replays block by block. */ export interface RunTrace { @@ -76,6 +88,10 @@ interface WorkspaceSessionOptions { cloudSyncLoading?: boolean; getCloudAuthToken?: () => Promise; persistenceMode?: WorkspacePersistenceMode; + /** The signed-in account id; enables the device checkpoint of not-yet-synced edits in cloud mode. */ + checkpointOwner?: string | null; + /** Localized name of the folder that receives this device's versions after a sync conflict. */ + recoveredFolderName?: string; } interface PendingTerminalInput { @@ -86,6 +102,10 @@ interface PendingTerminalInput { type SaveRequestSource = "autosave" | "manual"; +function persistenceModeUsesCheckpoint(options: WorkspaceSessionOptions): boolean { + return (options.persistenceMode ?? (options.cloudSyncEnabled ? "cloud" : "local")) === "cloud"; +} + function isInputRequestRuntimeError(stderr: string): boolean { return stderr.includes(INPUT_REQUEST_ERROR_TEXT); } @@ -128,17 +148,40 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes }); const [isRunning, setIsRunning] = useState(false); const [lastRunTrace, setLastRunTrace] = useState(null); + /** The error that stopped the last run, with its location, so Output can link straight to the line. */ + const [lastRunError, setLastRunError] = useState< + (Diagnostic & { documentId: string; source: string; syntaxId: string }) | null + >(null); const runTraceIdRef = useRef(0); const [runningTerminalPanelId, setRunningTerminalPanelId] = useState(null); const [saveError, setSaveError] = useState(null); const [hasPendingSave, setHasPendingSave] = useState(false); const [isSaving, setIsSaving] = useState(false); const [lastSavedAt, setLastSavedAt] = useState(null); + /** Cloud mode: every edit is already in this device's checkpoint, even if the account sync is still pending. */ + const [unsyncedEditsSafeOnDevice, setUnsyncedEditsSafeOnDevice] = useState(false); + const checkpointOwner = persistenceModeUsesCheckpoint(options) ? (options.checkpointOwner ?? null) : null; + const checkpointOwnerRef = useRef(checkpointOwner); + const recoveredFolderNameRef = useRef(options.recoveredFolderName); + useEffect(() => { + recoveredFolderNameRef.current = options.recoveredFolderName; + }, [options.recoveredFolderName]); + const checkpointTimerRef = useRef(null); + const checkpointFirstChangeAtRef = useRef(null); + /** Wall-clock time of the latest workspace change; ties checkpoints and saves to the edits they contain. */ + const lastEditAtRef = useRef(0); + const persistWorkspaceRef = useRef< + ((state: WorkspaceState, requestId: number, source: SaveRequestSource) => Promise) | null + >(null); const [appNotice, setAppNotice] = useState(null); + /** Account-sync situations the page explains (localized) right after loading. */ + const [syncNotice, setSyncNotice] = useState<"account-unreachable" | "recovered-copies" | null>(null); const workspaceRef = useRef(null); const saveTimerRef = useRef(null); const saveRequestIdRef = useRef(0); + const activeSaveCountRef = useRef(0); + const firstUnsavedChangeAtRef = useRef(null); const loadedRef = useRef(false); const getCloudAuthTokenRef = useRef(options.getCloudAuthToken); const pendingInputResolverRef = useRef<((value: string | null) => void) | null>(null); @@ -147,6 +190,31 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes getCloudAuthTokenRef.current = options.getCloudAuthToken; }, [options.getCloudAuthToken]); + useEffect(() => { + checkpointOwnerRef.current = checkpointOwner; + }, [checkpointOwner]); + + /** Writes the latest workspace to the device checkpoint now (cloud mode only). */ + const writeCheckpointNow = useCallback(() => { + if (checkpointTimerRef.current !== null) { + window.clearTimeout(checkpointTimerRef.current); + checkpointTimerRef.current = null; + } + checkpointFirstChangeAtRef.current = null; + const owner = checkpointOwnerRef.current; + const latestWorkspace = workspaceRef.current; + if (!owner || !latestWorkspace) return; + const revision = saveRequestIdRef.current; + void saveDeviceCheckpoint(latestWorkspace, owner, lastEditAtRef.current).then( + () => { + if (saveRequestIdRef.current === revision) setUnsyncedEditsSafeOnDevice(true); + }, + () => { + /* the account sync and unload warning still cover these edits */ + }, + ); + }, []); + const getCurrentCloudAuthToken = useCallback(async () => { return await (getCloudAuthTokenRef.current?.() ?? null); }, []); @@ -173,6 +241,9 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes void loadWorkspace(defaultSource, { ...(getCloudAuthTokenRef.current ? { getAuthToken: getCurrentCloudAuthToken } : {}), mode: persistenceMode, + ...(checkpointOwnerRef.current + ? { checkpointOwner: checkpointOwnerRef.current, recoveredFolderName: recoveredFolderNameRef.current } + : {}), }).then((loadedWorkspace) => { if (cancelled) { return; @@ -182,12 +253,50 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes setHasPendingSave(false); setLastSavedAt(null); loadedRef.current = true; + // Recovered edits (or an account that couldn't be read) still have to reach the account: sync through the + // normal save path so "Unsaved", failures and the leave warning all reflect it until it is acknowledged. + const owner = checkpointOwnerRef.current; + setSyncNotice( + isAccountUnreachable(owner) ? "account-unreachable" : takeRecoveredCopies(owner) ? "recovered-copies" : null, + ); + if (takeAccountSyncPending(owner)) { + // Stamp the recovered snapshot as this tab's latest edit so its acknowledged save clears the checkpoint. + lastEditAtRef.current = Math.max(Date.now(), lastEditAtRef.current + 1); + const requestId = saveRequestIdRef.current + 1; + saveRequestIdRef.current = requestId; + setHasPendingSave(true); + void persistWorkspaceRef.current?.(loadedWorkspace, requestId, "autosave"); + } }); return () => { cancelled = true; + const owner = checkpointOwnerRef.current; + const pendingWorkspace = workspaceRef.current; + if (checkpointTimerRef.current !== null) { + window.clearTimeout(checkpointTimerRef.current); + checkpointTimerRef.current = null; + // Unsynced edits must reach the checkpoint even when no account save is queued (an immediate save may + // already have taken the timer and could still fail). + if (owner && pendingWorkspace && loadedRef.current) { + void saveDeviceCheckpoint(pendingWorkspace, owner, lastEditAtRef.current).catch(() => {}); + } + } if (saveTimerRef.current !== null) { window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + // Leaving the workspace (another page in the app, or a storage-mode change) must not drop the edits + // that were waiting for autosave: write them to the store this workspace was loaded from. + if (pendingWorkspace && loadedRef.current && persistenceMode !== "memory") { + if (owner) void saveDeviceCheckpoint(pendingWorkspace, owner, lastEditAtRef.current).catch(() => {}); + void saveWorkspace(pendingWorkspace, { + ...(getCloudAuthTokenRef.current ? { getAuthToken: getCurrentCloudAuthToken } : {}), + mode: persistenceMode, + ...(owner ? { checkpointOwner: owner, editedAt: lastEditAtRef.current } : {}), + }).catch(() => { + /* best effort; the unload warning covers closing the tab */ + }); + } } const resolver = pendingInputResolverRef.current; if (resolver) { @@ -195,7 +304,7 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes resolver(null); } }; - }, [cloudSyncLoading, defaultSource, getCurrentCloudAuthToken, persistenceMode]); + }, [cloudSyncLoading, defaultSource, getCurrentCloudAuthToken, persistenceMode, checkpointOwner]); const persistWorkspace = useCallback(async ( nextWorkspace: WorkspaceState, @@ -213,14 +322,21 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes return false; } + activeSaveCountRef.current += 1; setIsSaving(true); + // Saves always write the latest workspace, so the latest edit time describes this snapshot. + const snapshotEditedAt = lastEditAtRef.current; try { await saveWorkspace(nextWorkspace, { ...(getCloudAuthTokenRef.current ? { getAuthToken: getCurrentCloudAuthToken } : {}), mode: persistenceMode, + ...(checkpointOwnerRef.current + ? { checkpointOwner: checkpointOwnerRef.current, editedAt: snapshotEditedAt } + : {}), }); if (saveRequestIdRef.current !== requestId) { - return false; + // The write succeeded; newer edits arrived meanwhile and stay flagged as unsaved for the next save. + return true; } setSaveError(null); setHasPendingSave(false); @@ -238,15 +354,23 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes setHasPendingSave(true); return false; } finally { - if (saveRequestIdRef.current === requestId) { + // "Saving" tracks writes in flight, not whether newer edits exist: an edit made during a save must not + // leave the control stuck on "Saving…" (newer edits stay flagged through hasPendingSave instead). + activeSaveCountRef.current = Math.max(0, activeSaveCountRef.current - 1); + if (activeSaveCountRef.current === 0) { setIsSaving(false); } } }, [getCurrentCloudAuthToken, persistenceMode]); + useEffect(() => { + persistWorkspaceRef.current = persistWorkspace; + }, [persistWorkspace]); + const commitWorkspace = useCallback( (nextWorkspace: WorkspaceState, mode: "immediate" | "debounced") => { workspaceRef.current = nextWorkspace; + lastEditAtRef.current = Math.max(Date.now(), lastEditAtRef.current + 1); setWorkspace(nextWorkspace); if (!loadedRef.current) { @@ -268,21 +392,85 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes window.clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } + firstUnsavedChangeAtRef.current = null; + if (checkpointOwnerRef.current) { + setUnsyncedEditsSafeOnDevice(false); + writeCheckpointNow(); + } void persistWorkspace(nextWorkspace, requestId, "autosave"); return; } - if (saveTimerRef.current !== null) { - window.clearTimeout(saveTimerRef.current); - } - saveTimerRef.current = window.setTimeout(() => { + const flush = () => { saveTimerRef.current = null; - void persistWorkspace(nextWorkspace, requestId, "autosave"); - }, autoSaveDelayMs); + firstUnsavedChangeAtRef.current = null; + const latestWorkspace = workspaceRef.current; + if (latestWorkspace) { + void persistWorkspace(latestWorkspace, saveRequestIdRef.current, "autosave"); + } + }; + + if (persistenceMode === "local") { + // Save a second after typing stops, but never let continuous typing hold a save back past the max wait. + const now = Date.now(); + firstUnsavedChangeAtRef.current ??= now; + const untilMaxWait = firstUnsavedChangeAtRef.current + LOCAL_SAVE_MAX_WAIT_MS - now; + if (saveTimerRef.current !== null) window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = window.setTimeout(flush, Math.max(0, Math.min(LOCAL_SAVE_IDLE_MS, untilMaxWait))); + return; + } + + // Account syncing is a network write, so it keeps the chosen interval; meanwhile a device checkpoint on the + // fast local schedule makes sure a crash or closed tab between syncs loses nothing. + setUnsyncedEditsSafeOnDevice(false); + if (checkpointOwnerRef.current) { + const now = Date.now(); + checkpointFirstChangeAtRef.current ??= now; + const untilMaxWait = checkpointFirstChangeAtRef.current + LOCAL_SAVE_MAX_WAIT_MS - now; + if (checkpointTimerRef.current !== null) window.clearTimeout(checkpointTimerRef.current); + checkpointTimerRef.current = window.setTimeout( + writeCheckpointNow, + Math.max(0, Math.min(LOCAL_SAVE_IDLE_MS, untilMaxWait)), + ); + } + + // The account interval is a ceiling, not a debounce: continuous typing must not postpone the sync forever. + // The first unsaved change starts the clock; the save writes the latest state. + if (saveTimerRef.current === null) { + saveTimerRef.current = window.setTimeout(flush, autoSaveDelayMs); + } }, - [autoSaveDelayMs, persistWorkspace, persistenceMode], + [autoSaveDelayMs, persistWorkspace, persistenceMode, writeCheckpointNow], ); + // Save before the page can be discarded: mobile Safari often skips beforeunload for backgrounded tabs. + useEffect(() => { + if (typeof window === "undefined" || persistenceMode === "memory") { + return; + } + const flushPendingSave = () => { + // The checkpoint is a local write, so it lands even when the account request is cut off by the unload. + if (checkpointTimerRef.current !== null) writeCheckpointNow(); + if (saveTimerRef.current === null) return; + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + firstUnsavedChangeAtRef.current = null; + const latestWorkspace = workspaceRef.current; + if (latestWorkspace) { + void persistWorkspace(latestWorkspace, saveRequestIdRef.current, "autosave"); + } + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") flushPendingSave(); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("pagehide", flushPendingSave); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("pagehide", flushPendingSave); + }; + }, [persistWorkspace, persistenceMode, writeCheckpointNow]); + const resolvePendingInput = useCallback((value: string | null) => { const resolver = pendingInputResolverRef.current; if (!resolver) { @@ -333,6 +521,7 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes window.clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } + firstUnsavedChangeAtRef.current = null; const requestId = saveRequestIdRef.current + 1; saveRequestIdRef.current = requestId; @@ -437,6 +626,77 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes [applyWorkspaceUpdate], ); + /** True while the active document still has the source and syntax a compile was started with. */ + const isStillActiveSnapshot = useCallback((documentId: string, source: string, syntaxId: string) => { + const latestWorkspace = workspaceRef.current; + const latestDocument = latestWorkspace ? getActiveDocument(latestWorkspace) : null; + return ( + !!latestWorkspace && + latestDocument?.id === documentId && + latestDocument.source === source && + (latestWorkspace.syntaxId ?? "cambridge-igcse") === syntaxId + ); + }, []); + + const liveDiagnosticsTimerRef = useRef(null); + + /** Checks the active document in the background shortly after typing stops, like an IDE's live diagnostics. */ + const scheduleLiveDiagnostics = useCallback((delayMs = LIVE_DIAGNOSTICS_DELAY_MS) => { + if (typeof window === "undefined") { + return; + } + if (liveDiagnosticsTimerRef.current !== null) { + window.clearTimeout(liveDiagnosticsTimerRef.current); + } + liveDiagnosticsTimerRef.current = window.setTimeout(() => { + liveDiagnosticsTimerRef.current = null; + const currentWorkspace = workspaceRef.current; + const document = currentWorkspace ? getActiveDocument(currentWorkspace) : null; + if (!currentWorkspace || !document) { + return; + } + const syntaxId = currentWorkspace.syntaxId ?? "cambridge-igcse"; + void compilePseudocodeInWorker( + { source: document.source, filename: document.name, strict: true, syntaxId }, + getCompileCacheKey(document.id, document.name, document.source, syntaxId), + { background: true }, + ).then((run) => { + const diagnostics = run?.result?.diagnostics; + const latestWorkspace = workspaceRef.current; + const latestDocument = latestWorkspace ? getActiveDocument(latestWorkspace) : null; + if ( + !latestWorkspace || + latestDocument?.id !== document.id || + latestDocument.source !== document.source || + (latestWorkspace.syntaxId ?? "cambridge-igcse") !== syntaxId || + !diagnostics + ) { + return; + } + setCompileDiagnostics(diagnostics); + }, () => { + // A failed background check leaves the current markers; Run still reports compiler failures. + }); + }, delayMs); + }, []); + + useEffect( + () => () => { + if (liveDiagnosticsTimerRef.current !== null) { + window.clearTimeout(liveDiagnosticsTimerRef.current); + } + }, + [], + ); + + const workspaceSyntaxId = workspace?.syntaxId; + useEffect(() => { + if (!activeDocumentId) { + return; + } + scheduleLiveDiagnostics(0); + }, [activeDocumentId, workspaceSyntaxId, scheduleLiveDiagnostics]); + const compileSource = useCallback(async (workspaceState?: WorkspaceState) => { const currentWorkspace = workspaceState ?? workspaceRef.current; if (!currentWorkspace) { @@ -502,7 +762,9 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes } const { document, result } = payload; - setCompileDiagnostics(result.diagnostics); + if (isStillActiveSnapshot(document.id, document.source, target.workspace.syntaxId ?? "cambridge-igcse")) { + setCompileDiagnostics(result.diagnostics); + } updateWorkspaceCompileSummary(document.id, result.diagnostics); updateTerminalOutput( @@ -519,7 +781,7 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes document, result, }; - }, [compileSource, ensureTerminalTarget, updateTerminalOutput, updateWorkspaceCompileSummary]); + }, [compileSource, ensureTerminalTarget, isStillActiveSnapshot, updateTerminalOutput, updateWorkspaceCompileSummary]); const runNow = useCallback(async () => { const target = ensureTerminalTarget(); @@ -538,13 +800,21 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes } const { document, result: compileResult } = payload; - setCompileDiagnostics(compileResult.diagnostics); + // The student may have switched files or kept typing while this compiled; never show stale markers. + if (isStillActiveSnapshot(document.id, document.source, target.workspace.syntaxId ?? "cambridge-igcse")) { + setCompileDiagnostics(compileResult.diagnostics); + } updateWorkspaceCompileSummary(document.id, compileResult.diagnostics); + setLastRunError(null); if (!compileResult.success || !compileResult.astJson) { track("program_run", { outcome: "compile_error", diagnostics: compileResult.diagnostics.length, inputs: 0 }); - updateTerminalOutput(target.panelId, `Compile failed for ${document.name}.\n\n${formatDiagnostics(compileResult.diagnostics)}`); - return; + const errorCount = compileResult.diagnostics.filter((diagnostic) => diagnostic.severity === "error").length; + updateTerminalOutput( + target.panelId, + `Couldn't run ${document.name}: fix the ${errorCount} problem${errorCount === 1 ? "" : "s"} in Problems first.`, + ); + return "compile_error" as const; } const astJson = compileResult.astJson; @@ -645,18 +915,31 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes inputs: stdinLines.length, }); - if (runResult.stderr.trim().length > 0) { + const reportedError = runResult.diagnostics.find((diagnostic) => diagnostic.severity === "error"); + // The runtime attaches the same generic hint to every error; it adds nothing, so only keep specific hints. + const runtimeError = + reportedError && reportedError.hint === GENERIC_RUNTIME_HINT ? { ...reportedError, hint: undefined } : reportedError; + if (runtimeError) { + // Output shows this as a linked error row; Problems and the editor mark the line until the next edit. + setLastRunError({ + ...runtimeError, + documentId: document.id, + source: document.source, + syntaxId: target.workspace.syntaxId ?? "cambridge-igcse", + }); + } else if (runResult.stderr.trim().length > 0) { transcript.push(...runResult.stderr.trim().split("\n")); } else if (runResult.diagnostics.length > 0) { transcript.push(...formatDiagnostics(runResult.diagnostics).split("\n")); } - if (transcript.length === 0) { + if (transcript.length === 0 && !runtimeError) { transcript.push("Program finished with no output."); } updateTerminalOutput(target.panelId, transcript.join("\n")); applyWorkspaceUpdate((current) => updateVirtualFiles(current, latestVirtualFiles), "immediate"); + return runtimeError ? ("runtime_error" as const) : ("success" as const); } finally { resolvePendingInput(null); setIsRunning(false); @@ -666,6 +949,7 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes applyWorkspaceUpdate, compileSource, ensureTerminalTarget, + isStillActiveSnapshot, resolvePendingInput, updateTerminalOutput, updateWorkspaceCompileSummary, @@ -697,10 +981,11 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes const handleDocumentSourceChange = useCallback( (documentId: string, source: string) => { - setCompileDiagnostics([]); + // Keep the current markers (Monaco moves them with the text) until the live check replaces them. applyWorkspaceUpdate((current) => updateDocumentSource(current, documentId, source), "debounced"); + scheduleLiveDiagnostics(); }, - [applyWorkspaceUpdate], + [applyWorkspaceUpdate, scheduleLiveDiagnostics], ); const toggleFolder = useCallback( @@ -887,6 +1172,7 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes const clearTerminal = useCallback( (panelId?: string) => { + setLastRunError(null); const current = workspaceRef.current; if (!current || !activeDocument) { return; @@ -922,20 +1208,48 @@ export function useWorkspaceSession(defaultSource: string, options: WorkspaceSes })); }, []); + // A runtime error stays marked for as long as the file still has the source it ran with, even across file switches. + const visibleDiagnostics = useMemo(() => { + if ( + !lastRunError || + !activeDocument || + lastRunError.documentId !== activeDocument.id || + lastRunError.source !== activeDocument.source || + lastRunError.syntaxId !== (workspace?.syntaxId ?? "cambridge-igcse") + ) { + return compileDiagnostics; + } + const runtimeDiagnostic: Diagnostic = { + code: lastRunError.code, + message: lastRunError.message, + severity: lastRunError.severity, + line: lastRunError.line, + column: lastRunError.column, + endLine: lastRunError.endLine, + endColumn: lastRunError.endColumn, + ...(lastRunError.hint ? { hint: lastRunError.hint } : {}), + }; + return [...compileDiagnostics, runtimeDiagnostic]; + }, [activeDocument, compileDiagnostics, lastRunError, workspace?.syntaxId]); + return { workspace, activeDocument, breadcrumbs, - compileDiagnostics, + compileDiagnostics: visibleDiagnostics, terminalOutputs, pendingInput, isRunning, lastRunTrace, + lastRunError, runningTerminalPanelId, saveError, hasPendingSave, isSaving, lastSavedAt, + unsyncedEditsSafeOnDevice, + syncNotice, + dismissSyncNotice: () => setSyncNotice(null), appNotice, dismissNotice, setPendingInputText, diff --git a/apps/web/src/i18n/messages/editor.en.ts b/apps/web/src/i18n/messages/editor.en.ts index e6b8412..f69d61e 100644 --- a/apps/web/src/i18n/messages/editor.en.ts +++ b/apps/web/src/i18n/messages/editor.en.ts @@ -11,29 +11,31 @@ export const editorEn = { }, loading: { editor: "Loading editor", flowchart: "Loading flowchart", workspace: "Loading workspace…", workspaceEyebrow: "Workspace", preparing: "Preparing the editor layout and runtime panels." }, settings: { - examBoard: "EXAM BOARD", syntax: "Syntax", examSyntax: "Exam syntax", + examBoard: "Exam board", syntax: "Syntax", examSyntax: "Exam syntax", syntaxDescription: "Choose the pseudocode notation the compiler, highlighter, and completions should follow.", - appearance: "APPEARANCE", theme: "Theme", themeDescription: "Choose how the compiler shell should look on this device.", + appearance: "Appearance", theme: "Theme", themeDescription: "Choose how the editor looks on this device.", system: "System", systemDescription: "Follow the operating system appearance automatically.", dark: "Dark", darkDescription: "Use the current graphite shell and dark editor palette.", light: "Light", lightDescription: "Switch to the warm paper light theme across the app.", activeAppearance: "Active appearance:", syntaxColors: "Syntax colours", syntaxColorsDescription: "Pick the colours the editor uses for keywords, strings, numbers, and comments.", - syntaxColorSchemes: { classic: "Classic", ocean: "Ocean", forest: "Forest", ember: "Ember", monochrome: "Monochrome", "high-contrast": "High contrast" }, - saving: "SAVING", autosave: "Autosave", autosaveInterval: "Autosave interval", + syntaxColorSchemes: { pseudoeditor: "PseudoEditor", classic: "Classic", ocean: "Ocean", forest: "Forest", ember: "Ember", monochrome: "Monochrome", "high-contrast": "High contrast" }, + saving: "Saving", autosave: "Autosave", autosaveInterval: "Autosave interval", autosaveIntervalMinutes: "Autosave interval minutes", minute: (count: number) => `${count} minute${count !== 1 ? "s" : ""}`, - autosaveEvery: (count: number) => `Automatically save your workspace every ${count} minute${count !== 1 ? "s" : ""}.`, - general: "General", heading: "SETTINGS", touchDescription: "Open the docs, review the language guide, and keep the workspace controls one tap away.", + autosaveEvery: (count: number) => `Saves to your account every ${count} minute${count !== 1 ? "s" : ""}, and whenever you leave or switch away from this page.`, + autosaveLocal: "Your work saves to this device about a second after you stop typing.", + autosaveMemory: "Sign in to save your work. Until then, it is kept only while this tab stays open.", + general: "General", heading: "Settings", touchDescription: "Open the docs, review the language guide, and keep the workspace controls one tap away.", }, files: { - explorer: "Explorer", file: "File", folder: "Folder", createFile: "Create File", createFolder: "Create Folder", - newFileHere: "New File Here", newFolderHere: "New Folder Here", moveUp: "Move Up", moveDown: "Move Down", - moveTopLevel: "Move to Top Level", rename: "Rename", explorerActions: "Explorer actions", + explorer: "Explorer", file: "File", folder: "Folder", createFile: "New file", createFolder: "New folder", + newFileHere: "New file", newFolderHere: "New folder", moveUp: "Move up", moveDown: "Move down", + moveTopLevel: "Move to top level", rename: "Rename", explorerActions: "Explorer actions", itemsSelected: (count: number) => `${count} items selected`, folderActions: "Folder actions", fileActions: "File actions", batchActions: "Batch actions", - collapseFolder: "Collapse Folder", expandFolder: "Expand Folder", deleteItems: (count: number) => `Delete ${count} items`, - noFileSelected: "No file selected", createAFile: "Create a file", fileName: "File name", itemName: "Item name", - createNewFile: "Create New File", createAndOpen: "Create & Open", renameItem: "Rename Item", currentName: "Current name:", - saveName: "Save Name", confirmDelete: "Confirm Delete", deleteOne: (name: string) => `Delete "${name}"?`, + collapseFolder: "Collapse", expandFolder: "Expand", deleteItems: (count: number) => `Delete ${count} items`, + noFileSelected: "No file selected", createAFile: "Create a file", fileName: "File name", itemName: "Name", + createNewFile: "New file", createAndOpen: "Create and open", renameItem: "Rename", currentName: "Current name:", + saveName: "Rename", confirmDelete: "Delete", deleteOne: (name: string) => `Delete "${name}"?`, cannotUndo: "This can't be undone.", create: "Create", deleteMany: (count: number) => `Delete ${count} selected items?`, closeDocument: (name: string) => `Close ${name}`, }, terminal: { @@ -82,13 +84,36 @@ export const editorEn = { saveWorkspace: "Save workspace", saving: "Saving", saved: "Saved", failed: "Save failed", failedTitle: "Save failed. Check the message bar for details.", cloudTitle: "Save workspace to cloud storage", localTitle: "Save workspace locally on this device", signInTitle: "Sign in to save in the browser", + failedKeptManual: "Save failed. Changes remain on this device.", + failedKeptAuto: "Autosave failed. Changes remain on this device.", leaveWarning: "Save or export your work before leaving or refreshing this page.", }, - empty: { welcome: "Welcome to PseudoEditor", description: "This workspace starts empty on purpose. Add a pseudocode file from the explorer, then write, compile, and run from there." }, + empty: { welcome: "Start writing pseudocode", description: "Create a file, write your algorithm, and press Run to see it work.", shortcuts: "Keyboard shortcuts", openDocs: "Read the pseudocode guide", practice: "Try a practice question" }, docs: { title: "Docs" }, breadcrumbs: { documentPath: "Document path" }, legal: { userAgreement: "User agreement", privacy: "Privacy", security: "Security", github: "GitHub", notice: (year: number) => `© ${year} Alex Xin LIU. Licensed under the Apache License 2.0.` }, - touchTabs: { editor: "EDITOR", files: "FILES", output: "OUTPUT", settings: "SETTINGS" }, + workbench: { + sections: "Workspace sections", toggleExplorer: "Toggle explorer", showExplorer: "Show explorer", hideExplorer: "Hide explorer", + togglePanel: "Toggle output panel", showPanel: "Show output panel", hidePanel: "Hide output panel", + commandPalette: "Show all commands", newFile: "New file", newFolder: "New folder", showProblems: "Show problems", + problems: "Problems", noProblems: "No problems found in this file.", problemsLabel: (errors: number, warnings: number) => + `${errors} error${errors !== 1 ? "s" : ""}, ${warnings} warning${warnings !== 1 ? "s" : ""}`, + goToProblem: (line: number, column: number) => `Go to line ${line}, column ${column}`, + lineColumn: (line: number, column: number) => `Ln ${line}, Col ${column}`, selectedCharacters: (count: number) => `(${count} selected)`, + goToLine: "Go to line", changeSyntax: "Change exam syntax", panelTabs: "Panel views", statusBar: "Status bar", + unsaved: "Unsaved changes", savedLocal: "Saved on this device", savedCloud: "Saved to your account", savedDeviceSyncing: "Saved on this device; will sync to your account", savedDeviceSyncingShort: "On this device · sync pending", notSaved: "Not saved: sign in to keep your work", + savingNow: "Saving…", saveFailed: "Save failed", moreActions: "More actions", + severity: { error: "Error", warning: "Warning", info: "Info" }, + runtimeErrorOnLine: (line: number) => `Runtime error on line ${line}:`, + runtimeErrorInFile: (file: string, line: number) => `Runtime error in ${file}, line ${line}:`, + runawayLoop: "The program ran for too many steps and was stopped. Is there a loop that never ends?", + // Command palette (F1) entries, in title case to match the editor's built-in commands. + commands: { + run: "Run Program", save: "Save Workspace", toggleExplorer: "Toggle Explorer", togglePanel: "Toggle Output Panel", + problems: "Show Problems", settings: "Open Settings", newFile: "New File", newFolder: "New Folder", commandPalette: "Show All Commands", + }, + }, + touchTabs: { editor: "Editor", files: "Files", output: "Output", settings: "Settings" }, sync: { cloudLoadFailed: "Couldn't reach your cloud workspace. Your cloud copy hasn't been changed. Reload to try again.", storageUnavailable: "This browser is blocking storage, so changes are kept only while this tab stays open.", @@ -99,6 +124,10 @@ export const editorEn = { conflict: "This workspace also changed in another tab or on another device. Your version is kept, and files that differ in the other version were copied into the \"Cloud copy\" folder.", conflictFolder: "Cloud copy", guestFolder: "Guest", + accountUnreachable: "Couldn't reach your account, so your work is kept on this device for now. Choose Retry to sync it.", + retry: "Retry", + recoveredCopies: "Some changes saved only on this device couldn't be matched to your account's latest version. Your account's files are unchanged, and this device's versions are in the \"From this device\" folder so nothing is lost.", + recoveredFolder: "From this device", guestImported: "Files you wrote before signing in were added to the \"Guest\" folder.", }, }; diff --git a/apps/web/src/i18n/messages/editor.zh.ts b/apps/web/src/i18n/messages/editor.zh.ts index 0ab91ce..2bc6882 100644 --- a/apps/web/src/i18n/messages/editor.zh.ts +++ b/apps/web/src/i18n/messages/editor.zh.ts @@ -4,17 +4,18 @@ export const editorZh: typeof editorEn = { common: { cancel: "取消", close: "关闭", delete: "删除", dismiss: "忽略" }, toolbar: { editor: "编辑器", run: "运行", running: "运行中", docs: "文档", openDocs: "打开文档", practice: "练习", openPractice: "练习一道随机题", settings: "设置", openSettings: "打开设置", hideSidebar: "隐藏侧边栏", showSidebar: "显示侧边栏", openFiles: "打开文件", backToEditor: "返回编辑器", switchToCode: "切换到代码视图", switchToFlowchart: "切换到流程图视图", home: "PseudoEditor 首页" }, loading: { editor: "正在加载编辑器", flowchart: "正在加载流程图", workspace: "正在加载工作区…", workspaceEyebrow: "工作区", preparing: "正在准备编辑器布局和运行面板。" }, - settings: { examBoard: "考试局", syntax: "语法", examSyntax: "考试语法", syntaxDescription: "选择编译器、语法高亮和自动补全所遵循的伪代码写法。", appearance: "外观", theme: "主题", themeDescription: "选择编译器界面在此设备上的外观。", system: "跟随系统", systemDescription: "自动跟随操作系统外观。", dark: "深色", darkDescription: "使用石墨色界面和深色编辑器。", light: "浅色", lightDescription: "在整个应用中使用暖纸色浅色主题。", activeAppearance: "当前外观:", syntaxColors: "语法配色", syntaxColorsDescription: "选择编辑器中关键字、字符串、数字和注释的颜色。", syntaxColorSchemes: { classic: "经典", ocean: "海洋", forest: "森林", ember: "余烬", monochrome: "黑白", "high-contrast": "高对比度" }, saving: "保存", autosave: "自动保存", autosaveInterval: "自动保存间隔", autosaveIntervalMinutes: "自动保存间隔(分钟)", minute: (count) => `${count} 分钟`, autosaveEvery: (count) => `每 ${count} 分钟自动保存工作区。`, general: "通用", heading: "设置", touchDescription: "打开文档、查看语言指南,并快速使用工作区控制项。" }, - files: { explorer: "文件浏览器", file: "文件", folder: "文件夹", createFile: "新建文件", createFolder: "新建文件夹", newFileHere: "在此新建文件", newFolderHere: "在此新建文件夹", moveUp: "上移", moveDown: "下移", moveTopLevel: "移至顶层", rename: "重命名", explorerActions: "文件浏览器操作", itemsSelected: (count) => `已选择 ${count} 个项目`, folderActions: "文件夹操作", fileActions: "文件操作", batchActions: "批量操作", collapseFolder: "折叠文件夹", expandFolder: "展开文件夹", deleteItems: (count) => `删除 ${count} 个项目`, noFileSelected: "未选择文件", createAFile: "新建文件", fileName: "文件名", itemName: "名称", createNewFile: "新建文件", createAndOpen: "创建并打开", renameItem: "重命名", currentName: "当前名称:", saveName: "保存名称", confirmDelete: "确认删除", deleteOne: (name) => `删除“${name}”?`, deleteMany: (count) => `删除选中的 ${count} 个项目?`, closeDocument: (name) => `关闭 ${name}` }, + settings: { examBoard: "考试局", syntax: "语法", examSyntax: "考试语法", syntaxDescription: "选择编译器、语法高亮和自动补全所遵循的伪代码写法。", appearance: "外观", theme: "主题", themeDescription: "选择编辑器在此设备上的外观。", system: "跟随系统", systemDescription: "自动跟随操作系统外观。", dark: "深色", darkDescription: "使用石墨色界面和深色编辑器。", light: "浅色", lightDescription: "在整个应用中使用暖纸色浅色主题。", activeAppearance: "当前外观:", syntaxColors: "语法配色", syntaxColorsDescription: "选择编辑器中关键字、字符串、数字和注释的颜色。", syntaxColorSchemes: { pseudoeditor: "PseudoEditor", classic: "经典", ocean: "海洋", forest: "森林", ember: "余烬", monochrome: "黑白", "high-contrast": "高对比度" }, saving: "保存", autosave: "自动保存", autosaveInterval: "自动保存间隔", autosaveIntervalMinutes: "自动保存间隔(分钟)", minute: (count) => `${count} 分钟`, autosaveEvery: (count) => `每 ${count} 分钟保存到你的账户,离开或切换页面时也会保存。`, autosaveLocal: "停止输入约一秒后,作品会自动保存到此设备。", autosaveMemory: "登录后即可保存作品。在此之前,作品只在此标签页打开期间保留。", general: "通用", heading: "设置", touchDescription: "打开文档、查看语言指南,并快速使用工作区控制项。" }, + files: { explorer: "文件浏览器", file: "文件", folder: "文件夹", createFile: "新建文件", createFolder: "新建文件夹", newFileHere: "在此新建文件", newFolderHere: "在此新建文件夹", moveUp: "上移", moveDown: "下移", moveTopLevel: "移至顶层", rename: "重命名", explorerActions: "文件浏览器操作", itemsSelected: (count) => `已选择 ${count} 个项目`, folderActions: "文件夹操作", fileActions: "文件操作", batchActions: "批量操作", collapseFolder: "折叠文件夹", expandFolder: "展开文件夹", deleteItems: (count) => `删除 ${count} 个项目`, noFileSelected: "未选择文件", createAFile: "新建文件", fileName: "文件名", itemName: "名称", createNewFile: "新建文件", createAndOpen: "创建并打开", renameItem: "重命名", currentName: "当前名称:", saveName: "重命名", confirmDelete: "确认删除", deleteOne: (name) => `删除“${name}”?`, cannotUndo: "此操作无法撤销。", create: "创建", deleteMany: (count) => `删除选中的 ${count} 个项目?`, closeDocument: (name) => `关闭 ${name}` }, terminal: { output: "输出", terminal: "终端", ready: "就绪", clearOutput: "清空输出", close: (title) => `关闭${title}`, closeTerminal: "关闭终端", input: "终端输入", inputPlaceholder: "在此输入并按 Enter", send: "发送", running: "运行中…", resizeOutput: "调整输出区域大小", resizeTerminal: "调整终端大小", resizeSidebar: "调整侧边栏大小" }, diagnostics: { hint: "提示:" }, autocomplete: { integerQuotient: "整数商", integerRemainder: "整数余数", stringLength: "字符串长度", lowerCase: "转换为小写", upperCase: "转换为大写", substring: "截取字符串", round: "实数四舍五入", random: "0 到 1 之间的随机数(含端点)", printAlias: "PRINT(别名)", outputAlias: "OUTPUT 的别名", quickStarter: "快速开始" }, flowchart: { name: "流程图", yes: "是", no: "否", createFirst: "请先创建文件", createFirstDescription: "流程图与伪代码文件关联。新建文件并命名后即可开始绘制流程图。", blocks: "组件", dragOntoFlow: "拖到流程中", generateCode: "生成代码", clearCanvas: "清空画布", confirmClearCanvas: "再次点击以清空", hidePalette: "隐藏组件栏", showPalette: "显示组件栏", inspector: "检查器", inspectorSelected: "编辑所选组件及其中显示的内容。", inspectorEmpty: "选择一个组件,以配置输入、输出或处理组件中的各行内容。", selected: "已选择", label: "标签", mode: "模式", input: "输入", output: "输出", variable: "变量", valueExpression: "值或表达式", blockTitle: "组件标题", blockLines: "组件内容", addLine: "添加一行", line: (number) => `第 ${number} 行`, removeLine: (number) => `删除第 ${number} 行`, condition: "条件", sideBranch: "侧分支", downBranch: "下方分支", callTarget: "调用目标", deleteSelected: "删除所选组件", buildLikeBlocks: "像搭积木一样构建。", helpIo: "拖入输入或输出组件,选中后设置它要读取或显示的文本。", helpProcess: "在处理组件中可以添加一行或多行伪代码。每次修改都会立即同步到代码中。", helpRun: "点击运行后,程序每执行到一个组件,流程图就会点亮它。可以前后单步查看,也可以自动播放。", process: "处理", processEmpty: "选择此组件并在其中添加内容。", until: "直到", routineHeader: "首行", run: { label: "程序运行", stepOf: (step, total) => `第 ${step} / ${total} 步`, first: "第一步", previous: "上一步", play: "播放", pause: "暂停", next: "下一步", last: "最后一步", speed: (speed) => `${speed} 倍速`, close: "关闭运行", output: "输出", stopped: "程序在这里因错误而停止。", truncated: (count) => `仅显示前 ${count.toLocaleString("zh")} 步。`, outOfDate: "此次运行后流程图已更改。请重新运行以在此查看。" }, palette: { start: { title: "开始", description: "程序入口" }, input: { title: "输入", description: "将值读入变量" }, process: { title: "处理", description: "在组件中添加可编辑内容" }, decision: { title: "判断", description: "按条件分支" }, output: { title: "输出", description: "显示值或消息" }, end: { title: "结束", description: "程序出口" }, subroutine: { title: "子程序", description: "调用过程或函数" } }, nodeTypes: { terminator: { label: "开始 / 停止", description: "流程的开始或结束" }, process: { label: "处理", description: "可包含一个或多个步骤的可视化组件" }, decision: { label: "判断", description: "是/否或真/假分支" }, inputOutput: { label: "输入 / 输出", description: "读取或输出数据" }, subroutine: { label: "子程序", description: "调用单独的过程或函数" } } }, auth: { checkingStatus: "正在检查登录状态", checking: "正在检查登录状态", logIn: "登录", signedOut: "未登录", signInToSave: "登录后保存", signInDescription: "在浏览器中保存需要账号。立即登录即可保存此工作区,并在浏览器中继续使用。", notNow: "暂不", appearance: "外观", general: "通用" }, - save: { saveWorkspace: "保存工作区", saving: "保存中", saved: "已保存", failed: "保存失败", failedTitle: "保存失败。请查看消息栏了解详情。", cloudTitle: "将工作区保存到云端", localTitle: "将工作区保存在此设备上", signInTitle: "登录后保存到浏览器", leaveWarning: "请在离开或刷新此页面前保存或导出工作。" }, - empty: { welcome: "欢迎使用 PseudoEditor", description: "工作区目前为空。请在文件浏览器中新建伪代码文件,然后开始编写、编译和运行。" }, + save: { saveWorkspace: "保存工作区", saving: "保存中", saved: "已保存", failed: "保存失败", failedTitle: "保存失败。请查看消息栏了解详情。", cloudTitle: "将工作区保存到云端", localTitle: "将工作区保存在此设备上", signInTitle: "登录后保存到浏览器", failedKeptManual: "保存失败,更改仍保留在此设备上。", failedKeptAuto: "自动保存失败,更改仍保留在此设备上。", leaveWarning: "请在离开或刷新此页面前保存或导出工作。" }, + empty: { welcome: "开始编写伪代码", description: "新建一个文件,写下你的算法,然后点击运行查看结果。", shortcuts: "键盘快捷键", openDocs: "阅读伪代码指南", practice: "做一道练习题" }, docs: { title: "文档" }, breadcrumbs: { documentPath: "文档路径" }, legal: { userAgreement: "用户协议", privacy: "隐私", security: "安全", github: "GitHub", notice: (year) => `© ${year} Alex Xin LIU。基于 Apache License 2.0 许可发布。` }, + workbench: { sections: "工作区分区", toggleExplorer: "切换文件浏览器", showExplorer: "显示文件浏览器", hideExplorer: "隐藏文件浏览器", togglePanel: "切换输出面板", showPanel: "显示输出面板", hidePanel: "隐藏输出面板", commandPalette: "显示所有命令", newFile: "新建文件", newFolder: "新建文件夹", showProblems: "显示问题", problems: "问题", noProblems: "此文件未发现问题。", problemsLabel: (errors, warnings) => `${errors} 个错误,${warnings} 个警告`, goToProblem: (line, column) => `转到第 ${line} 行第 ${column} 列`, lineColumn: (line, column) => `行 ${line},列 ${column}`, selectedCharacters: (count) => `(已选择 ${count})`, goToLine: "转到行", changeSyntax: "更改考试语法", panelTabs: "面板视图", statusBar: "状态栏", unsaved: "有未保存的更改", savedLocal: "已保存在此设备", savedCloud: "已保存到你的账户", savedDeviceSyncing: "已保存在此设备,将同步到你的账户", savedDeviceSyncingShort: "已存于此设备 · 待同步", notSaved: "未保存:登录后可保留你的作品", savingNow: "正在保存…", saveFailed: "保存失败", moreActions: "更多操作", severity: { error: "错误", warning: "警告", info: "提示" }, runtimeErrorOnLine: (line) => `第 ${line} 行运行出错:`, runtimeErrorInFile: (file, line) => `${file} 第 ${line} 行运行出错:`, runawayLoop: "程序运行的步骤过多,已被停止。是否有一个永远不会结束的循环?", commands: { run: "运行程序", save: "保存工作区", toggleExplorer: "切换文件浏览器", togglePanel: "切换输出面板", problems: "显示问题", settings: "打开设置", newFile: "新建文件", newFolder: "新建文件夹", commandPalette: "显示所有命令" } }, touchTabs: { editor: "编辑器", files: "文件", output: "输出", settings: "设置" }, sync: { cloudLoadFailed: "无法连接云端工作区。云端副本未被修改。请刷新页面重试。", @@ -26,6 +27,7 @@ export const editorZh: typeof editorEn = { conflict: "此工作区也在其他标签页或设备上被修改。已保留你的版本,另一版本中不同的文件已复制到“云端副本”文件夹。", conflictFolder: "云端副本", guestFolder: "访客", + accountUnreachable: "无法连接你的账户,作品暂时保存在此设备上。点击“重试”即可同步。", retry: "重试", recoveredCopies: "部分只保存在此设备上的更改无法与账户中的最新版本对应。账户中的文件保持不变,此设备上的版本已放入“来自此设备”文件夹,不会丢失任何内容。", recoveredFolder: "来自此设备", guestImported: "登录前编写的文件已添加到“访客”文件夹。", }, }; diff --git a/apps/web/src/lib/analytics.ts b/apps/web/src/lib/analytics.ts index 536802f..203c201 100644 --- a/apps/web/src/lib/analytics.ts +++ b/apps/web/src/lib/analytics.ts @@ -71,9 +71,16 @@ export const posthogConfig: Partial = { disable_web_experiments: true, disable_external_dependency_loading: true, advanced_disable_flags: true, - before_send: scrubUrls, + before_send: (event) => (isBenignEditorCancellation(event) ? null : scrubUrls(event)), }; +/** Monaco rejects in-flight work with `Canceled` when the editor switches models; that is not an app error. */ +function isBenignEditorCancellation(event: Parameters[0]): boolean { + if (!event || event.event !== "$exception") return false; + const list = (event.properties?.$exception_list ?? []) as Array<{ type?: string; value?: string }>; + return list.length > 0 && list.every((entry) => entry.type === "Canceled" || entry.value === "Canceled"); +} + /** * Do Not Track and Global Privacy Control turn analytics off entirely. PostHog's own * `respect_dnt` is ignored in cookieless "always" mode, so the check happens before init. diff --git a/apps/web/src/lib/appleTouch.test.ts b/apps/web/src/lib/appleTouch.test.ts index 3443527..abd15fc 100644 --- a/apps/web/src/lib/appleTouch.test.ts +++ b/apps/web/src/lib/appleTouch.test.ts @@ -83,12 +83,24 @@ describe("getTouchLayout", () => { expect(getTouchLayout(1024, false)).toBeNull(); }); - it("uses the phone layout for any window narrower than 744px", () => { + it("uses the phone layout for touch screens narrower than 744px and very narrow mouse windows", () => { expect(getTouchLayout(743, true)).toBe("phone"); expect(getTouchLayout(390, false)).toBe("phone"); + expect(getTouchLayout(559, false)).toBe("phone"); + }); + + it("keeps the desktop workbench for half-screen mouse windows", () => { + expect(getTouchLayout(683, false)).toBeNull(); + expect(getTouchLayout(560, false)).toBeNull(); }); it("uses the tablet layout for wide windows with a coarse pointer", () => { expect(getTouchLayout(744, true)).toBe("tablet"); + expect(getTouchLayout(1180, true, 820)).toBe("tablet"); + }); + + it("keeps phones in landscape on the phone layout", () => { + expect(getTouchLayout(844, true, 390)).toBe("phone"); + expect(getTouchLayout(932, true, 430)).toBe("phone"); }); }); diff --git a/apps/web/src/lib/appleTouch.ts b/apps/web/src/lib/appleTouch.ts index 1188d4e..0eff552 100644 --- a/apps/web/src/lib/appleTouch.ts +++ b/apps/web/src/lib/appleTouch.ts @@ -37,16 +37,21 @@ export function supportsDesktopNativeDragAndDrop( return !matchMedia("(pointer: coarse)").matches; } -/** Below this width every window gets the phone layout; at or above it, coarse pointers get the tablet layout. */ +/** Below this width touch screens get the phone layout; at or above it, coarse pointers get the tablet layout. */ export const TOUCH_TABLET_BREAKPOINT = 744; +/** Mouse-driven windows keep the desktop workbench down to this width (a laptop window snapped to half the screen). */ +export const DESKTOP_MIN_WIDTH = 560; +/** Touch screens whose shorter side is below this are phones, in either orientation. */ +export const PHONE_MAX_SHORT_SIDE = 600; export type TouchLayout = "tablet" | "phone" | null; /** - * Narrow windows use the phone layout whatever the input device, since the desktop panes do not fit. - * Wider windows use the tablet layout for any coarse pointer (iPad, Android tablets), not only Apple devices. + * Touch screens narrower than the tablet breakpoint use the phone layout; wider ones use the tablet layout (iPad, + * Android tablets). With a mouse the desktop workbench stays until the window is too narrow for its panes. */ -export function getTouchLayout(width: number, coarsePointer: boolean): TouchLayout { - if (width < TOUCH_TABLET_BREAKPOINT) return "phone"; - return coarsePointer ? "tablet" : null; +export function getTouchLayout(width: number, coarsePointer: boolean, height = Number.POSITIVE_INFINITY): TouchLayout { + // A phone turned sideways is wide but short: the tablet panes would leave a five-line editor. + if (coarsePointer) return width < TOUCH_TABLET_BREAKPOINT || Math.min(width, height) < PHONE_MAX_SHORT_SIDE ? "phone" : "tablet"; + return width < DESKTOP_MIN_WIDTH ? "phone" : null; } diff --git a/apps/web/src/lib/editorCursorStore.ts b/apps/web/src/lib/editorCursorStore.ts new file mode 100644 index 0000000..b84d7eb --- /dev/null +++ b/apps/web/src/lib/editorCursorStore.ts @@ -0,0 +1,37 @@ +/** + * The caret position of the focused editor, kept outside React state so the status bar can follow it + * without re-rendering the whole workspace on every keystroke. + */ +export interface EditorCursorSnapshot { + line: number; + column: number; + selectedCharacters: number; +} + +let snapshot: EditorCursorSnapshot | null = null; +const listeners = new Set<() => void>(); + +export function setEditorCursor(next: EditorCursorSnapshot | null): void { + if ( + snapshot?.line === next?.line && + snapshot?.column === next?.column && + snapshot?.selectedCharacters === next?.selectedCharacters + ) { + return; + } + snapshot = next; + for (const listener of listeners) listener(); +} + +export function subscribeEditorCursor(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getEditorCursor(): EditorCursorSnapshot | null { + return snapshot; +} + +export function getServerEditorCursor(): EditorCursorSnapshot | null { + return null; +} diff --git a/apps/web/src/lib/editorModels.ts b/apps/web/src/lib/editorModels.ts new file mode 100644 index 0000000..3567f32 --- /dev/null +++ b/apps/web/src/lib/editorModels.ts @@ -0,0 +1,28 @@ +import type * as Monaco from "monaco-editor"; + +/** + * Editor models outlive the editor component (phones unmount it when switching tabs), so the workspace owns their + * lifetime. This module stays free of runtime Monaco imports so the page can use it without loading the editor. + */ +const MODEL_PATH_PREFIX = "/workspace/"; +let loadedMonaco: typeof Monaco | null = null; + +/** One Monaco model per document, so undo history, cursor and scroll survive switching files. */ +export function modelPathForDocument(documentKey: string | undefined): string | undefined { + return documentKey ? `file://${MODEL_PATH_PREFIX}${encodeURIComponent(documentKey)}.pseudo` : undefined; +} + +export function registerLoadedMonaco(monaco: typeof Monaco): void { + loadedMonaco = monaco; +} + +/** Disposes the models of documents that no longer exist (or every workspace model when `keep` is empty). */ +export function disposeWorkspaceModels(keepDocumentKeys: ReadonlySet = new Set()): void { + if (!loadedMonaco) return; + const keepPaths = new Set([...keepDocumentKeys].map((key) => `${MODEL_PATH_PREFIX}${encodeURIComponent(key)}.pseudo`)); + for (const model of loadedMonaco.editor.getModels()) { + if (model.uri.path.startsWith(MODEL_PATH_PREFIX) && !keepPaths.has(model.uri.path) && !model.isAttachedToEditor()) { + model.dispose(); + } + } +} diff --git a/apps/web/src/lib/pseudocodeEditorOptions.ts b/apps/web/src/lib/pseudocodeEditorOptions.ts index 33c8063..8156f8a 100644 --- a/apps/web/src/lib/pseudocodeEditorOptions.ts +++ b/apps/web/src/lib/pseudocodeEditorOptions.ts @@ -1,13 +1,19 @@ import type * as Monaco from "monaco-editor"; +/** + * `touchScreen` (any coarse pointer, Android included) sizes text and scrollbars for fingers; `appleTouchDevice` + * only switches off the input path that iOS/iPadOS Safari handles badly. + */ export function getPseudocodeEditorOptions( appleTouchDevice: boolean, + touchScreen: boolean = appleTouchDevice, ): Monaco.editor.IStandaloneEditorConstructionOptions { return { minimap: { enabled: false }, + hideCursorInOverviewRuler: touchScreen, fontFamily: "'Fira Code', monospace", - fontSize: appleTouchDevice ? 16 : 13, - lineHeight: appleTouchDevice ? 24 : 21, + fontSize: touchScreen ? 16 : 14, + lineHeight: touchScreen ? 24 : 22, lineNumbers: "on", lineNumbersMinChars: 3, roundedSelection: false, @@ -15,8 +21,10 @@ export function getPseudocodeEditorOptions( renderLineHighlight: "all", scrollBeyondLastLine: false, smoothScrolling: true, - mouseWheelScrollSensitivity: appleTouchDevice ? 0.7 : 0.85, + mouseWheelScrollSensitivity: touchScreen ? 0.7 : 0.85, fastScrollSensitivity: 3, + // One colour for brackets keeps pseudocode calm; matching brackets are still highlighted on the caret. + bracketPairColorization: { enabled: false }, stickyScroll: { enabled: false, }, @@ -30,8 +38,10 @@ export function getPseudocodeEditorOptions( strings: false, }, scrollbar: { - verticalScrollbarSize: appleTouchDevice ? 14 : 10, - horizontalScrollbarSize: appleTouchDevice ? 14 : 10, + verticalScrollbarSize: touchScreen ? 14 : 10, + horizontalScrollbarSize: touchScreen ? 14 : 10, + // Touch screens scroll sideways with a swipe; a floating bar would sit over the phone's tab bar. + horizontal: touchScreen ? "hidden" : "auto", alwaysConsumeMouseWheel: false, }, suggestOnTriggerCharacters: true, @@ -46,7 +56,7 @@ export function getPseudocodeEditorOptions( // Safari on iPhone/iPad exposes EditContext, but Monaco input handling is unreliable there. editContext: !appleTouchDevice, hover: { - enabled: !appleTouchDevice, + enabled: !touchScreen, }, occurrencesHighlight: appleTouchDevice ? "off" : "singleFile", }; diff --git a/apps/web/src/lib/shortcuts.test.ts b/apps/web/src/lib/shortcuts.test.ts new file mode 100644 index 0000000..b49f627 --- /dev/null +++ b/apps/web/src/lib/shortcuts.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import type * as Monaco from "monaco-editor"; +import { SHORTCUTS, formatShortcut, matchesShortcut, toMonacoKeybinding } from "./shortcuts"; + +function key(init: KeyboardEventInit) { + return new KeyboardEvent("keydown", init); +} + +describe("shortcuts", () => { + it("maps mod to Cmd on Apple platforms and Ctrl elsewhere", () => { + const save = SHORTCUTS.save[0]; + expect(matchesShortcut(key({ key: "s", code: "KeyS", metaKey: true }), save, true)).toBe(true); + expect(matchesShortcut(key({ key: "s", code: "KeyS", ctrlKey: true }), save, true)).toBe(false); + expect(matchesShortcut(key({ key: "s", code: "KeyS", ctrlKey: true }), save, false)).toBe(true); + expect(matchesShortcut(key({ key: "s", code: "KeyS", metaKey: true }), save, false)).toBe(false); + }); + + it("requires exact modifiers so Cmd+Shift+S is not Save", () => { + expect(matchesShortcut(key({ key: "S", code: "KeyS", metaKey: true, shiftKey: true }), SHORTCUTS.save[0], true)).toBe(false); + }); + + it("follows the typed letter on remapped layouts and the physical key on non-Latin ones", () => { + const toggle = SHORTCUTS.toggleExplorer[0]; + // Dvorak: the physical N key types "b". + expect(matchesShortcut(key({ key: "b", code: "KeyN", ctrlKey: true }), toggle, false)).toBe(true); + // Russian layout: the physical B key types "и". + expect(matchesShortcut(key({ key: "и", code: "KeyB", ctrlKey: true }), toggle, false)).toBe(true); + }); + + it("treats Ctrl+` as the literal Control key on every platform", () => { + const panel = SHORTCUTS.togglePanel[1]; + expect(matchesShortcut(key({ key: "`", code: "Backquote", ctrlKey: true }), panel, true)).toBe(true); + expect(matchesShortcut(key({ key: "`", code: "Backquote", metaKey: true }), panel, true)).toBe(false); + }); + + it("formats labels per platform", () => { + expect(formatShortcut(SHORTCUTS.run[0], true)).toBe("⌘↩"); + expect(formatShortcut(SHORTCUTS.run[0], false)).toBe("Ctrl+Enter"); + expect(formatShortcut(SHORTCUTS.commandPalette[0], true)).toBe("⇧⌘P"); + expect(formatShortcut(SHORTCUTS.commandPalette[0], false)).toBe("Ctrl+Shift+P"); + expect(formatShortcut(SHORTCUTS.togglePanel[1], true)).toBe("⌃`"); + }); + + it("binds literal Ctrl to Monaco's WinCtrl only on Apple platforms", () => { + const monaco = { + KeyCode: { Backquote: 1 }, + KeyMod: { CtrlCmd: 1 << 11, WinCtrl: 1 << 8, Shift: 1 << 10, Alt: 1 << 9 }, + } as unknown as typeof Monaco; + const panel = SHORTCUTS.togglePanel[1]; + expect(toMonacoKeybinding(monaco, panel, true)).toBe((1 << 8) | 1); + expect(toMonacoKeybinding(monaco, panel, false)).toBe((1 << 11) | 1); + }); +}); diff --git a/apps/web/src/lib/shortcuts.ts b/apps/web/src/lib/shortcuts.ts new file mode 100644 index 0000000..612860f --- /dev/null +++ b/apps/web/src/lib/shortcuts.ts @@ -0,0 +1,117 @@ +import type * as Monaco from "monaco-editor"; + +/** + * A keyboard shortcut. `mod` is Cmd on Apple platforms and Ctrl elsewhere; `ctrl` is the literal Control key + * on every platform (VS Code's Ctrl+` works that way on macOS too). + */ +export interface ShortcutSpec { + key: ShortcutKey; + mod?: boolean; + ctrl?: boolean; + shift?: boolean; + alt?: boolean; +} + +export type ShortcutKey = + | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" + | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" + | "Enter" | "F1" | "F2" | "F5" | "Backquote" | "Comma"; + +export function isApplePlatform(): boolean { + if (typeof navigator === "undefined") { + return false; + } + const platform = + (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? + navigator.platform ?? + ""; + return /mac|iphone|ipad|ipod/i.test(platform) || /Mac OS X|iPhone|iPad/.test(navigator.userAgent); +} + +function eventKeyMatches(event: KeyboardEvent, key: ShortcutKey): boolean { + if (key === "Backquote") return event.code === "Backquote"; + if (key === "Comma") return event.key === "," || event.code === "Comma"; + if (key.length > 1) return event.key === key; + // Letters follow the typed character so AZERTY/Dvorak users get the shortcut printed on their key; fall back to + // the physical key for non-Latin layouts, where the typed character is not a Latin letter. + const typed = event.key.toLowerCase(); + if (/^[a-z]$/.test(typed)) return typed === key; + return event.code === `Key${key.toUpperCase()}`; +} + +export function matchesShortcut(event: KeyboardEvent, spec: ShortcutSpec, apple = isApplePlatform()): boolean { + if (!eventKeyMatches(event, spec.key)) return false; + const wantsMeta = apple && spec.mod === true; + const wantsCtrl = spec.ctrl === true || (!apple && spec.mod === true); + return ( + event.metaKey === wantsMeta && + event.ctrlKey === wantsCtrl && + event.shiftKey === (spec.shift === true) && + event.altKey === (spec.alt === true) + ); +} + +const KEY_LABELS: Partial> = { + Enter: { apple: "↩", other: "Enter" }, + Backquote: { apple: "`", other: "`" }, + Comma: { apple: ",", other: "," }, +}; + +/** "⌘S" on Apple platforms, "Ctrl+S" elsewhere. */ +export function formatShortcut(spec: ShortcutSpec, apple = isApplePlatform()): string { + const keyLabel = KEY_LABELS[spec.key]?.[apple ? "apple" : "other"] ?? spec.key.toUpperCase(); + if (apple) { + return `${spec.ctrl ? "⌃" : ""}${spec.alt ? "⌥" : ""}${spec.shift ? "⇧" : ""}${spec.mod ? "⌘" : ""}${keyLabel}`; + } + const parts: string[] = []; + if (spec.ctrl || spec.mod) parts.push("Ctrl"); + if (spec.shift) parts.push("Shift"); + if (spec.alt) parts.push("Alt"); + parts.push(keyLabel); + return parts.join("+"); +} + +/** Appends the shortcut to a tooltip, e.g. "Run (⌘↩)". */ +export function withShortcut(label: string, spec: ShortcutSpec | undefined, apple = isApplePlatform()): string { + return spec ? `${label} (${formatShortcut(spec, apple)})` : label; +} + +export function toMonacoKeybinding(monaco: typeof Monaco, spec: ShortcutSpec, apple = isApplePlatform()): number { + const { KeyCode, KeyMod } = monaco; + let keyCode: number; + switch (spec.key) { + case "Enter": keyCode = KeyCode.Enter; break; + case "F1": keyCode = KeyCode.F1; break; + case "F2": keyCode = KeyCode.F2; break; + case "F5": keyCode = KeyCode.F5; break; + case "Backquote": keyCode = KeyCode.Backquote; break; + case "Comma": keyCode = KeyCode.Comma; break; + default: + keyCode = KeyCode[`Key${spec.key.toUpperCase()}` as keyof typeof KeyCode] as number; + } + let binding = keyCode; + if (spec.mod) binding |= KeyMod.CtrlCmd; + // Monaco's WinCtrl is the Control key on macOS but the Windows/Meta key elsewhere, where Control is CtrlCmd. + if (spec.ctrl) binding |= apple ? KeyMod.WinCtrl : KeyMod.CtrlCmd; + if (spec.shift) binding |= KeyMod.Shift; + if (spec.alt) binding |= KeyMod.Alt; + return binding; +} + +/** A workspace action that is reachable from the global keymap and from the editor's command palette (F1). */ +export interface WorkspaceCommand { + id: string; + label: string; + shortcuts: ShortcutSpec[]; + run: () => void; +} + +export const SHORTCUTS = { + run: [{ key: "Enter", mod: true }, { key: "F5" }] as ShortcutSpec[], + save: [{ key: "s", mod: true }] as ShortcutSpec[], + toggleExplorer: [{ key: "b", mod: true }] as ShortcutSpec[], + togglePanel: [{ key: "j", mod: true }, { key: "Backquote", ctrl: true }] as ShortcutSpec[], + settings: [{ key: "Comma", mod: true }] as ShortcutSpec[], + commandPalette: [{ key: "p", mod: true, shift: true }] as ShortcutSpec[], + problems: [{ key: "m", mod: true, shift: true }] as ShortcutSpec[], +} satisfies Record; diff --git a/apps/web/src/lib/storage.test.ts b/apps/web/src/lib/storage.test.ts index e34212b..c374594 100644 --- a/apps/web/src/lib/storage.test.ts +++ b/apps/web/src/lib/storage.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, onTestFinished, vi } from "vitest"; import { createDocument, createEmptyWorkspace, @@ -16,6 +16,15 @@ interface MockDatabase { createObjectStore: ReturnType; get: (_storeName: string, key: string) => Promise; put: (_storeName: string, value: unknown, key: string) => Promise; + delete: (_storeName: string, key: string) => Promise; + transaction: (_storeName: string, _mode: string) => { + store: { + get: (key: string) => Promise; + delete: (key: string) => Promise; + put: (value: unknown, key: string) => Promise; + }; + done: Promise; + }; } interface MockOpenDbOptions { @@ -35,6 +44,25 @@ vi.mock("idb", () => ({ async put(_storeName: string, value: unknown, key: string) { dbStore.set(key, value); }, + async delete(_storeName: string, key: string) { + dbStore.delete(key); + }, + transaction() { + return { + store: { + async get(key: string) { + return dbStore.get(key); + }, + async delete(key: string) { + dbStore.delete(key); + }, + async put(value: unknown, key: string) { + dbStore.set(key, value); + }, + }, + done: Promise.resolve(), + }; + }, }; options?.upgrade?.(database); @@ -217,6 +245,140 @@ describe("workspace storage", () => { ); }); + it("restores this account's newer unsynced checkpoint over the cloud copy and syncs it", async () => { + const cloudWorkspace = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "main", source: 'OUTPUT "Cloud"', now: "2026-03-15T00:00:00.000Z", + }); + const newer = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "main", source: 'OUTPUT "Typed after the last sync"', now: "2026-03-15T00:05:00.000Z", + }); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: cloudWorkspace }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ workspace: cloudWorkspace }), { status: 200 })); + const storage = await import("@/lib/storage"); + await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + await storage.saveDeviceCheckpoint(newer, "user_a", Date.parse("2026-03-15T00:05:00.000Z")); + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + + expect(getActiveDocument(loaded)?.source).toBe('OUTPUT "Typed after the last sync"'); + expect(storage.takeRecoveredCopies("user_a")).toBe(false); + // The session syncs recovered edits itself (so its status reflects the outcome); storage just flags them. + expect(storage.takeAccountSyncPending("user_a")).toBe(true); + expect(storage.takeAccountSyncPending("user_a")).toBe(false); + expect(dbStore.has("unsynced:user_a")).toBe(true); + }); + + it("never restores another account's checkpoint", async () => { + const cloudWorkspace = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "main", source: 'OUTPUT "B cloud"', now: "2026-03-15T00:00:00.000Z", + }); + const othersNewer = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "main", source: 'OUTPUT "A on a shared computer"', now: "2026-03-15T00:09:00.000Z", + }); + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ workspace: cloudWorkspace }), { status: 200 })); + + const storage = await import("@/lib/storage"); + await storage.saveDeviceCheckpoint(othersNewer, "user_a", Date.parse("2026-03-15T00:09:00.000Z")); + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_b" }); + + expect(getActiveDocument(loaded)?.source).toBe('OUTPUT "B cloud"'); + expect(dbStore.has("unsynced:user_a")).toBe(true); + }); + + it("drops the checkpoint once an account sync succeeds", async () => { + const workspace = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "main", source: 'OUTPUT "Synced"', now: "2026-03-15T00:00:00.000Z", + }); + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + const storage = await import("@/lib/storage"); + await storage.saveDeviceCheckpoint(workspace, "user_a", 1000); + await storage.saveWorkspace(workspace, { mode: "cloud", checkpointOwner: "user_a", editedAt: 1000 }); + expect(dbStore.has("unsynced:user_a")).toBe(false); + }); + + it("keeps a checkpoint newer than the snapshot an account sync wrote", async () => { + const workspace = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "main", source: 'OUTPUT "A"', now: "2026-03-15T00:00:00.000Z", + }); + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + const storage = await import("@/lib/storage"); + await storage.saveDeviceCheckpoint(workspace, "user_a", 2000); + await storage.saveWorkspace(workspace, { mode: "cloud", checkpointOwner: "user_a", editedAt: 1000 }); + expect(dbStore.has("unsynced:user_a")).toBe(true); + }); + + it("restores a newer checkpoint even when its change was a deletion", async () => { + const cloudWorkspace = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "main", source: 'OUTPUT "Still here in the cloud"', now: "2026-03-15T00:00:00.000Z", + }); + const afterDelete = createEmptyWorkspace("2026-03-15T00:00:00.000Z"); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: cloudWorkspace }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ workspace: cloudWorkspace }), { status: 200 })); + const storage = await import("@/lib/storage"); + // A session that loaded the account, then deleted the file before it could sync. + await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + await storage.saveDeviceCheckpoint(afterDelete, "user_a", Date.parse("2026-03-15T00:10:00.000Z")); + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + expect(Object.values(loaded.nodes).some((node) => node.type === "document")).toBe(false); + }); + + it("prefers this account's checkpoint and skips the backfill when the account can't be read", async () => { + const shared = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "main", source: 'OUTPUT "Old shared cache"', now: "2026-03-15T00:00:00.000Z", + }); + const mine = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "main", source: 'OUTPUT "My unsynced edit"', now: "2026-03-15T00:03:00.000Z", + }); + dbStore.set("current", shared); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response("unavailable", { status: 503 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + const storage = await import("@/lib/storage"); + await storage.saveDeviceCheckpoint(mine, "user_a", Date.parse("2026-03-15T00:03:00.000Z")); + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + expect(getActiveDocument(loaded)?.source).toBe('OUTPUT "My unsynced edit"'); + expect(storage.takeAccountSyncPending("user_a")).toBe(true); + }); + + it("starts a new account empty instead of importing another student's device cache", async () => { + const previousStudent = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "alice-homework", source: 'OUTPUT "Alice"', now: "2026-03-15T00:00:00.000Z", + }); + dbStore.set("current", previousStudent); + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ workspace: null }), { status: 200 })); + const storage = await import("@/lib/storage"); + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_b" }); + expect(Object.values(loaded.nodes).some((node) => node.name === "alice-homework.pseudo")).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("opens only the account's own copy after a failed read and holds account writes", async () => { + dbStore.set("current", createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "someone-else", source: 'OUTPUT "Not mine"', now: "2026-03-15T00:00:00.000Z", + })); + const mine = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "mine", source: 'OUTPUT "Mine"', now: "2026-03-15T00:00:00.000Z", + }); + dbStore.set("current:user_b", mine); + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce(new Response("unavailable", { status: 503 })); + const storage = await import("@/lib/storage"); + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_b" }); + expect(getActiveDocument(loaded)?.source).toBe('OUTPUT "Mine"'); + await expect(storage.saveWorkspace(loaded, { mode: "cloud", checkpointOwner: "user_b" })).rejects.toThrow( + "couldn't be reached", + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("requires Convex to accept cloud-mode saves", async () => { const workspace = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { name: "cloud", @@ -261,4 +423,321 @@ describe("workspace storage", () => { }), ); }); + + it("writes to the account in order even when an earlier request is slower", async () => { + const order: string[] = []; + let releaseFirst: () => void = () => {}; + vi.mocked(fetch) + .mockImplementationOnce(async (_url, init) => { + await new Promise((resolve) => { releaseFirst = resolve; }); + order.push(JSON.parse(String(init?.body)).workspace.marker); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }) + .mockImplementationOnce(async (_url, init) => { + order.push(JSON.parse(String(init?.body)).workspace.marker); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + const storage = await import("@/lib/storage"); + const base = createEmptyWorkspace("2026-03-15T00:00:00.000Z"); + const first = storage.saveWorkspace({ ...base, marker: "A" } as typeof base, { mode: "cloud" }); + const second = storage.saveWorkspace({ ...base, marker: "B" } as typeof base, { mode: "cloud" }); + await vi.waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1)); + releaseFirst(); + await Promise.all([first, second]); + expect(order).toEqual(["A", "B"]); + }); + + it("never lets edits made while the account was unreachable replace it; they are added as copies", async () => { + const account = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "essay", source: 'OUTPUT "Twenty files of real work"', now: "2026-03-15T00:00:00.000Z", + }); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response("unavailable", { status: 503 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: account }), { status: 200 })); + const storage = await import("@/lib/storage"); + const offline = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + const typedOffline = createDocument(offline, { name: "scratch", source: 'OUTPUT "Typed offline"' }); + await storage.saveDeviceCheckpoint(typedOffline, "user_a", Date.now()); + + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + const names = Object.values(loaded.nodes).map((node) => node.name); + expect(names).toContain("essay.pseudo"); + expect(names).toContain(storage.RECOVERED_FOLDER_NAME); + expect(names).toContain("scratch.pseudo"); + expect(storage.takeRecoveredCopies("user_a")).toBe(true); + }); + + it("keeps work from another device when this device's older checkpoint differs", async () => { + // This device read the account at 00:30 by its own clock; its edits are stamped by the same clock. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-03-15T00:30:00.000Z")); + onTestFinished(() => { vi.useRealTimers(); }); + const atLoad = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "essay", id: "doc-essay", source: 'OUTPUT "v1"', now: "2026-03-15T00:00:00.000Z", + }); + const fromHome = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "essay", id: "doc-essay", source: 'OUTPUT "v2 from home"', now: "2026-03-16T00:00:00.000Z", + }); + const atSchool = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "essay", id: "doc-essay", source: 'OUTPUT "v1 plus school edits"', now: "2026-03-15T01:00:00.000Z", + }); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: atLoad }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: fromHome }), { status: 200 })); + const storage = await import("@/lib/storage"); + await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + await storage.saveDeviceCheckpoint(atSchool, "user_a", Date.parse("2026-03-15T01:00:00.000Z")); + + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + const sources = Object.values(loaded.nodes).flatMap((node) => (node.type === "document" ? [node.source] : [])); + expect(sources).toContain('OUTPUT "v2 from home"'); + expect(sources).toContain('OUTPUT "v1 plus school edits"'); + }); + + it("clears a recovered checkpoint once its sync is acknowledged, so reloads don't add copies again", async () => { + const account = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "essay", source: 'OUTPUT "Account"', now: "2026-03-15T00:00:00.000Z", + }); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response("unavailable", { status: 503 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: account }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + const storage = await import("@/lib/storage"); + const offline = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + await storage.saveDeviceCheckpoint(createDocument(offline, { name: "scratch", source: "OUTPUT 1" }), "user_a", Date.now() - 1000); + const recovered = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + // What the session does: sync the recovered snapshot stamped as its latest edit. + await storage.saveWorkspace(recovered, { mode: "cloud", checkpointOwner: "user_a", editedAt: Date.now() }); + expect(dbStore.has("unsynced:user_a")).toBe(false); + }); + + it("moves a newer checkpoint's base forward when this tab's earlier save is acknowledged", async () => { + const account = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "essay", id: "doc-essay", source: 'OUTPUT "v1"', now: "2026-03-15T00:00:00.000Z", + }); + const savedA = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "essay", id: "doc-essay", source: 'OUTPUT "A"', now: "2026-03-15T00:01:00.000Z", + }); + const checkpointB = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "essay", id: "doc-essay", source: 'OUTPUT "B"', now: "2026-03-15T00:02:00.000Z", + }); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: account }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: savedA }), { status: 200 })); + const storage = await import("@/lib/storage"); + await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + await storage.saveDeviceCheckpoint(checkpointB, "user_a", Date.parse("2026-03-15T00:02:00.000Z")); + await storage.saveWorkspace(savedA, { mode: "cloud", checkpointOwner: "user_a", editedAt: Date.parse("2026-03-15T00:01:00.000Z") }); + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + expect(getActiveDocument(loaded)?.source).toBe('OUTPUT "B"'); + expect(storage.takeRecoveredCopies("user_a")).toBe(false); + }); + + it("never lets another tab's save clear or rebase this tab's unsynced checkpoint", async () => { + const workspace = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "main", source: 'OUTPUT "Tab B edit"', now: "2026-03-15T00:00:00.000Z", + }); + const tabB = await import("@/lib/storage"); + await tabB.saveDeviceCheckpoint(workspace, "user_a", 1000); + const before = dbStore.get("unsynced:user_a"); + + vi.resetModules(); + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + const tabA = await import("@/lib/storage"); + await tabA.saveWorkspace(workspace, { mode: "cloud", checkpointOwner: "user_a", editedAt: 5000 }); + + expect(dbStore.get("unsynced:user_a")).toEqual(before); + }); + + it("copies only the files this device edited after its base version", async () => { + // This device read the account at 00:30 by its own clock; its edits are stamped by the same clock. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-03-15T00:30:00.000Z")); + onTestFinished(() => { vi.useRealTimers(); }); + const atLoad = createDocument( + createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "untouched", id: "doc-untouched", source: 'OUTPUT "old"', now: "2026-03-15T00:00:00.000Z", + }), + { name: "essay", id: "doc-essay", source: 'OUTPUT "v1"', now: "2026-03-15T00:00:00.000Z" }, + ); + const fromHome = createDocument( + createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "untouched", id: "doc-untouched", source: 'OUTPUT "updated at home"', now: "2026-03-16T00:00:00.000Z", + }), + { name: "essay", id: "doc-essay", source: 'OUTPUT "v2 home"', now: "2026-03-16T00:00:00.000Z" }, + ); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: atLoad }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: fromHome }), { status: 200 })); + const storage = await import("@/lib/storage"); + await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + const atSchool = updateDocumentSource(atLoad, "doc-essay", 'OUTPUT "school edit"', "2026-03-15T01:00:00.000Z"); + await storage.saveDeviceCheckpoint(atSchool, "user_a", Date.parse("2026-03-15T01:00:00.000Z")); + + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a", recoveredFolderName: "Recovered" }); + const folder = Object.values(loaded.nodes).find((node) => node.name === "Recovered"); + const copies = Object.values(loaded.nodes).filter((node) => node.parentId === folder?.id); + expect(copies.map((node) => (node.type === "document" ? node.source : ""))).toEqual(['OUTPUT "school edit"']); + }); + + it("keeps this device's edits as copies even when another device's clock ran fast", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-03-15T10:00:00.000Z")); + onTestFinished(() => { vi.useRealTimers(); }); + // The account was last written by a lab PC whose clock ran 20 minutes fast. + const fromFastLabPc = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "essay", id: "doc-essay", source: 'OUTPUT "E0"', now: "2026-03-15T10:20:00.000Z", + }); + const changedElsewhere = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "essay", id: "doc-essay", source: 'OUTPUT "E2 phone"', now: "2026-03-15T10:30:00.000Z", + }); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: fromFastLabPc }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: changedElsewhere }), { status: 200 })); + const storage = await import("@/lib/storage"); + await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + // This laptop edits at 10:05 by its own (correct) clock, earlier than the lab PC's skewed stamp. + const laptopEdit = updateDocumentSource(fromFastLabPc, "doc-essay", 'OUTPUT "E1 laptop"', "2026-03-15T10:05:00.000Z"); + await storage.saveDeviceCheckpoint(laptopEdit, "user_a", Date.parse("2026-03-15T10:05:00.000Z")); + + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + const sources = Object.values(loaded.nodes).flatMap((node) => (node.type === "document" ? [node.source] : [])); + expect(sources).toContain('OUTPUT "E2 phone"'); + expect(sources).toContain('OUTPUT "E1 laptop"'); + }); + + it("clears an already-synced checkpoint on load even though a different tab wrote it", async () => { + const synced = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "essay", id: "doc-essay", source: 'OUTPUT "E1"', now: "2026-03-15T00:00:00.000Z", + }); + const previousTab = await import("@/lib/storage"); + await previousTab.saveDeviceCheckpoint(synced, "user_a", 1000); + vi.resetModules(); + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ workspace: synced }), { status: 200 })); + const storage = await import("@/lib/storage"); + await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + await vi.waitFor(() => expect(dbStore.has("unsynced:user_a")).toBe(false)); + }); + + it("keeps an edit made while an earlier save was in flight recoverable after that save is acknowledged", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-03-15T10:00:00.000Z")); + onTestFinished(() => { vi.useRealTimers(); }); + const account = createDocument( + createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "notes", id: "doc-notes", source: 'OUTPUT "fast clock"', now: "2026-03-15T11:00:00.000Z", + }), + { name: "essay", id: "doc-essay", source: 'OUTPUT "v0"', now: "2026-03-15T09:00:00.000Z" }, + ); + const savedA = updateDocumentSource(account, "doc-essay", 'OUTPUT "A"', "2026-03-15T10:01:00.000Z"); + const editB = updateDocumentSource(savedA, "doc-essay", 'OUTPUT "B"', "2026-03-15T10:02:00.000Z"); + let releaseSave: () => void = () => {}; + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: account }), { status: 200 })) + .mockImplementationOnce(async () => { + await new Promise((resolve) => { releaseSave = resolve; }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }) + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: savedA }), { status: 200 })); + const storage = await import("@/lib/storage"); + // A different tab's checkpoint for B (so this tab's acknowledgement can't rebase it), written mid-save. + await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + const save = storage.saveWorkspace(savedA, { + mode: "cloud", checkpointOwner: "user_a", editedAt: Date.parse("2026-03-15T10:01:00.000Z"), + }); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + vi.setSystemTime(new Date("2026-03-15T10:02:00.000Z")); + await storage.saveDeviceCheckpoint(editB, "user_a", Date.parse("2026-03-15T10:02:00.000Z")); + vi.setSystemTime(new Date("2026-03-15T10:03:00.000Z")); + releaseSave(); + await save; + + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + const sources = Object.values(loaded.nodes).flatMap((node) => (node.type === "document" ? [node.source] : [])); + expect(sources).toContain('OUTPUT "B"'); + }); + + it("keeps restored edits recoverable when their sync fails and the tab crashes again", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-03-15T10:00:00.000Z")); + onTestFinished(() => { vi.useRealTimers(); }); + const account = createDocument( + createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "notes", id: "doc-notes", source: 'OUTPUT "N0"', now: "2026-03-15T09:00:00.000Z", + }), + { name: "essay", id: "doc-essay", source: 'OUTPUT "E0"', now: "2026-03-15T09:00:00.000Z" }, + ); + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ workspace: account }), { status: 200 })); + let storage = await import("@/lib/storage"); + // 1. Laptop edits the essay and crashes. + await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + vi.setSystemTime(new Date("2026-03-15T10:05:00.000Z")); + const essayEdit = updateDocumentSource(account, "doc-essay", 'OUTPUT "E1 laptop"', "2026-03-15T10:05:00.000Z"); + await storage.saveDeviceCheckpoint(essayEdit, "user_a", Date.parse("2026-03-15T10:05:00.000Z")); + + // 2. Reopen restores it in place (its sync then fails); 3. the student edits notes and the tab crashes again. + vi.resetModules(); + vi.setSystemTime(new Date("2026-03-15T11:00:00.000Z")); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ workspace: account }), { status: 200 })); + storage = await import("@/lib/storage"); + const restored = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + vi.setSystemTime(new Date("2026-03-15T11:05:00.000Z")); + const notesEdit = updateDocumentSource(restored, "doc-notes", 'OUTPUT "N1"', "2026-03-15T11:05:00.000Z"); + await storage.saveDeviceCheckpoint(notesEdit, "user_a", Date.parse("2026-03-15T11:05:00.000Z")); + + // 4. The phone changes another file; 5. the laptop reopens. + const fromPhone = createDocument(account, { name: "phone", source: 'OUTPUT "P"', now: "2026-03-15T12:00:00.000Z" }); + vi.resetModules(); + vi.setSystemTime(new Date("2026-03-15T13:00:00.000Z")); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ workspace: fromPhone }), { status: 200 })); + storage = await import("@/lib/storage"); + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + const sources = Object.values(loaded.nodes).flatMap((node) => (node.type === "document" ? [node.source] : [])); + expect(sources).toContain('OUTPUT "E1 laptop"'); + expect(sources).toContain('OUTPUT "N1"'); + }); + + it("keeps edits recoverable after a merged (copied) restore whose sync fails and a second crash", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-03-15T10:00:00.000Z")); + onTestFinished(() => { vi.useRealTimers(); }); + const account = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "essay", id: "doc-essay", source: 'OUTPUT "E0"', now: "2026-03-15T09:00:00.000Z", + }); + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ workspace: account }), { status: 200 })); + let storage = await import("@/lib/storage"); + await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + vi.setSystemTime(new Date("2026-03-15T10:05:00.000Z")); + const laptopEdit = updateDocumentSource(account, "doc-essay", 'OUTPUT "E1 laptop"', "2026-03-15T10:05:00.000Z"); + await storage.saveDeviceCheckpoint(laptopEdit, "user_a", Date.parse("2026-03-15T10:05:00.000Z")); + + // The account changes elsewhere before the reopen, so the restore merges the laptop edit as a copy; + // its sync then fails and the tab crashes again without further edits. + const changed = createDocument(account, { name: "phone", source: 'OUTPUT "P1"', now: "2026-03-15T10:30:00.000Z" }); + vi.resetModules(); + vi.setSystemTime(new Date("2026-03-15T11:00:00.000Z")); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ workspace: changed }), { status: 200 })); + storage = await import("@/lib/storage"); + const merged = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + await storage.saveDeviceCheckpoint(merged, "user_a", Date.parse("2026-03-15T11:00:00.000Z")); + + const changedAgain = createDocument(changed, { name: "phone2", source: 'OUTPUT "P2"', now: "2026-03-15T12:00:00.000Z" }); + vi.resetModules(); + vi.setSystemTime(new Date("2026-03-15T13:00:00.000Z")); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ workspace: changedAgain }), { status: 200 })); + storage = await import("@/lib/storage"); + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + const sources = Object.values(loaded.nodes).flatMap((node) => (node.type === "document" ? [node.source] : [])); + expect(sources).toContain('OUTPUT "E1 laptop"'); + }); }); diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index 83b8d7e..58be225 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -1,6 +1,6 @@ import { openDB } from "idb"; import type { WorkspaceState } from "@pseudoeditor/workspace"; -import { createEmptyWorkspace, migratePersistedWorkspace } from "@pseudoeditor/workspace"; +import { createDocument, createEmptyWorkspace, createFolder, migratePersistedWorkspace } from "@pseudoeditor/workspace"; import type { WorkspacePersistenceMode } from "@/lib/platform"; const DB_NAME = "igcse-pseudocode-workspace"; @@ -15,6 +15,15 @@ interface WorkspaceStorageOptions { mode?: WorkspacePersistenceMode; syncToCloud?: boolean; getAuthToken?: () => Promise; + /** + * The signed-in account's id. Cloud mode keeps this account's not-yet-synced edits in a device checkpoint so a + * crash or closed tab between account syncs loses nothing; the checkpoint is never restored for another account. + */ + checkpointOwner?: string | null; + /** When saving: the edit time of the snapshot being written; only a checkpoint no newer than it is cleared. */ + editedAt?: number; + /** Name of the folder that receives this device's versions when they can't replace the account's. */ + recoveredFolderName?: string; } interface CloudWorkspaceResponse { @@ -48,6 +57,181 @@ async function getDatabase() { }); } +const CHECKPOINT_KEY_PREFIX = "unsynced:"; +const ACCOUNT_CACHE_KEY_PREFIX = "current:"; +/** Accounts whose cloud copy could not be read this session; their account writes wait for a successful read. */ +const unreadableCloudOwners = new Set(); + +/** Signed-in accounts get their own device cache, so one student's files never appear in another's account. */ +function deviceCacheKey(owner: string | null): string { + return owner ? `${ACCOUNT_CACHE_KEY_PREFIX}${owner}` : PRIMARY_KEY; +} + +async function readOwnCache(cacheKey: string, sampleSource: string): Promise { + const database = await getDatabase(); + const cached = await database.get(STORE_NAME, cacheKey); + return cached ? migratePersistedWorkspace(cached, { sampleSource }) : null; +} + +interface DeviceCheckpoint { + owner: string; + state: WorkspaceState; + /** Wall-clock time of the last edit in `state` (covers deletions and settings, not just surviving nodes). */ + editedAt: number; + /** + * The account version these edits were made on top of (`null` when the account could not be read). The checkpoint + * only replaces the account while the account is still at that version; otherwise its files are added as copies. + */ + baseVersion: number | null; + /** + * When (by this device's clock) the account was at `baseVersion`. Edits are compared against this rather than the + * account's timestamps, so another device's clock skew can't hide this device's edits. + */ + baseReadAt?: number | null; + /** The tab that wrote it; other tabs never clear or rebase it (they can't know what it builds on). */ + tabId?: string; +} + +/** Identifies this tab's checkpoints, so one tab's save never deletes or rebases another tab's unsynced work. */ +const TAB_ID = + typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; + +/** The account version (newest node time) this tab last read or wrote, and when by this device's clock. */ +const accountBaseVersions = new Map(); + +/** True when every document in the checkpoint already has the same text in the account (nothing left to sync). */ +function checkpointMatchesAccount(device: WorkspaceState, account: WorkspaceState): boolean { + return Object.values(device.nodes).every((node) => { + if (node.type !== "document") return true; + const accountNode = account.nodes[node.id]; + return accountNode?.type === "document" && accountNode.source === node.source; + }); +} +/** Accounts whose recovered device edits were added as copies because the account changed meanwhile. */ +const recoveredCopyOwners = new Set(); +export const RECOVERED_FOLDER_NAME = "From this device"; + +/** True once after a load that added this device's unsynced files as copies next to the account's own. */ +export function takeRecoveredCopies(owner: string | null | undefined): boolean { + if (!owner) return false; + return recoveredCopyOwners.delete(owner); +} + +/** + * Keeps the account's workspace and adds, as copies in one folder, the documents this device changed: those edited + * after `baseVersion` when it is known, else every document whose text differs from the account's. + */ +function mergeCheckpointAsCopies( + account: WorkspaceState, + device: WorkspaceState, + editedAfter: number | null, + folderName: string, +): WorkspaceState { + let merged = account; + let folderId: string | null = null; + for (const node of Object.values(device.nodes)) { + if (node.type !== "document") continue; + const accountNode = account.nodes[node.id]; + if (accountNode?.type === "document" && accountNode.source === node.source) continue; + if (editedAfter !== null && !(Date.parse(node.updatedAt) > editedAfter)) continue; + if (!folderId) { + merged = createFolder(merged, { name: folderName }); + folderId = Object.values(merged.nodes).find( + (candidate) => candidate.type === "folder" && !account.nodes[candidate.id], + )?.id ?? null; + if (!folderId) break; + } + merged = createDocument(merged, { parentId: folderId, name: node.name, source: node.source }); + } + return merged; +} + +/** The newest edit time in a workspace, from its nodes' timestamps. */ +function latestEditTime(state: WorkspaceState): number { + let latest = 0; + for (const node of Object.values(state.nodes)) { + const time = Date.parse(node.updatedAt); + if (Number.isFinite(time) && time > latest) latest = time; + } + return latest; +} + +/** Records edits that have not reached the account yet, on this device only. */ +export async function saveDeviceCheckpoint(state: WorkspaceState, owner: string, editedAt: number): Promise { + const database = await getDatabase(); + const key = `${CHECKPOINT_KEY_PREFIX}${owner}`; + const known = accountBaseVersions.get(owner); + let baseVersion = known?.version ?? null; + let baseReadAt = known?.readAt ?? null; + if (unreadableCloudOwners.has(owner)) { + // Edits made while the account can't be read build on whatever the existing checkpoint was based on. + const existing = (await database.get(STORE_NAME, key)) as DeviceCheckpoint | undefined; + baseVersion = typeof existing?.baseVersion === "number" ? existing.baseVersion : null; + baseReadAt = typeof existing?.baseReadAt === "number" ? existing.baseReadAt : null; + } + const checkpoint: DeviceCheckpoint = { owner, state, editedAt, baseVersion, baseReadAt, tabId: TAB_ID }; + await database.put(STORE_NAME, checkpoint, key); +} + +async function readDeviceCheckpoint(owner: string): Promise { + const database = await getDatabase(); + const checkpoint = (await database.get(STORE_NAME, `${CHECKPOINT_KEY_PREFIX}${owner}`)) as DeviceCheckpoint | undefined; + return checkpoint?.owner === owner && typeof checkpoint.editedAt === "number" + ? { ...checkpoint, baseVersion: typeof checkpoint.baseVersion === "number" ? checkpoint.baseVersion : null } + : null; +} + +/** Clears the checkpoint only if nothing newer than `upToEditedAt` was recorded since (a newer edit stays safe). */ +async function clearDeviceCheckpoint( + owner: string, + upToEditedAt = Number.POSITIVE_INFINITY, + acknowledged?: { version: number; readAt: number }, + options: { anyTab?: boolean } = {}, +): Promise { + const database = await getDatabase(); + const key = `${CHECKPOINT_KEY_PREFIX}${owner}`; + // Compare and delete in one transaction so a checkpoint written in between is never lost. + const transaction = database.transaction(STORE_NAME, "readwrite"); + const found = (await transaction.store.get(key)) as DeviceCheckpoint | undefined; + // Only this tab's own checkpoint (or one recovered on load, which this tab now owns) may be cleared or rebased. + const checkpoint = found && (options.anyTab || !found.tabId || found.tabId === TAB_ID) ? found : undefined; + if (checkpoint && checkpoint.editedAt <= upToEditedAt) { + await transaction.store.delete(key); + } else if (checkpoint && acknowledged && checkpoint.baseVersion !== null) { + // Newer edits from this tab build on the snapshot the account just acknowledged: move their base forward so + // they replay normally instead of looking like a change made elsewhere. + await transaction.store.put( + { ...checkpoint, baseVersion: acknowledged.version, baseReadAt: acknowledged.readAt }, + key, + ); + } + await transaction.done; +} + +/** Account writes from this tab run one at a time, in order, so an older snapshot never lands after a newer one. */ +let cloudWriteQueue: Promise = Promise.resolve(); +function enqueueCloudWrite(write: () => Promise): Promise { + const result = cloudWriteQueue.then(write, write); + cloudWriteQueue = result.catch(() => undefined); + return result; +} + +/** Accounts whose loaded workspace still has to be written to the account (recovered edits, or an unread account). */ +const accountSyncPendingOwners = new Set(); + +/** True while this account's cloud copy couldn't be read this session (account writes are held). */ +export function isAccountUnreachable(owner: string | null | undefined): boolean { + return !!owner && unreadableCloudOwners.has(owner); +} + +/** True once per load when the session must sync (and show the status of syncing) what was loaded. */ +export function takeAccountSyncPending(owner: string | null | undefined): boolean { + if (!owner) return false; + return accountSyncPendingOwners.delete(owner); +} + export async function loadWorkspace( sampleSource: string, options: WorkspaceStorageOptions = {}, @@ -59,16 +243,95 @@ export async function loadWorkspace( } if (mode === "cloud") { - const cloudWorkspace = await loadWorkspaceFromCloud(sampleSource, options.getAuthToken); + const owner = options.checkpointOwner ?? null; + const cacheKey = deviceCacheKey(owner); + // Read the checkpoint before the cloud load overwrites the device cache. + const checkpoint = owner ? await readDeviceCheckpoint(owner).catch(() => null) : null; + const cloud = await loadWorkspaceFromCloud(sampleSource, options.getAuthToken, cacheKey); + const restoreCheckpoint = async (current: DeviceCheckpoint) => { + // Edits made after the last account sync (the tab closed or crashed first): keep them; the session syncs them + // straight away through its normal save path, so success or failure shows in the save status. + const restored = migratePersistedWorkspace(current.state, { sampleSource }); + const database = await getDatabase(); + await database.put(STORE_NAME, restored, cacheKey); + // This tab now carries these edits, so its acknowledged sync may clear the checkpoint. + await database.put( + STORE_NAME, + { ...current, state: restored, tabId: TAB_ID } satisfies DeviceCheckpoint, + `${CHECKPOINT_KEY_PREFIX}${current.owner}`, + ); + accountSyncPendingOwners.add(current.owner); + return restored; + }; + + if (!cloud.ok) { + if (!owner) { + return migrateWorkspace(sampleSource); + } + // The account could not be read (offline, server error). Open only this account's own device copy, and hold + // account writes until a read succeeds so a stale copy can never overwrite the real one. + unreadableCloudOwners.add(owner); + accountSyncPendingOwners.add(owner); + if (checkpoint) return restoreCheckpoint(checkpoint); + const ownCache = await readOwnCache(cacheKey, sampleSource); + return ownCache ?? createEmptyWorkspace(); + } + if (owner) unreadableCloudOwners.delete(owner); + + const cloudWorkspace = cloud.workspace; if (cloudWorkspace) { + const accountVersion = latestEditTime(cloudWorkspace); + if (owner) accountBaseVersions.set(owner, { version: accountVersion, readAt: Date.now() }); + // Restored edits are not in the account until their sync lands, so later checkpoints must keep measuring from + // when those edits began, not from this load (else a failed sync could let them be filtered out of recovery). + const keepRestoredBase = (restoring: DeviceCheckpoint) => { + if (owner) { + accountBaseVersions.set(owner, { + version: accountVersion, + readAt: restoring.baseVersion === null ? 0 : (restoring.baseReadAt ?? restoring.baseVersion), + }); + } + }; + if (checkpoint && checkpoint.baseVersion === accountVersion && checkpoint.editedAt > accountVersion) { + // The account hasn't changed since these edits began: they are simply its next version. + keepRestoredBase(checkpoint); + return restoreCheckpoint(checkpoint); + } + if (checkpoint) { + // The account moved on elsewhere (or these edits were made while it couldn't be read): never overwrite it, + // and never drop the device's work silently. Keep the account's version, add this device's changed files + // beside it, and tell the student. + const merged = mergeCheckpointAsCopies( + cloudWorkspace, + migratePersistedWorkspace(checkpoint.state, { sampleSource }), + checkpoint.baseVersion === null ? null : (checkpoint.baseReadAt ?? checkpoint.baseVersion), + options.recoveredFolderName ?? RECOVERED_FOLDER_NAME, + ); + if (merged !== cloudWorkspace) { + recoveredCopyOwners.add(checkpoint.owner); + keepRestoredBase(checkpoint); + return restoreCheckpoint({ ...checkpoint, state: merged }); + } + } + // A checkpoint the account already contains in full (its save landed but the reply never arrived) is done, + // whichever tab wrote it. One that still differs is kept, so its work is never deleted. + if (owner && checkpoint && checkpointMatchesAccount(checkpoint.state, cloudWorkspace)) { + void clearDeviceCheckpoint(owner, checkpoint.editedAt, undefined, { anyTab: true }).catch(() => {}); + } return cloudWorkspace; } + if (checkpoint) return restoreCheckpoint(checkpoint); - const localWorkspace = await migrateWorkspace(sampleSource); - void saveWorkspaceToCloud(localWorkspace, options.getAuthToken).catch(() => { + // The account has no workspace yet. Only this account's own device copy may seed it: on a shared computer the + // unscoped cache belongs to whoever used it before, so a new account starts empty instead. + const seed = owner ? await readOwnCache(cacheKey, sampleSource) : await migrateWorkspace(sampleSource); + if (!seed) { + return createEmptyWorkspace(); + } + void enqueueCloudWrite(() => saveWorkspaceToCloud(seed, options.getAuthToken)).catch(() => { /* Keep local cache usable even when an initial cloud backfill fails. */ }); - return localWorkspace; + return seed; } return migrateWorkspace(sampleSource); @@ -84,11 +347,24 @@ export async function saveWorkspace( return; } + const owner = mode === "cloud" ? (options.checkpointOwner ?? null) : null; const database = await getDatabase(); - await database.put(STORE_NAME, state, PRIMARY_KEY); + await database.put(STORE_NAME, state, deviceCacheKey(owner)); if (mode === "cloud") { - await saveWorkspaceToCloud(state, options.getAuthToken); + if (owner && unreadableCloudOwners.has(owner)) { + throw new Error("Your account couldn't be reached, so changes are kept on this device. Reload to sync them."); + } + await enqueueCloudWrite(async () => { + await saveWorkspaceToCloud(state, options.getAuthToken); + if (options.checkpointOwner) { + // The account now holds edits up to this snapshot's own edit time (not the reply's arrival time), so edits + // made while the request was in flight still count as newer than the acknowledged base. + const acknowledged = { version: latestEditTime(state), readAt: options.editedAt ?? Date.now() }; + accountBaseVersions.set(options.checkpointOwner, acknowledged); + await clearDeviceCheckpoint(options.checkpointOwner, options.editedAt, acknowledged).catch(() => {}); + } + }); } } @@ -150,12 +426,16 @@ async function getCloudRequestHeaders( }; } +/** `ok: false` means the account could not be read; `workspace: null` means the account has none yet. */ +type CloudLoadResult = { ok: false } | { ok: true; workspace: WorkspaceState | null }; + async function loadWorkspaceFromCloud( sampleSource: string, - getAuthToken?: () => Promise, -): Promise { + getAuthToken: (() => Promise) | undefined, + cacheKey: string, +): Promise { if (typeof window === "undefined") { - return null; + return { ok: false }; } try { @@ -168,20 +448,20 @@ async function loadWorkspaceFromCloud( }); if (!response.ok) { - return null; + return { ok: false }; } const data = (await response.json()) as CloudWorkspaceResponse; if (!data.workspace) { - return null; + return { ok: true, workspace: null }; } const state = migratePersistedWorkspace(data.workspace, { sampleSource }); const database = await getDatabase(); - await database.put(STORE_NAME, state, PRIMARY_KEY); - return state; + await database.put(STORE_NAME, state, cacheKey); + return { ok: true, workspace: state }; } catch { - return null; + return { ok: false }; } } diff --git a/apps/web/src/lib/syntaxColorSchemes.test.ts b/apps/web/src/lib/syntaxColorSchemes.test.ts index 934b0f3..d507f34 100644 --- a/apps/web/src/lib/syntaxColorSchemes.test.ts +++ b/apps/web/src/lib/syntaxColorSchemes.test.ts @@ -10,8 +10,9 @@ import { // The editor surfaces each scheme is drawn on: the background and the current-line highlight. const EDITOR_SURFACES = { - dark: ["#1C1C1E", "#2C2C2E"], - light: ["#FFFFFF", "#F5F5F5"], + // The editor background and its current-line highlight (see EDITOR_SURFACE_COLORS). + dark: ["#16171B", "#1D1E24"], + light: ["#FFFFFF", "#F5F6F9"], } as const; function luminance(hex: string): number { diff --git a/apps/web/src/lib/syntaxColorSchemes.ts b/apps/web/src/lib/syntaxColorSchemes.ts index 098c2aa..773aa3a 100644 --- a/apps/web/src/lib/syntaxColorSchemes.ts +++ b/apps/web/src/lib/syntaxColorSchemes.ts @@ -1,6 +1,7 @@ import type { ResolvedTheme } from "@/lib/theme"; export type SyntaxColorSchemeId = + | "pseudoeditor" | "classic" | "ocean" | "forest" @@ -27,11 +28,35 @@ export type SyntaxColorScheme = { boldKeywords?: boolean; }; -export const DEFAULT_SYNTAX_COLOR_SCHEME: SyntaxColorSchemeId = "classic"; +export const DEFAULT_SYNTAX_COLOR_SCHEME: SyntaxColorSchemeId = "pseudoeditor"; // Token colours only. The editor background, gutter and selection stay with the app theme, // so every scheme sits on the same surface as the rest of the workspace. export const SYNTAX_COLOR_SCHEMES: readonly SyntaxColorScheme[] = [ + { + // The default: keywords in the brand green, neutral identifiers so code reads as text, not a rainbow. + id: "pseudoeditor", + dark: { + keyword: "#6FCF9F", + type: "#7FB6F0", + routine: "#E2A6DC", + string: "#E8A585", + number: "#E9C46A", + operator: "#B8BCC9", + comment: "#7F8698", + identifier: "#E3E5EC", + }, + light: { + keyword: "#0B6E4F", + type: "#2F5FA8", + routine: "#9233AB", + string: "#A84A2A", + number: "#8A6400", + operator: "#4A5060", + comment: "#697080", + identifier: "#1C1F26", + }, + }, { id: "classic", dark: { diff --git a/apps/web/src/runtime/compilePseudocodeInWorker.test.ts b/apps/web/src/runtime/compilePseudocodeInWorker.test.ts index 0049eab..a8fce1f 100644 --- a/apps/web/src/runtime/compilePseudocodeInWorker.test.ts +++ b/apps/web/src/runtime/compilePseudocodeInWorker.test.ts @@ -56,6 +56,27 @@ describe("compilePseudocodeInWorker", () => { await expect(first).resolves.toMatchObject({ stale: true, cached: false }); }); + it("never lets a background (live diagnostics) compile make a foreground Run compile stale", async () => { + const { compilePseudocodeInWorker } = await import("./compilePseudocodeInWorker"); + + const run = compilePseudocodeInWorker( + { source: "OUTPUT 1", filename: "main.pseudo", strict: true }, + "run", + ); + const live = compilePseudocodeInWorker( + { source: "OUTPUT 12", filename: "main.pseudo", strict: true }, + "live", + { background: true }, + ); + + const worker = MockWorker.instances[0]; + worker.emit({ kind: "compiled", id: 2, result: successResult }); + worker.emit({ kind: "compiled", id: 1, result: successResult }); + + await expect(run).resolves.toMatchObject({ stale: false }); + await expect(live).resolves.toMatchObject({ stale: false }); + }); + it("returns cached compile results without posting another worker request", async () => { const { compilePseudocodeInWorker } = await import("./compilePseudocodeInWorker"); const request = { source: "OUTPUT 1", filename: "main.pseudo", strict: true as const }; diff --git a/apps/web/src/runtime/compilePseudocodeInWorker.ts b/apps/web/src/runtime/compilePseudocodeInWorker.ts index a462448..f2054fd 100644 --- a/apps/web/src/runtime/compilePseudocodeInWorker.ts +++ b/apps/web/src/runtime/compilePseudocodeInWorker.ts @@ -112,9 +112,18 @@ class CompilerRunner { } } - async compile(request: CompileRequest, cacheKey: string | null = null): Promise { + /** + * `background` compiles (live diagnostics while typing) never mark a foreground compile, such as Run, + * as stale; their own callers check freshness against the current source instead. + */ + async compile( + request: CompileRequest, + cacheKey: string | null = null, + options: { background?: boolean } = {}, + ): Promise { const id = this.nextId++; - this.latestRequestId = id; + const background = options.background === true; + if (!background) this.latestRequestId = id; // The caller's key is a namespace, not proof that two sources are identical. const identity = cacheKey === null ? null : JSON.stringify([ cacheKey, request.source, request.filename, request.syntaxId, request.strict, @@ -146,7 +155,7 @@ class CompilerRunner { } catch (error) { result = compilerErrorResult(error instanceof Error ? error.message : "Unknown compiler error"); } - return { result, requestId: id, stale: id !== this.latestRequestId, cached: false }; + return { result, requestId: id, stale: !background && id !== this.latestRequestId, cached: false }; } private async execute(request: CompileRequest, id: number): Promise { @@ -196,8 +205,9 @@ export const pseudocodeCompilerRunner = new CompilerRunner(); export function compilePseudocodeInWorker( request: CompileRequest, cacheKey?: string | null, + options?: { background?: boolean }, ): Promise { - return pseudocodeCompilerRunner.compile(request, cacheKey ?? null); + return pseudocodeCompilerRunner.compile(request, cacheKey ?? null, options); } export function preloadPseudocodeCompiler(): void { From 6b633cf3f76cdef8eb94839ec60f9af42472bfb1 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 5 Nov 2026 21:11:04 +0800 Subject: [PATCH 2/5] Bind queued account writes to their sign-in and keep editor state per file Co-Authored-By: Claude Opus 5.5 --- apps/web/src/app/[locale]/app/page.tsx | 8 +++---- .../app/components/MonacoPseudocodeEditor.tsx | 7 ++++++ apps/web/src/lib/storage.test.ts | 23 +++++++++++++++++++ apps/web/src/lib/storage.ts | 8 +++++-- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/[locale]/app/page.tsx b/apps/web/src/app/[locale]/app/page.tsx index 44fe359..4db443f 100644 --- a/apps/web/src/app/[locale]/app/page.tsx +++ b/apps/web/src/app/[locale]/app/page.tsx @@ -1707,16 +1707,16 @@ export default function HomePage() { ); function focusEditorWhenReady() { + // Each file gets its own editor instance, so after opening a file wait for the new instance; if the open + // file did not change (e.g. a background tab closed), fall back to the current editor after a few frames. + const initialApi = editorApiRef.current; let attempts = 0; - let previousApi: PseudocodeEditorApi | null = null; const tryFocus = () => { const api = editorApiRef.current; - // Two consecutive frames with the same editor means any remount for the new file has finished. - if (api && api === previousApi) { + if (api && (api !== initialApi || attempts >= 20)) { api.focus(); return; } - previousApi = api; if (attempts++ < 120) window.requestAnimationFrame(tryFocus); }; window.requestAnimationFrame(tryFocus); diff --git a/apps/web/src/app/components/MonacoPseudocodeEditor.tsx b/apps/web/src/app/components/MonacoPseudocodeEditor.tsx index d60476a..c655329 100644 --- a/apps/web/src/app/components/MonacoPseudocodeEditor.tsx +++ b/apps/web/src/app/components/MonacoPseudocodeEditor.tsx @@ -385,6 +385,7 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({ void ensureMonacoNullCaretHitTestGuard(); }; + const markersRef = useRef([]); const markers = useMemo(() => { return diagnostics.map((diagnostic) => ({ startLineNumber: diagnostic.line, @@ -575,6 +576,8 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({ const model = editor.getModel(); if (model) { monaco.editor.setModelLanguage(model, languageId); + // Diagnostics that arrived while Monaco was still loading. + monaco.editor.setModelMarkers(model, "igcse-compiler", markersRef.current); } for (const command of commandsRef.current ?? []) { @@ -819,6 +822,7 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({ ); useEffect(() => { + markersRef.current = markers; if (!monacoRef.current || !editorRef.current) { return; } @@ -850,6 +854,9 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({ return ( { const sources = Object.values(loaded.nodes).flatMap((node) => (node.type === "document" ? [node.source] : [])); expect(sources).toContain('OUTPUT "E1 laptop"'); }); + + it("sends a queued account write with the sign-in of the account that made it", async () => { + let releaseFirst: () => void = () => {}; + const fetchMock = vi.mocked(fetch); + fetchMock + .mockImplementationOnce(async () => { + await new Promise((resolve) => { releaseFirst = resolve; }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + const storage = await import("@/lib/storage"); + let signedInAs = "account-A"; + const getAuthToken = async () => signedInAs; + const workspace = createEmptyWorkspace("2026-03-15T00:00:00.000Z"); + const first = storage.saveWorkspace(workspace, { mode: "cloud", getAuthToken }); + const second = storage.saveWorkspace(workspace, { mode: "cloud", getAuthToken }); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + signedInAs = "account-B"; + releaseFirst(); + await Promise.all([first, second]); + const headers = fetchMock.mock.calls[1]?.[1]?.headers as Record; + expect(headers.Authorization).toBe("Bearer account-A"); + }); }); diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index 58be225..67477e0 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -328,7 +328,8 @@ export async function loadWorkspace( if (!seed) { return createEmptyWorkspace(); } - void enqueueCloudWrite(() => saveWorkspaceToCloud(seed, options.getAuthToken)).catch(() => { + const seedToken = options.getAuthToken ? await options.getAuthToken().catch(() => null) : null; + void enqueueCloudWrite(() => saveWorkspaceToCloud(seed, async () => seedToken)).catch(() => { /* Keep local cache usable even when an initial cloud backfill fails. */ }); return seed; @@ -355,8 +356,11 @@ export async function saveWorkspace( if (owner && unreadableCloudOwners.has(owner)) { throw new Error("Your account couldn't be reached, so changes are kept on this device. Reload to sync them."); } + // Resolve the sign-in token now, so a write that waits in the queue still goes to the account that made it, + // even if someone signs in as another account before it is sent. + const token = options.getAuthToken ? await options.getAuthToken() : null; await enqueueCloudWrite(async () => { - await saveWorkspaceToCloud(state, options.getAuthToken); + await saveWorkspaceToCloud(state, async () => token); if (options.checkpointOwner) { // The account now holds edits up to this snapshot's own edit time (not the reply's arrival time), so edits // made while the request was in flight still count as newer than the acknowledged base. From 66ddf3ccf750ddfa9fe36f3fedc5a2c06f178be3 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 5 Nov 2026 21:18:05 +0800 Subject: [PATCH 3/5] Take the sign-in token and write slot before any await so saves keep their account and order Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/storage.test.ts | 33 +++++++++++++++++++++++ apps/web/src/lib/storage.ts | 46 ++++++++++++++++++++------------ 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/apps/web/src/lib/storage.test.ts b/apps/web/src/lib/storage.test.ts index 6c749c7..c3dac7d 100644 --- a/apps/web/src/lib/storage.test.ts +++ b/apps/web/src/lib/storage.test.ts @@ -763,4 +763,37 @@ describe("workspace storage", () => { const headers = fetchMock.mock.calls[1]?.[1]?.headers as Record; expect(headers.Authorization).toBe("Bearer account-A"); }); + + it("keeps saves in request order even when an earlier save's sign-in token is slower", async () => { + const order: string[] = []; + const fetchMock = vi.mocked(fetch); + fetchMock.mockImplementation(async (_url, init) => { + order.push(JSON.parse(String(init?.body)).workspace.marker); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + const storage = await import("@/lib/storage"); + const base = createEmptyWorkspace("2026-03-15T00:00:00.000Z"); + let releaseSlowToken: (token: string) => void = () => {}; + const slowToken = () => new Promise((resolve) => { releaseSlowToken = resolve; }); + const older = storage.saveWorkspace({ ...base, marker: "older" } as typeof base, { mode: "cloud", getAuthToken: slowToken }); + const newer = storage.saveWorkspace({ ...base, marker: "newer" } as typeof base, { mode: "cloud", getAuthToken: async () => "t" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + releaseSlowToken("t"); + await Promise.all([older, newer]); + expect(order).toEqual(["older", "newer"]); + }); + + it("asks for the sign-in token as soon as the save is requested", async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + const storage = await import("@/lib/storage"); + let signedInAs = "account-A"; + const save = storage.saveWorkspace(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + mode: "cloud", + getAuthToken: async () => signedInAs, + }); + signedInAs = "account-B"; + await save; + const headers = vi.mocked(fetch).mock.calls[0]?.[1]?.headers as Record; + expect(headers.Authorization).toBe("Bearer account-A"); + }); }); diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index 67477e0..14b49e6 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -349,27 +349,39 @@ export async function saveWorkspace( } const owner = mode === "cloud" ? (options.checkpointOwner ?? null) : null; - const database = await getDatabase(); - await database.put(STORE_NAME, state, deviceCacheKey(owner)); + if (mode !== "cloud") { + const database = await getDatabase(); + await database.put(STORE_NAME, state, deviceCacheKey(owner)); + return; + } - if (mode === "cloud") { + // Before anything is awaited: ask for this account's sign-in token and take the next account-write slot. An + // account switch or a slow token request after this point can neither redirect this save nor reorder it. + const tokenRequest = options.getAuthToken ? options.getAuthToken() : Promise.resolve(null); + tokenRequest.catch(() => { + /* reported when the queued write awaits it */ + }); + let deviceWrite: Promise = Promise.resolve(); + const accountWrite = enqueueCloudWrite(async () => { + await deviceWrite; if (owner && unreadableCloudOwners.has(owner)) { throw new Error("Your account couldn't be reached, so changes are kept on this device. Reload to sync them."); } - // Resolve the sign-in token now, so a write that waits in the queue still goes to the account that made it, - // even if someone signs in as another account before it is sent. - const token = options.getAuthToken ? await options.getAuthToken() : null; - await enqueueCloudWrite(async () => { - await saveWorkspaceToCloud(state, async () => token); - if (options.checkpointOwner) { - // The account now holds edits up to this snapshot's own edit time (not the reply's arrival time), so edits - // made while the request was in flight still count as newer than the acknowledged base. - const acknowledged = { version: latestEditTime(state), readAt: options.editedAt ?? Date.now() }; - accountBaseVersions.set(options.checkpointOwner, acknowledged); - await clearDeviceCheckpoint(options.checkpointOwner, options.editedAt, acknowledged).catch(() => {}); - } - }); - } + const token = await tokenRequest; + await saveWorkspaceToCloud(state, async () => token); + if (options.checkpointOwner) { + // The account now holds edits up to this snapshot's own edit time (not the reply's arrival time), so edits + // made while the request was in flight still count as newer than the acknowledged base. + const acknowledged = { version: latestEditTime(state), readAt: options.editedAt ?? Date.now() }; + accountBaseVersions.set(options.checkpointOwner, acknowledged); + await clearDeviceCheckpoint(options.checkpointOwner, options.editedAt, acknowledged).catch(() => {}); + } + }); + deviceWrite = (async () => { + const database = await getDatabase(); + await database.put(STORE_NAME, state, deviceCacheKey(owner)); + })(); + await Promise.all([deviceWrite, accountWrite]); } export async function migrateWorkspace(sampleSource: string): Promise { From 07c02aa3eea288c5ed8a6da97cad72d71f33fcfa Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 5 Nov 2026 21:37:57 +0800 Subject: [PATCH 4/5] Reject account-mismatched saves and detect any account change before replaying a checkpoint The workspace API now refuses a request whose bearer token verifies to a different account than the cookie session, so a save queued under one account can't land in another after a sign-in switch. Device checkpoints now record a fingerprint of the account snapshot (files, names, text, syntax) instead of its newest edit time, so a deletion, rename or syntax change made on another device is never overwritten by replaying stale local edits. Co-Authored-By: Claude Opus 5.5 --- .../app/api/workspace/workspaceAuth.test.ts | 24 ++++++++++ .../src/app/api/workspace/workspaceAuth.ts | 20 ++++++++ apps/web/src/lib/storage.test.ts | 33 +++++++++++++ apps/web/src/lib/storage.ts | 46 +++++++++++++++---- 4 files changed, 115 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/api/workspace/workspaceAuth.test.ts b/apps/web/src/app/api/workspace/workspaceAuth.test.ts index 06635c7..78a7ac3 100644 --- a/apps/web/src/app/api/workspace/workspaceAuth.test.ts +++ b/apps/web/src/app/api/workspace/workspaceAuth.test.ts @@ -68,4 +68,28 @@ describe("getWorkspaceRequestAuth", () => { await expect(getWorkspaceRequestAuth(new Request("https://app.test/api/workspace"))).resolves.toBeNull(); expect(verifyTokenMock).not.toHaveBeenCalled(); }); + + it("rejects a request whose bearer token belongs to a different account than the session", async () => { + authMock.mockResolvedValue({ + userId: "user_B", + getToken: vi.fn().mockResolvedValue("convex-session-token"), + sessionClaims: {}, + } as unknown as Awaited>); + verifyTokenMock.mockResolvedValue({ sub: "user_A" } as unknown as Awaited>); + const request = new Request("https://app.test/api/workspace", { headers: { authorization: "Bearer token-A" } }); + + await expect(getWorkspaceRequestAuth(request)).resolves.toBeNull(); + }); + + it("accepts a bearer token that matches the session", async () => { + authMock.mockResolvedValue({ + userId: "user_A", + getToken: vi.fn().mockResolvedValue("convex-session-token"), + sessionClaims: {}, + } as unknown as Awaited>); + verifyTokenMock.mockResolvedValue({ sub: "user_A" } as unknown as Awaited>); + const request = new Request("https://app.test/api/workspace", { headers: { authorization: "Bearer token-A" } }); + + await expect(getWorkspaceRequestAuth(request)).resolves.toMatchObject({ userId: "user_A" }); + }); }); diff --git a/apps/web/src/app/api/workspace/workspaceAuth.ts b/apps/web/src/app/api/workspace/workspaceAuth.ts index 76cd32b..5ea7321 100644 --- a/apps/web/src/app/api/workspace/workspaceAuth.ts +++ b/apps/web/src/app/api/workspace/workspaceAuth.ts @@ -23,6 +23,26 @@ function getBearerToken(request: Request): string | null { export async function getWorkspaceRequestAuth(request: Request): Promise { const authState = await auth(); if (authState.userId) { + // A request that names its account with a bearer token must match the cookie session. Otherwise a save queued + // by one account could be written into another account the browser switched to before it was sent. + const sessionBearer = getBearerToken(request); + const sessionSecret = process.env.CLERK_SECRET_KEY; + if (sessionBearer && sessionSecret) { + let bearerUserId: string | null = null; + try { + const claims = await verifyToken(sessionBearer, { secretKey: sessionSecret }); + bearerUserId = typeof claims.sub === "string" ? claims.sub : null; + } catch (error) { + // An unverifiable token cannot name another account; keep the session as before rather than block saving. + console.warn("Workspace API bearer token verification failed; using the session.", { + message: error instanceof Error ? error.message : "Unknown error", + }); + } + if (bearerUserId && bearerUserId !== authState.userId) { + console.warn("Workspace API unauthorized: bearer token account does not match the session."); + return null; + } + } return { userId: authState.userId, convexToken: await authState.getToken({ template: "convex" }), diff --git a/apps/web/src/lib/storage.test.ts b/apps/web/src/lib/storage.test.ts index c3dac7d..e7add7b 100644 --- a/apps/web/src/lib/storage.test.ts +++ b/apps/web/src/lib/storage.test.ts @@ -468,6 +468,39 @@ describe("workspace storage", () => { expect(storage.takeRecoveredCopies("user_a")).toBe(true); }); + it("never replays a checkpoint over a deletion made elsewhere, even when no surviving file got newer", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-03-15T00:10:00.000Z")); + onTestFinished(() => { vi.useRealTimers(); }); + const withOld = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "old", id: "doc-old", source: 'OUTPUT "old"', now: "2026-03-15T00:00:00.000Z", + }); + const atLoad = createDocument(withOld, { + name: "newest", id: "doc-newest", source: 'OUTPUT "v1"', now: "2026-03-15T00:05:00.000Z", + }); + // Another device deleted "old"; "newest" is still the newest file, so the newest edit time did not change. + const afterDeleteElsewhere = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "newest", id: "doc-newest", source: 'OUTPUT "v1"', now: "2026-03-15T00:05:00.000Z", + }); + const deviceEdit = createDocument(withOld, { + name: "newest", id: "doc-newest", source: 'OUTPUT "v1 plus edits here"', now: "2026-03-15T00:30:00.000Z", + }); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: atLoad }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: afterDeleteElsewhere }), { status: 200 })); + const storage = await import("@/lib/storage"); + await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + await storage.saveDeviceCheckpoint(deviceEdit, "user_a", Date.parse("2026-03-15T00:30:00.000Z")); + + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + const sources = Object.values(loaded.nodes).flatMap((node) => (node.type === "document" ? [node.source] : [])); + expect(sources).not.toContain('OUTPUT "old"'); + expect(sources).toContain('OUTPUT "v1"'); + expect(sources).toContain('OUTPUT "v1 plus edits here"'); + expect(storage.takeRecoveredCopies("user_a")).toBe(true); + }); + it("keeps work from another device when this device's older checkpoint differs", async () => { // This device read the account at 00:30 by its own clock; its edits are stamped by the same clock. vi.useFakeTimers({ toFake: ["Date"] }); diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index 14b49e6..ac56882 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -79,8 +79,9 @@ interface DeviceCheckpoint { /** Wall-clock time of the last edit in `state` (covers deletions and settings, not just surviving nodes). */ editedAt: number; /** - * The account version these edits were made on top of (`null` when the account could not be read). The checkpoint - * only replaces the account while the account is still at that version; otherwise its files are added as copies. + * Fingerprint of the account snapshot these edits were made on top of (`null` when the account could not be read). + * The checkpoint only replaces the account while the account is still that snapshot; otherwise its files are added + * as copies. */ baseVersion: number | null; /** @@ -98,7 +99,7 @@ const TAB_ID = ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`; -/** The account version (newest node time) this tab last read or wrote, and when by this device's clock. */ +/** The account snapshot (its fingerprint) this tab last read or wrote, and when by this device's clock. */ const accountBaseVersions = new Map(); /** True when every document in the checkpoint already has the same text in the account (nothing left to sync). */ @@ -148,6 +149,31 @@ function mergeCheckpointAsCopies( return merged; } +/** + * Identifies an account snapshot by everything a student can change in it (files, folders, names, text, syntax), so + * a deletion or settings change made elsewhere counts as a new version even when no surviving file got newer. + */ +function accountFingerprint(state: WorkspaceState): number { + const parts = Object.values(state.nodes) + .map((node) => + [node.id, node.type, node.parentId ?? "", node.name, node.type === "document" ? node.source : "", node.updatedAt] + .join("\u0000"), + ) + .sort(); + parts.push(`syntax:${state.syntaxId}`); + // FNV-1a over the sorted parts. + let hash = 0x811c9dc5; + for (const part of parts) { + for (let index = 0; index < part.length; index += 1) { + hash ^= part.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + hash ^= 0x1e; + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + /** The newest edit time in a workspace, from its nodes' timestamps. */ function latestEditTime(state: WorkspaceState): number { let latest = 0; @@ -280,7 +306,7 @@ export async function loadWorkspace( const cloudWorkspace = cloud.workspace; if (cloudWorkspace) { - const accountVersion = latestEditTime(cloudWorkspace); + const accountVersion = accountFingerprint(cloudWorkspace); if (owner) accountBaseVersions.set(owner, { version: accountVersion, readAt: Date.now() }); // Restored edits are not in the account until their sync lands, so later checkpoints must keep measuring from // when those edits began, not from this load (else a failed sync could let them be filtered out of recovery). @@ -288,11 +314,15 @@ export async function loadWorkspace( if (owner) { accountBaseVersions.set(owner, { version: accountVersion, - readAt: restoring.baseVersion === null ? 0 : (restoring.baseReadAt ?? restoring.baseVersion), + readAt: restoring.baseVersion === null ? 0 : (restoring.baseReadAt ?? 0), }); } }; - if (checkpoint && checkpoint.baseVersion === accountVersion && checkpoint.editedAt > accountVersion) { + if ( + checkpoint && + checkpoint.baseVersion === accountVersion && + checkpoint.editedAt > latestEditTime(cloudWorkspace) + ) { // The account hasn't changed since these edits began: they are simply its next version. keepRestoredBase(checkpoint); return restoreCheckpoint(checkpoint); @@ -304,7 +334,7 @@ export async function loadWorkspace( const merged = mergeCheckpointAsCopies( cloudWorkspace, migratePersistedWorkspace(checkpoint.state, { sampleSource }), - checkpoint.baseVersion === null ? null : (checkpoint.baseReadAt ?? checkpoint.baseVersion), + checkpoint.baseVersion === null ? null : (checkpoint.baseReadAt ?? null), options.recoveredFolderName ?? RECOVERED_FOLDER_NAME, ); if (merged !== cloudWorkspace) { @@ -372,7 +402,7 @@ export async function saveWorkspace( if (options.checkpointOwner) { // The account now holds edits up to this snapshot's own edit time (not the reply's arrival time), so edits // made while the request was in flight still count as newer than the acknowledged base. - const acknowledged = { version: latestEditTime(state), readAt: options.editedAt ?? Date.now() }; + const acknowledged = { version: accountFingerprint(state), readAt: options.editedAt ?? Date.now() }; accountBaseVersions.set(options.checkpointOwner, acknowledged); await clearDeviceCheckpoint(options.checkpointOwner, options.editedAt, acknowledged).catch(() => {}); } From f07bccb9ad430179edc9f0c4b9f342b90688f7ef Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 5 Nov 2026 21:49:59 +0800 Subject: [PATCH 5/5] Count runtime data files as account changes and stop comparing against other devices' clocks The checkpoint's account fingerprint now covers runtime data files, so a data file changed on another device is never overwritten by replaying stale local edits. Replay now depends only on that fingerprint, so a device whose clock ran ahead can no longer make an unsynced local deletion come back. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/storage.test.ts | 56 ++++++++++++++++++++++++++++++++ apps/web/src/lib/storage.ts | 23 ++++--------- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/apps/web/src/lib/storage.test.ts b/apps/web/src/lib/storage.test.ts index e7add7b..aba9ea9 100644 --- a/apps/web/src/lib/storage.test.ts +++ b/apps/web/src/lib/storage.test.ts @@ -4,6 +4,7 @@ import { createEmptyWorkspace, getActiveDocument, updateDocumentSource, + updateVirtualFiles, } from "@pseudoeditor/workspace"; const dbStore = new Map(); @@ -501,6 +502,61 @@ describe("workspace storage", () => { expect(storage.takeRecoveredCopies("user_a")).toBe(true); }); + it.each([ + { before: ["old"], after: ["new from home"] }, + { before: [], after: [""] }, + ])("never replays a checkpoint over runtime data files changed elsewhere ($before → $after)", async ({ before, after }) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-03-15T00:10:00.000Z")); + onTestFinished(() => { vi.useRealTimers(); }); + const doc = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "main", id: "doc-main", source: 'OUTPUT "v1"', now: "2026-03-15T00:00:00.000Z", + }); + const atLoad = updateVirtualFiles(doc, { "data.txt": before }, "2026-03-15T00:00:00.000Z"); + const changedElsewhere = updateVirtualFiles(doc, { "data.txt": after }, "2026-03-15T00:00:00.000Z"); + const deviceEdit = updateVirtualFiles( + createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "main", id: "doc-main", source: 'OUTPUT "v1 plus edits here"', now: "2026-03-15T00:30:00.000Z", + }), + { "data.txt": before }, + "2026-03-15T00:30:00.000Z", + ); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: atLoad }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: changedElsewhere }), { status: 200 })); + const storage = await import("@/lib/storage"); + await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + await storage.saveDeviceCheckpoint(deviceEdit, "user_a", Date.parse("2026-03-15T00:30:00.000Z")); + + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + expect(loaded.virtualFiles["data.txt"]).toEqual(after); + const sources = Object.values(loaded.nodes).flatMap((node) => (node.type === "document" ? [node.source] : [])); + expect(sources).toContain('OUTPUT "v1 plus edits here"'); + }); + + it("replays a checkpoint on the unchanged account even when another device's clock ran ahead", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-03-15T00:10:00.000Z")); + onTestFinished(() => { vi.useRealTimers(); }); + // Saved by a device whose clock was a year fast. + const account = createDocument(createEmptyWorkspace("2026-03-15T00:00:00.000Z"), { + name: "future", id: "doc-future", source: 'OUTPUT "future"', now: "2027-03-15T00:00:00.000Z", + }); + const afterLocalDelete = createEmptyWorkspace("2026-03-15T00:00:00.000Z"); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: account }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ workspace: account }), { status: 200 })); + const storage = await import("@/lib/storage"); + await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + await storage.saveDeviceCheckpoint(afterLocalDelete, "user_a", Date.parse("2026-03-15T00:20:00.000Z")); + + const loaded = await storage.loadWorkspace('OUTPUT "Fallback"', { mode: "cloud", checkpointOwner: "user_a" }); + expect(Object.values(loaded.nodes).some((node) => node.type === "document")).toBe(false); + expect(storage.takeAccountSyncPending("user_a")).toBe(true); + }); + it("keeps work from another device when this device's older checkpoint differs", async () => { // This device read the account at 00:30 by its own clock; its edits are stamped by the same clock. vi.useFakeTimers({ toFake: ["Date"] }); diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index ac56882..f74f602 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -150,8 +150,8 @@ function mergeCheckpointAsCopies( } /** - * Identifies an account snapshot by everything a student can change in it (files, folders, names, text, syntax), so - * a deletion or settings change made elsewhere counts as a new version even when no surviving file got newer. + * Identifies an account snapshot by everything a student can change in it (files, folders, names, text, runtime data + * files, syntax), so a deletion or settings change made elsewhere counts as a new version even when no file got newer. */ function accountFingerprint(state: WorkspaceState): number { const parts = Object.values(state.nodes) @@ -160,6 +160,9 @@ function accountFingerprint(state: WorkspaceState): number { .join("\u0000"), ) .sort(); + for (const [fileName, lines] of Object.entries(state.virtualFiles ?? {}).sort(([a], [b]) => (a < b ? -1 : 1))) { + parts.push(`file:${JSON.stringify([fileName, lines])}`); + } parts.push(`syntax:${state.syntaxId}`); // FNV-1a over the sorted parts. let hash = 0x811c9dc5; @@ -174,16 +177,6 @@ function accountFingerprint(state: WorkspaceState): number { return hash >>> 0; } -/** The newest edit time in a workspace, from its nodes' timestamps. */ -function latestEditTime(state: WorkspaceState): number { - let latest = 0; - for (const node of Object.values(state.nodes)) { - const time = Date.parse(node.updatedAt); - if (Number.isFinite(time) && time > latest) latest = time; - } - return latest; -} - /** Records edits that have not reached the account yet, on this device only. */ export async function saveDeviceCheckpoint(state: WorkspaceState, owner: string, editedAt: number): Promise { const database = await getDatabase(); @@ -318,11 +311,7 @@ export async function loadWorkspace( }); } }; - if ( - checkpoint && - checkpoint.baseVersion === accountVersion && - checkpoint.editedAt > latestEditTime(cloudWorkspace) - ) { + if (checkpoint && checkpoint.baseVersion === accountVersion) { // The account hasn't changed since these edits began: they are simply its next version. keepRestoredBase(checkpoint); return restoreCheckpoint(checkpoint);