Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 69 additions & 0 deletions packages/playground/src/react/compilation/compile.ts
Original file line number Diff line number Diff line change
@@ -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<CompilationState> {
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<string[]> {
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 });
}
}
13 changes: 13 additions & 0 deletions packages/playground/src/react/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -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";
136 changes: 136 additions & 0 deletions packages/playground/src/react/hooks/use-compilation.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

export function useCompilation({
host,
selectedEmitter,
compilerOptions,
typespecModel,
}: UseCompilationOptions): UseCompilationResult {
const [compilationState, setCompilationState] = useState<CompilationState | undefined>(undefined);
const [isCompiling, setIsCompiling] = useState(false);
const [isOutputStale, setIsOutputStale] = useState(false);
const lastSuccessfulOutputRef = useRef<string[]>([]);

// 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<void>>(() => 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 };
}
28 changes: 28 additions & 0 deletions packages/playground/src/react/hooks/use-debounced-compile.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
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]);
}
87 changes: 87 additions & 0 deletions packages/playground/src/react/hooks/use-editor-actions.ts
Original file line number Diff line number Diff line change
@@ -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<editor.IStandaloneCodeEditor | undefined>;
selectedEmitter: string;
compilerOptions: CompilerOptions;
selectedSampleName: string;
isSampleUntouched: boolean;
selectedViewer?: string;
viewerState: Record<string, any>;
onSave?: (value: PlaygroundSaveData) => void;
onFileBug?: () => void;
}

export interface UseEditorActionsResult {
saveCode: () => void;
formatCode: () => void;
fileBug: () => Promise<void>;
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 };
}
Loading
Loading