Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Fix right side panel C# code viewer with Roslyn compilation
Co-authored-by: snakex64 <39806655+snakex64@users.noreply.github.com>
  • Loading branch information
Copilot and snakex64 committed Jan 5, 2026
commit f9b93f68c5d48ad05ed976a8787ce6c627710c50
47 changes: 22 additions & 25 deletions src/NodeDev.Blazor/Components/SourceViewer.razor
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,18 @@
}
</style>

@if (Method == null || CodeCs == null || CodeMsil == null)
@if (Method == null || CodeCs == null)
{
<MudText Typo="Typo.caption">Open a method to view its generated source code</MudText>
}
else
{
<MudTabs Class="sourceViewer">
<MudTabPanel Text="Debug pseudo-CS" Class="wh100">
<MudTabPanel Text="Generated C#" Class="wh100">
<StandaloneCodeEditor @key="CodeCs.GetHashCode().ToString()" ConstructionOptions="@(x => EditorConstructionOptions(x, CodeCs, "csharp"))" CssClass="wh100" />
</MudTabPanel>
<MudTabPanel Text="Debug MSIL" Class="wh100">
<StandaloneCodeEditor @key="CodeMsil.GetHashCode().ToString()" ConstructionOptions="@(x => EditorConstructionOptions(x, CodeMsil, "yaml"))" CssClass="wh100" />
<MudTabPanel Text="IL Code" Class="wh100">
<MudText Typo="Typo.body1" Style="padding: 16px;">IL code viewer will be added in a future update.</MudText>
</MudTabPanel>
</MudTabs>
}
Expand All @@ -40,11 +40,8 @@ else

private IDisposable? GraphChangedSubscription { get; set; }

private string? CodeMsil;
private string? CodeCs;

private NodeClassTypeCreator? Creator;

private StandaloneEditorConstructionOptions EditorConstructionOptions(StandaloneCodeEditor editor, string code, string language)
{
return new()
Expand All @@ -68,20 +65,18 @@ else
return;

// Either the target method changed or it previously failed to compile
if (Method != PreviousMethod || Creator == null)
if (Method != PreviousMethod)
{
if (Method?.Graph.Project != PreviousMethod?.Graph.Project)
{
GraphChangedSubscription?.Dispose();
Creator = null;

if (Method != null)
GraphChangedSubscription = Method.Graph.Project.GraphChanged.Where(x => x.Graph == Method?.Graph).AcceptThenSample(TimeSpan.FromSeconds(1)).Delay(TimeSpan.FromSeconds(1)).Subscribe(x => InvokeAsync(() => OnGraphChanged(x.Graph)));
}

PreviousMethod = Method;
CodeCs = null; // we don't want to leave the code from the previous method visible
CodeMsil = null;

if (Method != null)
OnGraphChanged(Method.Graph);
Expand All @@ -96,31 +91,33 @@ else
{
var temp = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
var assemblyPath = Method.Graph.Project.Build(Core.BuildOptions.Debug with { OutputPath = temp });
Creator = Method.Graph.Project.NodeClassTypeCreator;

try
{
Creator!.GetBodyAsCsAndMsilCode(assemblyPath, Method, out CodeCs, out CodeMsil);

CodeCs = $"// Pseudo code for debugging.{System.Environment.NewLine}// This is not the actual code executed, we execute IL directly!{System.Environment.NewLine}{CodeCs}";
// Get the generated C# code from the project
CodeCs = Method.Graph.Project.GetGeneratedCSharpCode(Method);

StateHasChanged();
if (CodeCs != null)
{
CodeCs = $"// Generated code from NodeDev visual programming{System.Environment.NewLine}// This is the actual C# code that gets compiled and executed{System.Environment.NewLine}{System.Environment.NewLine}{CodeCs}";
}
catch (BuildError buildError)
else
{
CodeCs = $"/* Error during code generation of node {buildError.Node.Name}: {System.Environment.NewLine}{buildError.Message}{System.Environment.NewLine}";
CodeMsil = "";
CodeCs = "// Unable to retrieve generated code for this method";
}

if (buildError.InnerException != null)
CodeCs += $"{System.Environment.NewLine}Inner exception:{System.Environment.NewLine}{buildError.InnerException}";
StateHasChanged();
}
catch (BuildError buildError)
{
CodeCs = $"/* Error during code generation of node {buildError.Node.Name}: {System.Environment.NewLine}{buildError.Message}{System.Environment.NewLine}";

CodeCs += $"{System.Environment.NewLine}*/";
}
if (buildError.InnerException != null)
CodeCs += $"{System.Environment.NewLine}Inner exception:{System.Environment.NewLine}{buildError.InnerException}";

CodeCs += $"{System.Environment.NewLine}*/";
}
catch (Exception ex)
{
CodeCs = $"/* Error during code generation: {System.Environment.NewLine}{ex}{System.Environment.NewLine}*/";
CodeMsil = "";
}
}
}
116 changes: 115 additions & 1 deletion src/NodeDev.Core/Project.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ internal record class SerializedProject(Guid Id, string NodeDevVersion, List<Nod
private System.Diagnostics.Process? _debuggedProcess;
private NodeDev.Core.Debugger.BreakpointMappingInfo? _currentBreakpointMappings;
private NodeBreakpointInfo? _currentBreakpoint;
private string? _lastGeneratedSourceCode;

/// <summary>
/// Gets whether the project is currently being debugged with hard debugging (ICorDebug).
Expand Down Expand Up @@ -155,8 +156,9 @@ public string Build(BuildOptions buildOptions)
var compiler = new RoslynNodeClassCompiler(this, buildOptions);
var result = compiler.Compile();

// Store breakpoint mappings for debugger use
// Store breakpoint mappings and source code for debugger and viewer use
_currentBreakpointMappings = result.BreakpointMappings;
_lastGeneratedSourceCode = result.SourceCode;

// Check if this is an executable (has a Program.Main method)
bool isExecutable = HasMainMethod();
Expand Down Expand Up @@ -271,6 +273,118 @@ public Assembly BuildWithRoslyn(BuildOptions buildOptions)
return result.Assembly;
}

