Skip to content
Closed
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"
---

Add multi-file editor with file explorer support
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions packages/playground/src/react/editor-panel/editor-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<EditorPanelProps> = ({
Expand All @@ -55,8 +63,12 @@ export const EditorPanel: FunctionComponent<EditorPanelProps> = ({
onCompilerOptionsChange,
onSelectedEmitterChange,
commandBar,
inputFiles,
selectedInputFile,
onSelectedInputFileChange,
}) => {
const [selectedTab, setSelectedTab] = useState<EditorPanelTab>("tsp");
const showFileTree = inputFiles && inputFiles.length > 1;

const onTabSelect = useCallback<SelectTabEventHandler>((_, data) => {
setSelectedTab(data.value as EditorPanelTab);
Expand All @@ -78,6 +90,15 @@ export const EditorPanel: FunctionComponent<EditorPanelProps> = ({
</Tab>
</TabList>
</div>
{showFileTree && selectedTab === "tsp" && (
<div className={style["file-tree-container"]}>
<FileTreeExplorer
files={inputFiles}
selected={selectedInputFile ?? inputFiles[0]}
onSelect={(file) => onSelectedInputFileChange?.(file)}
/>
</div>
)}
<div className={style["panel-content"]}>
{commandBar}
{selectedTab === "tsp" ? (
Expand Down
66 changes: 66 additions & 0 deletions packages/playground/src/react/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,69 @@
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<string, string>,
selectedFile: string,
language: string = "typespec",
): { activeModel: editor.IModel; allModels: Map<string, editor.IModel> } {
const modelsRef = useRef<Map<string, editor.IModel>>(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()) {

Check warning on line 125 in packages/playground/src/react/editor.tsx

View workflow job for this annotation

GitHub Actions / Lint

The ref value 'modelsRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'modelsRef.current' to a variable inside the effect, and use that variable in the cleanup function
model.dispose();
}
modelsRef.current.clear();
};
}, []);

return { activeModel, allModels };
}
65 changes: 50 additions & 15 deletions packages/playground/src/react/playground.tsx
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a pr experimenting adding this a few weeks ago but held on as unsure it was that valuable and to find the correct UI for it so we don't make this even more crowded(playground already getting tight on small screen).

What motivated this?

Not fully sure what it's supposed to look like in your pr as it doesn't seem to work in the preview playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ha yeah I see it is not working. The motivation for this is to ultimately be able to load in specs from the rest spec repo.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can already do that with the import it just combine them into one file

Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -164,7 +164,6 @@ export const Playground: FunctionComponent<PlaygroundProps> = (props) => {
debugGlobals().host = host;
}, [host]);

const typespecModel = useMonacoModel("inmemory://test/main.tsp", "typespec");
const [compilationState, setCompilationState] = useState<CompilationState | undefined>(undefined);
const lastSuccessfulOutputRef = useRef<string[]>([]);
const [isCompiling, setIsCompiling] = useState(false);
Expand All @@ -190,37 +189,53 @@ export const Playground: FunctionComponent<PlaygroundProps> = (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);
Expand All @@ -236,7 +251,7 @@ export const Playground: FunctionComponent<PlaygroundProps> = (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);
Expand Down Expand Up @@ -289,7 +304,7 @@ export const Playground: FunctionComponent<PlaygroundProps> = (props) => {
updateDiagnosticsForCodeFixes(typespecCompiler, []);
editor.setModelMarkers(typespecModel, "owner", []);
}
}, [host, selectedEmitter, compilerOptions, typespecModel]);
}, [host, selectedEmitter, compilerOptions, typespecModel, files]);

const currentEmitterOptions = selectedEmitter
? props.emitterOptions?.[selectedEmitter]
Expand Down Expand Up @@ -427,6 +442,9 @@ export const Playground: FunctionComponent<PlaygroundProps> = (props) => {
onCompilerOptionsChange={onCompilerOptionsChange}
onSelectedEmitterChange={onSelectedEmitterChange}
commandBar={isMobile ? undefined : commandBar}
inputFiles={isMultiFile ? Object.keys(files) : undefined}
selectedInputFile={selectedFile}
onSelectedInputFileChange={onSelectedFileChange}
/>
);

Expand Down Expand Up @@ -492,8 +510,25 @@ async function compile(
content: string,
selectedEmitter: string,
options: CompilerOptions,
files?: Record<string, string>,
): Promise<CompilationState> {
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;
Expand Down
54 changes: 53 additions & 1 deletion packages/playground/src/react/use-playground-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,16 @@ export interface PlaygroundState {
selectedViewer?: string;
/** Internal state of viewers */
viewerState?: Record<string, any>;
/** 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<string, string>;
/** Currently selected file in the editor (multi-file mode) */
selectedFile?: string;
}

export interface UsePlaygroundStateProps {
Expand Down Expand Up @@ -51,13 +59,24 @@ export interface PlaygroundStateResult {
viewerState: Record<string, any>;
content: string;

/** All input files (normalized: always populated, even in single-file mode) */
files: Record<string, string>;
/** 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;
onSelectedSampleNameChange: (sampleName: string) => void;
onSelectedViewerChange: (selectedViewer: string) => void;
onViewerStateChange: (viewerState: Record<string, any>) => void;
onContentChange: (content: string) => void;
onFilesChange: (files: Record<string, string>) => void;
onSelectedFileChange: (selectedFile: string) => void;
/** Update the content of a specific file */
onFileContentChange: (path: string, content: string) => void;

// Full state management
playgroundState: PlaygroundState;
Expand Down Expand Up @@ -136,6 +155,33 @@ export function usePlaygroundState({
[updateState],
);
const onContentChange = useCallback((content: string) => updateState({ content }), [updateState]);
const onFilesChange = useCallback(
(files: Record<string, string>) => 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<string, string>, even in single-file mode
const files = useMemo<Record<string, string>>(() => {
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<string>("");
Expand Down Expand Up @@ -166,6 +212,9 @@ export function usePlaygroundState({
selectedViewer,
viewerState: playgroundState.viewerState ?? {},
content,
files,
selectedFile,
isMultiFile,

// State setters
onSelectedEmitterChange,
Expand All @@ -174,6 +223,9 @@ export function usePlaygroundState({
onSelectedViewerChange,
onViewerStateChange,
onContentChange,
onFilesChange,
onSelectedFileChange,
onFileContentChange,

// Full state management
playgroundState,
Expand Down
Loading
Loading