diff --git a/packages/dev/inspector-v2/src/cli/bridge.ts b/packages/dev/inspector-v2/src/cli/bridge.ts index 477f2e8a1c5..5b0bc4dde54 100644 --- a/packages/dev/inspector-v2/src/cli/bridge.ts +++ b/packages/dev/inspector-v2/src/cli/bridge.ts @@ -124,7 +124,8 @@ export async function StartBridge(config: IBridgeConfig): Promise break; } case "commandListResponse": - case "commandResponse": { + case "commandResponse": + case "infoResponse": { // Forward response back to the CLI that requested it. const resolve = pendingBrowserRequests.get(message.requestId); if (resolve) { @@ -165,9 +166,27 @@ export async function StartBridge(config: IBridgeConfig): Promise case "sessions": { // Wait for at least one session to connect before responding. await waitForSession(); + + // Query each session for its current name. + const updatedNames = new Map(); + const infoPromises = Array.from(sessions.values()).map(async (s) => { + const requestId = generateRequestId(); + sendBrowserRequest(s, { type: "getInfo", requestId }); + try { + const raw = await waitForBrowserResponse(requestId, 5000); + const info = JSON.parse(raw); + if (info.type === "infoResponse" && typeof info.name === "string") { + updatedNames.set(s.id, info.name); + } + } catch { + // Keep the existing name if the session doesn't respond. + } + }); + await Promise.all(infoPromises); + const sessionList: SessionInfo[] = Array.from(sessions.values()).map((s) => ({ id: s.id, - name: s.name, + name: updatedNames.get(s.id) ?? s.name, connectedAt: s.connectedAt, })); sendCliResponse({ type: "sessionsResponse", sessions: sessionList }); diff --git a/packages/dev/inspector-v2/src/cli/cli.ts b/packages/dev/inspector-v2/src/cli/cli.ts index 804b6f5e6ab..6c841173791 100644 --- a/packages/dev/inspector-v2/src/cli/cli.ts +++ b/packages/dev/inspector-v2/src/cli/cli.ts @@ -1,6 +1,7 @@ /* eslint-disable no-console */ import { spawn } from "child_process"; import { dirname, join, resolve } from "path"; +import { readFileSync } from "fs"; import { fileURLToPath } from "url"; import { parseArgs } from "util"; import { WebSocket } from "./webSocket.js"; @@ -320,7 +321,8 @@ export function PrintCommandHelp(commandId: string, descriptor: CommandInfo, mis const maxLen = Math.max(...descriptor.args.map((a) => `--${a.name}${a.required ? " (required)" : ""}`.length)); for (const arg of descriptor.args) { const label = `--${arg.name}${arg.required ? " (required)" : ""}`; - console.log(` ${label.padEnd(maxLen)} ${arg.description}`); + const typeHint = arg.type === "file" ? " [reads file content from path]" : ""; + console.log(` ${label.padEnd(maxLen)} ${arg.description}${typeHint}`); } } if (missingRequired.length > 0 && !wantsHelp) { @@ -404,6 +406,20 @@ async function HandleCommand(socket: WebSocket, args: IParsedArgs): Promise(socket, { type: "exec", sessionId, diff --git a/packages/dev/inspector-v2/src/cli/protocol.ts b/packages/dev/inspector-v2/src/cli/protocol.ts index 9d87995eef7..b49a44b21da 100644 --- a/packages/dev/inspector-v2/src/cli/protocol.ts +++ b/packages/dev/inspector-v2/src/cli/protocol.ts @@ -10,6 +10,8 @@ export type CommandArgInfo = { description: string; /** Whether this argument is required. */ required?: boolean; + /** The type of the argument. Defaults to "string". When "file", the CLI reads the file and sends its contents. */ + type?: "string" | "file"; }; /** @@ -170,10 +172,22 @@ export type CommandResponse = { error?: string; }; +/** + * Browser → Bridge: Response to a getInfo request from the bridge. + */ +export type InfoResponse = { + /** The message type discriminator. */ + type: "infoResponse"; + /** The identifier of the original request. */ + requestId: string; + /** The current display name of the session. */ + name: string; +}; + /** * All messages that the browser sends to the bridge. */ -export type BrowserRequest = RegisterRequest | CommandListResponse | CommandResponse; +export type BrowserRequest = RegisterRequest | CommandListResponse | CommandResponse | InfoResponse; /** * Bridge → Browser: Request the list of registered commands. @@ -199,7 +213,17 @@ export type ExecCommandRequest = { args: Record; }; +/** + * Bridge → Browser: Request current session information. + */ +export type GetInfoRequest = { + /** The message type discriminator. */ + type: "getInfo"; + /** A unique identifier for this request. */ + requestId: string; +}; + /** * All messages that the bridge sends to the browser. */ -export type BrowserResponse = ListCommandsRequest | ExecCommandRequest; +export type BrowserResponse = ListCommandsRequest | ExecCommandRequest | GetInfoRequest; diff --git a/packages/dev/inspector-v2/src/inspectable.ts b/packages/dev/inspector-v2/src/inspectable.ts index 8e27e80e751..d5757c98991 100644 --- a/packages/dev/inspector-v2/src/inspectable.ts +++ b/packages/dev/inspector-v2/src/inspectable.ts @@ -62,6 +62,7 @@ export type InternalInspectableToken = InspectableToken & { // Track shared state per scene: the service container, ref count, and teardown logic. type InspectableState = { refCount: number; + readonly fullyDisposed: boolean; serviceContainer: ServiceContainer; sceneDisposeObserver: { remove: () => void }; fullyDispose: () => void; @@ -80,13 +81,12 @@ export function _StartInspectable(scene: Scene, options?: Partial { InspectableStates.delete(scene); + fullyDisposed = true; serviceContainer.dispose(); sceneDisposeObserver.remove(); }; @@ -105,8 +105,10 @@ export function _StartInspectable(scene: Scene, options?: Partial {} }, fullyDispose, @@ -175,7 +180,7 @@ export function _StartInspectable(scene: Scene, options?: Partial { return new Promise((resolve) => setTimeout(resolve, ms)); } +function autoRespondGetInfo(ws: WebSocket, name: string | (() => string)): void { + ws.on("message", (data) => { + try { + const msg = JSON.parse(data.toString()); + if (msg.type === "getInfo") { + const response: InfoResponse = { + type: "infoResponse", + requestId: msg.requestId, + name: typeof name === "function" ? name() : name, + }; + ws.send(JSON.stringify(response)); + } + } catch { + // ignore + } + }); +} + describe("Inspector Bridge", () => { let bridge: IBridgeHandle; @@ -76,6 +95,7 @@ describe("Inspector Bridge", () => { it("registers a browser session and lists it via CLI", async () => { const browser = await connect(bridge.browserPort); + autoRespondGetInfo(browser, "Test Scene"); browser.send(JSON.stringify({ type: "register", name: "Test Scene" })); await tick(); @@ -110,6 +130,7 @@ describe("Inspector Bridge", () => { it("removes session when browser disconnects", async () => { const browser = await connect(bridge.browserPort); + autoRespondGetInfo(browser, "Temporary Scene"); browser.send(JSON.stringify({ type: "register", name: "Temporary Scene" })); await tick(); @@ -132,6 +153,7 @@ describe("Inspector Bridge", () => { it("forwards command listing from browser to CLI", async () => { const browser = await connect(bridge.browserPort); + autoRespondGetInfo(browser, "Scene"); browser.send(JSON.stringify({ type: "register", name: "Scene" })); await tick(); @@ -166,6 +188,7 @@ describe("Inspector Bridge", () => { it("forwards command execution from CLI to browser", async () => { const browser = await connect(bridge.browserPort); + autoRespondGetInfo(browser, "Scene"); browser.send(JSON.stringify({ type: "register", name: "Scene" })); await tick(); @@ -237,10 +260,12 @@ describe("Inspector Bridge", () => { it("supports multiple browser sessions", async () => { const browser1 = await connect(bridge.browserPort); + autoRespondGetInfo(browser1, "Scene A"); browser1.send(JSON.stringify({ type: "register", name: "Scene A" })); await tick(); const browser2 = await connect(bridge.browserPort); + autoRespondGetInfo(browser2, "Scene B"); browser2.send(JSON.stringify({ type: "register", name: "Scene B" })); await tick(); @@ -258,6 +283,7 @@ describe("Inspector Bridge", () => { it("ignores malformed JSON on browser port", async () => { const browser = await connect(bridge.browserPort); + autoRespondGetInfo(browser, "After Bad JSON"); browser.send("not valid json{{{"); await tick(); @@ -281,6 +307,7 @@ describe("Inspector Bridge", () => { // Bridge should still be functional — send a valid request. const browser = await connect(bridge.browserPort); + autoRespondGetInfo(browser, "Scene"); browser.send(JSON.stringify({ type: "register", name: "Scene" })); await tick(); @@ -290,4 +317,30 @@ describe("Inspector Bridge", () => { await close(browser); await close(cli); }); + + it("returns updated session name via getInfo on each listing", async () => { + let currentName = "Initial Name"; + const browser = await connect(bridge.browserPort); + autoRespondGetInfo(browser, () => currentName); + browser.send(JSON.stringify({ type: "register", name: "Initial Name" })); + await tick(); + + const cli = await connect(bridge.cliPort); + + // First listing returns the initial name. + const first = await sendAndReceive(cli, { type: "sessions" }); + expect(first.sessions).toHaveLength(1); + expect(first.sessions[0].name).toBe("Initial Name"); + + // Update the name on the browser side. + currentName = "Updated Name"; + + // Second listing reflects the updated name. + const second = await sendAndReceive(cli, { type: "sessions" }); + expect(second.sessions).toHaveLength(1); + expect(second.sessions[0].name).toBe("Updated Name"); + + await close(browser); + await close(cli); + }); }); diff --git a/packages/tools/playground/src/components/editor/monacoComponent.tsx b/packages/tools/playground/src/components/editor/monacoComponent.tsx index f8fd10d9690..9fd64c5cdd2 100644 --- a/packages/tools/playground/src/components/editor/monacoComponent.tsx +++ b/packages/tools/playground/src/components/editor/monacoComponent.tsx @@ -161,6 +161,7 @@ export class MonacoComponent extends React.Component ({ ...s })); }) diff --git a/packages/tools/playground/src/components/rendererComponent.tsx b/packages/tools/playground/src/components/rendererComponent.tsx index ddd52742315..7caac629288 100644 --- a/packages/tools/playground/src/components/rendererComponent.tsx +++ b/packages/tools/playground/src/components/rendererComponent.tsx @@ -6,9 +6,11 @@ import { type GlobalState, RuntimeMode } from "../globalState"; import { Utilities } from "../tools/utilities"; import { DownloadManager } from "../tools/downloadManager"; import { AddFileRevision } from "../tools/localSession"; +import { type InspectableToken } from "inspector/inspectable"; import { Engine, EngineStore, WebGPUEngine, LastCreatedAudioEngine, Logger, type IDisposable, type Nullable, type Scene, type ThinEngine } from "@dev/core"; +import { MakePlaygroundCommandServiceDefinition } from "../tools/playgroundCommandService"; import "../scss/rendering.scss"; type InspectorV2Module = typeof import("inspector/legacy/legacy") & typeof import("inspector/index"); @@ -36,6 +38,7 @@ export class RenderingComponent extends React.Component = null; + private _inspectableToken: Nullable = null; /** * Create the rendering component. @@ -123,10 +126,26 @@ export class RenderingComponent extends React.Component { this.props.globalState.onRunExecutedObservable.notifyObservers(); }); diff --git a/packages/tools/playground/src/globalState.ts b/packages/tools/playground/src/globalState.ts index f77cc78ab84..5dea1d4806f 100644 --- a/packages/tools/playground/src/globalState.ts +++ b/packages/tools/playground/src/globalState.ts @@ -18,6 +18,19 @@ export enum RuntimeMode { Frame = 2, } +/** + * Optional payload for onSaveRequiredObservable that allows callers to + * supply metadata and bypass the metadata dialog. + */ +export interface ISaveOptions { + /** Snippet title. Defaults to empty string if not provided. */ + title?: string; + /** Snippet description. Defaults to empty string if not provided. */ + description?: string; + /** Snippet tags. Defaults to empty string if not provided. */ + tags?: string; +} + export interface IEngineSwitchDialogOptions { currentEngine: string; targetEngine: string; @@ -27,6 +40,22 @@ export interface IEngineSwitchDialogRequest extends IEngineSwitchDialogOptions { resolve: (shouldSwitch: boolean) => void; } +/** + * A diagnostic marker from the editor. + */ +export interface IDiagnosticInfo { + /** The file name. */ + file: string; + /** The diagnostic message. */ + message: string; + /** The severity: "error", "warning", or "info". */ + severity: "error" | "warning" | "info"; + /** The line number. */ + line: number; + /** The column number. */ + column: number; +} + export class GlobalState { // eslint-disable-next-line @typescript-eslint/naming-convention public readonly MobileSizeTrigger = 1024; @@ -38,6 +67,7 @@ export class GlobalState { public getRunnable: () => Promise = async () => { throw new Error("Must be set in runtime"); }; + public getDiagnostics: () => IDiagnosticInfo[] = () => []; currentRunner?: V2Runner | null; public language = Utilities.ReadStringFromStore("language", "JS"); @@ -75,7 +105,7 @@ export class GlobalState { public onNewRequiredObservable = new Observable(); public onInsertSnippetRequiredObservable = new Observable(); public onClearRequiredObservable = new Observable(); - public onSaveRequiredObservable = new Observable(); + public onSaveRequiredObservable = new Observable(); public onLocalSaveRequiredObservable = new Observable(); public onLoadRequiredObservable = new Observable(); public onLocalLoadRequiredObservable = new Observable(); diff --git a/packages/tools/playground/src/playground.tsx b/packages/tools/playground/src/playground.tsx index e4a8d89d339..db676d5328a 100644 --- a/packages/tools/playground/src/playground.tsx +++ b/packages/tools/playground/src/playground.tsx @@ -18,6 +18,7 @@ import { ExamplesComponent } from "./components/examplesComponent"; import { QRCodeComponent } from "./components/qrCodeComponent"; import { SplitContainer } from "shared-ui-components/split/splitContainer"; import { Splitter } from "shared-ui-components/split/splitter"; +import { type IObserver } from "core/Misc"; import "./scss/main.scss"; import { ControlledSize, SplitDirection } from "shared-ui-components/split/splitContext"; @@ -54,6 +55,8 @@ export class Playground extends React.Component< private _splitContainerRef: React.RefObject; private _globalState: GlobalState; + private _syncTitle: () => void = () => {}; + private _savedObserver: IObserver | null = null; /** * @@ -111,12 +114,34 @@ export class Playground extends React.Component< this.saveManager = new SaveManager(this._globalState); this.loadManager = new LoadManager(this._globalState); this.shortcutManager = new ShortcutManager(this._globalState); + + // Keep document.title in sync with the snippet ID from the URL hash. + const baseTitle = typeof document !== "undefined" ? document.title : ""; + this._syncTitle = () => { + if (typeof document === "undefined") { + return; + } + const hash = location.hash; + if (hash && hash.length > 1) { + document.title = `${baseTitle} - ${hash}`; + } else { + document.title = baseTitle; + } + }; + this._syncTitle(); + window.addEventListener("hashchange", this._syncTitle); + this._savedObserver = this._globalState.onSavedObservable.add(this._syncTitle); } override componentDidMount() { this.checkSize(); } + override componentWillUnmount() { + window.removeEventListener("hashchange", this._syncTitle); + this._savedObserver?.remove(); + } + override componentDidUpdate() { this.checkSize(); } diff --git a/packages/tools/playground/src/tools/monaco/monacoManager.ts b/packages/tools/playground/src/tools/monaco/monacoManager.ts index 48fffb72de2..e8b6af84b34 100644 --- a/packages/tools/playground/src/tools/monaco/monacoManager.ts +++ b/packages/tools/playground/src/tools/monaco/monacoManager.ts @@ -188,6 +188,29 @@ export class MonacoManager { }); this.globalState.onFilesChangedObservable.add(() => { + // Create models for any new files that don't have models yet, + // and sync content for existing models that have changed externally. + for (const [path, code] of Object.entries(this.globalState.files)) { + if (!this._files.has(path)) { + this._files.addFile(path, code, (p, c) => { + this.globalState.files[p] = c; + this._files.setDirty(true); + }); + } else { + const model = this._files.getModel(path); + if (model && model.getValue() !== code) { + model.setValue(code); + } + } + } + + // Remove models for files that no longer exist. + for (const path of this._files.paths()) { + if (!(path in this.globalState.files)) { + this._files.removeFile(path); + } + } + // Prevent worker restart during hydration to avoid race conditions if (!this._hydrating) { this._tsPipeline.forceSyncModels(); @@ -206,6 +229,24 @@ export class MonacoManager { // Initialize getRunnable as a bound method this.globalState.getRunnable = this.getRunnableAsync.bind(this); + + // Expose diagnostics from Monaco editor markers. + this.globalState.getDiagnostics = () => { + const result: import("../../globalState").IDiagnosticInfo[] = []; + const markers = monaco.editor.getModelMarkers({}); + for (const marker of markers) { + const uri = marker.resource?.path ?? ""; + const fileName = uri.startsWith("/pg/") ? uri.slice(4) : uri; + result.push({ + file: fileName, + message: marker.message, + severity: marker.severity === monaco.MarkerSeverity.Error ? "error" : marker.severity === monaco.MarkerSeverity.Warning ? "warning" : "info", + line: marker.startLineNumber, + column: marker.startColumn, + }); + } + return result; + }; } private _initializeFileState(entry: string) { diff --git a/packages/tools/playground/src/tools/playgroundCommandService.ts b/packages/tools/playground/src/tools/playgroundCommandService.ts new file mode 100644 index 00000000000..365771aca41 --- /dev/null +++ b/packages/tools/playground/src/tools/playgroundCommandService.ts @@ -0,0 +1,259 @@ +import { type GlobalState } from "../globalState"; +import { type WeaklyTypedServiceDefinition } from "inspector/modularity/serviceContainer"; + +type InspectorV2Module = typeof import("inspector/legacy/legacy") & typeof import("inspector/index"); + +/** + * Creates a service definition that registers Playground-specific CLI commands. + * @param globalState The Playground's shared global state. + * @param inspectorModule The Inspector v2 UMD module from the global INSPECTOR object. + * @returns A weakly-typed service definition to pass as part of InspectableOptions.serviceDefinitions. + */ +export function MakePlaygroundCommandServiceDefinition(globalState: GlobalState, inspectorModule: InspectorV2Module): WeaklyTypedServiceDefinition { + return { + friendlyName: "Playground Command Service", + consumes: [inspectorModule.InspectableCommandRegistryIdentity], + factory: (commandRegistry: any) => { + // Track the last error from the Playground for the get-errors command. + let lastError: { message: string; lineNumber?: number; columnNumber?: number } | null = null; + const errorTracker = globalState.onErrorObservable.add((error) => { + if (error) { + const msg = error.message; + lastError = { + message: typeof msg === "string" ? msg : (msg?.messageText ?? "Unknown error"), + lineNumber: error.lineNumber, + columnNumber: error.columnNumber, + }; + } else { + lastError = null; + } + }); + + const listFilesReg = commandRegistry.addCommand({ + id: "list-files", + description: "List all files managed in the Playground editor.", + executeAsync: async () => { + const paths = Object.keys(globalState.files); + return JSON.stringify(paths, null, 2); + }, + }); + + const getContentReg = commandRegistry.addCommand({ + id: "get-content", + description: "Get the content of a file by name.", + args: [ + { + name: "name", + description: "The file name (e.g. index.js, index.ts).", + required: true, + }, + ], + executeAsync: async (args: Record) => { + const content = globalState.files[args.name]; + if (content === undefined) { + const names = Object.keys(globalState.files); + throw new Error(`File "${args.name}" not found. Available files: ${names.join(", ")}`); + } + return content; + }, + }); + + const setContentReg = commandRegistry.addCommand({ + id: "set-content", + description: "Set the content of a file by name.", + args: [ + { + name: "name", + description: "The file name (e.g. index.js, index.ts).", + required: true, + }, + { + name: "content", + description: "The new file content.", + required: true, + type: "file", + }, + ], + executeAsync: async (args: Record) => { + if (!(args.name in globalState.files)) { + const names = Object.keys(globalState.files); + throw new Error(`File "${args.name}" not found. Available files: ${names.join(", ")}`); + } + globalState.files[args.name] = args.content; + globalState.onFilesChangedObservable.notifyObservers(); + return `File "${args.name}" updated.`; + }, + }); + + const createFileReg = commandRegistry.addCommand({ + id: "create-file", + description: "Create a new file in the Playground editor.", + args: [ + { + name: "name", + description: "The file name to create (e.g. utils.ts).", + required: true, + }, + { + name: "content", + description: "The initial file content. Defaults to empty.", + required: false, + type: "file", + }, + ], + executeAsync: async (args: Record) => { + if (args.name in globalState.files) { + throw new Error(`File "${args.name}" already exists.`); + } + const content = args.content ?? ""; + globalState.files[args.name] = content; + globalState.onFilesChangedObservable.notifyObservers(); + globalState.onManifestChangedObservable.notifyObservers(); + + // Open the new file as the active tab in the editor. + globalState.activeFilePath = args.name; + globalState.onActiveFileChangedObservable.notifyObservers(); + + return `File "${args.name}" created.`; + }, + }); + + const deleteFileReg = commandRegistry.addCommand({ + id: "delete-file", + description: "Delete a file from the Playground editor.", + args: [ + { + name: "name", + description: "The file name to delete.", + required: true, + }, + ], + executeAsync: async (args: Record) => { + if (!(args.name in globalState.files)) { + const names = Object.keys(globalState.files); + throw new Error(`File "${args.name}" not found. Available files: ${names.join(", ")}`); + } + if (Object.keys(globalState.files).length <= 1) { + throw new Error("Cannot delete the last file."); + } + delete globalState.files[args.name]; + globalState.onFilesChangedObservable.notifyObservers(); + globalState.onManifestChangedObservable.notifyObservers(); + return `File "${args.name}" deleted.`; + }, + }); + + const getSnippetIdReg = commandRegistry.addCommand({ + id: "get-snippet-id", + description: "Get the current Playground snippet ID and revision.", + executeAsync: async () => { + const token = globalState.currentSnippetToken; + if (!token) { + return "No snippet ID. This playground has not been saved yet."; + } + const revision = globalState.currentSnippetRevision; + return revision && revision !== "0" ? `${token}#${revision}` : token; + }, + }); + + const savePlaygroundReg = commandRegistry.addCommand({ + id: "save-playground", + description: "Save the current Playground code and return the snippet ID.", + args: [ + { + name: "title", + description: "Snippet title. Uses existing value if not provided.", + required: false, + }, + { + name: "description", + description: "Snippet description. Uses existing value if not provided.", + required: false, + }, + { + name: "tags", + description: "Snippet tags. Uses existing value if not provided.", + required: false, + }, + ], + executeAsync: async (args: Record) => { + const result = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + errorObserver.remove(); + savedObserver.remove(); + reject(new Error("Save timed out.")); + }, 30000); + + const savedObserver = globalState.onSavedObservable.addOnce(() => { + clearTimeout(timeout); + errorObserver.remove(); + const token = globalState.currentSnippetToken; + const revision = globalState.currentSnippetRevision; + resolve(revision && revision !== "0" ? `${token}#${revision}` : token); + }); + + const errorObserver = globalState.onErrorObservable.add((error) => { + if (!error) { + return; + } + clearTimeout(timeout); + errorObserver.remove(); + savedObserver.remove(); + const msg = error?.message; + reject(new Error(typeof msg === "string" ? msg : (msg?.messageText ?? "Save failed."))); + }); + + globalState.onSaveRequiredObservable.notifyObservers({ + title: args.title, + description: args.description, + tags: args.tags, + }); + }); + + return result; + }, + }); + + const runPlaygroundReg = commandRegistry.addCommand({ + id: "run-playground", + description: "Run the current Playground code. The session will restart with a new session ID.", + executeAsync: async () => { + // Defer the run so the command response is sent before the + // scene teardown kills the inspectable WebSocket session. + setTimeout(() => globalState.onRunRequiredObservable.notifyObservers(), 0); + return "Run triggered."; + }, + }); + + const getErrorsReg = commandRegistry.addCommand({ + id: "get-errors", + description: "Get compile and runtime errors from the Playground.", + executeAsync: async () => { + return JSON.stringify( + { + compileErrors: globalState.getDiagnostics(), + runtimeError: lastError, + }, + null, + 2 + ); + }, + }); + + return { + dispose: () => { + errorTracker.remove(); + listFilesReg.dispose(); + getContentReg.dispose(); + setContentReg.dispose(); + createFileReg.dispose(); + deleteFileReg.dispose(); + getSnippetIdReg.dispose(); + savePlaygroundReg.dispose(); + runPlaygroundReg.dispose(); + getErrorsReg.dispose(); + }, + }; + }, + }; +} diff --git a/packages/tools/playground/src/tools/saveManager.ts b/packages/tools/playground/src/tools/saveManager.ts index 568f9109c97..291610180ac 100644 --- a/packages/tools/playground/src/tools/saveManager.ts +++ b/packages/tools/playground/src/tools/saveManager.ts @@ -13,7 +13,16 @@ export class SaveManager { * @param globalState Shared global state instance. */ public constructor(public globalState: GlobalState) { - globalState.onSaveRequiredObservable.add(() => { + globalState.onSaveRequiredObservable.add((options) => { + // If save options are provided, apply them and skip the metadata dialog. + if (options) { + this.globalState.currentSnippetTitle = options.title ?? (this.globalState.currentSnippetTitle || ""); + this.globalState.currentSnippetDescription = options.description ?? (this.globalState.currentSnippetDescription || ""); + this.globalState.currentSnippetTags = options.tags ?? (this.globalState.currentSnippetTags || ""); + this._saveSnippet(); + return; + } + if (!this.globalState.currentSnippetTitle || !this.globalState.currentSnippetDescription || !this.globalState.currentSnippetTags) { this.globalState.onMetadataWindowHiddenObservable.addOnce((status) => { if (status) {