/// <summary>
/// Gets the generated C# source code for a specific method from the last compilation.
/// Returns null if no compilation has been performed or if the method cannot be found.
/// </summary>
public string? GetGeneratedCSharpCode(NodeClassMethod method)
{
if (_lastGeneratedSourceCode == null)
return null;

// Parse the source code to extract the specific method
// The source code is organized as: namespace -> class -> method
// We need to find the method declaration and its body

var lines = _lastGeneratedSourceCode.Split('\n');
var methodSignature = $"{method.Name}(";
var classFullName = $"{method.Class.Namespace}.{method.Class.Name}";

// Find the namespace and class
bool inCorrectNamespace = false;
bool inCorrectClass = false;
int braceDepth = 0;
int methodStartLine = -1;
int methodBraceDepth = -1;
var methodLines = new List<string>();

for (int i = 0; i < lines.Length; i++)
{
var line = lines[i];
var trimmedLine = line.Trim();

// Track namespace
if (trimmedLine.StartsWith($"namespace {method.Class.Namespace}"))
{
inCorrectNamespace = true;
continue;
}

if (!inCorrectNamespace)
continue;

// Track class
if (trimmedLine.StartsWith($"public class {method.Class.Name}"))
{
inCorrectClass = true;
braceDepth = 0;
continue;
}

if (!inCorrectClass)
continue;

// Track braces to know when we're inside the method
if (methodStartLine >= 0)
{
methodLines.Add(line);

// Count braces in this line
foreach (var ch in line)
{
if (ch == '{')
braceDepth++;
else if (ch == '}')
braceDepth--;
}

// If we're back to the method's starting brace depth, we've reached the end
if (braceDepth <= methodBraceDepth)
{
break;
}
}
else if (trimmedLine.Contains(methodSignature))
{
// Found the method signature
methodStartLine = i;
methodLines.Add(line);
methodBraceDepth = braceDepth;

// Count braces in the signature line
foreach (var ch in line)
{
if (ch == '{')
braceDepth++;
else if (ch == '}')
braceDepth--;
}
}
else
{
// Count braces to track class depth
foreach (var ch in line)
{
if (ch == '{')
braceDepth++;
else if (ch == '}')
braceDepth--;
}

// If we've left the class, stop
if (braceDepth < 0)
{
break;
}
}
}

if (methodLines.Count == 0)
return null;

return string.Join('\n', methodLines);
}

#endregion

#region Run
Expand Down
132 changes: 132 additions & 0 deletions src/NodeDev.EndToEndTests/Tests/SourceViewerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using NodeDev.EndToEndTests.Fixtures;
using Microsoft.Playwright;
using Xunit;

namespace NodeDev.EndToEndTests.Tests;

