diff --git a/eng/tsp-core/scripts/upload-csharp-emitter-package.js b/eng/tsp-core/scripts/upload-csharp-emitter-package.js new file mode 100644 index 00000000000..37e5cc12733 --- /dev/null +++ b/eng/tsp-core/scripts/upload-csharp-emitter-package.js @@ -0,0 +1,56 @@ +// @ts-check +// Standalone script to bundle and upload the http-client-csharp emitter +// to the playground package storage. Run from an ADO pipeline after +// building the emitter. +// +// This is separate from the core upload-bundler-packages.js because +// http-client-csharp is not in the pnpm workspace. + +import { AzureCliCredential } from "@azure/identity"; +import { createTypeSpecBundle } from "@typespec/bundler"; +import { resolve } from "path"; +import { join as joinUnix } from "path/posix"; +import { repoRoot } from "../../common/scripts/helpers.js"; + +const credential = new AzureCliCredential(); + +// Dynamically import the uploader (it's in the bundle-uploader package which must be built first) +const { TypeSpecBundledPackageUploader } = await import( + "../../../packages/bundle-uploader/dist/src/upload-browser-package.js" +); +const { getPackageVersion } = await import( + "../../../packages/bundle-uploader/dist/src/index.js" +); + +const packagePath = resolve(repoRoot, "packages/http-client-csharp"); +const indexName = "typespec"; +const indexVersion = await getPackageVersion(repoRoot, "@typespec/compiler"); + +console.log(`Bundling http-client-csharp emitter from: ${packagePath}`); +console.log(`Index version: ${indexVersion}`); + +const bundle = await createTypeSpecBundle(packagePath); +const manifest = bundle.manifest; + +const uploader = new TypeSpecBundledPackageUploader(credential); +await uploader.createIfNotExists(); + +const result = await uploader.upload(bundle); +if (result.status === "uploaded") { + console.log(`✔ Bundle for ${manifest.name}@${manifest.version} uploaded.`); +} else { + console.log(`Bundle for ${manifest.name} already exists for version ${manifest.version}.`); +} + +// Update the index with the new import map entries +const existingIndex = await uploader.getIndex(indexName, indexVersion); +const importMap = { ...existingIndex?.imports }; +for (const [key, value] of Object.entries(result.imports)) { + importMap[joinUnix(manifest.name, key)] = value; +} + +await uploader.updateIndex(indexName, { + version: indexVersion, + imports: importMap, +}); +console.log(`✔ Updated index for version ${indexVersion}.`); diff --git a/packages/http-client-csharp/emitter/src/code-model-writer.ts b/packages/http-client-csharp/emitter/src/code-model-writer.ts index 994d0bad2e7..e60360c32b5 100644 --- a/packages/http-client-csharp/emitter/src/code-model-writer.ts +++ b/packages/http-client-csharp/emitter/src/code-model-writer.ts @@ -8,6 +8,16 @@ import { CSharpEmitterContext } from "./sdk-context.js"; import { CodeModel } from "./type/code-model.js"; import { Configuration } from "./type/configuration.js"; +/** + * Serializes the code model to a JSON string with reference tracking. + * @param context - The CSharp emitter context + * @param codeModel - The code model to serialize + * @beta + */ +export function serializeCodeModel(context: CSharpEmitterContext, codeModel: CodeModel): string { + return prettierOutput(JSON.stringify(buildJson(context, codeModel), transformJSONProperties, 2)); +} + /** * Writes the code model to the output folder. Should only be used by autorest.csharp. * @param context - The CSharp emitter context @@ -22,7 +32,7 @@ export async function writeCodeModel( ) { await context.program.host.writeFile( resolvePath(outputFolder, tspOutputFileName), - prettierOutput(JSON.stringify(buildJson(context, codeModel), transformJSONProperties, 2)), + serializeCodeModel(context, codeModel), ); } diff --git a/packages/http-client-csharp/emitter/src/emitter.ts b/packages/http-client-csharp/emitter/src/emitter.ts index 936f79dd191..f372b430f73 100644 --- a/packages/http-client-csharp/emitter/src/emitter.ts +++ b/packages/http-client-csharp/emitter/src/emitter.ts @@ -12,10 +12,7 @@ import { Program, resolvePath, } from "@typespec/compiler"; -import fs, { statSync } from "fs"; -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { writeCodeModel, writeConfiguration } from "./code-model-writer.js"; +import { writeCodeModel, writeConfiguration, serializeCodeModel } from "./code-model-writer.js"; import { _minSupportedDotNetSdkVersion, configurationFileName, @@ -34,14 +31,21 @@ import { Configuration } from "./type/configuration.js"; /** * Look for the project root by looking up until a `package.json` is found. * @param path Path to start looking + * @param statSyncFn The statSync function (injected to avoid top-level fs import) */ -function findProjectRoot(path: string): string | undefined { +function findProjectRoot( + path: string, + statSyncFn: (p: string) => { isFile(): boolean }, +): string | undefined { let current = path; while (true) { const pkgPath = joinPaths(current, "package.json"); - const stats = checkFile(pkgPath); - if (stats?.isFile()) { - return current; + try { + if (statSyncFn(pkgPath)?.isFile()) { + return current; + } + } catch { + // file doesn't exist } const parent = getDirectoryPath(current); if (parent === current) { @@ -107,64 +111,46 @@ export async function emitCodeModel( // Apply optional code model update callback const updatedRoot = updateCodeModel ? updateCodeModel(root, sdkContext) : root; - const generatedFolder = resolvePath(outputFolder, "src", "Generated"); - - if (!fs.existsSync(generatedFolder)) { - fs.mkdirSync(generatedFolder, { recursive: true }); - } - - // emit tspCodeModel.json - await writeCodeModel(sdkContext, updatedRoot, outputFolder); - const namespace = updatedRoot.name; const configurations: Configuration = createConfiguration(options, namespace, sdkContext); - //emit configuration.json - await writeConfiguration(sdkContext, configurations, outputFolder); + const playgroundServerUrl = + options["playground-server-url"] || + (typeof globalThis.process === "undefined" + ? ((globalThis as any).__TYPESPEC_PLAYGROUND_SERVER_URL__ ?? "http://localhost:5174") + : undefined); - const csProjFile = resolvePath( - outputFolder, - "src", - `${configurations["package-name"]}.csproj`, - ); - logger.info(`Checking if ${csProjFile} exists`); - - const emitterPath = options["emitter-extension-path"] ?? import.meta.url; - const projectRoot = findProjectRoot(dirname(fileURLToPath(emitterPath))); - const generatorPath = resolvePath( - projectRoot + "/dist/generator/Microsoft.TypeSpec.Generator.dll", - ); + if (playgroundServerUrl) { + // Playground mode: serialize and send directly to server without writing to virtual FS + const codeModelJson = serializeCodeModel(sdkContext, updatedRoot); + const configJson = JSON.stringify(configurations, null, 2) + "\n"; + await generateViaPlaygroundServer( + playgroundServerUrl, + sdkContext, + outputFolder, + codeModelJson, + configJson, + options["generator-name"], + ); + } else { + // Local mode: write files and run .NET generator + await writeCodeModel(sdkContext, updatedRoot, outputFolder); + await writeConfiguration(sdkContext, configurations, outputFolder); - try { - const result = await execCSharpGenerator(sdkContext, { - generatorPath: generatorPath, - outputFolder: outputFolder, + await runLocalGenerator(sdkContext, diagnostics, { + outputFolder, + packageName: configurations["package-name"] ?? "", generatorName: options["generator-name"], - newProject: options["new-project"] || !checkFile(csProjFile), + newProject: options["new-project"], debug: options.debug ?? false, + emitterExtensionPath: options["emitter-extension-path"], + logger, }); - if (result.exitCode !== 0) { - const isValid = diagnostics.pipe( - await _validateDotNetSdk(sdkContext, _minSupportedDotNetSdkVersion), - ); - // if the dotnet sdk is valid, the error is not dependency issue, log it as normal - if (isValid) { - throw new Error( - `Failed to generate the library. Exit code: ${result.exitCode}.\nStackTrace: \n${result.stderr}`, - ); - } + + if (!options["save-inputs"]) { + context.program.host.rm(resolvePath(outputFolder, tspOutputFileName)); + context.program.host.rm(resolvePath(outputFolder, configurationFileName)); } - } catch (error: any) { - const isValid = diagnostics.pipe( - await _validateDotNetSdk(sdkContext, _minSupportedDotNetSdkVersion), - ); - // if the dotnet sdk is valid, the error is not dependency issue, log it as normal - if (isValid) throw new Error(error, { cause: error }); - } - if (!options["save-inputs"]) { - // delete - context.program.host.rm(resolvePath(outputFolder, tspOutputFileName)); - context.program.host.rm(resolvePath(outputFolder, configurationFileName)); } } } @@ -199,6 +185,7 @@ export function createConfiguration( "generate-protocol-methods", "generate-convenience-methods", "emitter-extension-path", + "playground-server-url", ]; const derivedOptions = Object.fromEntries( Object.entries(options).filter(([key]) => !skipKeys.includes(key)), @@ -291,10 +278,113 @@ function validateDotNetSdkVersionCore( } } -function checkFile(pkgPath: string) { +/** + * Sends the code model and configuration to a playground server for C# generation. + * Used when the emitter runs in a browser environment. + */ +async function generateViaPlaygroundServer( + serverUrl: string, + sdkContext: CSharpEmitterContext, + outputFolder: string, + codeModelJson: string, + configJson: string, + generatorName: string, +): Promise { + const response = await fetch(`${serverUrl}/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + codeModel: codeModelJson, + configuration: configJson, + generatorName, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Playground server error (${response.status}): ${errorText}`); + } + + const result: { files: Array<{ path: string; content: string }> } = await response.json(); + + for (const file of result.files) { + await sdkContext.program.host.writeFile(resolvePath(outputFolder, file.path), file.content); + } +} + +/** + * Runs the .NET generator locally via subprocess. + * Uses dynamic imports for Node.js modules (fs, path, url) to keep the + * emitter module loadable in browser environments. + */ +async function runLocalGenerator( + sdkContext: CSharpEmitterContext, + diagnostics: ReturnType, + options: { + outputFolder: string; + packageName: string; + generatorName: string; + newProject: boolean; + debug: boolean; + emitterExtensionPath?: string; + logger: Logger; + }, +): Promise { + const fs = await import("fs"); + const { dirname } = await import("path"); + const { fileURLToPath } = await import("url"); + + const generatedFolder = resolvePath(options.outputFolder, "src", "Generated"); + + if (!fs.existsSync(generatedFolder)) { + fs.mkdirSync(generatedFolder, { recursive: true }); + } + + const csProjFile = resolvePath( + options.outputFolder, + "src", + `${options.packageName}.csproj`, + ); + options.logger.info(`Checking if ${csProjFile} exists`); + + const emitterPath = options.emitterExtensionPath ?? import.meta.url; + const projectRoot = findProjectRoot(dirname(fileURLToPath(emitterPath)), fs.statSync); + const generatorPath = resolvePath( + projectRoot + "/dist/generator/Microsoft.TypeSpec.Generator.dll", + ); + + const checkFile = (path: string) => { + try { + return fs.statSync(path); + } catch { + return undefined; + } + }; + try { - return statSync(pkgPath); - } catch (error) { - return undefined; + const result = await execCSharpGenerator(sdkContext, { + generatorPath: generatorPath, + outputFolder: options.outputFolder, + generatorName: options.generatorName, + newProject: options.newProject || !checkFile(csProjFile), + debug: options.debug, + }); + if (result.exitCode !== 0) { + const isValid = diagnostics.pipe( + await _validateDotNetSdk(sdkContext, _minSupportedDotNetSdkVersion), + ); + // if the dotnet sdk is valid, the error is not dependency issue, log it as normal + if (isValid) { + throw new Error( + `Failed to generate the library. Exit code: ${result.exitCode}.\nStackTrace: \n${result.stderr}`, + ); + } + } + } catch (error: any) { + const isValid = diagnostics.pipe( + await _validateDotNetSdk(sdkContext, _minSupportedDotNetSdkVersion), + ); + // if the dotnet sdk is valid, the error is not dependency issue, log it as normal + if (isValid) throw new Error(error, { cause: error }); } } diff --git a/packages/http-client-csharp/emitter/src/lib/utils.ts b/packages/http-client-csharp/emitter/src/lib/utils.ts index aa8af4d3889..8275ef27bf7 100644 --- a/packages/http-client-csharp/emitter/src/lib/utils.ts +++ b/packages/http-client-csharp/emitter/src/lib/utils.ts @@ -9,7 +9,7 @@ import { } from "@azure-tools/typespec-client-generator-core"; import { getNamespaceFullName, Namespace, NoTarget, Type } from "@typespec/compiler"; import { Visibility } from "@typespec/http"; -import { spawn, SpawnOptions } from "child_process"; +import type { SpawnOptions } from "child_process"; import { CSharpEmitterContext } from "../sdk-context.js"; export async function execCSharpGenerator( @@ -22,6 +22,7 @@ export async function execCSharpGenerator( debug: boolean; }, ): Promise<{ exitCode: number; stderr: string; proc: any }> { + const { spawn } = await import("child_process"); const command = "dotnet"; const args = [ "--roll-forward", @@ -110,6 +111,7 @@ export async function execAsync( args: string[] = [], options: SpawnOptions = {}, ): Promise<{ exitCode: number; stdio: string; stdout: string; stderr: string; proc: any }> { + const { spawn } = await import("child_process"); const child = spawn(command, args, options); return new Promise((resolve, reject) => { diff --git a/packages/http-client-csharp/emitter/src/options.ts b/packages/http-client-csharp/emitter/src/options.ts index 9ae08884a9e..dfa82a09fb3 100644 --- a/packages/http-client-csharp/emitter/src/options.ts +++ b/packages/http-client-csharp/emitter/src/options.ts @@ -22,6 +22,7 @@ export interface CSharpEmitterOptions { "generate-protocol-methods"?: boolean; "generate-convenience-methods"?: boolean; "package-name"?: string; + "playground-server-url"?: string; license?: { name: string; company?: string; @@ -133,6 +134,12 @@ export const CSharpEmitterOptionsSchema: JSONSchemaType = description: "The SDK context options that implement the `CreateSdkContextOptions` interface from the [`@azure-tools/typespec-client-generator-core`](https://www.npmjs.com/package/@azure-tools/typespec-client-generator-core) package to be used by the CSharp emitter.", }, + "playground-server-url": { + type: "string", + nullable: true, + description: + "URL of a playground server that runs the .NET generator. When set, the emitter sends the code model to this server instead of spawning a local dotnet process. Used for browser-based playground environments.", + }, }, required: [], }; diff --git a/packages/http-client-csharp/package.json b/packages/http-client-csharp/package.json index e0bbfda7540..da59c45fd8a 100644 --- a/packages/http-client-csharp/package.json +++ b/packages/http-client-csharp/package.json @@ -30,6 +30,7 @@ "build:emitter": "tsc -p ./emitter/tsconfig.build.json", "build:generator": "dotnet build ./generator", "build": "npm run build:emitter && npm run build:generator && npm run extract-api", + "dev:playground": "npx concurrently -k -n playground,server -c cyan,green \"npx vite --config ../../packages/playground-website/vite.config.ts ../../packages/playground-website\" \"dotnet run --project ./playground-server\"", "gen-extern-signature": "tspd --enable-experimental gen-extern-signature .", "watch": "tsc -p ./emitter/tsconfig.build.json --watch", "lint-typespec-library": "tsp compile . --warn-as-error --import @typespec/library-linter --no-emit", diff --git a/packages/http-client-csharp/playground-server/.gitignore b/packages/http-client-csharp/playground-server/.gitignore new file mode 100644 index 00000000000..cd42ee34e87 --- /dev/null +++ b/packages/http-client-csharp/playground-server/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/packages/http-client-csharp/playground-server/Dockerfile b/packages/http-client-csharp/playground-server/Dockerfile new file mode 100644 index 00000000000..c6894a3ab17 --- /dev/null +++ b/packages/http-client-csharp/playground-server/Dockerfile @@ -0,0 +1,33 @@ +# Build from the http-client-csharp package root: +# docker build -f playground-server/Dockerfile -t csharp-playground-server . +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Build the generator (populates dist/generator/) +COPY generator/ generator/ +COPY global.json . +RUN dotnet build generator -c Release + +# Build the server +COPY playground-server/playground-server.csproj playground-server/ +RUN dotnet restore playground-server/playground-server.csproj +COPY playground-server/ playground-server/ +RUN dotnet publish playground-server -c Release -o /app + +# Copy generator output +RUN cp -r dist/generator /app/generator + +# Need full SDK (not just aspnet) because the server spawns `dotnet` to run the generator DLL +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS runtime +WORKDIR /app +COPY --from=build /app . + +RUN groupadd -r playground && useradd -r -g playground playground +USER playground + +ENV ASPNETCORE_URLS=http://+:5174 +ENV DOTNET_ENVIRONMENT=Production +ENV GENERATOR_PATH=/app/generator/Microsoft.TypeSpec.Generator.dll + +EXPOSE 5174 +ENTRYPOINT ["dotnet", "playground-server.dll"] diff --git a/packages/http-client-csharp/playground-server/Program.cs b/packages/http-client-csharp/playground-server/Program.cs new file mode 100644 index 00000000000..85bd18aff1f --- /dev/null +++ b/packages/http-client-csharp/playground-server/Program.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.RateLimiting; + +var builder = WebApplication.CreateBuilder(args); + +var allowedOrigins = new HashSet(StringComparer.OrdinalIgnoreCase) +{ + "http://localhost:5173", // vite dev + "http://localhost:4173", // vite preview + "http://localhost:3000", +}; +var playgroundUrl = Environment.GetEnvironmentVariable("PLAYGROUND_URL"); +if (!string.IsNullOrEmpty(playgroundUrl) && Uri.TryCreate(playgroundUrl, UriKind.Absolute, out var uri)) +{ + allowedOrigins.Add(uri.GetLeftPart(UriPartial.Authority)); +} + +builder.Services.AddCors(); +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = 429; + options.AddFixedWindowLimiter("generate", limiter => + { + limiter.PermitLimit = 10; + limiter.Window = TimeSpan.FromMinutes(1); + limiter.QueueLimit = 2; + limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + }); +}); + +var app = builder.Build(); + +app.UseCors(policy => policy + .WithOrigins([.. allowedOrigins]) + .AllowAnyMethod() + .AllowAnyHeader()); + +app.UseRateLimiter(); + +// Resolve the generator DLL path. Default: dist/generator in the http-client-csharp package. +var generatorPath = Environment.GetEnvironmentVariable("GENERATOR_PATH") + ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "dist", "generator", "Microsoft.TypeSpec.Generator.dll")); + +if (!File.Exists(generatorPath)) +{ + Console.Error.WriteLine($"WARNING: Generator DLL not found at {generatorPath}"); + Console.Error.WriteLine("Set GENERATOR_PATH environment variable to the correct path."); +} +else +{ + Console.WriteLine($"Generator DLL: {generatorPath}"); +} + +app.MapGet("/health", () => Results.Ok(new +{ + status = "ok", + generatorFound = File.Exists(generatorPath), + generatorPath +})); + +app.MapPost("/generate", async (HttpRequest request) => +{ + var body = await JsonSerializer.DeserializeAsync( + request.Body, GenerateJsonContext.Default.GenerateRequest); + + if (body?.CodeModel is null || body?.Configuration is null) + { + return Results.BadRequest(new { error = "Missing 'codeModel' or 'configuration' fields" }); + } + + if (!File.Exists(generatorPath)) + { + return Results.StatusCode(503); + } + + // Create a temporary working directory + var tempDir = Path.Combine(Path.GetTempPath(), "tsp-playground", Guid.NewGuid().ToString("N")); + var generatedDir = Path.Combine(tempDir, "src", "Generated"); + Directory.CreateDirectory(generatedDir); + + try + { + // Write the input files the generator expects + await File.WriteAllTextAsync(Path.Combine(tempDir, "tspCodeModel.json"), body.CodeModel); + await File.WriteAllTextAsync(Path.Combine(tempDir, "Configuration.json"), body.Configuration); + + var generatorName = body.GeneratorName ?? "ScmCodeModelGenerator"; + + // Run the .NET generator as a subprocess (same approach as the TypeSpec emitter) + var psi = new ProcessStartInfo + { + FileName = "dotnet", + ArgumentList = { "--roll-forward", "Major", generatorPath, tempDir, "-g", generatorName, "--new-project" }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(psi)!; + var stdout = await process.StandardOutput.ReadToEndAsync(); + var stderr = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + { + return Results.Json( + new GenerateErrorResponse($"Generator failed with exit code {process.ExitCode}", stderr), + GenerateJsonContext.Default.GenerateErrorResponse, + statusCode: 500); + } + + // Collect all generated files + var files = new List(); + if (Directory.Exists(tempDir)) + { + foreach (var filePath in Directory.EnumerateFiles(tempDir, "*", SearchOption.AllDirectories)) + { + // Skip the input files + var fileName = Path.GetFileName(filePath); + if (fileName is "tspCodeModel.json" or "Configuration.json") + continue; + + var relativePath = Path.GetRelativePath(tempDir, filePath).Replace('\\', '/'); + var content = await File.ReadAllTextAsync(filePath); + files.Add(new GeneratedFile(relativePath, content)); + } + } + + return Results.Json( + new GenerateResponse(files), + GenerateJsonContext.Default.GenerateResponse); + } + finally + { + // Clean up temp directory + try { Directory.Delete(tempDir, recursive: true); } catch { } + } +}).RequireRateLimiting("generate"); + +var url = Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://localhost:5174"; +Console.WriteLine($"C# playground server listening on {url}"); +app.Run(url); + +// --- Request/Response types --- + +record GenerateRequest(string? CodeModel, string? Configuration, string? GeneratorName); +record GeneratedFile(string Path, string Content); +record GenerateResponse(List Files); +record GenerateErrorResponse(string Error, string? Details); + +[JsonSerializable(typeof(GenerateRequest))] +[JsonSerializable(typeof(GenerateResponse))] +[JsonSerializable(typeof(GenerateErrorResponse))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +partial class GenerateJsonContext : JsonSerializerContext { } diff --git a/packages/http-client-csharp/playground-server/playground-server.csproj b/packages/http-client-csharp/playground-server/playground-server.csproj new file mode 100644 index 00000000000..8c5ce456c81 --- /dev/null +++ b/packages/http-client-csharp/playground-server/playground-server.csproj @@ -0,0 +1,10 @@ + + + + net10.0 + enable + enable + PlaygroundServer + + + diff --git a/packages/playground-website/vite.config.ts b/packages/playground-website/vite.config.ts index dd4bc0e5a3b..54a27e2e4f7 100644 --- a/packages/playground-website/vite.config.ts +++ b/packages/playground-website/vite.config.ts @@ -30,11 +30,16 @@ export default defineConfig(({ mode }) => { ); const prNumber = getPrNumber(); - if (prNumber) { - config.define = { + const playgroundServerUrl = env["VITE_PLAYGROUND_SERVER_URL"]; + config.define = { + ...config.define, + ...(prNumber && { __PR__: JSON.stringify(prNumber), __COMMIT_HASH__: JSON.stringify(getCommit()), - }; - } + }), + ...(playgroundServerUrl && { + __PLAYGROUND_SERVER_URL__: JSON.stringify(playgroundServerUrl), + }), + }; return config; }); diff --git a/packages/playground/src/react/file-output/file-output.tsx b/packages/playground/src/react/file-output/file-output.tsx index 691d8f544e9..7ee4e7bb3bb 100644 --- a/packages/playground/src/react/file-output/file-output.tsx +++ b/packages/playground/src/react/file-output/file-output.tsx @@ -8,18 +8,36 @@ export interface FileOutputProps { readonly filename: string; readonly content: string; readonly viewers: Record; + /** Line numbers to highlight as changed (1-based). */ + readonly changedLineNumbers?: number[]; } /** * Display a file output using different viewers. */ -export const FileOutput: FunctionComponent = ({ filename, content, viewers }) => { +export const FileOutput: FunctionComponent = ({ + filename, + content, + viewers, + changedLineNumbers, +}) => { const resolvedViewers: Record = useMemo( () => ({ - [RawFileViewer.key]: RawFileViewer, + [RawFileViewer.key]: changedLineNumbers + ? { + ...RawFileViewer, + render: ({ filename, content }: { filename: string; content: string }) => ( + + ), + } + : RawFileViewer, ...viewers, }), - [viewers], + [viewers, changedLineNumbers], ); const keys = Object.keys(resolvedViewers); diff --git a/packages/playground/src/react/file-tree/file-tree.tsx b/packages/playground/src/react/file-tree/file-tree.tsx index 22e799bbeed..06b5899ebec 100644 --- a/packages/playground/src/react/file-tree/file-tree.tsx +++ b/packages/playground/src/react/file-tree/file-tree.tsx @@ -8,10 +8,12 @@ export interface FileTreeExplorerProps { readonly files: string[]; readonly selected: string; readonly onSelect: (file: string) => void; + readonly changedFiles?: Set; } interface FileTreeNode extends TreeNode { readonly isDirectory: boolean; + readonly changed?: boolean; } const FileNodeIcon: FC<{ node: FileTreeNode }> = ({ node }) => { @@ -21,10 +23,18 @@ const FileNodeIcon: FC<{ node: FileTreeNode }> = ({ node }) => { return ; }; +const FileNodeLabel: FC<{ node: FileTreeNode }> = ({ node }) => { + return ( + + {node.name} + + ); +}; + /** * Builds a tree structure from a flat list of file paths. */ -function buildTree(files: string[]): FileTreeNode { +function buildTree(files: string[], changedFiles?: Set): FileTreeNode { const root: FileTreeNode = { id: "__root__", name: "root", isDirectory: true, children: [] }; const dirMap = new Map(); dirMap.set("", root); @@ -54,6 +64,7 @@ function buildTree(files: string[]): FileTreeNode { id: file, name: file, isDirectory: false, + changed: changedFiles?.has(file), }); } else { const dirPath = file.substring(0, lastSlash); @@ -63,6 +74,7 @@ function buildTree(files: string[]): FileTreeNode { id: file, name: fileName, isDirectory: false, + changed: changedFiles?.has(file), }); } } @@ -90,8 +102,9 @@ export const FileTreeExplorer: FunctionComponent = ({ files, selected, onSelect, + changedFiles, }) => { - const tree = useMemo(() => buildTree(files), [files]); + const tree = useMemo(() => buildTree(files, changedFiles), [files, changedFiles]); return (
@@ -101,6 +114,7 @@ export const FileTreeExplorer: FunctionComponent = ({ selected={selected} onSelect={onSelect} nodeIcon={FileNodeIcon} + nodeLabel={FileNodeLabel} />
); diff --git a/packages/playground/src/react/output-view/file-viewer.tsx b/packages/playground/src/react/output-view/file-viewer.tsx index e744129ff54..c36c4716c15 100644 --- a/packages/playground/src/react/output-view/file-viewer.tsx +++ b/packages/playground/src/react/output-view/file-viewer.tsx @@ -1,11 +1,12 @@ import { FolderListRegular } from "@fluentui/react-icons"; import { Pane, SplitPane } from "@typespec/react-components"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FileBreadcrumb } from "../breadcrumb/index.js"; import { FileOutput } from "../file-output/file-output.js"; import { FileTreeExplorer } from "../file-tree/index.js"; import { OutputTabs } from "../output-tabs/output-tabs.js"; import type { FileOutputViewer, OutputViewerProps, ProgramViewer } from "../types.js"; +import { getChangedLineNumbers } from "../typespec-editor.js"; import style from "./output-view.module.css"; @@ -13,9 +14,16 @@ const FileViewerComponent = ({ program, outputFiles, fileViewers, -}: OutputViewerProps & { fileViewers: Record }) => { + highlightChanges, +}: OutputViewerProps & { + fileViewers: Record; + highlightChanges: boolean; +}) => { const [filename, setFilename] = useState(""); const [content, setContent] = useState(""); + const [changedFiles, setChangedFiles] = useState>(new Set()); + const [changedLines, setChangedLines] = useState>(new Map()); + const prevContentsRef = useRef>(new Map()); const showFileTree = useMemo( () => outputFiles.some((f) => f.includes("/")) || outputFiles.length >= 3, @@ -30,6 +38,63 @@ const FileViewerComponent = ({ [program.host], ); + // When output files change, diff all file contents against cached versions + useEffect(() => { + if (!highlightChanges) return; + let cancelled = false; + async function diffFiles() { + const changed = new Set(); + const lines = new Map(); + const newContents = new Map(); + + // If no files from the new output exist in the cache, this is an emitter + // switch or initial load — populate the cache without highlighting. + const isEmitterSwitch = + prevContentsRef.current.size > 0 && + !outputFiles.some((f) => prevContentsRef.current.has(f)); + + let hasAnyChange = false; + for (const file of outputFiles) { + try { + const contents = await program.host.readFile("./tsp-output/" + file); + newContents.set(file, contents.text); + if (!isEmitterSwitch) { + const prev = prevContentsRef.current.get(file); + if (prev === undefined && prevContentsRef.current.size > 0) { + changed.add(file); + // New file: highlight all lines + const lineCount = contents.text.split("\n").length; + lines.set(file, Array.from({ length: lineCount }, (_, i) => i + 1)); + hasAnyChange = true; + } else if (prev !== undefined && prev !== contents.text) { + changed.add(file); + lines.set(file, getChangedLineNumbers(prev, contents.text)); + hasAnyChange = true; + } else if (prev === undefined) { + hasAnyChange = true; + } + } else { + hasAnyChange = true; + } + } catch { + // file may not be readable + } + } + if (cancelled) return; + // Only update cache and changed state when something actually changed. + // This prevents spurious effect re-runs from clearing the highlights. + if (hasAnyChange || prevContentsRef.current.size === 0) { + prevContentsRef.current = newContents; + setChangedFiles(changed); + setChangedLines(lines); + } + } + void diffFiles(); + return () => { + cancelled = true; + }; + }, [program, outputFiles, highlightChanges]); + useEffect(() => { if (outputFiles.length > 0) { const fileStillThere = outputFiles.find((x) => x === filename); @@ -65,13 +130,19 @@ const FileViewerComponent = ({ files={outputFiles} selected={filename} onSelect={handleFileSelection} + changedFiles={highlightChanges ? changedFiles : undefined} />
- +
@@ -84,20 +155,40 @@ const FileViewerComponent = ({
- +
); }; -export function createFileViewer(fileViewers: FileOutputViewer[]): ProgramViewer { +export interface FileViewerOptions { + /** When true, highlights changed files in the tree and changed lines in the editor after recompilation. */ + highlightChanges?: boolean; +} + +export function createFileViewer( + fileViewers: FileOutputViewer[], + options?: FileViewerOptions, +): ProgramViewer { const viewerMap = Object.fromEntries(fileViewers.map((x) => [x.key, x])); + const highlightChanges = options?.highlightChanges ?? false; return { key: "file-output", label: "Output explorer", icon: , render: (props) => { - return ; + return ( + + ); }, }; } diff --git a/packages/playground/src/react/output-view/output-view.module.css b/packages/playground/src/react/output-view/output-view.module.css index b6bd903e0ef..08d921e7275 100644 --- a/packages/playground/src/react/output-view/output-view.module.css +++ b/packages/playground/src/react/output-view/output-view.module.css @@ -41,3 +41,17 @@ .viewer-error { padding: 20px; } + +.output-compiling { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.output-compiling-overlay { + position: absolute; + top: 8px; + right: 40px; + z-index: 10; +} diff --git a/packages/playground/src/react/output-view/output-view.tsx b/packages/playground/src/react/output-view/output-view.tsx index c71808e73d9..a480d8d321c 100644 --- a/packages/playground/src/react/output-view/output-view.tsx +++ b/packages/playground/src/react/output-view/output-view.tsx @@ -1,4 +1,4 @@ -import { Button, Tab, TabList, type SelectTabEventHandler } from "@fluentui/react-components"; +import { Button, Spinner, Tab, TabList, type SelectTabEventHandler } from "@fluentui/react-components"; import { useCallback, useMemo, useState, type FunctionComponent } from "react"; import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; import type { PlaygroundEditorsOptions } from "../playground.js"; @@ -10,12 +10,17 @@ import style from "./output-view.module.css"; export interface OutputViewProps { compilationState: CompilationState | undefined; + isCompiling?: boolean; editorOptions?: PlaygroundEditorsOptions; /** * List of custom viewers to display the output. It can be file viewers or program viewers. */ viewers?: ProgramViewer[]; fileViewers?: FileOutputViewer[]; + /** + * When true, highlights changed files and lines after recompilation. + */ + highlightChanges?: boolean; /** * The currently selected viewer key. */ @@ -36,41 +41,58 @@ export interface OutputViewProps { export const OutputView: FunctionComponent = ({ compilationState, + isCompiling, viewers, fileViewers, + highlightChanges, selectedViewer, onViewerChange, viewerState, onViewerStateChange, }) => { const resolvedViewers = useMemo( - () => resolveViewers(viewers, fileViewers), - [fileViewers, viewers], + () => resolveViewers(viewers, fileViewers, highlightChanges), + [fileViewers, viewers, highlightChanges], ); if (compilationState === undefined) { + if (isCompiling) { + return ( +
+ +
+ ); + } return <>; } if ("internalCompilerError" in compilationState) { return <>; } return ( - +
+ {isCompiling && ( +
+ +
+ )} + +
); }; function resolveViewers( viewers: ProgramViewer[] | undefined, fileViewers: FileOutputViewer[] | undefined, + highlightChanges?: boolean, ): ResolvedViewers { - const fileViewer = createFileViewer(fileViewers ?? []); + const fileViewer = createFileViewer(fileViewers ?? [], { highlightChanges }); const output: ResolvedViewers = { programViewers: { [fileViewer.key]: fileViewer, diff --git a/packages/playground/src/react/playground.tsx b/packages/playground/src/react/playground.tsx index 4598b46ddf2..ec91b6cd430 100644 --- a/packages/playground/src/react/playground.tsx +++ b/packages/playground/src/react/playground.tsx @@ -71,6 +71,9 @@ export interface PlaygroundProps { /** Custom file viewers that enabled for certain emitters. Key of the map is emitter name */ emitterViewers?: Record; + /** Set of emitter names that should highlight changed files/lines after recompilation. */ + emittersWithChangeHighlighting?: Set; + onSave?: (value: PlaygroundSaveData) => void; editorOptions?: PlaygroundEditorsOptions; @@ -154,6 +157,8 @@ export const Playground: FunctionComponent = (props) => { const typespecModel = useMonacoModel("inmemory://test/main.tsp", "typespec"); const [compilationState, setCompilationState] = useState(undefined); + const lastSuccessfulOutputRef = useRef([]); + const [isCompiling, setIsCompiling] = useState(false); // Use the playground state hook const state = usePlaygroundState({ @@ -205,12 +210,48 @@ export const Playground: FunctionComponent = (props) => { return Boolean(selectedSampleName && content === props.samples?.[selectedSampleName]?.content); }, [content, selectedSampleName, props.samples]); + const compileIdRef = useRef(0); + const doCompile = useCallback(async () => { const currentContent = typespecModel.getValue(); const typespecCompiler = host.compiler; + const compileId = ++compileIdRef.current; + + setIsCompiling(true); + let state: CompilationState; + try { + state = await compile(host, currentContent, selectedEmitter, compilerOptions); + } catch (error) { + setIsCompiling(false); + // eslint-disable-next-line no-console + console.error("Compilation failed", error); + return; + } - const state = await compile(host, currentContent, selectedEmitter, compilerOptions); - setCompilationState(state); + // Discard stale results from an older compilation + if (compileId !== compileIdRef.current) return; + + setIsCompiling(false); + + // When compilation has errors and produced no output files, preserve the + // previous successful output so the user doesn't lose their selected file + // while typing (transient syntax errors). + if ( + "program" in state && + state.program.hasError() && + state.outputFiles.length === 0 && + lastSuccessfulOutputRef.current.length > 0 + ) { + setCompilationState({ + ...state, + outputFiles: lastSuccessfulOutputRef.current, + }); + } else { + if ("program" in state && state.outputFiles.length > 0) { + lastSuccessfulOutputRef.current = state.outputFiles; + } + setCompilationState(state); + } if ("program" in state) { const markers: editor.IMarkerData[] = state.program.diagnostics.map((diag) => ({ ...getMonacoRange(typespecCompiler, diag.target), @@ -370,9 +411,11 @@ export const Playground: FunctionComponent = (props) => { const outputPanel = ( = ({ return ; }; +/** + * Computes which lines in the new text are changed or inserted compared to the old text. + * Uses a longest common subsequence (LCS) approach to handle insertions/deletions properly. + */ +export function getChangedLineNumbers(oldText: string, newText: string): number[] { + const oldLines = oldText.split("\n"); + const newLines = newText.split("\n"); + + // Build a set of old lines that are "matched" via LCS + const oldSet = new Set(oldLines); + const matchedNewIndices = new Set(); + + // Simple greedy match: walk both arrays with two pointers + let oi = 0; + for (let ni = 0; ni < newLines.length; ni++) { + // Try to find current new line in remaining old lines + let found = false; + for (let j = oi; j < oldLines.length; j++) { + if (newLines[ni] === oldLines[j]) { + matchedNewIndices.add(ni); + oi = j + 1; + found = true; + break; + } + } + // If not found in forward scan, it might still match a later occurrence + // but for highlighting purposes, treating it as changed is acceptable + } + + // Lines not matched are changed/inserted + const changed: number[] = []; + for (let ni = 0; ni < newLines.length; ni++) { + if (!matchedNewIndices.has(ni)) { + changed.push(ni + 1); // Monaco lines are 1-based + } + } + return changed; +} + export const OutputEditor: FunctionComponent<{ filename: string; value: string; + changedLineNumbers?: number[]; editorOptions?: PlaygroundEditorsOptions; -}> = ({ filename, value, editorOptions }) => { +}> = ({ filename, value, changedLineNumbers, editorOptions }) => { const model = useMonacoModel(filename); + const [editorInstance, setEditorInstance] = useState(null); + const decorationCollectionRef = useRef(null); + const fadeTimerRef = useRef | null>(null); + + const onMount = useCallback(({ editor: ed }: { editor: editor.IStandaloneCodeEditor }) => { + decorationCollectionRef.current = ed.createDecorationsCollection(); + setEditorInstance(ed); + }, []); + + useEffect(() => { + if (filename === "") return; + model.setValue(value); + }, [filename, value, model]); + + // Apply changed line decorations when provided + useEffect(() => { + if (!editorInstance || !decorationCollectionRef.current) return; + + if (changedLineNumbers && changedLineNumbers.length > 0 && changedLineNumbers.length < 500) { + decorationCollectionRef.current.set( + changedLineNumbers.map((line) => ({ + range: new Range(line, 1, line, 1), + options: { + isWholeLine: true, + className: "playground-changed-line", + }, + })), + ); + + if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current); + fadeTimerRef.current = setTimeout(() => { + decorationCollectionRef.current?.clear(); + }, 3000); + } else { + decorationCollectionRef.current.clear(); + } + }, [changedLineNumbers, editorInstance]); + if (filename === "") { return null; } @@ -41,6 +119,5 @@ export const OutputEditor: FunctionComponent<{ enabled: false, }, }; - model.setValue(value); - return ; + return ; }; diff --git a/packages/react-components/src/tree/tree-row.tsx b/packages/react-components/src/tree/tree-row.tsx index e19edc303be..838f7ce71d2 100644 --- a/packages/react-components/src/tree/tree-row.tsx +++ b/packages/react-components/src/tree/tree-row.tsx @@ -14,10 +14,11 @@ export interface TreeViewRowProps { readonly active: boolean; readonly columns?: Array>; readonly icon?: FC<{ node: TreeNode }>; + readonly label?: FC<{ node: TreeNode }>; readonly activate: (row: TreeRow) => void; } -export function TreeViewRow({ id, row, active, focussed, activate, icon: Icon }: TreeViewRowProps) { +export function TreeViewRow({ id, row, active, focussed, activate, icon: Icon, label: Label }: TreeViewRowProps) { const paddingLeft = row.depth * INDENT_SIZE; const onClick = useCallback(() => activate(row), [activate, row]); @@ -46,7 +47,7 @@ export function TreeViewRow({ id, row, active, focussed, activate, icon: Icon }: )} - {row.item.name} + {Label ? ); diff --git a/packages/react-components/src/tree/tree.tsx b/packages/react-components/src/tree/tree.tsx index 9922f60b4ea..f7b77d91995 100644 --- a/packages/react-components/src/tree/tree.tsx +++ b/packages/react-components/src/tree/tree.tsx @@ -21,6 +21,7 @@ export interface TreeProps { readonly selectionMode?: "none" | "single"; readonly tree: T; readonly nodeIcon?: FC<{ node: T }>; + readonly nodeLabel?: FC<{ node: T }>; readonly selected?: string; readonly onSelect?: (id: string) => void; readonly expanded?: Set; @@ -33,6 +34,7 @@ export function Tree({ onSelect, onSetExpanded, nodeIcon, + nodeLabel, selectionMode = "none", }: TreeProps) { const id = useId(); @@ -130,6 +132,7 @@ export function Tree({