diff --git a/.chronus/changes/playground-state-refactor-2026-6-2-12-30-0.md b/.chronus/changes/playground-state-refactor-2026-6-2-12-30-0.md new file mode 100644 index 00000000000..23d052ee9fb --- /dev/null +++ b/.chronus/changes/playground-state-refactor-2026-6-2-12-30-0.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/playground" +--- + +Refactor playground component: extract compilation, Monaco sync, debounced compile, and editor actions into dedicated hooks for better maintainability. diff --git a/packages/playground/src/react/compilation/compile.ts b/packages/playground/src/react/compilation/compile.ts new file mode 100644 index 00000000000..ab48201cf6b --- /dev/null +++ b/packages/playground/src/react/compilation/compile.ts @@ -0,0 +1,69 @@ +import type { CompilerOptions } from "@typespec/compiler"; +import { Uri, editor } from "monaco-editor"; +import { resolveVirtualPath } from "../../browser-host.js"; +import type { BrowserHost } from "../../types.js"; +import type { CompilationState } from "../types.js"; + +const outputDir = resolveVirtualPath("tsp-output"); + +export async function compile( + host: BrowserHost, + content: string, + selectedEmitter: string, + options: CompilerOptions, +): Promise { + await host.writeFile("main.tsp", content); + await emptyOutputDir(host); + try { + const typespecCompiler = host.compiler; + const program = await typespecCompiler.compile(host, resolveVirtualPath("main.tsp"), { + ...options, + options: { + ...options.options, + [selectedEmitter]: { + ...options.options?.[selectedEmitter], + "emitter-output-dir": outputDir, + }, + }, + outputDir, + emit: selectedEmitter ? [selectedEmitter] : [], + }); + const outputFiles = await findOutputFiles(host); + return { program, outputFiles }; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Internal compiler error", error); + return { internalCompilerError: error }; + } +} + +async function findOutputFiles(host: BrowserHost): Promise { + const files: string[] = []; + + async function addFiles(dir: string) { + const items = await host.readDir(outputDir + dir); + for (const item of items) { + const itemPath = `${dir}/${item}`; + if ((await host.stat(outputDir + itemPath)).isDirectory()) { + await addFiles(itemPath); + } else { + files.push(dir === "" ? item : `${dir}/${item}`); + } + } + } + await addFiles(""); + return files; +} + +async function emptyOutputDir(host: BrowserHost) { + const dirs = await host.readDir("./tsp-output"); + for (const file of dirs) { + const path = "./tsp-output/" + file; + const uri = Uri.parse(host.pathToFileURL(path)); + const model = editor.getModel(uri); + if (model) { + model.dispose(); + } + await host.rm(path, { recursive: true }); + } +} diff --git a/packages/playground/src/react/hooks/index.ts b/packages/playground/src/react/hooks/index.ts new file mode 100644 index 00000000000..e6deb74af31 --- /dev/null +++ b/packages/playground/src/react/hooks/index.ts @@ -0,0 +1,13 @@ +export { + useCompilation, + type UseCompilationOptions, + type UseCompilationResult, +} from "./use-compilation.js"; +export { useDebouncedCompile, type UseDebouncedCompileOptions } from "./use-debounced-compile.js"; +export { + useEditorActions, + type PlaygroundSaveData, + type UseEditorActionsOptions, + type UseEditorActionsResult, +} from "./use-editor-actions.js"; +export { useMonacoSync, type UseMonacoSyncOptions } from "./use-monaco-sync.js"; diff --git a/packages/playground/src/react/hooks/use-compilation.ts b/packages/playground/src/react/hooks/use-compilation.ts new file mode 100644 index 00000000000..08ae6038876 --- /dev/null +++ b/packages/playground/src/react/hooks/use-compilation.ts @@ -0,0 +1,136 @@ +import type { CompilerOptions } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; +import { MarkerSeverity, MarkerTag, editor } from "monaco-editor"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { getMonacoRange, updateDiagnosticsForCodeFixes } from "../../services.js"; +import type { BrowserHost } from "../../types.js"; +import { compile } from "../compilation/compile.js"; +import { debugGlobals } from "../debug.js"; +import type { CompilationState } from "../types.js"; + +export interface UseCompilationOptions { + host: BrowserHost; + selectedEmitter: string; + compilerOptions: CompilerOptions; + typespecModel: editor.ITextModel; +} + +export interface UseCompilationResult { + compilationState: CompilationState | undefined; + isCompiling: boolean; + isOutputStale: boolean; + doCompile: () => Promise; +} + +export function useCompilation({ + host, + selectedEmitter, + compilerOptions, + typespecModel, +}: UseCompilationOptions): UseCompilationResult { + const [compilationState, setCompilationState] = useState(undefined); + const [isCompiling, setIsCompiling] = useState(false); + const [isOutputStale, setIsOutputStale] = useState(false); + const lastSuccessfulOutputRef = useRef([]); + + // Clear preserved output when switching emitters + useEffect(() => { + lastSuccessfulOutputRef.current = []; + setIsOutputStale(false); + }, [selectedEmitter]); + + const compileIdRef = useRef(0); + const isCompilingRef = useRef(false); + const pendingRecompileRef = useRef(false); + const doCompileRef = useRef<() => Promise>(() => Promise.resolve()); + + const doCompile = useCallback(async () => { + if (isCompilingRef.current) { + pendingRecompileRef.current = true; + return; + } + const currentContent = typespecModel.getValue(); + const typespecCompiler = host.compiler; + const compileId = ++compileIdRef.current; + + isCompilingRef.current = true; + setIsCompiling(true); + let state: CompilationState; + try { + state = await compile(host, currentContent, selectedEmitter, compilerOptions); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Compilation failed", error); + isCompilingRef.current = false; + setIsCompiling(false); + if (pendingRecompileRef.current) { + pendingRecompileRef.current = false; + void doCompileRef.current(); + } + return; + } + isCompilingRef.current = false; + setIsCompiling(false); + + // Discard stale results from an older compilation + if (compileId !== compileIdRef.current) return; + + // When compilation has errors and produced no output files, preserve the + // previous successful output so the user doesn't lose their selected file + // while typing (transient syntax errors). + if ( + "program" in state && + state.program.hasError() && + state.outputFiles.length === 0 && + lastSuccessfulOutputRef.current.length > 0 + ) { + setIsOutputStale(true); + setCompilationState({ + ...state, + outputFiles: lastSuccessfulOutputRef.current, + }); + } else { + setIsOutputStale(false); + if ("program" in state && state.outputFiles.length > 0) { + lastSuccessfulOutputRef.current = state.outputFiles; + } + setCompilationState(state); + } + if ("program" in state) { + const markers: editor.IMarkerData[] = state.program.diagnostics.map((diag) => ({ + ...getMonacoRange(typespecCompiler, diag.target), + message: diag.message, + severity: diag.severity === "error" ? MarkerSeverity.Error : MarkerSeverity.Warning, + tags: diag.code === "deprecated" ? [MarkerTag.Deprecated] : undefined, + })); + + updateDiagnosticsForCodeFixes(typespecCompiler, state.program.diagnostics); + + debugGlobals().program = state.program; + debugGlobals().$$ = $(state.program); + + editor.setModelMarkers(typespecModel, "owner", markers ?? []); + } else { + updateDiagnosticsForCodeFixes(host.compiler, []); + editor.setModelMarkers(typespecModel, "owner", []); + } + + // If typing happened while this compile was running, trigger a trailing + // compile so the output stays in sync with the latest content. + if (pendingRecompileRef.current) { + pendingRecompileRef.current = false; + void doCompileRef.current(); + } + }, [host, selectedEmitter, compilerOptions, typespecModel]); + + useEffect(() => { + doCompileRef.current = doCompile; + }, [doCompile]); + + // Compile on mount and when dependencies change + useEffect(() => { + void doCompile(); + }, [doCompile]); + + return { compilationState, isCompiling, isOutputStale, doCompile }; +} diff --git a/packages/playground/src/react/hooks/use-debounced-compile.ts b/packages/playground/src/react/hooks/use-debounced-compile.ts new file mode 100644 index 00000000000..8a36df74d45 --- /dev/null +++ b/packages/playground/src/react/hooks/use-debounced-compile.ts @@ -0,0 +1,28 @@ +import debounce from "debounce"; +import { editor } from "monaco-editor"; +import { useEffect } from "react"; + +export interface UseDebouncedCompileOptions { + typespecModel: editor.ITextModel; + doCompile: () => Promise; + debounceDelay?: number; +} + +/** + * Subscribes to Monaco model content changes and triggers compilation + * after a debounce delay. + */ +export function useDebouncedCompile({ + typespecModel, + doCompile, + debounceDelay = 200, +}: UseDebouncedCompileOptions): void { + useEffect(() => { + const debouncer = debounce(() => doCompile(), debounceDelay); + const disposable = typespecModel.onDidChangeContent(debouncer); + return () => { + debouncer.clear(); + disposable.dispose(); + }; + }, [typespecModel, doCompile, debounceDelay]); +} diff --git a/packages/playground/src/react/hooks/use-editor-actions.ts b/packages/playground/src/react/hooks/use-editor-actions.ts new file mode 100644 index 00000000000..bb523e9ab11 --- /dev/null +++ b/packages/playground/src/react/hooks/use-editor-actions.ts @@ -0,0 +1,87 @@ +import type { CompilerOptions } from "@typespec/compiler"; +import { KeyCode, KeyMod, editor } from "monaco-editor"; +import { useCallback, useMemo, type RefObject } from "react"; +import type { PlaygroundState } from "../use-playground-state.js"; + +export interface PlaygroundSaveData extends PlaygroundState { + /** Current content of the playground. */ + content: string; + /** Emitter name. */ + emitter: string; +} + +export interface UseEditorActionsOptions { + typespecModel: editor.ITextModel; + editorRef: RefObject; + selectedEmitter: string; + compilerOptions: CompilerOptions; + selectedSampleName: string; + isSampleUntouched: boolean; + selectedViewer?: string; + viewerState: Record; + onSave?: (value: PlaygroundSaveData) => void; + onFileBug?: () => void; +} + +export interface UseEditorActionsResult { + saveCode: () => void; + formatCode: () => void; + fileBug: () => Promise; + editorActions: editor.IActionDescriptor[]; +} + +export function useEditorActions({ + typespecModel, + editorRef, + selectedEmitter, + compilerOptions, + selectedSampleName, + isSampleUntouched, + selectedViewer, + viewerState, + onSave, + onFileBug, +}: UseEditorActionsOptions): UseEditorActionsResult { + const saveCode = useCallback(() => { + if (onSave) { + const currentContent = typespecModel.getValue(); + onSave({ + content: currentContent, + emitter: selectedEmitter, + compilerOptions, + sampleName: isSampleUntouched ? selectedSampleName : undefined, + selectedViewer, + viewerState, + }); + } + }, [ + typespecModel, + onSave, + selectedEmitter, + compilerOptions, + selectedSampleName, + isSampleUntouched, + selectedViewer, + viewerState, + ]); + + const formatCode = useCallback(() => { + void editorRef.current?.getAction("editor.action.formatDocument")?.run(); + }, [editorRef]); + + const fileBug = useCallback(async () => { + if (onFileBug) { + saveCode(); + onFileBug(); + } + }, [onFileBug, saveCode]); + + const editorActions = useMemo( + (): editor.IActionDescriptor[] => [ + { id: "save", label: "Save", keybindings: [KeyMod.CtrlCmd | KeyCode.KeyS], run: saveCode }, + ], + [saveCode], + ); + + return { saveCode, formatCode, fileBug, editorActions }; +} diff --git a/packages/playground/src/react/hooks/use-monaco-sync.ts b/packages/playground/src/react/hooks/use-monaco-sync.ts new file mode 100644 index 00000000000..d1e59d1213b --- /dev/null +++ b/packages/playground/src/react/hooks/use-monaco-sync.ts @@ -0,0 +1,58 @@ +import { editor } from "monaco-editor"; +import { useEffect, useRef } from "react"; + +export interface UseMonacoSyncOptions { + typespecModel: editor.ITextModel; + content: string; + onContentChange: (content: string) => void; +} + +/** + * Bidirectional sync between a Monaco editor model and React state. + * + * - When `content` changes externally (e.g. sample selection), the model is updated. + * - When the user types in the editor, `onContentChange` is called. + * - Uses a ref guard (`isModelDrivenChangeRef`) to prevent infinite loops. + */ +export function useMonacoSync({ + typespecModel, + content, + onContentChange, +}: UseMonacoSyncOptions): void { + // Track whether content changes originated from the model (user typing) + // to avoid the sync effect resetting the model during typing + const isModelDrivenChangeRef = useRef(false); + + // Sync external content changes → Monaco model + useEffect(() => { + if (isModelDrivenChangeRef.current) { + isModelDrivenChangeRef.current = false; + return; + } + if (typespecModel.getValue() !== (content ?? "")) { + typespecModel.setValue(content ?? ""); + } + }, [content, typespecModel]); + + // Use refs to avoid re-subscribing to onDidChangeContent on every keystroke + const contentRef = useRef(content); + const onContentChangeRef = useRef(onContentChange); + useEffect(() => { + contentRef.current = content; + }, [content]); + useEffect(() => { + onContentChangeRef.current = onContentChange; + }, [onContentChange]); + + // Sync Monaco model changes → React state + useEffect(() => { + const disposable = typespecModel.onDidChangeContent(() => { + const newContent = typespecModel.getValue(); + if (newContent !== contentRef.current) { + isModelDrivenChangeRef.current = true; + onContentChangeRef.current(newContent); + } + }); + return () => disposable.dispose(); + }, [typespecModel]); +} diff --git a/packages/playground/src/react/playground.tsx b/packages/playground/src/react/playground.tsx index eb3e01a548e..583c72d37bb 100644 --- a/packages/playground/src/react/playground.tsx +++ b/packages/playground/src/react/playground.tsx @@ -1,9 +1,7 @@ -import type { CompilerOptions, Diagnostic } from "@typespec/compiler"; -import { $ } from "@typespec/compiler/typekit"; +import type { Diagnostic } from "@typespec/compiler"; import { Pane, SplitPane } from "@typespec/react-components"; import "@typespec/react-components/style.css"; -import debounce from "debounce"; -import { KeyCode, KeyMod, MarkerSeverity, MarkerTag, Uri, editor } from "monaco-editor"; +import { editor } from "monaco-editor"; import { useCallback, useEffect, @@ -13,20 +11,26 @@ import { type FunctionComponent, type ReactNode, } from "react"; -import { resolveVirtualPath } from "../browser-host.js"; import { EditorCommandBar } from "../editor-command-bar/editor-command-bar.js"; -import { getMonacoRange, updateDiagnosticsForCodeFixes } from "../services.js"; +import { getMonacoRange } from "../services.js"; import type { BrowserHost, PlaygroundSample } from "../types.js"; import { PlaygroundContextProvider } from "./context/playground-context.js"; import { debugGlobals, printDebugInfo } from "./debug.js"; import { DefaultFooter } from "./default-footer.js"; import { EditorPanel } from "./editor-panel/editor-panel.js"; import { useMonacoModel, type OnMountData } from "./editor.js"; +import { + useCompilation, + useDebouncedCompile, + useEditorActions, + useMonacoSync, + type PlaygroundSaveData, +} from "./hooks/index.js"; import { OutputView } from "./output-view/output-view.js"; import style from "./playground.module.css"; import { ProblemPane } from "./problem-pane/index.js"; import type { CommandBarItem } from "./responsive-command-bar/index.js"; -import type { CompilationState, FileOutputViewer, ProgramViewer } from "./types.js"; +import type { FileOutputViewer, ProgramViewer } from "./types.js"; import { useIsMobile } from "./use-mobile.js"; import { usePlaygroundState, type PlaygroundState } from "./use-playground-state.js"; import { ViewToggle, type ViewMode } from "./view-toggle.js"; @@ -94,13 +98,8 @@ export interface PlaygroundEditorsOptions { theme?: string; } -export interface PlaygroundSaveData extends PlaygroundState { - /** Current content of the playground. */ - content: string; - - /** Emitter name. */ - emitter: string; -} +// Re-export PlaygroundSaveData from hooks for backward compatibility +export type { PlaygroundSaveData }; /** * Playground component for TypeSpec with consolidated state management. @@ -157,15 +156,10 @@ export const Playground: FunctionComponent = (props) => { useEffect(() => { printDebugInfo(); - debugGlobals().host = host; }, [host]); const typespecModel = useMonacoModel("inmemory://test/main.tsp", "typespec"); - const [compilationState, setCompilationState] = useState(undefined); - const lastSuccessfulOutputRef = useRef([]); - const [isCompiling, setIsCompiling] = useState(false); - const [isOutputStale, setIsOutputStale] = useState(false); // Use the playground state hook const state = usePlaygroundState({ @@ -179,7 +173,6 @@ export const Playground: FunctionComponent = (props) => { defaultContent: props.defaultContent, }); - // Extract values from the state hook const { selectedEmitter, compilerOptions, @@ -195,207 +188,44 @@ export const Playground: FunctionComponent = (props) => { onContentChange, } = state; - // Clear preserved output when switching emitters - useEffect(() => { - lastSuccessfulOutputRef.current = []; - setIsOutputStale(false); - }, [selectedEmitter]); - - // Track whether content changes originated from the model (user typing) - // to avoid the sync effect resetting the model during typing - const isModelDrivenChangeRef = useRef(false); - - // Sync Monaco model with state content (only for external/programmatic changes) - useEffect(() => { - if (isModelDrivenChangeRef.current) { - isModelDrivenChangeRef.current = false; - return; - } - if (typespecModel.getValue() !== (content ?? "")) { - typespecModel.setValue(content ?? ""); - } - }, [content, typespecModel]); - - // Use refs to avoid re-subscribing to onDidChangeContent on every keystroke - const contentRef = useRef(content); - const onContentChangeRef = useRef(onContentChange); - useEffect(() => { - contentRef.current = content; - }, [content]); - useEffect(() => { - onContentChangeRef.current = onContentChange; - }, [onContentChange]); - - // Update state when Monaco model changes - useEffect(() => { - const disposable = typespecModel.onDidChangeContent(() => { - const newContent = typespecModel.getValue(); - if (newContent !== contentRef.current) { - isModelDrivenChangeRef.current = true; - onContentChangeRef.current(newContent); - } - }); - return () => disposable.dispose(); - }, [typespecModel]); - - const isSampleUntouched = useMemo(() => { - return Boolean(selectedSampleName && content === props.samples?.[selectedSampleName]?.content); - }, [content, selectedSampleName, props.samples]); + // Bidirectional Monaco ↔ state sync + useMonacoSync({ typespecModel, content, onContentChange }); - const compileIdRef = useRef(0); - const isCompilingRef = useRef(false); - const pendingRecompileRef = useRef(false); - const doCompileRef = useRef<() => Promise>(() => Promise.resolve()); - - const doCompile = useCallback(async () => { - // If a compile is already in progress, mark that a recompile is needed and - // bail out. The in-flight compile will re-trigger on completion. This avoids - // stacking up synchronous compiles that block the UI thread during typing. - if (isCompilingRef.current) { - pendingRecompileRef.current = true; - return; - } - const currentContent = typespecModel.getValue(); - const typespecCompiler = host.compiler; - const compileId = ++compileIdRef.current; - - isCompilingRef.current = true; - setIsCompiling(true); - let state: CompilationState; - try { - state = await compile(host, currentContent, selectedEmitter, compilerOptions); - } catch (error) { - // eslint-disable-next-line no-console - console.error("Compilation failed", error); - isCompilingRef.current = false; - setIsCompiling(false); - if (pendingRecompileRef.current) { - pendingRecompileRef.current = false; - void doCompileRef.current(); - } - return; - } - isCompilingRef.current = false; - setIsCompiling(false); - - // Discard stale results from an older compilation - if (compileId !== compileIdRef.current) return; - - // When compilation has errors and produced no output files, preserve the - // previous successful output so the user doesn't lose their selected file - // while typing (transient syntax errors). - if ( - "program" in state && - state.program.hasError() && - state.outputFiles.length === 0 && - lastSuccessfulOutputRef.current.length > 0 - ) { - setIsOutputStale(true); - setCompilationState({ - ...state, - outputFiles: lastSuccessfulOutputRef.current, - }); - } else { - setIsOutputStale(false); - if ("program" in state && state.outputFiles.length > 0) { - lastSuccessfulOutputRef.current = state.outputFiles; - } - setCompilationState(state); - } - if ("program" in state) { - const markers: editor.IMarkerData[] = state.program.diagnostics.map((diag) => ({ - ...getMonacoRange(typespecCompiler, diag.target), - message: diag.message, - severity: diag.severity === "error" ? MarkerSeverity.Error : MarkerSeverity.Warning, - tags: diag.code === "deprecated" ? [MarkerTag.Deprecated] : undefined, - })); - - // Update code action provider with current diagnostics (for codefix support). - updateDiagnosticsForCodeFixes(typespecCompiler, state.program.diagnostics); - - // Set the program on the window. - debugGlobals().program = state.program; - debugGlobals().$$ = $(state.program); - - editor.setModelMarkers(typespecModel, "owner", markers ?? []); - } else { - updateDiagnosticsForCodeFixes(typespecCompiler, []); - editor.setModelMarkers(typespecModel, "owner", []); - } - - // If typing happened while this compile was running, trigger a trailing - // compile so the output stays in sync with the latest content. - if (pendingRecompileRef.current) { - pendingRecompileRef.current = false; - void doCompileRef.current(); - } - }, [host, selectedEmitter, compilerOptions, typespecModel]); - - useEffect(() => { - doCompileRef.current = doCompile; - }, [doCompile]); + // Compilation + const { compilationState, isCompiling, isOutputStale, doCompile } = useCompilation({ + host, + selectedEmitter, + compilerOptions, + typespecModel, + }); + // Debounced recompile on content changes const currentEmitterOptions = selectedEmitter ? props.emitterOptions?.[selectedEmitter] : undefined; + useDebouncedCompile({ + typespecModel, + doCompile, + debounceDelay: currentEmitterOptions?.debounce, + }); - useEffect(() => { - const delay = currentEmitterOptions?.debounce ?? 200; - const debouncer = debounce(() => doCompile(), delay); - const disposable = typespecModel.onDidChangeContent(debouncer); - return () => { - debouncer.clear(); - disposable.dispose(); - }; - }, [typespecModel, doCompile, currentEmitterOptions?.debounce]); + // Editor actions (save, format, file-bug, keybindings) + const isSampleUntouched = useMemo(() => { + return Boolean(selectedSampleName && content === props.samples?.[selectedSampleName]?.content); + }, [content, selectedSampleName, props.samples]); - useEffect(() => { - void doCompile(); - }, [doCompile]); - - const saveCode = useCallback(() => { - if (onSave) { - // Read directly from the model to ensure we save the latest content, - // not a potentially stale React state value - const currentContent = typespecModel.getValue(); - onSave({ - content: currentContent, - emitter: selectedEmitter, - compilerOptions, - sampleName: isSampleUntouched ? selectedSampleName : undefined, - selectedViewer, - viewerState, - }); - } - }, [ + const { saveCode, formatCode, fileBug, editorActions } = useEditorActions({ typespecModel, - onSave, + editorRef, selectedEmitter, compilerOptions, selectedSampleName, isSampleUntouched, selectedViewer, viewerState, - ]); - - const formatCode = useCallback(() => { - void editorRef.current?.getAction("editor.action.formatDocument")?.run(); - }, []); - - const fileBug = useCallback(async () => { - if (props.onFileBug) { - saveCode(); - props.onFileBug(); - } - }, [props, saveCode]); - - const typespecEditorActions = useMemo( - (): editor.IActionDescriptor[] => [ - // ctrl/cmd+S => save - { id: "save", label: "Save", keybindings: [KeyMod.CtrlCmd | KeyCode.KeyS], run: saveCode }, - ], - [saveCode], - ); + onSave, + onFileBug: props.onFileBug, + }); const onTypeSpecEditorMount = useCallback(({ editor }: OnMountData) => { editorRef.current = editor; @@ -466,7 +296,7 @@ export const Playground: FunctionComponent = (props) => { { - await host.writeFile("main.tsp", content); - await emptyOutputDir(host); - try { - const typespecCompiler = host.compiler; - const program = await typespecCompiler.compile(host, resolveVirtualPath("main.tsp"), { - ...options, - options: { - ...options.options, - [selectedEmitter]: { - ...options.options?.[selectedEmitter], - "emitter-output-dir": outputDir, - }, - }, - outputDir, - emit: selectedEmitter ? [selectedEmitter] : [], - }); - const outputFiles = await findOutputFiles(host); - return { program, outputFiles }; - } catch (error) { - // eslint-disable-next-line no-console - console.error("Internal compiler error", error); - return { internalCompilerError: error }; - } -} -async function findOutputFiles(host: BrowserHost): Promise { - const files: string[] = []; - - async function addFiles(dir: string) { - const items = await host.readDir(outputDir + dir); - for (const item of items) { - const itemPath = `${dir}/${item}`; - if ((await host.stat(outputDir + itemPath)).isDirectory()) { - await addFiles(itemPath); - } else { - files.push(dir === "" ? item : `${dir}/${item}`); - } - } - } - await addFiles(""); - return files; -} - -async function emptyOutputDir(host: BrowserHost) { - // empty output directory - const dirs = await host.readDir("./tsp-output"); - for (const file of dirs) { - const path = "./tsp-output/" + file; - const uri = Uri.parse(host.pathToFileURL(path)); - const model = editor.getModel(uri); - if (model) { - model.dispose(); - } - await host.rm(path, { recursive: true }); - } -} diff --git a/packages/playground/test/use-debounced-compile.test.ts b/packages/playground/test/use-debounced-compile.test.ts new file mode 100644 index 00000000000..14d3d6dc687 --- /dev/null +++ b/packages/playground/test/use-debounced-compile.test.ts @@ -0,0 +1,131 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useDebouncedCompile } from "../src/react/hooks/use-debounced-compile.js"; + +function createMockModel() { + const listeners: Array<() => void> = []; + return { + onDidChangeContent: vi.fn((cb: () => void) => { + listeners.push(cb); + return { dispose: vi.fn() }; + }), + simulateChange() { + for (const cb of listeners) cb(); + }, + }; +} + +describe("useDebouncedCompile", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it("triggers compile after debounce delay on content change", () => { + const model = createMockModel(); + const doCompile = vi.fn(() => Promise.resolve()); + + renderHook(() => + useDebouncedCompile({ + typespecModel: model as any, + doCompile, + debounceDelay: 100, + }), + ); + + act(() => { + model.simulateChange(); + }); + + // Not called immediately + expect(doCompile).not.toHaveBeenCalled(); + + // Called after delay + act(() => { + vi.advanceTimersByTime(100); + }); + expect(doCompile).toHaveBeenCalledTimes(1); + }); + + it("debounces rapid changes into a single compile", () => { + const model = createMockModel(); + const doCompile = vi.fn(() => Promise.resolve()); + + renderHook(() => + useDebouncedCompile({ + typespecModel: model as any, + doCompile, + debounceDelay: 200, + }), + ); + + act(() => { + model.simulateChange(); + }); + act(() => { + vi.advanceTimersByTime(50); + }); + act(() => { + model.simulateChange(); + }); + act(() => { + vi.advanceTimersByTime(50); + }); + act(() => { + model.simulateChange(); + }); + + // Still no call + expect(doCompile).not.toHaveBeenCalled(); + + // After full debounce from last change + act(() => { + vi.advanceTimersByTime(200); + }); + expect(doCompile).toHaveBeenCalledTimes(1); + }); + + it("uses default 200ms delay when debounceDelay is not provided", () => { + const model = createMockModel(); + const doCompile = vi.fn(() => Promise.resolve()); + + renderHook(() => + useDebouncedCompile({ + typespecModel: model as any, + doCompile, + }), + ); + + act(() => { + model.simulateChange(); + }); + + act(() => { + vi.advanceTimersByTime(199); + }); + expect(doCompile).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(doCompile).toHaveBeenCalledTimes(1); + }); + + it("cleans up subscription on unmount", () => { + const disposeMock = vi.fn(); + const model = { + onDidChangeContent: vi.fn(() => ({ dispose: disposeMock })), + }; + const doCompile = vi.fn(() => Promise.resolve()); + + const { unmount } = renderHook(() => + useDebouncedCompile({ + typespecModel: model as any, + doCompile, + debounceDelay: 100, + }), + ); + + unmount(); + expect(disposeMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/playground/test/use-editor-actions.test.ts b/packages/playground/test/use-editor-actions.test.ts new file mode 100644 index 00000000000..58db6c492c2 --- /dev/null +++ b/packages/playground/test/use-editor-actions.test.ts @@ -0,0 +1,181 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useEditorActions } from "../src/react/hooks/use-editor-actions.js"; + +function createMockModel(value = "model content") { + return { + getValue: vi.fn(() => value), + }; +} + +function createMockEditorRef(hasFormatAction = true) { + const runMock = vi.fn(); + const ref = { + current: { + getAction: vi.fn((id: string) => { + if (id === "editor.action.formatDocument" && hasFormatAction) { + return { run: runMock }; + } + return null; + }), + }, + }; + return { ref, runMock }; +} + +describe("useEditorActions", () => { + const baseProps = { + selectedEmitter: "openapi3", + compilerOptions: {}, + selectedSampleName: "basic", + isSampleUntouched: true, + selectedViewer: "openapi", + viewerState: {}, + }; + + describe("saveCode", () => { + it("calls onSave with current model content and state", () => { + const model = createMockModel("my content"); + const { ref } = createMockEditorRef(); + const onSave = vi.fn(); + + const { result } = renderHook(() => + useEditorActions({ + ...baseProps, + typespecModel: model as any, + editorRef: ref as any, + onSave, + }), + ); + + result.current.saveCode(); + + expect(onSave).toHaveBeenCalledWith({ + content: "my content", + emitter: "openapi3", + compilerOptions: {}, + sampleName: "basic", + selectedViewer: "openapi", + viewerState: {}, + }); + }); + + it("does not include sampleName when sample is modified", () => { + const model = createMockModel("modified"); + const { ref } = createMockEditorRef(); + const onSave = vi.fn(); + + const { result } = renderHook(() => + useEditorActions({ + ...baseProps, + typespecModel: model as any, + editorRef: ref as any, + isSampleUntouched: false, + onSave, + }), + ); + + result.current.saveCode(); + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ sampleName: undefined })); + }); + + it("does nothing when onSave is not provided", () => { + const model = createMockModel(); + const { ref } = createMockEditorRef(); + + const { result } = renderHook(() => + useEditorActions({ + ...baseProps, + typespecModel: model as any, + editorRef: ref as any, + }), + ); + + // Should not throw + expect(() => result.current.saveCode()).not.toThrow(); + }); + }); + + describe("formatCode", () => { + it("runs the format document action", () => { + const model = createMockModel(); + const { ref, runMock } = createMockEditorRef(); + + const { result } = renderHook(() => + useEditorActions({ + ...baseProps, + typespecModel: model as any, + editorRef: ref as any, + }), + ); + + result.current.formatCode(); + + expect(ref.current.getAction).toHaveBeenCalledWith("editor.action.formatDocument"); + expect(runMock).toHaveBeenCalled(); + }); + }); + + describe("fileBug", () => { + it("calls saveCode then onFileBug", async () => { + const model = createMockModel(); + const { ref } = createMockEditorRef(); + const onSave = vi.fn(); + const onFileBug = vi.fn(); + + const { result } = renderHook(() => + useEditorActions({ + ...baseProps, + typespecModel: model as any, + editorRef: ref as any, + onSave, + onFileBug, + }), + ); + + await result.current.fileBug(); + + expect(onSave).toHaveBeenCalled(); + expect(onFileBug).toHaveBeenCalled(); + }); + + it("does nothing when onFileBug is not provided", async () => { + const model = createMockModel(); + const { ref } = createMockEditorRef(); + const onSave = vi.fn(); + + const { result } = renderHook(() => + useEditorActions({ + ...baseProps, + typespecModel: model as any, + editorRef: ref as any, + onSave, + }), + ); + + await result.current.fileBug(); + expect(onSave).not.toHaveBeenCalled(); + }); + }); + + describe("editorActions", () => { + it("returns a save action with Ctrl+S keybinding", () => { + const model = createMockModel(); + const { ref } = createMockEditorRef(); + + const { result } = renderHook(() => + useEditorActions({ + ...baseProps, + typespecModel: model as any, + editorRef: ref as any, + }), + ); + + expect(result.current.editorActions).toHaveLength(1); + expect(result.current.editorActions[0].id).toBe("save"); + expect(result.current.editorActions[0].label).toBe("Save"); + expect(result.current.editorActions[0].keybindings).toBeDefined(); + }); + }); +}); diff --git a/packages/playground/test/use-monaco-sync.test.ts b/packages/playground/test/use-monaco-sync.test.ts new file mode 100644 index 00000000000..da1dfda4bc2 --- /dev/null +++ b/packages/playground/test/use-monaco-sync.test.ts @@ -0,0 +1,125 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useMonacoSync } from "../src/react/hooks/use-monaco-sync.js"; + +function createMockModel(initialValue = "") { + let value = initialValue; + const listeners: Array<() => void> = []; + return { + getValue: vi.fn(() => value), + setValue: vi.fn((v: string) => { + value = v; + }), + onDidChangeContent: vi.fn((cb: () => void) => { + listeners.push(cb); + return { dispose: vi.fn() }; + }), + // Test helper to simulate typing + simulateTyping(newValue: string) { + value = newValue; + for (const cb of listeners) cb(); + }, + }; +} + +describe("useMonacoSync", () => { + it("sets model value when content changes externally", () => { + const model = createMockModel(""); + + const { rerender } = renderHook( + ({ content }) => + useMonacoSync({ + typespecModel: model as any, + content, + onContentChange: vi.fn(), + }), + { initialProps: { content: "initial" } }, + ); + + expect(model.setValue).toHaveBeenCalledWith("initial"); + + rerender({ content: "updated" }); + expect(model.setValue).toHaveBeenCalledWith("updated"); + }); + + it("does not set model value when content matches model", () => { + const model = createMockModel("same"); + + renderHook(() => + useMonacoSync({ + typespecModel: model as any, + content: "same", + onContentChange: vi.fn(), + }), + ); + + expect(model.setValue).not.toHaveBeenCalled(); + }); + + it("calls onContentChange when model content changes (user typing)", () => { + const model = createMockModel("hello"); + const onContentChange = vi.fn(); + + renderHook(() => + useMonacoSync({ + typespecModel: model as any, + content: "hello", + onContentChange, + }), + ); + + act(() => { + model.simulateTyping("hello world"); + }); + + expect(onContentChange).toHaveBeenCalledWith("hello world"); + }); + + it("does not call onContentChange when model value matches current content", () => { + const model = createMockModel("same"); + const onContentChange = vi.fn(); + + renderHook(() => + useMonacoSync({ + typespecModel: model as any, + content: "same", + onContentChange, + }), + ); + + act(() => { + model.simulateTyping("same"); + }); + + expect(onContentChange).not.toHaveBeenCalled(); + }); + + it("does not reset model after model-driven content change", () => { + const model = createMockModel("initial"); + const onContentChange = vi.fn(); + + const { rerender } = renderHook( + ({ content }) => + useMonacoSync({ + typespecModel: model as any, + content, + onContentChange, + }), + { initialProps: { content: "initial" } }, + ); + + // Simulate typing which triggers onContentChange + act(() => { + model.simulateTyping("typed"); + }); + + expect(onContentChange).toHaveBeenCalledWith("typed"); + model.setValue.mockClear(); + + // Now React re-renders with the new content from state + rerender({ content: "typed" }); + + // Should NOT call setValue again since it was model-driven + expect(model.setValue).not.toHaveBeenCalled(); + }); +});