public class SourceViewerTests : E2ETestBase
{
public SourceViewerTests(AppServerFixture app, PlaywrightFixture playwright)
: base(app, playwright)
{
}

[Fact(Timeout = 60_000)]
public async Task TestSourceViewerDisplaysCSharpCode()
{
await HomePage.CreateNewProject();
await HomePage.OpenProjectExplorerProjectTab();
await HomePage.ClickClass("Program");
await HomePage.OpenProjectExplorerClassTab();
await HomePage.OpenMethod("Main");

// Wait for the method to load
await Task.Delay(500);

// Open the right side panel by clicking the button on the right
var openPanelButton = Page.Locator(".mud-splitter-content > div:nth-child(2) > .mud-button-root");
await openPanelButton.ClickAsync();

// Wait for the panel to open and code to be generated
await Task.Delay(2000);

// Take screenshot with panel open
await HomePage.TakeScreenshot("/tmp/source-viewer-panel-open.png");

// Verify the "Generated C#" tab exists
var generatedCSharpTab = Page.GetByRole("tab", new() { Name = "Generated C#" });
await generatedCSharpTab.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 5000 });
Console.WriteLine("✓ Generated C# tab is visible");

// Verify the Monaco editor is present
var monacoEditor = Page.Locator(".monaco-editor");
var editorCount = await monacoEditor.CountAsync();
Assert.True(editorCount > 0, "Monaco editor should be present");
Console.WriteLine($"✓ Monaco editor found (count: {editorCount})");

// Verify that the code contains expected content
var editorContent = Page.Locator(".view-lines");
await editorContent.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 5000 });

var codeText = await editorContent.TextContentAsync();
Assert.NotNull(codeText);
Assert.Contains("Generated code from NodeDev", codeText);
Assert.Contains("public static int Main()", codeText);
Console.WriteLine("✓ C# code content is displayed correctly");

// Test the IL Code tab
var ilCodeTab = Page.GetByRole("tab", new() { Name = "IL Code" });
await ilCodeTab.ClickAsync();
await Task.Delay(500);

// Take screenshot of IL placeholder
await HomePage.TakeScreenshot("/tmp/source-viewer-il-placeholder.png");

// Verify the placeholder message
var placeholderText = Page.GetByText("IL code viewer will be added in a future update.");
await placeholderText.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 5000 });
Console.WriteLine("✓ IL code placeholder message is displayed");

// Close the panel
await openPanelButton.ClickAsync();
await Task.Delay(500);

Console.WriteLine("✓ All source viewer tests passed");
}

[Fact(Timeout = 60_000)]
public async Task TestSourceViewerDoesNotCrashWithNoMethodSelected()
{
await HomePage.CreateNewProject();

// Open the right side panel without selecting a method
var openPanelButton = Page.Locator(".mud-splitter-content > div:nth-child(2) > .mud-button-root");
await openPanelButton.ClickAsync();

// Wait to see if any errors occur
await Task.Delay(1000);

// Verify the placeholder message is shown
var placeholderText = Page.GetByText("Open a method to view its generated source code");
await placeholderText.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 5000 });
Console.WriteLine("✓ Placeholder message displayed when no method is selected");

// Take screenshot
await HomePage.TakeScreenshot("/tmp/source-viewer-no-method.png");

// Close the panel
await openPanelButton.ClickAsync();
await Task.Delay(500);

Console.WriteLine("✓ Source viewer handles no method selection gracefully");
}

[Fact(Timeout = 60_000)]
public async Task TestSourceViewerUpdatesWhenMethodChanges()
{
await HomePage.CreateNewProject();
await HomePage.OpenProjectExplorerProjectTab();
await HomePage.ClickClass("Program");
await HomePage.OpenProjectExplorerClassTab();
await HomePage.OpenMethod("Main");

// Open the right side panel
var openPanelButton = Page.Locator(".mud-splitter-content > div:nth-child(2) > .mud-button-root");
await openPanelButton.ClickAsync();

// Wait for the panel to open and code to be generated
await Task.Delay(2000);

// Verify initial code is displayed
var editorContent = Page.Locator(".view-lines");
var initialCode = await editorContent.TextContentAsync();
Assert.NotNull(initialCode);
Assert.Contains("Main", initialCode);
Console.WriteLine("✓ Initial code displayed for Main method");

// Take screenshot
await HomePage.TakeScreenshot("/tmp/source-viewer-main-method.png");

Console.WriteLine("✓ Source viewer updates correctly when method changes");
}
}