diff --git a/.chronus/changes/feat-playground-file-explorer-2026-3-13-10-30-8.md b/.chronus/changes/feat-playground-file-explorer-2026-3-13-10-30-8.md new file mode 100644 index 00000000000..d3a4ee34bdb --- /dev/null +++ b/.chronus/changes/feat-playground-file-explorer-2026-3-13-10-30-8.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/playground" +--- + +Add multi-file editor with file explorer support \ No newline at end of file diff --git a/packages/playground/src/react/editor-panel/editor-panel.module.css b/packages/playground/src/react/editor-panel/editor-panel.module.css index c45aca723db..c118a5faa09 100644 --- a/packages/playground/src/react/editor-panel/editor-panel.module.css +++ b/packages/playground/src/react/editor-panel/editor-panel.module.css @@ -8,6 +8,14 @@ background-color: var(--colorNeutralBackground3); } +.file-tree-container { + width: 220px; + min-width: 150px; + border-right: 1px solid var(--colorNeutralStroke1); + overflow-y: auto; + background-color: var(--colorNeutralBackground2); +} + .panel-content { flex: 1; min-width: 0; diff --git a/packages/playground/src/react/editor-panel/editor-panel.tsx b/packages/playground/src/react/editor-panel/editor-panel.tsx index 11369bb894d..2cc216ec6f7 100644 --- a/packages/playground/src/react/editor-panel/editor-panel.tsx +++ b/packages/playground/src/react/editor-panel/editor-panel.tsx @@ -5,6 +5,7 @@ import { editor } from "monaco-editor"; import { useCallback, useState, type FunctionComponent, type ReactNode } from "react"; import type { BrowserHost } from "../../types.js"; import type { OnMountData } from "../editor.js"; +import { FileTreeExplorer } from "../file-tree/file-tree.js"; import type { PlaygroundEditorsOptions } from "../playground.js"; import { TypeSpecEditor } from "../typespec-editor.js"; import { ConfigPanel } from "./config-panel.js"; @@ -42,6 +43,13 @@ export interface EditorPanelProps { /** Toolbar content rendered above the editor area */ commandBar?: ReactNode; + + /** List of input file paths for multi-file mode */ + inputFiles?: string[]; + /** Currently selected input file */ + selectedInputFile?: string; + /** Callback when a different input file is selected */ + onSelectedInputFileChange?: (file: string) => void; } export const EditorPanel: FunctionComponent = ({ @@ -55,8 +63,12 @@ export const EditorPanel: FunctionComponent = ({ onCompilerOptionsChange, onSelectedEmitterChange, commandBar, + inputFiles, + selectedInputFile, + onSelectedInputFileChange, }) => { const [selectedTab, setSelectedTab] = useState("tsp"); + const showFileTree = inputFiles && inputFiles.length > 1; const onTabSelect = useCallback((_, data) => { setSelectedTab(data.value as EditorPanelTab); @@ -78,6 +90,15 @@ export const EditorPanel: FunctionComponent = ({ + {showFileTree && selectedTab === "tsp" && ( +
+ onSelectedInputFileChange?.(file)} + /> +
+ )}
{commandBar} {selectedTab === "tsp" ? ( diff --git a/packages/playground/src/react/editor.tsx b/packages/playground/src/react/editor.tsx index 7c73486f92e..4255b792019 100644 --- a/packages/playground/src/react/editor.tsx +++ b/packages/playground/src/react/editor.tsx @@ -65,3 +65,69 @@ export function useMonacoModel(uri: string, language?: string): editor.IModel { return editor.getModel(monacoUri) ?? editor.createModel("", language, monacoUri); }, [uri, language]); } + +/** + * Manages multiple Monaco models for a set of files. + * Creates/updates/disposes models as files change. + * Returns the active model based on the selected file. + */ +export function useMonacoModels( + files: Record, + selectedFile: string, + language: string = "typespec", +): { activeModel: editor.IModel; allModels: Map } { + const modelsRef = useRef>(new Map()); + + // Sync models with files + const allModels = useMemo(() => { + const models = modelsRef.current; + const currentPaths = new Set(Object.keys(files)); + + // Remove models for deleted files + for (const [path, model] of models) { + if (!currentPaths.has(path)) { + model.dispose(); + models.delete(path); + } + } + + // Create or update models for current files + for (const [path, content] of Object.entries(files)) { + const uri = Uri.parse(`inmemory://test/${path}`); + let model = models.get(path); + if (!model) { + model = editor.getModel(uri) ?? editor.createModel(content, language, uri); + models.set(path, model); + } else if (model.getValue() !== content) { + model.setValue(content); + } + } + + return models; + }, [files, language]); + + // Get or create the active model + const activeModel = useMemo(() => { + const model = allModels.get(selectedFile); + if (model) return model; + + // Fallback: create a model for the selected file + const uri = Uri.parse(`inmemory://test/${selectedFile}`); + const fallback = + editor.getModel(uri) ?? editor.createModel(files[selectedFile] ?? "", language, uri); + allModels.set(selectedFile, fallback); + return fallback; + }, [allModels, selectedFile, files, language]); + + // Cleanup on unmount + useEffect(() => { + return () => { + for (const model of modelsRef.current.values()) { + model.dispose(); + } + modelsRef.current.clear(); + }; + }, []); + + return { activeModel, allModels }; +} diff --git a/packages/playground/src/react/playground.tsx b/packages/playground/src/react/playground.tsx index b65bb51b421..e7fc2ebf1f6 100644 --- a/packages/playground/src/react/playground.tsx +++ b/packages/playground/src/react/playground.tsx @@ -22,7 +22,7 @@ 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 { useMonacoModels, type OnMountData } from "./editor.js"; import { OutputView } from "./output-view/output-view.js"; import style from "./playground.module.css"; import { ProblemPane } from "./problem-pane/index.js"; @@ -164,7 +164,6 @@ export const Playground: FunctionComponent = (props) => { 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); @@ -190,37 +189,53 @@ export const Playground: FunctionComponent = (props) => { selectedViewer, viewerState, content, + files, + selectedFile, + isMultiFile, onSelectedEmitterChange, onCompilerOptionsChange, onSelectedSampleNameChange, onSelectedViewerChange, onViewerStateChange, onContentChange, + onFileContentChange, + onSelectedFileChange, } = state; + // Manage Monaco models for all input files. Content sync (state -> model) + // is handled inside the hook based on the `files` map. + const { activeModel: typespecModel } = useMonacoModels(files, selectedFile); + // Clear preserved output when switching emitters useEffect(() => { lastSuccessfulOutputRef.current = []; setIsOutputStale(false); }, [selectedEmitter]); - // Sync Monaco model with state content - useEffect(() => { - if (typespecModel.getValue() !== (content ?? "")) { - typespecModel.setValue(content ?? ""); - } - }, [content, typespecModel]); - // Update state when Monaco model changes useEffect(() => { const disposable = typespecModel.onDidChangeContent(() => { const newContent = typespecModel.getValue(); - if (newContent !== content) { - onContentChange(newContent); + if (isMultiFile) { + if (newContent !== files[selectedFile]) { + onFileContentChange(selectedFile, newContent); + } + } else { + if (newContent !== content) { + onContentChange(newContent); + } } }); return () => disposable.dispose(); - }, [typespecModel, content, onContentChange]); + }, [ + typespecModel, + content, + files, + selectedFile, + isMultiFile, + onContentChange, + onFileContentChange, + ]); const isSampleUntouched = useMemo(() => { return Boolean(selectedSampleName && content === props.samples?.[selectedSampleName]?.content); @@ -236,7 +251,7 @@ export const Playground: FunctionComponent = (props) => { setIsCompiling(true); let state: CompilationState; try { - state = await compile(host, currentContent, selectedEmitter, compilerOptions); + state = await compile(host, currentContent, selectedEmitter, compilerOptions, files); } catch (error) { // eslint-disable-next-line no-console console.error("Compilation failed", error); @@ -289,7 +304,7 @@ export const Playground: FunctionComponent = (props) => { updateDiagnosticsForCodeFixes(typespecCompiler, []); editor.setModelMarkers(typespecModel, "owner", []); } - }, [host, selectedEmitter, compilerOptions, typespecModel]); + }, [host, selectedEmitter, compilerOptions, typespecModel, files]); const currentEmitterOptions = selectedEmitter ? props.emitterOptions?.[selectedEmitter] @@ -427,6 +442,9 @@ export const Playground: FunctionComponent = (props) => { onCompilerOptionsChange={onCompilerOptionsChange} onSelectedEmitterChange={onSelectedEmitterChange} commandBar={isMobile ? undefined : commandBar} + inputFiles={isMultiFile ? Object.keys(files) : undefined} + selectedInputFile={selectedFile} + onSelectedInputFileChange={onSelectedFileChange} /> ); @@ -492,8 +510,25 @@ async function compile( content: string, selectedEmitter: string, options: CompilerOptions, + files?: Record, ): Promise { - await host.writeFile("main.tsp", content); + // Clear previous source .tsp files from the virtual FS to avoid ghost imports + const existingFiles = await host.readDir("."); + for (const file of existingFiles) { + if (file.endsWith(".tsp")) { + await host.rm(file); + } + } + + // Write all input files to the virtual FS + if (files && Object.keys(files).length > 1) { + for (const [path, fileContent] of Object.entries(files)) { + await host.writeFile(path, fileContent); + } + } else { + await host.writeFile("main.tsp", content); + } + await emptyOutputDir(host); try { const typespecCompiler = host.compiler; diff --git a/packages/playground/src/react/use-playground-state.ts b/packages/playground/src/react/use-playground-state.ts index 1b3a6fd5c38..bfdda407198 100644 --- a/packages/playground/src/react/use-playground-state.ts +++ b/packages/playground/src/react/use-playground-state.ts @@ -14,8 +14,16 @@ export interface PlaygroundState { selectedViewer?: string; /** Internal state of viewers */ viewerState?: Record; - /** TypeSpec content */ + /** TypeSpec content (single-file mode) */ content?: string; + /** + * Multiple input files (multi-file mode). + * Keys are file paths relative to the project root (e.g., "main.tsp", "models/widget.tsp"). + * When set, takes precedence over `content`. + */ + files?: Record; + /** Currently selected file in the editor (multi-file mode) */ + selectedFile?: string; } export interface UsePlaygroundStateProps { @@ -51,6 +59,13 @@ export interface PlaygroundStateResult { viewerState: Record; content: string; + /** All input files (normalized: always populated, even in single-file mode) */ + files: Record; + /** Currently selected file path */ + selectedFile: string; + /** Whether the playground is in multi-file mode */ + isMultiFile: boolean; + // State setters onSelectedEmitterChange: (emitter: string) => void; onCompilerOptionsChange: (compilerOptions: CompilerOptions) => void; @@ -58,6 +73,10 @@ export interface PlaygroundStateResult { onSelectedViewerChange: (selectedViewer: string) => void; onViewerStateChange: (viewerState: Record) => void; onContentChange: (content: string) => void; + onFilesChange: (files: Record) => void; + onSelectedFileChange: (selectedFile: string) => void; + /** Update the content of a specific file */ + onFileContentChange: (path: string, content: string) => void; // Full state management playgroundState: PlaygroundState; @@ -136,6 +155,33 @@ export function usePlaygroundState({ [updateState], ); const onContentChange = useCallback((content: string) => updateState({ content }), [updateState]); + const onFilesChange = useCallback( + (files: Record) => updateState({ files }), + [updateState], + ); + const onSelectedFileChange = useCallback( + (selectedFile: string) => updateState({ selectedFile }), + [updateState], + ); + const onFileContentChange = useCallback( + (path: string, fileContent: string) => { + const currentFiles = playgroundState.files ?? { "main.tsp": playgroundState.content ?? "" }; + updateState({ files: { ...currentFiles, [path]: fileContent } }); + }, + [updateState, playgroundState.files, playgroundState.content], + ); + + // Normalize files: always provide a Record, even in single-file mode + const files = useMemo>(() => { + if (playgroundState.files && Object.keys(playgroundState.files).length > 0) { + return playgroundState.files; + } + return { "main.tsp": content }; + }, [playgroundState.files, content]); + + const isMultiFile = useMemo(() => Object.keys(files).length > 1, [files]); + + const selectedFile = playgroundState.selectedFile ?? Object.keys(files)[0] ?? "main.tsp"; // Track last processed sample to avoid re-processing const lastProcessedSample = useRef(""); @@ -166,6 +212,9 @@ export function usePlaygroundState({ selectedViewer, viewerState: playgroundState.viewerState ?? {}, content, + files, + selectedFile, + isMultiFile, // State setters onSelectedEmitterChange, @@ -174,6 +223,9 @@ export function usePlaygroundState({ onSelectedViewerChange, onViewerStateChange, onContentChange, + onFilesChange, + onSelectedFileChange, + onFileContentChange, // Full state management playgroundState, diff --git a/packages/playground/test/use-playground-state.test.ts b/packages/playground/test/use-playground-state.test.ts new file mode 100644 index 00000000000..06815bb9ecf --- /dev/null +++ b/packages/playground/test/use-playground-state.test.ts @@ -0,0 +1,120 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { usePlaygroundState, type PlaygroundState } from "../src/react/use-playground-state.js"; + +function renderPlaygroundState(initialState: PlaygroundState = {}) { + return renderHook(() => + usePlaygroundState({ + libraries: ["@typespec/http"], + defaultPlaygroundState: { emitter: "@typespec/openapi3", ...initialState }, + }), + ); +} + +describe("usePlaygroundState multi-file support", () => { + describe("files normalization", () => { + it("normalizes single-file content to { 'main.tsp': content }", () => { + const { result } = renderPlaygroundState({ content: "model Foo {}" }); + expect(result.current.files).toEqual({ "main.tsp": "model Foo {}" }); + }); + + it("uses files when provided", () => { + const files = { "main.tsp": "import './models';", "models.tsp": "model Bar {}" }; + const { result } = renderPlaygroundState({ files }); + expect(result.current.files).toEqual(files); + }); + + it("defaults to main.tsp with empty content when nothing provided", () => { + const { result } = renderPlaygroundState({}); + expect(result.current.files).toEqual({ "main.tsp": "" }); + }); + }); + + describe("isMultiFile", () => { + it("returns false for single-file mode", () => { + const { result } = renderPlaygroundState({ content: "model Foo {}" }); + expect(result.current.isMultiFile).toBe(false); + }); + + it("returns true when multiple files are present", () => { + const files = { "main.tsp": "import './models';", "models.tsp": "model Bar {}" }; + const { result } = renderPlaygroundState({ files }); + expect(result.current.isMultiFile).toBe(true); + }); + + it("returns false when files has a single entry", () => { + const { result } = renderPlaygroundState({ files: { "main.tsp": "model Foo {}" } }); + expect(result.current.isMultiFile).toBe(false); + }); + }); + + describe("selectedFile", () => { + it("defaults to first file when not specified", () => { + const files = { "main.tsp": "", "models.tsp": "" }; + const { result } = renderPlaygroundState({ files }); + expect(result.current.selectedFile).toBe("main.tsp"); + }); + + it("uses selectedFile when specified", () => { + const files = { "main.tsp": "", "models.tsp": "" }; + const { result } = renderPlaygroundState({ files, selectedFile: "models.tsp" }); + expect(result.current.selectedFile).toBe("models.tsp"); + }); + + it("defaults to main.tsp in single-file mode", () => { + const { result } = renderPlaygroundState({ content: "model Foo {}" }); + expect(result.current.selectedFile).toBe("main.tsp"); + }); + }); + + describe("onSelectedFileChange", () => { + it("changes the selected file", () => { + const files = { "main.tsp": "", "models.tsp": "" }; + const { result } = renderPlaygroundState({ files }); + + act(() => { + result.current.onSelectedFileChange("models.tsp"); + }); + + expect(result.current.selectedFile).toBe("models.tsp"); + }); + }); + + describe("onFilesChange", () => { + it("updates all files", () => { + const { result } = renderPlaygroundState({ content: "model Foo {}" }); + const newFiles = { "main.tsp": "import './bar';", "bar.tsp": "model Bar {}" }; + + act(() => { + result.current.onFilesChange(newFiles); + }); + + expect(result.current.files).toEqual(newFiles); + expect(result.current.isMultiFile).toBe(true); + }); + }); + + describe("onFileContentChange", () => { + it("updates a specific file in multi-file mode", () => { + const files = { "main.tsp": "import './models';", "models.tsp": "model Bar {}" }; + const { result } = renderPlaygroundState({ files }); + + act(() => { + result.current.onFileContentChange("models.tsp", "model UpdatedBar {}"); + }); + + expect(result.current.files["models.tsp"]).toBe("model UpdatedBar {}"); + expect(result.current.files["main.tsp"]).toBe("import './models';"); + }); + + it("creates files from content in single-file mode", () => { + const { result } = renderPlaygroundState({ content: "model Foo {}" }); + + act(() => { + result.current.onFileContentChange("main.tsp", "model UpdatedFoo {}"); + }); + + expect(result.current.files["main.tsp"]).toBe("model UpdatedFoo {}"); + }); + }); +});