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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,42 @@ const nextConfig: NextConfig = {
env: {
NEXT_PUBLIC_POSTHOG_ENABLED: analyticsEnabled ? "1" : "",
},
experimental: {
// Inline the stylesheet (about 15 KB compressed) into the HTML, so first paint does not wait for
// a separate render-blocking CSS request. Client-side navigations still load it as a file.
inlineCss: true,
},
images: {
unoptimized: true,
},
poweredByHeader: false,
async redirects() {
// The manual's content now lives in the docs as the pseudocode reference.
return ["/manual", "/app/manual"].flatMap((source) => [
const manual = ["/manual", "/app/manual"].flatMap((source) => [
{ source, destination: "/docs", permanent: true },
{ source: `/:locale(en|zh)${source}`, destination: "/:locale/docs", permanent: true },
]);
// English is served at the bare path, so `/en/...` redirects there and every page has one URL.
const englishPrefix = [
{ source: "/en", destination: "/", permanent: true },
{ source: "/en/:path*", destination: "/:path*", permanent: true },
];
return [...manual, ...englishPrefix];
},
async rewrites() {
// English pages live under app/[locale] as `/en/...`; the bare path is rewritten there. These run
// in Vercel's routing layer, so cached pages are served from the CDN without invoking the proxy
// (middleware). `afterFiles` runs after public files and static routes (robots.txt, icons,
// /api, /_next) but before dynamic routes, so `/docs` reaches `/en/docs` rather than
// `[locale]` = "docs".
return {
beforeFiles: [],
afterFiles: [
{ source: "/", destination: "/en" },
{ source: "/:path((?!(?:en|zh|api|_next)(?:/|$)).+)", destination: "/en/:path" },
],
fallback: [],
};
},
async headers() {
const immutableAssetHeaders = [
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/app/[locale]/(public)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ export default async function LandingPage({ params }: LocaleParams) {

return (
<main>
{/* The stylesheet is inlined, so the browser could paint before the hero window has arrived and then grow
it. Hold the first paint until the hero has been parsed (browsers without rel=expect ignore this). */}
<link rel="expect" href="#after-hero" blocking="render" />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} />
<PublicHeader locale={locale} />

Expand Down Expand Up @@ -193,7 +196,7 @@ export default async function LandingPage({ params }: LocaleParams) {
<HeroWindow workspace={t.windowWorkspace} compiled={t.windowCompiled} terminal={t.windowTerminal} />
</section>

<section className="border-t border-[var(--line)]">
<section id="after-hero" className="border-t border-[var(--line)]">
<div className="site-wrap py-16 md:py-20">
<p className="site-eyebrow">{t.featuresEyebrow}</p>
<h2 className="site-h2 mt-3 max-w-2xl">{t.featuresTitle}</h2>
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/app/[locale]/app/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const { loadWorkspaceMock, saveWorkspaceMock, compilePseudocodeMock, runMock, ed
lastName?: string | null;
},
loading: false,
// Whether the browser holds a Clerk session cookie (see mayHaveClerkSession).
sessionHint: true,
},
}));
const localStore = new Map<string, string>();
Expand All @@ -41,6 +43,7 @@ vi.mock("@/lib/auth-components", () => ({
: null;
},
isCloudAuthConfigured: () => true,
mayHaveClerkSession: () => authState.sessionHint,
SignInButton: ({ children }: { children: React.ReactNode }) => children,
SignUpButton: ({ children }: { children: React.ReactNode }) => children,
UserButton: Object.assign(
Expand Down Expand Up @@ -310,6 +313,7 @@ describe("HomePage workspace flow", () => {
lastName: null,
};
authState.loading = false;
authState.sessionHint = true;
Object.defineProperty(window as Window & { electron?: { isDesktop?: boolean } }, "electron", {
configurable: true,
value: undefined,
Expand Down Expand Up @@ -495,6 +499,49 @@ describe("HomePage workspace flow", () => {
});
});

it("runs the source typed just before Run is tapped", async () => {
mockLoadedWorkspace(createWorkspaceFixture());
compilePseudocodeMock.mockReturnValue({ success: true, diagnostics: [], astJson: "{}" });
runMock.mockResolvedValue({ success: true, stdout: "done", stderr: "", diagnostics: [], virtualFiles: {} });
render(<HomePage />);
const editor = await screen.findByRole("textbox", { name: "Mock editor" });

fireEvent.change(editor, { target: { value: 'OUTPUT "Typed just now"' } });
fireEvent.click(screen.getByRole("button", { name: "Run" }));

// Run starts after the next paint, with the latest source.
await waitFor(() => {
expect(compilePseudocodeMock).toHaveBeenCalledWith(expect.objectContaining({ source: 'OUTPUT "Typed just now"' }));
});
});

it("waits for Clerk before loading the workspace when a session cookie is present", async () => {
authState.user = null;
authState.loading = true;
mockLoadedWorkspace(createWorkspaceFixture());
render(<HomePage />);

expect(screen.getByRole("status", { name: "Loading workspace…" })).toBeInTheDocument();
expect(screen.getByText("PseudoEditor")).toBeInTheDocument();
await act(async () => {
await Promise.resolve();
});
expect(loadWorkspaceMock).not.toHaveBeenCalled();
});

it("opens the in-memory workspace without waiting for Clerk when nobody is signed in", async () => {
authState.user = null;
authState.loading = true;
authState.sessionHint = false;
mockLoadedWorkspace(createWorkspaceFixture());
render(<HomePage />);

await screen.findByRole("textbox", { name: "Mock editor" });
expect(loadWorkspaceMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ mode: "memory" }));
// The account control stays a placeholder until Clerk has loaded.
expect(screen.getByRole("button", { name: "Checking sign-in status" })).toBeDisabled();
});

it("offers the flowchart from the toolbar without a beta setting, loading it on first open", async () => {
authState.user = null;
mockLoadedWorkspace(createWorkspaceFixture());
Expand Down
79 changes: 64 additions & 15 deletions apps/web/src/app/[locale]/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,11 @@ import {
SignInButton,
UserButton,
isCloudAuthConfigured,
mayHaveClerkSession,
useAuth,
} from "@/lib/auth-components";
import { track } from "@/lib/analytics";
import { afterNextPaint } from "@/lib/afterNextPaint";
import { getTouchLayout } from "@/lib/appleTouch";
import type { Diagnostic } from "@/compiler/types";
import { useDictionary, useLocale } from "@/i18n/context";
Expand Down Expand Up @@ -309,9 +311,13 @@ export default function HomePage() {
{ href: "https://github.com/The1UNeed/pseudoeditor.dev", label: t.legal.github },
];
const { getToken, isLoaded: authLoaded, isSignedIn, userId } = useAuth();
const authLoading = !authLoaded;
const [appPlatform] = useState(() => getClientAppPlatform());
const cloudSavingRequired = platformUsesCloudSaving(appPlatform);
// Signed-out visitors (no Clerk session cookie) get the in-memory workspace straight away instead
// of waiting for Clerk's scripts and API calls. If Clerk then reports a session anyway, the
// persistence mode changes and the workspace reloads from the cloud.
const [awaitClerkSession] = useState(() => mayHaveClerkSession());
const authLoading = !authLoaded && awaitClerkSession;
const workspacePersistenceMode = getWorkspacePersistenceMode({
platform: appPlatform,
signedIn: Boolean(isSignedIn),
Expand Down Expand Up @@ -579,6 +585,12 @@ export default function HomePage() {
currentDocumentRef.current = currentDocument;
}, [currentDocument]);

// Run executes the workspace's active document (the last focused panel), so the deferred Run
// checks that one, not the first panel's.
useEffect(() => {
runTargetIdRef.current = activeDocument?.id;
}, [activeDocument]);

useEffect(() => {
if (renameDialog) {
renameInputRef.current?.focus();
Expand Down Expand Up @@ -847,17 +859,33 @@ export default function HomePage() {

/* ── actions ── */

// A run re-renders the workspace, terminal and layout (and on a tablet the keyboard closes).
// Starting it after the next paint lets the tap itself paint at once.
const cancelScheduledRunRef = useRef<(() => void) | null>(null);
const runTargetIdRef = useRef<string | undefined>(undefined);
useEffect(() => () => cancelScheduledRunRef.current?.(), []);
const handleRun = useCallback(() => {
if (cancelScheduledRunRef.current) {
return;
}
// The run is for the file that was active when Run was pressed.
const requestedId = runTargetIdRef.current;
shouldAutoScrollOutputRef.current = true;
setShowTerminal(true);
setPanelView("output");
preloadRunRuntime();
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");
cancelScheduledRunRef.current = afterNextPaint(() => {
cancelScheduledRunRef.current = null;
if (runTargetIdRef.current !== requestedId) {
return;
}
setShowTerminal(true);
setPanelView("output");
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]);

Expand Down Expand Up @@ -1138,7 +1166,8 @@ export default function HomePage() {
return null;
}

if (authLoading) {
// Still a placeholder until Clerk loads, even when the workspace did not wait for it.
if (!authLoaded) {
return (
<button
type="button"
Expand Down Expand Up @@ -1745,11 +1774,16 @@ export default function HomePage() {
const baseName = normalizeDocumentEditableName(createFileName);
if (!baseName) return;
const name = `${baseName}${PSEUDO_EXTENSION}`;
createDocumentInWorkspace(createFileParentId, { name });
const parentId = createFileParentId;
// Close the dialog in this frame; opening the new file lays out the whole editor, so it waits
// until the tap has painted.
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();
afterNextPaint(() => {
createDocumentInWorkspace(parentId, { name });
// 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<HTMLFormElement>) => {
Expand Down Expand Up @@ -2112,9 +2146,24 @@ export default function HomePage() {
/* ── loading state ── */

if (!workspace) {
// This is also the prerendered HTML, so it paints the title bar before any script runs. It must
// not depend on browser-only state (platform, viewport), or hydration would mismatch.
return (
<main>
<EditorLoader fullScreen label={t.loading.workspace} />
<main className="flex h-dvh flex-col overflow-hidden bg-[var(--bg)]">
<header className="flex h-[52px] shrink-0 items-center bg-[var(--titlebar)] px-4">
<Link
href="/"
className="flex items-center gap-2 transition hover:opacity-80"
aria-label={t.toolbar.home}
>
<BrandMark size={20} />
<span className="text-[13px] font-semibold text-[var(--text)]">PseudoEditor</span>
</Link>
</header>
<div className="h-px shrink-0 bg-[var(--separator)]" />
<div className="min-h-0 flex-1">
<EditorLoader label={t.loading.workspace} />
</div>
</main>
);
}
Expand Down
26 changes: 24 additions & 2 deletions apps/web/src/app/components/MonacoPseudocodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

import { memo, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import Editor, { BeforeMount, OnMount } from "@monaco-editor/react";
import "@/lib/monacoLocalLoader";
import { initializeMonaco } from "@/lib/monacoLocalLoader";
import type * as Monaco from "monaco-editor";
import { EditorLoader } from "@/app/components/EditorLoader";
import { Diagnostic } from "@/compiler/types";
import { autoCorrectPseudocodeLine } from "@/app/components/pseudocodeAutocorrect";
import { afterNextPaint } from "@/lib/afterNextPaint";
import { isAppleTouchDevice } from "@/lib/appleTouch";
import { ensureMonacoNullCaretHitTestGuard } from "@/lib/monacoNullCaretHitTestGuard";
import { getPseudocodeEditorOptions } from "@/lib/pseudocodeEditorOptions";
Expand Down Expand Up @@ -171,7 +172,6 @@ let pseudocodeLanguageRegistered = false;
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;
Expand Down Expand Up @@ -381,6 +381,18 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({
[appleTouchDevice, touchScreen],
);

// Monaco loads in small steps (monacoLocalLoader) so that taps during loading stay responsive.
const [monacoReady, setMonacoReady] = useState(false);
const [loadingError, setLoadingError] = useState<unknown>(null);
useEffect(() => {
let active = true;
void initializeMonaco(editorOptions).then(
() => { if (active) setMonacoReady(true); },
(error: unknown) => { if (active) setLoadingError(error); },
);
return () => { active = false; };
}, [editorOptions]);

const handleBeforeMount: BeforeMount = () => {
void ensureMonacoNullCaretHitTestGuard();
};
Expand Down Expand Up @@ -852,6 +864,16 @@ export const MonacoPseudocodeEditor = memo(function MonacoPseudocodeEditor({
monaco.editor.setModelLanguage(model, languageId);
}, [languageId, documentKey]);

// Creating a Monaco editor takes about 100 ms on a tablet. A tap that opens a file would wait for
// it before painting, so the editor for a document is created after the next paint instead.
const [readyDocumentKey, setReadyDocumentKey] = useState<string | undefined | null>(null);
useEffect(() => afterNextPaint(() => setReadyDocumentKey(documentKey)), [documentKey]);

if (loadingError) throw loadingError;
if (!monacoReady || readyDocumentKey !== documentKey) {
return <EditorLoader />;
}

return (
<Editor
// One editor per document: on switch the wrapper saves this file's view state under its own path and the next
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@
U+FFFD;
}

/* Arial scaled to Inter's metrics, shown until Inter loads, so the swap does not move text. */
@font-face {
font-family: "Inter Fallback";
src: local("Arial"), local("ArialMT"), local("Helvetica");
ascent-override: 90.44%;
descent-override: 22.52%;
line-gap-override: 0%;
size-adjust: 107.12%;
}

/*
* 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.
Expand All @@ -130,7 +140,7 @@
:root,
:root[data-theme="dark"] {
color-scheme: dark;
--font-sans: "Inter";
--font-sans: "Inter", "Inter Fallback";
--font-mono: "Fira Code";
--bg: #16171B;
--sidebar: #1B1C21;
Expand Down Expand Up @@ -300,7 +310,7 @@ html {
:root:lang(zh),
:root[data-theme]:lang(zh) {
/* Inter has no CJK glyphs; name the platform Chinese fonts before the generic fallback. */
--font-sans: "Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Noto Sans SC";
--font-sans: "Inter", "Inter Fallback", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Noto Sans SC";
}

body {
Expand Down
Loading
Loading