diff --git a/packages/playground/src/react/context/playground-context.tsx b/packages/playground/src/react/context/playground-context.tsx index e7424a5754e..370150e43d7 100644 --- a/packages/playground/src/react/context/playground-context.tsx +++ b/packages/playground/src/react/context/playground-context.tsx @@ -3,7 +3,22 @@ import type { BrowserHost } from "../../types.js"; export interface PlaygroundContext { readonly host: BrowserHost; + /** @deprecated Use file management functions instead */ readonly setContent: (content: string) => void; + /** All files in the playground. Maps relative path to content. */ + readonly files: Record; + /** Currently active file path. */ + readonly activeFile: string; + /** Switch the active file being edited. */ + readonly setActiveFile: (path: string) => void; + /** Update content of a specific file. */ + readonly setFileContent: (path: string, content: string) => void; + /** Add a new file to the playground. */ + readonly addFile: (path: string, content?: string) => void; + /** Remove a file from the playground. */ + readonly removeFile: (path: string) => void; + /** Rename a file. */ + readonly renameFile: (oldPath: string, newPath: string) => void; } const PlaygroundContext = createContext(undefined); 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..f92071e4b45 100644 --- a/packages/playground/src/react/editor-panel/editor-panel.module.css +++ b/packages/playground/src/react/editor-panel/editor-panel.module.css @@ -5,9 +5,36 @@ } .panel-tabs-container { + display: flex; + flex-direction: column; background-color: var(--colorNeutralBackground3); } +.tree-toggle { + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + border: none; + background: none; + cursor: pointer; + color: var(--colorNeutralForeground2); + font-size: 16px; +} + +.tree-toggle:hover { + color: var(--colorNeutralForeground1); + background: var(--colorNeutralBackground4); +} + +.file-tree-panel { + width: 200px; + min-width: 120px; + max-width: 300px; + border-right: 1px solid var(--colorNeutralStroke2); + overflow: hidden; +} + .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..aa6790e6b60 100644 --- a/packages/playground/src/react/editor-panel/editor-panel.tsx +++ b/packages/playground/src/react/editor-panel/editor-panel.tsx @@ -1,16 +1,18 @@ import { Tab, TabList, type SelectTabEventHandler } from "@fluentui/react-components"; -import { SettingsRegular } from "@fluentui/react-icons"; +import { ChevronRightRegular, DocumentRegular, SettingsRegular } from "@fluentui/react-icons"; import type { CompilerOptions } from "@typespec/compiler"; import { editor } from "monaco-editor"; -import { useCallback, useState, type FunctionComponent, type ReactNode } from "react"; +import { useCallback, useMemo, useState, type FunctionComponent, type ReactNode } from "react"; import type { BrowserHost } from "../../types.js"; import type { OnMountData } from "../editor.js"; +import { InputFileTree } from "../input-file-tree/index.js"; import type { PlaygroundEditorsOptions } from "../playground.js"; import { TypeSpecEditor } from "../typespec-editor.js"; import { ConfigPanel } from "./config-panel.js"; import style from "./editor-panel.module.css"; -export type EditorPanelTab = "tsp" | "cfg"; +/** Special tab ID for the tspconfig file */ +const CONFIG_TAB = "$$config$$"; const TypeSpecIcon = () => ( // icons/raw/tsp-logo-inverted.svg @@ -28,6 +30,13 @@ const TypeSpecIcon = () => ( ); +function getFileIcon(filePath: string) { + if (filePath === "main.tsp") { + return ; + } + return ; +} + export interface EditorPanelProps { host: BrowserHost; model: editor.IModel; @@ -42,6 +51,19 @@ export interface EditorPanelProps { /** Toolbar content rendered above the editor area */ commandBar?: ReactNode; + + /** Input file paths for file tree */ + files?: string[]; + /** Currently active file */ + activeFile?: string; + /** Callback when a file is selected in the tree */ + onFileSelect?: (file: string) => void; + /** Callback to add a new file */ + onFileAdd?: (path: string, content?: string) => void; + /** Callback to remove a file */ + onFileRemove?: (path: string) => void; + /** Callback to rename a file */ + onFileRename?: (oldPath: string, newPath: string) => void; } export const EditorPanel: FunctionComponent = ({ @@ -55,48 +77,130 @@ export const EditorPanel: FunctionComponent = ({ onCompilerOptionsChange, onSelectedEmitterChange, commandBar, + files, + activeFile, + onFileSelect, + onFileAdd, + onFileRemove, + onFileRename, }) => { - const [selectedTab, setSelectedTab] = useState("tsp"); + const [fileTreeExpanded, setFileTreeExpanded] = useState(false); - const onTabSelect = useCallback((_, data) => { - setSelectedTab(data.value as EditorPanelTab); + const [showConfig, setShowConfig] = useState(false); + + // The selected tab in the sidebar: either a file path or CONFIG_TAB + const selectedTab = showConfig ? CONFIG_TAB : (activeFile ?? "main.tsp"); + + const onTabSelect = useCallback( + (_, data) => { + const value = data.value as string; + if (value === CONFIG_TAB) { + setShowConfig(true); + } else { + setShowConfig(false); + onFileSelect?.(value); + } + }, + [onFileSelect], + ); + + const handleFileTreeSelect = useCallback( + (file: string) => { + setShowConfig(false); + onFileSelect?.(file); + }, + [onFileSelect], + ); + + const toggleFileTree = useCallback(() => { + setFileTreeExpanded((v) => !v); }, []); + // Files list including tspconfig for the tree view + const allFiles = useMemo(() => { + const tspFiles = files ?? ["main.tsp"]; + return [...tspFiles, "tspconfig.yaml"]; + }, [files]); + + // Build the tab icons for the sidebar + const fileTabs = useMemo(() => { + const tabs: { id: string; icon: ReactNode; title: string }[] = []; + if (files) { + for (const f of files) { + tabs.push({ id: f, icon: getFileIcon(f), title: f }); + } + } else { + tabs.push({ id: "main.tsp", icon: , title: "main.tsp" }); + } + // Always add config tab at the end + tabs.push({ id: CONFIG_TAB, icon: , title: "tspconfig.yaml" }); + return tabs; + }, [files]); + + const editorContent = ( + + ); + + const mainContent = showConfig ? ( + + ) : ( + editorContent + ); + + const handleTreeFileSelect = useCallback( + (file: string) => { + if (file === "tspconfig.yaml") { + setShowConfig(true); + } else { + setShowConfig(false); + onFileSelect?.(file); + } + }, + [onFileSelect], + ); + return (
-
- - - - - - - - - - - - -
+ {fileTreeExpanded ? ( +
+ {})} + onFileRemove={onFileRemove ?? (() => {})} + onFileRename={onFileRename ?? (() => {})} + onCollapse={toggleFileTree} + /> +
+ ) : ( +
+ + + {fileTabs.map((tab) => ( + + {tab.icon} + + ))} + +
+ )}
{commandBar} - {selectedTab === "tsp" ? ( - - ) : ( - - )} + {mainContent}
); diff --git a/packages/playground/src/react/editor-panel/index.ts b/packages/playground/src/react/editor-panel/index.ts index e86a41dc6b6..9bebedf1fad 100644 --- a/packages/playground/src/react/editor-panel/index.ts +++ b/packages/playground/src/react/editor-panel/index.ts @@ -1,2 +1,2 @@ export { ConfigPanel, type ConfigPanelProps } from "./config-panel.js"; -export { EditorPanel, type EditorPanelProps, type EditorPanelTab } from "./editor-panel.js"; +export { EditorPanel, type EditorPanelProps } from "./editor-panel.js"; diff --git a/packages/playground/src/react/file-tree/file-tree.module.css b/packages/playground/src/react/file-tree/file-tree.module.css index 885dcf573fb..3ffe547df6e 100644 --- a/packages/playground/src/react/file-tree/file-tree.module.css +++ b/packages/playground/src/react/file-tree/file-tree.module.css @@ -4,3 +4,54 @@ background: var(--colorNeutralBackground3); padding-top: 4px; } + +/* Reduce empty caret column for leaf nodes (no expand arrow) */ +.file-tree div[role="treeitem"] > span:first-child:empty { + width: 4px; + flex: 0 0 4px; +} + +.file-tree-with-actions { + display: flex; + flex-direction: column; + height: 100%; + background: var(--colorNeutralBackground3); +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 4px 4px 8px; + border-bottom: 1px solid var(--colorNeutralStroke2); +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 2px; +} + +.title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: var(--colorNeutralForeground3); +} + +.tree-container { + flex: 1; + overflow: hidden; +} + +.inline-input { + padding: 4px 8px; +} + +.file-actions { + padding: 2px 4px; + border-top: 1px solid var(--colorNeutralStroke2); + display: flex; + align-items: center; + justify-content: flex-end; +} diff --git a/packages/playground/src/react/file-tree/file-tree.tsx b/packages/playground/src/react/file-tree/file-tree.tsx index 22e799bbeed..6a16eaa15f0 100644 --- a/packages/playground/src/react/file-tree/file-tree.tsx +++ b/packages/playground/src/react/file-tree/file-tree.tsx @@ -10,11 +10,11 @@ export interface FileTreeExplorerProps { readonly onSelect: (file: string) => void; } -interface FileTreeNode extends TreeNode { +export interface FileTreeNode extends TreeNode { readonly isDirectory: boolean; } -const FileNodeIcon: FC<{ node: FileTreeNode }> = ({ node }) => { +export const FileNodeIcon: FC<{ node: FileTreeNode }> = ({ node }) => { if (node.isDirectory) { return ; } @@ -24,7 +24,7 @@ const FileNodeIcon: FC<{ node: FileTreeNode }> = ({ node }) => { /** * Builds a tree structure from a flat list of file paths. */ -function buildTree(files: string[]): FileTreeNode { +export function buildFileTree(files: string[]): FileTreeNode { const root: FileTreeNode = { id: "__root__", name: "root", isDirectory: true, children: [] }; const dirMap = new Map(); dirMap.set("", root); @@ -91,7 +91,7 @@ export const FileTreeExplorer: FunctionComponent = ({ selected, onSelect, }) => { - const tree = useMemo(() => buildTree(files), [files]); + const tree = useMemo(() => buildFileTree(files), [files]); return (
diff --git a/packages/playground/src/react/file-tree/index.ts b/packages/playground/src/react/file-tree/index.ts index c8022943d20..a85c70bfa5c 100644 --- a/packages/playground/src/react/file-tree/index.ts +++ b/packages/playground/src/react/file-tree/index.ts @@ -1 +1,7 @@ -export { FileTreeExplorer, type FileTreeExplorerProps } from "./file-tree.js"; +export { + buildFileTree, + FileNodeIcon, + FileTreeExplorer, + type FileTreeExplorerProps, + type FileTreeNode, +} from "./file-tree.js"; diff --git a/packages/playground/src/react/input-file-tree/index.ts b/packages/playground/src/react/input-file-tree/index.ts new file mode 100644 index 00000000000..6dad4b894ca --- /dev/null +++ b/packages/playground/src/react/input-file-tree/index.ts @@ -0,0 +1 @@ +export { InputFileTree, type InputFileTreeProps } from "./input-file-tree.js"; diff --git a/packages/playground/src/react/input-file-tree/input-file-tree.tsx b/packages/playground/src/react/input-file-tree/input-file-tree.tsx new file mode 100644 index 00000000000..5ccadd73905 --- /dev/null +++ b/packages/playground/src/react/input-file-tree/input-file-tree.tsx @@ -0,0 +1,195 @@ +import { + Button, + Input, + Menu, + MenuItem, + MenuList, + MenuPopover, + MenuTrigger, +} from "@fluentui/react-components"; +import { + AddRegular, + ChevronLeftRegular, + DeleteRegular, + MoreHorizontalRegular, + RenameRegular, +} from "@fluentui/react-icons"; +import { useCallback, useState, type FunctionComponent, type KeyboardEvent } from "react"; +import { FileTreeExplorer } from "../file-tree/index.js"; +import style from "../file-tree/file-tree.module.css"; + +export interface InputFileTreeProps { + readonly files: string[]; + readonly activeFile: string; + readonly onFileSelect: (file: string) => void; + readonly onFileAdd: (path: string, content?: string) => void; + readonly onFileRemove: (path: string) => void; + readonly onFileRename: (oldPath: string, newPath: string) => void; + readonly onCollapse?: () => void; +} + +export const InputFileTree: FunctionComponent = ({ + files, + activeFile, + onFileSelect, + onFileAdd, + onFileRemove, + onFileRename, + onCollapse, +}) => { + const [isCreating, setIsCreating] = useState(false); + const [newFileName, setNewFileName] = useState(""); + const [renamingFile, setRenamingFile] = useState(null); + const [renameValue, setRenameValue] = useState(""); + + const handleSelect = useCallback( + (id: string) => { + if (files.includes(id)) { + onFileSelect(id); + } + }, + [files, onFileSelect], + ); + + const handleCreateStart = useCallback(() => { + setIsCreating(true); + setNewFileName(""); + }, []); + + const handleCreateConfirm = useCallback(() => { + const name = newFileName.trim(); + if (name && !files.includes(name)) { + const finalName = name.endsWith(".tsp") ? name : `${name}.tsp`; + onFileAdd(finalName); + } + setIsCreating(false); + setNewFileName(""); + }, [newFileName, files, onFileAdd]); + + const handleCreateKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter") { + handleCreateConfirm(); + } else if (e.key === "Escape") { + setIsCreating(false); + setNewFileName(""); + } + }, + [handleCreateConfirm], + ); + + const handleRenameStart = useCallback((filePath: string) => { + setRenamingFile(filePath); + setRenameValue(filePath); + }, []); + + const handleRenameConfirm = useCallback(() => { + if (renamingFile && renameValue.trim() && renameValue.trim() !== renamingFile) { + const finalName = renameValue.trim().endsWith(".tsp") + ? renameValue.trim() + : `${renameValue.trim()}.tsp`; + onFileRename(renamingFile, finalName); + } + setRenamingFile(null); + setRenameValue(""); + }, [renamingFile, renameValue, onFileRename]); + + const handleRenameKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter") { + handleRenameConfirm(); + } else if (e.key === "Escape") { + setRenamingFile(null); + setRenameValue(""); + } + }, + [handleRenameConfirm], + ); + + const handleDelete = useCallback( + (filePath: string) => { + if (filePath !== "main.tsp" && filePath !== "tspconfig.yaml") { + onFileRemove(filePath); + } + }, + [onFileRemove], + ); + + return ( +
+
+ {onCollapse && ( +
+
+
+ +
+ {isCreating && ( +
+ setNewFileName(data.value)} + onKeyDown={handleCreateKeyDown} + onBlur={handleCreateConfirm} + placeholder="filename.tsp" + autoFocus + /> +
+ )} + {renamingFile && ( +
+ setRenameValue(data.value)} + onKeyDown={handleRenameKeyDown} + onBlur={handleRenameConfirm} + autoFocus + /> +
+ )} +
+ {activeFile && activeFile !== "main.tsp" && activeFile !== "tspconfig.yaml" && ( + + + + )} +
+
+ ); +}; diff --git a/packages/playground/src/react/playground.tsx b/packages/playground/src/react/playground.tsx index 4598b46ddf2..d33abf2094c 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 { 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"; @@ -86,11 +86,17 @@ export interface PlaygroundEditorsOptions { } export interface PlaygroundSaveData extends PlaygroundState { - /** Current content of the playground. */ + /** Current content of the playground (active file content for backward compat). */ content: string; /** Emitter name. */ emitter: string; + + /** All files in the playground. */ + files?: Record; + + /** Currently active file. */ + activeFile?: string; } /** @@ -152,7 +158,6 @@ export const Playground: FunctionComponent = (props) => { debugGlobals().host = host; }, [host]); - const typespecModel = useMonacoModel("inmemory://test/main.tsp", "typespec"); const [compilationState, setCompilationState] = useState(undefined); // Use the playground state hook @@ -175,72 +180,125 @@ export const Playground: FunctionComponent = (props) => { selectedViewer, viewerState, content, + files, + activeFile, onSelectedEmitterChange, onCompilerOptionsChange, onSelectedSampleNameChange, onSelectedViewerChange, onViewerStateChange, onContentChange, + addFile, + removeFile, + renameFile, + updateFileContent, + onActiveFileChange, } = state; - // Sync Monaco model with state content - useEffect(() => { - if (typespecModel.getValue() !== (content ?? "")) { - typespecModel.setValue(content ?? ""); + // Dynamic Monaco model management + const modelsRef = useRef>(new Map()); + const updateFileContentRef = useRef(updateFileContent); + updateFileContentRef.current = updateFileContent; + + const getOrCreateModel = useCallback((filePath: string, fileContent: string): editor.IModel => { + const existing = modelsRef.current.get(filePath); + if (existing && !existing.isDisposed()) { + return existing; + } + const uri = Uri.parse(`inmemory://test/${filePath}`); + const existingByUri = editor.getModel(uri); + if (existingByUri) { + modelsRef.current.set(filePath, existingByUri); + return existingByUri; } - }, [content, typespecModel]); + const model = editor.createModel(fileContent, "typespec", uri); + modelsRef.current.set(filePath, model); + return model; + }, []); - // Update state when Monaco model changes + // Sync Monaco models with files state useEffect(() => { - const disposable = typespecModel.onDidChangeContent(() => { - const newContent = typespecModel.getValue(); - if (newContent !== content) { - onContentChange(newContent); + for (const [filePath, fileContent] of Object.entries(files)) { + const model = getOrCreateModel(filePath, fileContent); + if (model.getValue() !== fileContent) { + model.setValue(fileContent); } - }); - return () => disposable.dispose(); - }, [typespecModel, content, onContentChange]); + } + // Dispose models that no longer exist + for (const [filePath, model] of modelsRef.current.entries()) { + if (!(filePath in files)) { + model.dispose(); + modelsRef.current.delete(filePath); + } + } + }, [files, getOrCreateModel]); + + // Get current active model + const activeModel = useMemo(() => { + return getOrCreateModel(activeFile, files[activeFile] ?? ""); + }, [activeFile, files, getOrCreateModel]); const isSampleUntouched = useMemo(() => { return Boolean(selectedSampleName && content === props.samples?.[selectedSampleName]?.content); }, [content, selectedSampleName, props.samples]); const doCompile = useCallback(async () => { - const currentContent = typespecModel.getValue(); const typespecCompiler = host.compiler; - const state = await compile(host, currentContent, selectedEmitter, compilerOptions); - 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" ? [CompletionItemTag.Deprecated] : undefined, - })); - + const compilationResult = await compile(host, files, selectedEmitter, compilerOptions); + setCompilationState(compilationResult); + if ("program" in compilationResult) { // Update code action provider with current diagnostics (for codefix support). - updateDiagnosticsForCodeFixes(typespecCompiler, state.program.diagnostics); + updateDiagnosticsForCodeFixes(typespecCompiler, compilationResult.program.diagnostics); // Set the program on the window. - debugGlobals().program = state.program; - debugGlobals().$$ = $(state.program); - - editor.setModelMarkers(typespecModel, "owner", markers ?? []); + debugGlobals().program = compilationResult.program; + debugGlobals().$$ = $(compilationResult.program); + + // Set markers for each file model + for (const [filePath, model] of modelsRef.current.entries()) { + const fileDiags = compilationResult.program.diagnostics.filter((diag) => { + if (!diag.target || typeof diag.target === "symbol" || !("file" in diag.target)) { + return filePath === "main.tsp"; + } + const diagPath = diag.target.file.path; + return diagPath === resolveVirtualPath(filePath); + }); + const markers: editor.IMarkerData[] = fileDiags.map((diag) => ({ + ...getMonacoRange(typespecCompiler, diag.target), + message: diag.message, + severity: diag.severity === "error" ? MarkerSeverity.Error : MarkerSeverity.Warning, + tags: diag.code === "deprecated" ? [CompletionItemTag.Deprecated] : undefined, + })); + editor.setModelMarkers(model, "owner", markers); + } } else { updateDiagnosticsForCodeFixes(typespecCompiler, []); - editor.setModelMarkers(typespecModel, "owner", []); + for (const model of modelsRef.current.values()) { + editor.setModelMarkers(model, "owner", []); + } } - }, [host, selectedEmitter, compilerOptions, typespecModel]); + }, [host, selectedEmitter, compilerOptions, files]); + // Debounced recompile and state sync on model content changes useEffect(() => { const debouncer = debounce(() => doCompile(), 200); - const disposable = typespecModel.onDidChangeContent(debouncer); + + const disposables: { dispose: () => void }[] = []; + for (const [filePath, model] of modelsRef.current.entries()) { + disposables.push( + model.onDidChangeContent(() => { + updateFileContentRef.current(filePath, model.getValue()); + void debouncer(); + }), + ); + } + return () => { debouncer.clear(); - disposable.dispose(); + for (const d of disposables) d.dispose(); }; - }, [typespecModel, doCompile]); + }, [doCompile, files]); useEffect(() => { void doCompile(); @@ -252,6 +310,8 @@ export const Playground: FunctionComponent = (props) => { content: content ?? "", emitter: selectedEmitter, compilerOptions, + files, + activeFile, sampleName: isSampleUntouched ? selectedSampleName : undefined, selectedViewer, viewerState, @@ -259,6 +319,8 @@ export const Playground: FunctionComponent = (props) => { } }, [ content, + files, + activeFile, onSave, selectedEmitter, compilerOptions, @@ -319,11 +381,27 @@ export const Playground: FunctionComponent = (props) => { return { host, setContent: (val: string) => { - typespecModel.setValue(val); onContentChange(val); }, + files, + activeFile, + setActiveFile: onActiveFileChange, + setFileContent: updateFileContent, + addFile, + removeFile, + renameFile, }; - }, [host, typespecModel, onContentChange]); + }, [ + host, + onContentChange, + files, + activeFile, + onActiveFileChange, + updateFileContent, + addFile, + removeFile, + renameFile, + ]); const isMobile = useIsMobile(); const [viewMode, setViewMode] = useState("editor"); @@ -355,7 +433,7 @@ export const Playground: FunctionComponent = (props) => { const editorPanel = ( = (props) => { onCompilerOptionsChange={onCompilerOptionsChange} onSelectedEmitterChange={onSelectedEmitterChange} commandBar={isMobile ? undefined : commandBar} + files={Object.keys(files)} + activeFile={activeFile} + onFileSelect={onActiveFileChange} + onFileAdd={addFile} + onFileRemove={removeFile} + onFileRename={renameFile} /> ); @@ -423,11 +507,14 @@ const outputDir = resolveVirtualPath("tsp-output"); async function compile( host: BrowserHost, - content: string, + files: Record, selectedEmitter: string, options: CompilerOptions, ): Promise { - await host.writeFile("main.tsp", content); + // Write all source files to virtual FS + for (const [filePath, fileContent] of Object.entries(files)) { + await host.writeFile(filePath, fileContent); + } await emptyOutputDir(host); try { const typespecCompiler = host.compiler; diff --git a/packages/playground/src/react/standalone.tsx b/packages/playground/src/react/standalone.tsx index 017d8be2e9e..dbfcd0f1efd 100644 --- a/packages/playground/src/react/standalone.tsx +++ b/packages/playground/src/react/standalone.tsx @@ -102,12 +102,14 @@ export const StandalonePlayground: FunctionComponent = (c // Auto-save state changes to storage without showing toast // Preserve the last known content or use empty string if none const saveData: PlaygroundSaveData = { - content: lastSavedData?.content || "", + content: newState.content || lastSavedData?.content || "", emitter: newState.emitter || "", compilerOptions: newState.compilerOptions, sampleName: newState.sampleName, selectedViewer: newState.selectedViewer, viewerState: newState.viewerState, + files: newState.files, + activeFile: newState.activeFile, }; saveToStorage(saveData); }, @@ -129,6 +131,8 @@ export const StandalonePlayground: FunctionComponent = (c context.initialState.selectedViewer ?? config.defaultPlaygroundState?.selectedViewer, viewerState: context.initialState.viewerState ?? config.defaultPlaygroundState?.viewerState, + files: context.initialState.files ?? config.defaultPlaygroundState?.files, + activeFile: context.initialState.activeFile ?? config.defaultPlaygroundState?.activeFile, }, }, [config.defaultPlaygroundState, config.libraries, context], @@ -190,17 +194,50 @@ export function createStandalonePlaygroundStateStorage(): UrlStateStorage 1; + const saveData = { ...data } as Partial; + if (hasMultipleFiles) { + // Save as files map, clear single content to keep URL shorter + saveData.content = undefined; + } else { + // Single file: save as content for backward-compatible shorter URLs + saveData.files = undefined; + saveData.activeFile = undefined; + } + stateStorage.save(saveData as PlaygroundSaveData); + } }, }; } diff --git a/packages/playground/src/react/use-playground-state.ts b/packages/playground/src/react/use-playground-state.ts index 1b3a6fd5c38..ca08813c4c5 100644 --- a/packages/playground/src/react/use-playground-state.ts +++ b/packages/playground/src/react/use-playground-state.ts @@ -14,8 +14,15 @@ export interface PlaygroundState { selectedViewer?: string; /** Internal state of viewers */ viewerState?: Record; - /** TypeSpec content */ + /** + * TypeSpec content for single-file mode. + * @deprecated Use `files` instead. Kept for backward compatibility. + */ content?: string; + /** Map of file paths to their content. */ + files?: Record; + /** Currently active file path being edited. */ + activeFile?: string; } export interface UsePlaygroundStateProps { @@ -50,6 +57,10 @@ export interface PlaygroundStateResult { selectedViewer?: string; viewerState: Record; content: string; + /** All files in the playground. Maps relative path to content. */ + files: Record; + /** Currently active file path. */ + activeFile: string; // State setters onSelectedEmitterChange: (emitter: string) => void; @@ -58,6 +69,14 @@ export interface PlaygroundStateResult { onSelectedViewerChange: (selectedViewer: string) => void; onViewerStateChange: (viewerState: Record) => void; onContentChange: (content: string) => void; + onFilesChange: (files: Record) => void; + onActiveFileChange: (activeFile: string) => void; + + // File management + addFile: (path: string, content?: string) => void; + removeFile: (path: string) => void; + renameFile: (oldPath: string, newPath: string) => void; + updateFileContent: (path: string, content: string) => void; // Full state management playgroundState: PlaygroundState; @@ -104,7 +123,19 @@ export function usePlaygroundState({ ); const selectedSampleName = playgroundState.sampleName ?? ""; const selectedViewer = playgroundState.selectedViewer; - const content = playgroundState.content ?? ""; + + // Resolve files: use `files` if present, otherwise derive from `content` + const files = useMemo((): Record => { + if (playgroundState.files && Object.keys(playgroundState.files).length > 0) { + return playgroundState.files; + } + return { "main.tsp": playgroundState.content ?? "" }; + }, [playgroundState.files, playgroundState.content]); + + const activeFile = playgroundState.activeFile ?? "main.tsp"; + + // `content` reflects the active file's content for backward compat + const content = files[activeFile] ?? ""; // Create a generic state updater that can handle any field const updateState = useCallback( @@ -135,7 +166,77 @@ export function usePlaygroundState({ (viewerState: Record) => updateState({ viewerState }), [updateState], ); - const onContentChange = useCallback((content: string) => updateState({ content }), [updateState]); + const onContentChange = useCallback( + (newContent: string) => { + const newFiles = { ...files, [activeFile]: newContent }; + updateState({ files: newFiles, content: newFiles["main.tsp"] ?? "" }); + }, + [updateState, files, activeFile], + ); + const onFilesChange = useCallback( + (newFiles: Record) => + updateState({ files: newFiles, content: newFiles["main.tsp"] ?? "" }), + [updateState], + ); + const onActiveFileChange = useCallback( + (newActiveFile: string) => updateState({ activeFile: newActiveFile }), + [updateState], + ); + + // File management helpers + const addFile = useCallback( + (path: string, fileContent: string = "") => { + const newFiles = { ...files, [path]: fileContent }; + updateState({ files: newFiles, activeFile: path, content: newFiles["main.tsp"] ?? "" }); + }, + [updateState, files], + ); + + const removeFile = useCallback( + (path: string) => { + // Prevent removing main.tsp or the last file + if (path === "main.tsp" || Object.keys(files).length <= 1) { + return; + } + const newFiles = { ...files }; + delete newFiles[path]; + const newActiveFile = activeFile === path ? "main.tsp" : activeFile; + updateState({ + files: newFiles, + activeFile: newActiveFile, + content: newFiles["main.tsp"] ?? "", + }); + }, + [updateState, files, activeFile], + ); + + const renameFile = useCallback( + (oldPath: string, newPath: string) => { + // Prevent overwriting an existing file + if (newPath in files && newPath !== oldPath) { + return; + } + const newFiles = { ...files }; + const fileContent = newFiles[oldPath] ?? ""; + delete newFiles[oldPath]; + newFiles[newPath] = fileContent; + const newActiveFile = activeFile === oldPath ? newPath : activeFile; + updateState({ + files: newFiles, + activeFile: newActiveFile, + content: newFiles["main.tsp"] ?? "", + }); + }, + [updateState, files, activeFile], + ); + + const updateFileContent = useCallback( + (path: string, fileContent: string) => { + const newFiles = { ...files, [path]: fileContent }; + updateState({ files: newFiles, content: newFiles["main.tsp"] ?? "" }); + }, + [updateState, files], + ); // Track last processed sample to avoid re-processing const lastProcessedSample = useRef(""); @@ -144,9 +245,18 @@ export function usePlaygroundState({ useEffect(() => { if (selectedSampleName && samples && selectedSampleName !== lastProcessedSample.current) { const config = samples[selectedSampleName]; - if (config?.content) { + if (config?.content || config?.files) { lastProcessedSample.current = selectedSampleName; - const updates: Partial = { content: config.content }; + const updates: Partial = {}; + if (config.files) { + updates.files = config.files; + updates.content = config.files["main.tsp"] ?? ""; + updates.activeFile = "main.tsp"; + } else if (config.content) { + updates.content = config.content; + updates.files = { "main.tsp": config.content }; + updates.activeFile = "main.tsp"; + } if (config.preferredEmitter) { updates.emitter = config.preferredEmitter; } @@ -166,6 +276,8 @@ export function usePlaygroundState({ selectedViewer, viewerState: playgroundState.viewerState ?? {}, content, + files, + activeFile, // State setters onSelectedEmitterChange, @@ -174,6 +286,14 @@ export function usePlaygroundState({ onSelectedViewerChange, onViewerStateChange, onContentChange, + onFilesChange, + onActiveFileChange, + + // File management + addFile, + removeFile, + renameFile, + updateFileContent, // Full state management playgroundState, diff --git a/packages/playground/src/types.ts b/packages/playground/src/types.ts index f9431be5236..1e09910a348 100644 --- a/packages/playground/src/types.ts +++ b/packages/playground/src/types.ts @@ -20,6 +20,12 @@ export interface PlaygroundSample { * Compiler options for the sample. */ compilerOptions?: CompilerOptions; + + /** + * Multiple files for a multi-file sample. Maps relative path to content. + * When provided, `content` is ignored and files are used instead. + */ + files?: Record; } export interface PlaygroundTspLibrary { diff --git a/packages/playground/stories/footer/footer-version-item.stories.tsx b/packages/playground/stories/footer/footer-version-item.stories.tsx index bb6f4857df5..cfd4dd355c7 100644 --- a/packages/playground/stories/footer/footer-version-item.stories.tsx +++ b/packages/playground/stories/footer/footer-version-item.stories.tsx @@ -31,7 +31,19 @@ const meta: Meta = { title: "Components/Footer/FooterVersionItem", decorators: [ (Story) => ( - null }}> + null, + files: { "main.tsp": "" }, + activeFile: "main.tsp", + setActiveFile: () => {}, + setFileContent: () => {}, + addFile: () => {}, + removeFile: () => {}, + renameFile: () => {}, + }} + >
{Story()}
), diff --git a/packages/playground/stories/footer/footer.stories.tsx b/packages/playground/stories/footer/footer.stories.tsx index 41157e96886..b1124f942a7 100644 --- a/packages/playground/stories/footer/footer.stories.tsx +++ b/packages/playground/stories/footer/footer.stories.tsx @@ -26,7 +26,19 @@ const meta: Meta = { component: Footer, decorators: [ (Story) => ( - null }}> + null, + files: { "main.tsp": "" }, + activeFile: "main.tsp", + setActiveFile: () => {}, + setFileContent: () => {}, + addFile: () => {}, + removeFile: () => {}, + renameFile: () => {}, + }} + > ),