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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/playground"
---

Make the raw `tspconfig.yaml` editor the source of truth so manual edits (comments, `output-dir`, `warn-as-error`, ordering and any unknown fields) are preserved instead of being reverted, compile by resolving the written `tspconfig.yaml` natively, and add language-server completion to the config editor.
34 changes: 24 additions & 10 deletions packages/playground/src/react/compilation/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,38 @@ export async function compile(
host: BrowserHost,
content: string,
selectedEmitter: string,
options: CompilerOptions,
tspconfig: string,
): Promise<CompilationState> {
await host.writeFile("main.tsp", content);
await host.writeFile("tspconfig.yaml", tspconfig);
await emptyOutputDir(host);
try {
const typespecCompiler = host.compiler;
const program = await typespecCompiler.compile(host, resolveVirtualPath("main.tsp"), {
...options,

// Resolve the compiler options natively from the tspconfig.yaml so the playground
// honors the full config (emit, options, linter, imports, warn-as-error, ...).
const [resolvedOptions] = await typespecCompiler.resolveCompilerOptions(host, {
cwd: resolveVirtualPath("."),
entrypoint: resolveVirtualPath("main.tsp"),
});

const options: CompilerOptions = {
...resolvedOptions,
options: {
...options.options,
[selectedEmitter]: {
...options.options?.[selectedEmitter],
"emitter-output-dir": outputDir,
},
...resolvedOptions.options,
...(selectedEmitter
? {
[selectedEmitter]: {
...resolvedOptions.options?.[selectedEmitter],
"emitter-output-dir": outputDir,
},
}
: {}),
},
outputDir,
emit: selectedEmitter ? [selectedEmitter] : [],
});
};

const program = await typespecCompiler.compile(host, resolveVirtualPath("main.tsp"), options);
const outputFiles = await findOutputFiles(host);
return { program, outputFiles };
} catch (error) {
Expand Down
74 changes: 31 additions & 43 deletions packages/playground/src/react/editor-panel/config-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ import { Editor, useMonacoModel } from "../editor.js";
import type { PlaygroundEditorsOptions } from "../playground.js";
import { CompilerSettings } from "../settings/compiler-settings.js";
import style from "./config-panel.module.css";
import { compilerOptionsToTspConfig, parseTspConfigYaml } from "./tspconfig-utils.js";

export interface ConfigPanelProps {
host: BrowserHost;
selectedEmitter: string;
compilerOptions: CompilerOptions;
/** Raw tspconfig.yaml content (source of truth). */
tspconfig: string;
onCompilerOptionsChange: (options: CompilerOptions) => void;
onSelectedEmitterChange: (emitter: string) => void;
onTspconfigChange: (tspconfig: string) => void;
editorOptions?: PlaygroundEditorsOptions;
}

Expand All @@ -24,70 +26,56 @@ export const ConfigPanel: FunctionComponent<ConfigPanelProps> = ({
host,
selectedEmitter,
compilerOptions,
tspconfig,
onCompilerOptionsChange,
onSelectedEmitterChange,
onTspconfigChange,
editorOptions,
}) => {
const [mode, setMode] = useState<ConfigMode>("form");
const yamlModel = useMonacoModel("inmemory://test/tspconfig.yaml", "yaml");

// Tracks whether the last state change originated from the YAML editor.
// Persists across the render cycle so the sync-back effect can see it.
// Tracks whether the last model change originated from the YAML editor itself so the
// state → model sync effect doesn't clobber in-flight edits (state updates are debounced).
const changeFromYamlRef = useRef(false);

// Sync external changes (e.g. emitter dropdown) → YAML model when in yaml mode.
// Skips when the change originated from the YAML editor itself.
useEffect(() => {
if (mode !== "yaml") return;
if (changeFromYamlRef.current) {
changeFromYamlRef.current = false;
return;
}
const yaml = compilerOptionsToTspConfig(selectedEmitter, compilerOptions);
const current = yamlModel.getValue();
if (current !== yaml) {
yamlModel.setValue(yaml);
}
}, [selectedEmitter, compilerOptions, mode, yamlModel]);

// Debounced YAML → CompilerOptions parsing
const parseAndSync = useMemo(
// Debounced YAML → state propagation. The raw text is the source of truth so it is
// stored verbatim (comments, ordering and unknown fields are all preserved).
const propagateChange = useMemo(
() =>
debounce((content: string) => {
const parsed = parseTspConfigYaml(content);
if (!parsed) return; // Invalid YAML — don't touch state
changeFromYamlRef.current = true;
if (parsed.selectedEmitter && parsed.selectedEmitter !== selectedEmitter) {
onSelectedEmitterChange(parsed.selectedEmitter);
}
onCompilerOptionsChange(parsed.compilerOptions);
onTspconfigChange(content);
}, 200),
[selectedEmitter, onCompilerOptionsChange, onSelectedEmitterChange],
[onTspconfigChange],
);

// Listen for YAML model changes
useEffect(() => {
const disposable = yamlModel.onDidChangeContent(() => {
parseAndSync(yamlModel.getValue());
changeFromYamlRef.current = true;
propagateChange(yamlModel.getValue());
});
return () => {
parseAndSync.clear();
propagateChange.clear();
disposable.dispose();
};
}, [yamlModel, parseAndSync]);
}, [yamlModel, propagateChange]);

// Populate YAML model when switching to yaml mode
const handleModeChange = useCallback<SelectTabEventHandler>(
(_, data) => {
const newMode = data.value as ConfigMode;
if (newMode === "yaml") {
const yaml = compilerOptionsToTspConfig(selectedEmitter, compilerOptions);
yamlModel.setValue(yaml);
}
setMode(newMode);
},
[selectedEmitter, compilerOptions, yamlModel],
);
// Sync state → YAML model (initial load, visual-form edits, samples, external changes).
// Skips when the change originated from the YAML editor to avoid reverting live edits.
useEffect(() => {
if (changeFromYamlRef.current) {
changeFromYamlRef.current = false;
return;
}
if (yamlModel.getValue() !== tspconfig) {
yamlModel.setValue(tspconfig);
}
}, [tspconfig, yamlModel]);

const handleModeChange = useCallback<SelectTabEventHandler>((_, data) => {
setMode(data.value as ConfigMode);
}, []);

return (
<div className={style["config-panel"]}>
Expand Down
6 changes: 6 additions & 0 deletions packages/playground/src/react/editor-panel/editor-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ export interface EditorPanelProps {

selectedEmitter: string;
compilerOptions: CompilerOptions;
tspconfig: string;
onCompilerOptionsChange: (options: CompilerOptions) => void;
onSelectedEmitterChange: (emitter: string) => void;
onTspconfigChange: (tspconfig: string) => void;

/** Toolbar content rendered above the editor area */
commandBar?: ReactNode;
Expand All @@ -52,8 +54,10 @@ export const EditorPanel: FunctionComponent<EditorPanelProps> = ({
onMount,
selectedEmitter,
compilerOptions,
tspconfig,
onCompilerOptionsChange,
onSelectedEmitterChange,
onTspconfigChange,
commandBar,
}) => {
const [selectedTab, setSelectedTab] = useState<EditorPanelTab>("tsp");
Expand Down Expand Up @@ -92,8 +96,10 @@ export const EditorPanel: FunctionComponent<EditorPanelProps> = ({
host={host}
selectedEmitter={selectedEmitter}
compilerOptions={compilerOptions}
tspconfig={tspconfig}
onCompilerOptionsChange={onCompilerOptionsChange}
onSelectedEmitterChange={onSelectedEmitterChange}
onTspconfigChange={onTspconfigChange}
editorOptions={editorOptions}
/>
)}
Expand Down
56 changes: 54 additions & 2 deletions packages/playground/src/react/editor-panel/tspconfig-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CompilerOptions, LinterRuleSet } from "@typespec/compiler";
import { parse, stringify } from "yaml";
import { isMap, parse, parseDocument, stringify } from "yaml";

export interface TspConfig {
emit?: string[];
Expand All @@ -11,6 +11,15 @@ export interface TspConfig {
"output-dir"?: string;
}

function hasLinterRules(linterRuleSet: LinterRuleSet | undefined): boolean {
if (!linterRuleSet) return false;
return Boolean(
(linterRuleSet.extends && linterRuleSet.extends.length > 0) ||
(linterRuleSet.enable && Object.keys(linterRuleSet.enable).length > 0) ||
(linterRuleSet.disable && Object.keys(linterRuleSet.disable).length > 0),
);
}

/**
* Serialize the current playground state (emitter + compiler options) to tspconfig.yaml content.
*/
Expand All @@ -28,13 +37,56 @@ export function compilerOptionsToTspConfig(
config.options = compilerOptions.options;
}

if (compilerOptions.linterRuleSet) {
if (hasLinterRules(compilerOptions.linterRuleSet)) {
config.linter = compilerOptions.linterRuleSet;
}

return stringify(config, { indent: 2 }) || "";
}

/**
* Update an existing tspconfig.yaml with structured changes coming from the visual form
* (emitter + compiler options) while preserving comments and any other fields the form
* doesn't manage (e.g. `output-dir`, `warn-as-error`, `imports`).
*/
export function updateTspConfigYaml(
existingYaml: string,
selectedEmitter: string,
compilerOptions: CompilerOptions,
): string {
let doc;
try {
doc = parseDocument(existingYaml ?? "");
} catch {
return compilerOptionsToTspConfig(selectedEmitter, compilerOptions);
}

// If the existing content isn't a clean mapping (empty/invalid), rebuild from scratch.
if (doc.errors.length > 0 || !isMap(doc.contents)) {
return compilerOptionsToTspConfig(selectedEmitter, compilerOptions);
}

if (selectedEmitter) {
doc.setIn(["emit"], [selectedEmitter]);
} else {
doc.deleteIn(["emit"]);
}

if (compilerOptions.options && Object.keys(compilerOptions.options).length > 0) {
doc.setIn(["options"], compilerOptions.options);
} else {
doc.deleteIn(["options"]);
}

if (hasLinterRules(compilerOptions.linterRuleSet)) {
doc.setIn(["linter"], compilerOptions.linterRuleSet);
} else {
doc.deleteIn(["linter"]);
}

return doc.toString();
}

export interface ParsedTspConfig {
selectedEmitter?: string;
compilerOptions: CompilerOptions;
Expand Down
10 changes: 5 additions & 5 deletions packages/playground/src/react/hooks/use-compilation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
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";
Expand All @@ -11,7 +10,8 @@ import type { CompilationState } from "../types.js";
export interface UseCompilationOptions {
host: BrowserHost;
selectedEmitter: string;
compilerOptions: CompilerOptions;
/** Raw tspconfig.yaml content used to configure the compilation. */
tspconfig: string;
typespecModel: editor.ITextModel;
}

Expand All @@ -25,7 +25,7 @@ export interface UseCompilationResult {
export function useCompilation({
host,
selectedEmitter,
compilerOptions,
tspconfig,
typespecModel,
}: UseCompilationOptions): UseCompilationResult {
const [compilationState, setCompilationState] = useState<CompilationState | undefined>(undefined);
Expand Down Expand Up @@ -57,7 +57,7 @@ export function useCompilation({
setIsCompiling(true);
let state: CompilationState;
try {
state = await compile(host, currentContent, selectedEmitter, compilerOptions);
state = await compile(host, currentContent, selectedEmitter, tspconfig);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Compilation failed", error);
Expand Down Expand Up @@ -121,7 +121,7 @@ export function useCompilation({
pendingRecompileRef.current = false;
void doCompileRef.current();
}
}, [host, selectedEmitter, compilerOptions, typespecModel]);
}, [host, selectedEmitter, tspconfig, typespecModel]);

useEffect(() => {
doCompileRef.current = doCompile;
Expand Down
4 changes: 4 additions & 0 deletions packages/playground/src/react/hooks/use-editor-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface UseEditorActionsOptions {
editorRef: RefObject<editor.IStandaloneCodeEditor | undefined>;
selectedEmitter: string;
compilerOptions: CompilerOptions;
tspconfig: string;
selectedSampleName: string;
isSampleUntouched: boolean;
selectedViewer?: string;
Expand All @@ -35,6 +36,7 @@ export function useEditorActions({
editorRef,
selectedEmitter,
compilerOptions,
tspconfig,
selectedSampleName,
isSampleUntouched,
selectedViewer,
Expand All @@ -49,6 +51,7 @@ export function useEditorActions({
content: currentContent,
emitter: selectedEmitter,
compilerOptions,
tspconfig,
sampleName: isSampleUntouched ? selectedSampleName : undefined,
selectedViewer,
viewerState,
Expand All @@ -59,6 +62,7 @@ export function useEditorActions({
onSave,
selectedEmitter,
compilerOptions,
tspconfig,
selectedSampleName,
isSampleUntouched,
selectedViewer,
Expand Down
Loading
Loading