diff --git a/src/NodeDev.Blazor/Components/GraphCanvas.razor.cs b/src/NodeDev.Blazor/Components/GraphCanvas.razor.cs
index ab7549da..e9e9793c 100644
--- a/src/NodeDev.Blazor/Components/GraphCanvas.razor.cs
+++ b/src/NodeDev.Blazor/Components/GraphCanvas.razor.cs
@@ -515,22 +515,9 @@ private void Diagram_KeyDown(global::Blazor.Diagrams.Core.Events.KeyboardEventAr
else if (obj.Key == "F9")
{
var node = Diagram.Nodes.Where(x => x.Selected).OfType
().FirstOrDefault();
- if (node != null && !node.Node.CanBeInlined)
+ if (node != null)
{
- // If debugging, use Project API to dynamically set/remove breakpoint
- if (Graph.Project.IsHardDebugging)
- {
- if (node.Node.HasBreakpoint)
- Graph.Project.RemoveBreakpointForNode(node.Node.Id);
- else
- Graph.Project.SetBreakpointForNode(node.Node.Id);
- }
- else
- {
- // Not debugging - just toggle decoration
- node.Node.ToggleBreakpoint();
- }
- node.Refresh();
+ ToggleBreakpoint(node);
}
}
}
@@ -593,26 +580,46 @@ private void CancelPopup()
#region ToggleBreakpoint
+ ///
+ /// Toggles the breakpoint on the selected graph node when one is selected.
+ ///
public void ToggleBreakpointOnSelectedNode()
{
var node = Diagram.Nodes.Where(x => x.Selected).OfType().FirstOrDefault();
- if (node != null && !node.Node.CanBeInlined)
+ if (node != null)
+ {
+ ToggleBreakpoint(node);
+ }
+ }
+
+ ///
+ /// Applies the correct breakpoint operation for the current debugging mode.
+ ///
+ /// The graph node whose breakpoint state should change.
+ private void ToggleBreakpoint(GraphNodeModel node)
+ {
+ if (node.Node.CanBeInlined)
+ {
+ return;
+ }
+
+ if (Graph.Project.IsHardDebugging)
{
- // If debugging, use Project API to dynamically set/remove breakpoint
- if (Graph.Project.IsHardDebugging)
+ if (node.Node.HasBreakpoint)
{
- if (node.Node.HasBreakpoint)
- Graph.Project.RemoveBreakpointForNode(node.Node.Id);
- else
- Graph.Project.SetBreakpointForNode(node.Node.Id);
+ Graph.Project.RemoveBreakpointForNode(node.Node.Id);
}
else
{
- // Not debugging - just toggle decoration
- node.Node.ToggleBreakpoint();
+ Graph.Project.SetBreakpointForNode(node.Node.Id);
}
- node.Refresh();
}
+ else
+ {
+ node.Node.ToggleBreakpoint();
+ }
+
+ node.Refresh();
}
#endregion
diff --git a/src/NodeDev.Blazor/Components/NameDialog.razor b/src/NodeDev.Blazor/Components/NameDialog.razor
new file mode 100644
index 00000000..e6d0ceb4
--- /dev/null
+++ b/src/NodeDev.Blazor/Components/NameDialog.razor
@@ -0,0 +1,62 @@
+
+
+
+
+
+ Cancel
+ @ConfirmText
+
+
+
+@code {
+ [CascadingParameter]
+ private IMudDialogInstance MudDialog { get; set; } = null!;
+
+ [Parameter]
+ public string InitialValue { get; set; } = "";
+
+ [Parameter]
+ public string Label { get; set; } = "Name";
+
+ [Parameter]
+ public string ConfirmText { get; set; } = "OK";
+
+ [Parameter]
+ public string InputTestId { get; set; } = "name-input";
+
+ [Parameter]
+ public string ConfirmTestId { get; set; } = "confirm-name";
+
+ private string Value { get; set; } = "";
+ private string? ErrorText { get; set; }
+
+ ///
+ /// Initializes the editable value from the value supplied by the dialog caller.
+ ///
+ protected override void OnInitialized()
+ {
+ Value = InitialValue;
+ }
+
+ ///
+ /// Closes the dialog without returning a value to its caller.
+ ///
+ private void Cancel()
+ {
+ MudDialog.Cancel();
+ }
+
+ ///
+ /// Validates the name and returns its trimmed value when it can be used by a workspace command.
+ ///
+ private void Submit()
+ {
+ if (string.IsNullOrWhiteSpace(Value))
+ {
+ ErrorText = $"{Label} cannot be empty";
+ return;
+ }
+
+ MudDialog.Close(DialogResult.Ok(Value.Trim()));
+ }
+}
diff --git a/src/NodeDev.Blazor/Components/OpenProjectDialog.razor b/src/NodeDev.Blazor/Components/OpenProjectDialog.razor
index 954b7d78..acb2be8b 100644
--- a/src/NodeDev.Blazor/Components/OpenProjectDialog.razor
+++ b/src/NodeDev.Blazor/Components/OpenProjectDialog.razor
@@ -1,66 +1,47 @@
-@using System.Text.Json
-@using NodeDev.Blazor.Services
-@inject ProjectService ProjectService
-@inject ISnackbar Snackbar
-
-
-
-
-
- @foreach (var item in RecentProjects)
- {
-
- }
-
-
-
-
-
- Open
- Cancel
-
+
+
+
+
+ @foreach (var item in ProjectNames)
+ {
+
+ }
+
+
+
+
+
+ Open
+ Cancel
+
@code {
- [CascadingParameter]
- private IMudDialogInstance MudDialog { get; set; } = null!;
+ [CascadingParameter]
+ private IMudDialogInstance MudDialog { get; set; } = null!;
- private string? ProjectName { get; set; }
- private List RecentProjects { get; set; } = new List();
+ [Parameter]
+ public IReadOnlyList ProjectNames { get; set; } = [];
- private void Close() => MudDialog.Cancel();
+ private string? ProjectName { get; set; }
- protected override void OnInitialized()
- {
- base.OnInitialized();
- try
- {
- RecentProjects = ProjectService.GetSavedProjectNames().ToList();
- }
- catch (Exception ex)
- {
- Snackbar.Add(ex.Message, Severity.Error);
- RecentProjects = [];
- }
- }
+ ///
+ /// Closes the dialog without selecting a project.
+ ///
+ private void Cancel()
+ {
+ MudDialog.Cancel();
+ }
- private async Task LoadProject()
- {
- if (string.IsNullOrWhiteSpace(ProjectName))
- {
- return;
- }
- try
- {
- await ProjectService.LoadProjectAsync(ProjectName);
- MudDialog.Close(DialogResult.Ok(ProjectName));
- }
- catch (Exception ex)
- {
- Snackbar.Configuration.VisibleStateDuration = 10000;
- Snackbar.Configuration.PositionClass = Defaults.Classes.Position.TopCenter;
- Snackbar.Add(ex.Message, Severity.Error);
- }
- }
+ ///
+ /// Returns the selected project name when one has been selected.
+ ///
+ private void Submit()
+ {
+ if (!string.IsNullOrWhiteSpace(ProjectName))
+ {
+ MudDialog.Close(DialogResult.Ok(ProjectName));
+ }
+ }
}
diff --git a/src/NodeDev.Blazor/Components/OptionsDialog.razor b/src/NodeDev.Blazor/Components/OptionsDialog.razor
index 1b4741e7..cd5abdad 100644
--- a/src/NodeDev.Blazor/Components/OptionsDialog.razor
+++ b/src/NodeDev.Blazor/Components/OptionsDialog.razor
@@ -1,5 +1,4 @@
@using NodeDev.Blazor.Services
-@inject IDialogService DialogService
@inject Services.AppOptionsContainer AppOptionsContainer
@@ -8,11 +7,6 @@
-
-
- Debug
- Release
-
Cancel
@@ -25,25 +19,31 @@
private IMudDialogInstance MudDialog { get; set; } = null!;
private AppOptions AppOptions { get; set; } = null!;
- private bool AutoSaveEnabled { get; set; } = false;
- private string BuildConfiguration { get; set; } = "Debug";
+ ///
+ /// Copies the persisted options so Cancel can leave the current application settings untouched.
+ ///
protected override void OnInitialized()
{
base.OnInitialized();
AppOptions = AppOptionsContainer.AppOptions with { };
- // Initialize from AppOptions if they exist
- // For now, just use defaults
}
+ ///
+ /// Replaces the persisted settings with the edited copy and closes the dialog.
+ ///
private void Accept()
{
AppOptionsContainer.AppOptions = AppOptions;
- // Save auto-save and build config settings
- // For now, these are just UI elements
MudDialog.Close();
}
- private void Close() => MudDialog.Close();
+ ///
+ /// Closes the dialog without persisting the edited options.
+ ///
+ private void Close()
+ {
+ MudDialog.Close();
+ }
-}
\ No newline at end of file
+}
diff --git a/src/NodeDev.Blazor/Components/PopupOverlay.razor b/src/NodeDev.Blazor/Components/PopupOverlay.razor
new file mode 100644
index 00000000..3ea30734
--- /dev/null
+++ b/src/NodeDev.Blazor/Components/PopupOverlay.razor
@@ -0,0 +1,13 @@
+
+
+@code {
+ [Parameter, EditorRequired]
+ public RenderFragment ChildContent { get; set; } = null!;
+
+ [Parameter]
+ public EventCallback OnDismiss { get; set; }
+}
diff --git a/src/NodeDev.Blazor/Components/ProjectExplorer.razor b/src/NodeDev.Blazor/Components/ProjectExplorer.razor
index 0dbe1c0b..6e47ee0d 100644
--- a/src/NodeDev.Blazor/Components/ProjectExplorer.razor
+++ b/src/NodeDev.Blazor/Components/ProjectExplorer.razor
@@ -1,5 +1,6 @@
@inject IDialogService DialogService
@inject ISnackbar Snackbar
+@inject NodeDev.Blazor.Services.WorkspaceCommandService Commands
@@ -58,10 +59,11 @@
public EventCallback
ClassDeleted { get; set; }
private TreeItem? Selected = null;
- private TreeItem? HoveredClass = null;
-
private List> Items { get; } = new();
+ ///
+ /// Publishes the selected class, or clears it when a folder is selected.
+ ///
private void OnSelectedItemChanged()
{
if (Selected?.Type == TreeItemType.Class)
@@ -76,14 +78,23 @@
}
}
+ ///
+ /// Adds existing project classes to the explorer tree.
+ ///
protected override void OnInitialized()
{
base.OnInitialized();
foreach (var nodeClass in Project.Classes)
+ {
AddClass(nodeClass);
+ }
}
+ ///
+ /// Adds a class to its namespace branch, creating missing namespace folders as needed.
+ ///
+ /// The class to represent in the tree.
private void AddClass(NodeDev.Core.Class.NodeClass nodeClass)
{
// find the folder that already exists in the tree
@@ -106,7 +117,9 @@
}
if (folder?.Children == null)
+ {
throw new Exception("Call cannot have no namespace ??");
+ }
folder.Children.Add(new()
{
@@ -114,76 +127,96 @@
});
}
+ ///
+ /// Prompts for a class name and adds the created class to the appropriate namespace branch.
+ ///
private async Task ShowCreateClassDialog()
{
- var result = await DialogService.Show("Create New Class", new()
+ var dialog = await DialogService.ShowAsync("Create New Class", new()
{
- [nameof(CreateClassDialog.Project)] = Project
- }, new DialogOptions()
+ [nameof(NameDialog.InitialValue)] = "NewClass",
+ [nameof(NameDialog.Label)] = "Class Name",
+ [nameof(NameDialog.ConfirmText)] = "Create",
+ [nameof(NameDialog.InputTestId)] = "class-name-input",
+ [nameof(NameDialog.ConfirmTestId)] = "confirm-create-class"
+ }, DialogDefaults.SmallForm);
+ var result = await dialog.Result;
+
+ if (result is { Canceled: false, Data: string className })
{
- MaxWidth = MaxWidth.Small,
- FullWidth = true
- }).Result;
-
- if (result != null && !result.Canceled && result.Data is NodeDev.Core.Class.NodeClass newClass)
- {
- AddClass(newClass);
- Snackbar.Add($"Class '{newClass.Name}' created successfully", Severity.Success);
- StateHasChanged();
+ var commandResult = Commands.CreateClass(className);
+ Snackbar.ShowCommandResult(commandResult);
+ if (commandResult is { Succeeded: true, Value: not null })
+ {
+ AddClass(commandResult.Value);
+ StateHasChanged();
+ }
}
}
+ ///
+ /// Prompts for and applies a replacement name for a class tree item.
+ ///
+ /// The tree item containing the class to rename.
private async Task ShowRenameClassDialog(TreeItem item)
{
- if (item.Class == null) return;
-
- var dialogReference = DialogService.Show("Rename Class", new()
+ if (item.Class == null)
{
- [nameof(RenameDialog.CurrentName)] = item.Class.Name,
- [nameof(RenameDialog.Label)] = "Class Name",
- [nameof(RenameDialog.IsMethod)] = false
- }, new DialogOptions()
+ return;
+ }
+
+ var dialogReference = await DialogService.ShowAsync("Rename Class", new()
{
- MaxWidth = MaxWidth.Small,
- FullWidth = true
- });
+ [nameof(NameDialog.InitialValue)] = item.Class.Name,
+ [nameof(NameDialog.Label)] = "Class Name",
+ [nameof(NameDialog.ConfirmText)] = "Rename",
+ [nameof(NameDialog.InputTestId)] = "class-name-input",
+ [nameof(NameDialog.ConfirmTestId)] = "confirm-rename"
+ }, DialogDefaults.SmallForm);
var result = await dialogReference.Result;
- if (result != null && !result.Canceled && result.Data is string newName)
+ if (result is { Canceled: false, Data: string newName })
{
- var oldName = item.Class.Name;
- try
+ var commandResult = Commands.RenameClass(item.Class, newName);
+ Snackbar.ShowCommandResult(commandResult);
+ if (commandResult.Succeeded)
{
- item.Class.Rename(newName);
item.Name = newName;
- Snackbar.Add($"Class renamed from '{oldName}' to '{newName}'", Severity.Success);
StateHasChanged();
}
- catch (Exception ex)
- {
- Snackbar.Add(ex.Message, Severity.Error);
- }
}
}
+ ///
+ /// Confirms and deletes a class, then removes its representation from the explorer tree.
+ ///
+ /// The tree item containing the class to delete.
private async Task DeleteClass(TreeItem item)
{
- if (item.Class == null) return;
+ if (item.Class == null)
+ {
+ return;
+ }
var confirm = await DialogService.ShowMessageBox(
"Delete Class",
$"Are you sure you want to delete class '{item.Class.Name}'?",
yesText: "Delete", cancelText: "Cancel",
- options: new DialogOptions() { MaxWidth = MaxWidth.Small }
+ options: DialogDefaults.Confirmation
);
if (confirm == true)
{
- try
+ var commandResult = Commands.DeleteClass(item.Class);
+ Snackbar.ShowCommandResult(commandResult);
+ if (commandResult.Succeeded)
{
- Project.RemoveClass(item.Class);
-
+ ///
+ /// Finds and removes the specified item from a branch of the explorer tree.
+ ///
+ /// The branch to search recursively.
+ /// The removed item, or when this branch does not contain it.
TreeItemData? Remove(List> items)
{
foreach (var i in items.ToList())
@@ -197,7 +230,9 @@
{
var found = Remove(i.Children);
if (found != null)
+ {
return found;
+ }
}
}
return null;
@@ -210,13 +245,8 @@
OnSelectedItemChanged();
}
await ClassDeleted.InvokeAsync(item.Class);
- Snackbar.Add($"Class '{item.Class.Name}' deleted", Severity.Success);
StateHasChanged();
}
- catch (Exception ex)
- {
- Snackbar.Add(ex.Message, Severity.Error);
- }
}
}
diff --git a/src/NodeDev.Blazor/Components/ProjectToolbar.razor b/src/NodeDev.Blazor/Components/ProjectToolbar.razor
index 2a1a86f1..f11aece0 100644
--- a/src/NodeDev.Blazor/Components/ProjectToolbar.razor
+++ b/src/NodeDev.Blazor/Components/ProjectToolbar.razor
@@ -1,13 +1,10 @@
-@using Microsoft.AspNetCore.Components.Forms
@using NodeDev.Blazor.Services
@using NodeDev.Core
@implements IDisposable
-@inject ProjectService ProjectService
+@inject WorkspaceCommandService Commands
@inject ISnackbar Snackbar
-@inject AppOptionsContainer AppOptionsContainer
@inject IDialogService DialogService
-
Open
New Project
Save
@@ -17,7 +14,6 @@
@if (Project.IsHardDebugging)
{
-
@if (Project.IsPausedAtBreakpoint && Project.CurrentBreakpoint != null)
{
@@ -36,58 +32,65 @@ else
}
-Export
-Add node
-@(Project.IsLiveDebuggingEnabled ? "Stop Live Debugging" : "Start Live Debugging")
+@(Project.IsLiveDebuggingEnabled ? "Stop Live Debugging" : "Start Live Debugging")
Options
-
-
@code {
-
[CascadingParameter]
public NodeDev.Blazor.Index? IndexPage { get; set; }
- private Project Project => ProjectService.Project;
-
- private DialogOptions DialogOptions => new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true };
-
+ private Project Project => Commands.Project;
private IDisposable? HardDebugStateSubscription;
private IDisposable? CurrentBreakpointSubscription;
+ ///
+ /// Subscribes to debug-state notifications so toolbar controls track the active project.
+ ///
protected override void OnInitialized()
{
- base.OnInitialized();
-
- // Subscribe to hard debug state changes to refresh UI
- HardDebugStateSubscription = Project.HardDebugStateChanged.Subscribe(_ =>
- {
- InvokeAsync(StateHasChanged);
- });
-
- // Subscribe to current breakpoint changes to refresh UI
- CurrentBreakpointSubscription = Project.CurrentBreakpointChanged.Subscribe(breakpoint =>
- {
- Console.WriteLine($"[ProjectToolbar] CurrentBreakpoint changed: {(breakpoint != null ? $"{breakpoint.NodeName}" : "null")}");
- InvokeAsync(StateHasChanged);
- });
+ HardDebugStateSubscription = Project.HardDebugStateChanged.Subscribe(value => { _ = InvokeAsync(StateHasChanged); });
+ CurrentBreakpointSubscription = Project.CurrentBreakpointChanged.Subscribe(value => { _ = InvokeAsync(StateHasChanged); });
}
+ ///
+ /// Disposes subscriptions that are tied to this toolbar instance.
+ ///
public void Dispose()
{
HardDebugStateSubscription?.Dispose();
CurrentBreakpointSubscription?.Dispose();
}
- private Task Open()
+ ///
+ /// Loads saved project names and opens the selected project.
+ ///
+ private async Task Open()
{
- return DialogService.ShowAsync("Open Project", DialogOptions);
+ var projectsResult = Commands.GetSavedProjectNames();
+ if (!projectsResult.Succeeded || projectsResult.Value == null)
+ {
+ Snackbar.ShowCommandResult(projectsResult);
+ return;
+ }
+
+ var dialog = await DialogService.ShowAsync("Open Project", new()
+ {
+ [nameof(OpenProjectDialog.ProjectNames)] = projectsResult.Value
+ }, DialogDefaults.MediumForm);
+ var result = await dialog.Result;
+ if (result is { Canceled: false, Data: string projectName })
+ {
+ Snackbar.ShowCommandResult(await Commands.OpenProjectAsync(projectName));
+ }
}
+ ///
+ /// Saves the project, prompting for a name when it has not yet been named.
+ ///
private async Task Save()
{
if (string.IsNullOrWhiteSpace(Project.Settings.ProjectName))
@@ -96,139 +99,96 @@ else
return;
}
- try
+ Snackbar.ShowCommandResult(await Commands.SaveProjectAsync());
+ }
+
+ ///
+ /// Prompts for a project name and saves when the dialog returns one.
+ ///
+ private async Task SaveAs()
+ {
+ var dialog = await DialogService.ShowAsync("Save As Project", new()
{
- await ProjectService.SaveProjectToFileAsync();
- Snackbar.Add("Project saved", Severity.Success);
- }
- catch (Exception ex)
+ [nameof(SaveAsProjectDialog.ProjectName)] = Project.Settings.ProjectName
+ }, DialogDefaults.MediumForm);
+ var result = await dialog.Result;
+ if (result is { Canceled: false, Data: string projectName })
{
- Snackbar.Add(ex.Message, Severity.Error);
+ Snackbar.ShowCommandResult(await Commands.SaveProjectAsync(projectName));
}
}
- private Task SaveAs()
- {
- return DialogService.ShowAsync("Save As Project", DialogOptions);
- }
-
+ ///
+ /// Replaces the active project with a fresh default project.
+ ///
private void NewProject()
{
- ProjectService.ChangeProject(Core.Project.CreateNewDefaultProject());
- }
-
- private void Add()
- {
- //GraphCanvas?.ShowAddNode();
+ Snackbar.ShowCommandResult(Commands.CreateNewProject());
}
- public void Run()
+ ///
+ /// Builds and runs the active project using the appropriate live-debugging mode.
+ ///
+ public async Task Run()
{
- new Thread(() =>
- {
- Project.Run(Project.IsLiveDebuggingEnabled ? Core.BuildOptions.Debug : Core.BuildOptions.Release);
- }).Start();
+ var options = Project.IsLiveDebuggingEnabled ? Core.BuildOptions.Debug : Core.BuildOptions.Release;
+ Snackbar.ShowCommandResult(await Commands.RunProjectAsync(options));
}
- public void RunWithDebug()
+ ///
+ /// Builds and runs the active project with the hard debugger attached.
+ ///
+ public async Task RunWithDebug()
{
- new Thread(() =>
- {
- try
- {
- Project.RunWithDebug(Core.BuildOptions.Debug);
- }
- catch (Exception ex)
- {
- // Show error dialog on UI thread
- InvokeAsync(async () =>
- {
- await DialogService.ShowMessageBox(
- "Debug Attachment Failed",
- ex.Message,
- yesText: "OK");
- });
- }
- }).Start();
+ Snackbar.ShowCommandResult(await Commands.RunWithDebugAsync(Core.BuildOptions.Debug));
}
+ ///
+ /// Requests that the active hard-debugging session stop.
+ ///
public void StopDebugging()
{
- try
- {
- Project.StopDebugging();
- Snackbar.Add("Debugging stopped", Severity.Info);
- }
- catch (Exception ex)
- {
- Snackbar.Add($"Failed to stop debugging: {ex.Message}", Severity.Error);
- }
- }
-
- public void PauseDebugging()
- {
- // Placeholder for future implementation
- Snackbar.Add("Pause functionality coming soon", Severity.Info);
+ Snackbar.ShowCommandResult(Commands.StopDebugging(), Severity.Info);
}
+ ///
+ /// Continues a hard-debugging session that is paused at a breakpoint.
+ ///
public void ResumeDebugging()
{
- try
- {
- Project.ContinueExecution();
- Snackbar.Add("Execution resumed", Severity.Success);
- }
- catch (InvalidOperationException ex)
- {
- Snackbar.Add($"Cannot resume: {ex.Message}", Severity.Error);
- }
- catch (Exception ex)
- {
- Snackbar.Add($"Failed to resume: {ex.Message}", Severity.Error);
- }
- }
-
- public void Build()
- {
- try
- {
- Project.Build(Core.BuildOptions.Debug);
- Snackbar.Add("Project built successfully", Severity.Success);
- }
- catch (Exception ex)
- {
- Snackbar.Add($"Build failed: {ex.Message}", Severity.Error);
- }
+ Snackbar.ShowCommandResult(Commands.ResumeDebugging());
}
- public void Export()
+ ///
+ /// Builds the active project with debug symbols.
+ ///
+ public async Task Build()
{
- try
- {
- // Export functionality - for now, just show a message
- Snackbar.Add("Export functionality not yet implemented", Severity.Info);
- }
- catch (Exception ex)
- {
- Snackbar.Add($"Export failed: {ex.Message}", Severity.Error);
- }
+ Snackbar.ShowCommandResult(await Commands.BuildProjectAsync(Core.BuildOptions.Debug));
}
+ ///
+ /// Starts or stops live debugging based on the current project state.
+ ///
private void SwitchLiveDebugging()
{
- if (Project.IsLiveDebuggingEnabled)
- Project.StopLiveDebugging();
- else
- Project.StartLiveDebugging();
+ Snackbar.ShowCommandResult(Commands.ToggleLiveDebugging(), Severity.Info);
}
+ ///
+ /// Toggles a breakpoint for the node currently selected on the active graph canvas.
+ ///
private void ToggleBreakpoint()
{
IndexPage?.ToggleBreakpointOnSelectedNode();
}
+ ///
+ /// Opens the options dialog without waiting for a result because the dialog persists its own changes.
+ ///
+ /// A task that completes after the dialog is displayed.
private Task OpenOptionsDialogAsync()
{
- return DialogService.ShowAsync("Options", DialogOptions);
+ return DialogService.ShowAsync("Options", DialogDefaults.MediumForm);
}
}
diff --git a/src/NodeDev.Blazor/Components/RenameDialog.razor b/src/NodeDev.Blazor/Components/RenameDialog.razor
deleted file mode 100644
index e9c90a19..00000000
--- a/src/NodeDev.Blazor/Components/RenameDialog.razor
+++ /dev/null
@@ -1,45 +0,0 @@
-@inject ISnackbar Snackbar
-
-
-
-
-
-
- Cancel
- Rename
-
-
-
-@code {
- [CascadingParameter]
- IMudDialogInstance MudDialog { get; set; } = null!;
-
- [Parameter]
- public string CurrentName { get; set; } = "";
-
- [Parameter]
- public string Label { get; set; } = "New Name";
-
- [Parameter]
- public bool IsMethod { get; set; } = false;
-
- private string NewName { get; set; } = "";
-
- protected override void OnInitialized()
- {
- NewName = CurrentName;
- }
-
- private void Cancel() => MudDialog.Cancel();
-
- private void Submit()
- {
- if (string.IsNullOrWhiteSpace(NewName))
- {
- Snackbar.Add($"{Label} cannot be empty", Severity.Error);
- return;
- }
-
- MudDialog.Close(DialogResult.Ok(NewName));
- }
-}
diff --git a/src/NodeDev.Blazor/Components/SaveAsProjectDialog.razor b/src/NodeDev.Blazor/Components/SaveAsProjectDialog.razor
index 1f7cb1b0..fc9eb541 100644
--- a/src/NodeDev.Blazor/Components/SaveAsProjectDialog.razor
+++ b/src/NodeDev.Blazor/Components/SaveAsProjectDialog.razor
@@ -1,44 +1,33 @@
-@using System.Text.Json
-@using NodeDev.Blazor.Services
-@inject ProjectService ProjectService
-@inject ISnackbar Snackbar
-
-
-
-
-
- Save
- Cancel
-
+
+
+
+
+ Save
+ Cancel
+
@code {
- [CascadingParameter]
- private IMudDialogInstance MudDialog { get; set; } = null!;
-
- public string? ProjectName { get; set; }
-
- private void Close() => MudDialog.Cancel();
+ [CascadingParameter]
+ private IMudDialogInstance MudDialog { get; set; } = null!;
- protected override void OnInitialized()
- {
- base.OnInitialized();
- ProjectName = ProjectService.Project.Settings.ProjectName;
- }
+ [Parameter]
+ public string? ProjectName { get; set; }
- private async Task SaveProject()
- {
- try
- {
- await ProjectService.SaveProjectToFileAsync(ProjectName!);
- Snackbar.Add("Project saved", Severity.Success);
- MudDialog.Close(DialogResult.Ok(ProjectName));
- }
- catch (Exception ex)
- {
- Snackbar.Add(ex.Message, Severity.Error);
- }
- }
+ ///
+ /// Closes the dialog without saving the project.
+ ///
+ private void Cancel()
+ {
+ MudDialog.Cancel();
+ }
+ ///
+ /// Returns the requested project name to the toolbar.
+ ///
+ private void Submit()
+ {
+ MudDialog.Close(DialogResult.Ok(ProjectName));
+ }
}
diff --git a/src/NodeDev.Blazor/Components/TypeSelector.razor b/src/NodeDev.Blazor/Components/TypeSelector.razor
index 0922c507..7949e1d5 100644
--- a/src/NodeDev.Blazor/Components/TypeSelector.razor
+++ b/src/NodeDev.Blazor/Components/TypeSelector.razor
@@ -22,12 +22,6 @@
@code {
- [Parameter]
- public int PositionX { get; set; }
-
- [Parameter]
- public int PositionY { get; set; }
-
[Parameter]
public TypeFactory TypeFactory { get; set; } = null!;
diff --git a/src/NodeDev.Blazor/DiagramsModels/GraphNodeModel.cs b/src/NodeDev.Blazor/DiagramsModels/GraphNodeModel.cs
index 5bf4c375..e9cfb382 100644
--- a/src/NodeDev.Blazor/DiagramsModels/GraphNodeModel.cs
+++ b/src/NodeDev.Blazor/DiagramsModels/GraphNodeModel.cs
@@ -24,30 +24,6 @@ public class GraphNodeModel : NodeModel
public GraphPortModel GetPort(Connection connection) => Ports.OfType().First(x => x.Connection == connection);
- internal void OnConnectionPathHighlighted(Connection connection)
- {
- var port = GetPort(connection);
-
- foreach (var link in port.Links.OfType())
- {
- if (!link.Classes.Contains("highlighted"))
- link.Classes += " highlighted";
-
- link.Refresh();
- }
- }
-
- internal void OnConnectionPathUnhighlighted(Connection connection)
- {
- var port = GetPort(connection);
-
- foreach (var link in port.Links.OfType())
- {
- link.Classes = link.Classes.Replace(" highlighted", "");
- link.Refresh();
- }
- }
-
internal async Task OnNodeExecuting(Connection exec)
{
var port = GetPort(exec);
diff --git a/src/NodeDev.Blazor/DiagramsModels/GraphNodeWidget.razor b/src/NodeDev.Blazor/DiagramsModels/GraphNodeWidget.razor
index f43c245b..1d565298 100644
--- a/src/NodeDev.Blazor/DiagramsModels/GraphNodeWidget.razor
+++ b/src/NodeDev.Blazor/DiagramsModels/GraphNodeWidget.razor
@@ -45,11 +45,11 @@
@if (i < inputs.Count)
{
-
+
}
@if (i < outputs.Count)
{
-
+
}
}
@@ -92,4 +92,4 @@
Node.IsEditingName = false;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NodeDev.Blazor/DiagramsModels/GraphNodeWidgetPort.razor b/src/NodeDev.Blazor/DiagramsModels/GraphNodeWidgetPort.razor
index cb36e148..5e58e5d5 100644
--- a/src/NodeDev.Blazor/DiagramsModels/GraphNodeWidgetPort.razor
+++ b/src/NodeDev.Blazor/DiagramsModels/GraphNodeWidgetPort.razor
@@ -18,9 +18,6 @@
[Parameter]
public GraphCanvas GraphCanvas { get; set; } = null!;
- [Parameter]
- public GraphNodeModel Node { get; set; } = null!;
-
[Parameter]
public GraphPortModel Port { get; set; } = null!;
diff --git a/src/NodeDev.Blazor/Index.razor b/src/NodeDev.Blazor/Index.razor
index b0a08cdb..31646d3b 100644
--- a/src/NodeDev.Blazor/Index.razor
+++ b/src/NodeDev.Blazor/Index.razor
@@ -1,7 +1,6 @@
@using NodeDev.Blazor.Services
@inject Services.DebuggedPathService DebuggedPathService
@inject NavigationManager NavigationManager
-@inject ISnackbar Snackbar
@inject ProjectService ProjectService
@implements IDisposable
diff --git a/src/NodeDev.Blazor/Services/ServicesExtension.cs b/src/NodeDev.Blazor/Services/ServicesExtension.cs
index ff95d363..ff439bb2 100644
--- a/src/NodeDev.Blazor/Services/ServicesExtension.cs
+++ b/src/NodeDev.Blazor/Services/ServicesExtension.cs
@@ -6,13 +6,19 @@ namespace NodeDev.Blazor.Services
public static class ServicesExtension
{
+ ///
+ /// Registers the UI services with lifetimes appropriate for an individual Blazor circuit.
+ ///
+ /// The collection receiving the application service registrations.
+ /// The supplied service collection for further composition.
public static IServiceCollection AddNodeDev(this IServiceCollection services)
{
services
.AddMudServices()
.AddScoped()
.AddScoped()
- .AddSingleton()
+ .AddScoped()
+ .AddScoped()
.AddSingleton(new AppOptionsContainer("AppOptions.json"));
return services;
diff --git a/src/NodeDev.Blazor/Services/SnackbarExtensions.cs b/src/NodeDev.Blazor/Services/SnackbarExtensions.cs
new file mode 100644
index 00000000..6bf717f6
--- /dev/null
+++ b/src/NodeDev.Blazor/Services/SnackbarExtensions.cs
@@ -0,0 +1,20 @@
+using MudBlazor;
+
+namespace NodeDev.Blazor.Services;
+
+///
+/// Provides consistent snackbar rendering for workspace command outcomes.
+///
+public static class SnackbarExtensions
+{
+ ///
+ /// Adds the command message to a snackbar with a severity derived from its outcome.
+ ///
+ /// The snackbar service that displays the notification.
+ /// The command outcome to display.
+ /// The severity to use when succeeded.
+ public static void ShowCommandResult(this ISnackbar snackbar, WorkspaceCommandResult result, Severity successSeverity = Severity.Success)
+ {
+ snackbar.Add(result.Message, result.Succeeded ? successSeverity : Severity.Error);
+ }
+}
diff --git a/src/NodeDev.Blazor/Services/WorkspaceCommandResult.cs b/src/NodeDev.Blazor/Services/WorkspaceCommandResult.cs
new file mode 100644
index 00000000..416321c0
--- /dev/null
+++ b/src/NodeDev.Blazor/Services/WorkspaceCommandResult.cs
@@ -0,0 +1,65 @@
+namespace NodeDev.Blazor.Services;
+
+///
+/// Represents a workspace command outcome that can be presented by the UI.
+///
+/// Whether the requested operation completed successfully.
+/// A user-facing description of the operation outcome.
+/// The underlying failure when the operation did not succeed.
+public record WorkspaceCommandResult(bool Succeeded, string Message, Exception? Exception = null)
+{
+ ///
+ /// Creates a successful command result.
+ ///
+ /// The user-facing success message.
+ /// A successful result without an exception.
+ public static WorkspaceCommandResult Success(string message)
+ {
+ return new WorkspaceCommandResult(true, message);
+ }
+
+ ///
+ /// Creates a failed command result while preserving the underlying exception.
+ ///
+ /// The user-facing failure message.
+ /// The exception that caused the operation to fail.
+ /// A failed result.
+ public static WorkspaceCommandResult Failure(string message, Exception? exception = null)
+ {
+ return new WorkspaceCommandResult(false, message, exception);
+ }
+}
+
+///
+/// Represents a workspace command outcome that may include a value.
+///
+/// The type returned by a successful command.
+/// Whether the requested operation completed successfully.
+/// A user-facing description of the operation outcome.
+/// The value produced by a successful command.
+/// The underlying failure when the operation did not succeed.
+public sealed record WorkspaceCommandResult(bool Succeeded, string Message, T? Value, Exception? Exception = null)
+ : WorkspaceCommandResult(Succeeded, Message, Exception)
+{
+ ///
+ /// Creates a successful result that carries the command value.
+ ///
+ /// The value produced by the command.
+ /// The user-facing success message.
+ /// A successful typed result.
+ public static WorkspaceCommandResult Success(T value, string message)
+ {
+ return new WorkspaceCommandResult(true, message, value);
+ }
+
+ ///
+ /// Creates a failed typed result with no command value.
+ ///
+ /// The user-facing failure message.
+ /// The exception that caused the operation to fail.
+ /// A failed typed result.
+ public new static WorkspaceCommandResult Failure(string message, Exception? exception = null)
+ {
+ return new WorkspaceCommandResult(false, message, default, exception);
+ }
+}
diff --git a/src/NodeDev.Blazor/Services/WorkspaceCommandService.cs b/src/NodeDev.Blazor/Services/WorkspaceCommandService.cs
new file mode 100644
index 00000000..7ed04420
--- /dev/null
+++ b/src/NodeDev.Blazor/Services/WorkspaceCommandService.cs
@@ -0,0 +1,473 @@
+using Microsoft.Extensions.Logging;
+using NodeDev.Core;
+using NodeDev.Core.Class;
+using NodeDev.Core.Types;
+
+namespace NodeDev.Blazor.Services;
+
+///
+/// Provides a UI-facing boundary for workspace mutations and execution requests.
+///
+///
+/// Components use this service instead of changing core model objects directly so failures are logged and can be presented consistently.
+///
+public sealed class WorkspaceCommandService(ProjectService projectService, ILogger logger)
+{
+ ///
+ /// Gets the project currently managed by the active UI scope.
+ ///
+ public Project Project => projectService.Project;
+
+ ///
+ /// Replaces the active project with a default project.
+ ///
+ /// A result suitable for a status notification.
+ public WorkspaceCommandResult CreateNewProject()
+ {
+ return Execute(() => projectService.ChangeProject(Project.CreateNewDefaultProject()), "New project created");
+ }
+
+ ///
+ /// Retrieves saved project names from the configured projects directory.
+ ///
+ /// The saved names, or a failure result when they cannot be read.
+ public WorkspaceCommandResult> GetSavedProjectNames()
+ {
+ return Execute(projectService.GetSavedProjectNames, "Saved projects loaded");
+ }
+
+ ///
+ /// Loads a saved project into the active UI scope.
+ ///
+ /// The saved project name to load.
+ /// A task that completes with the operation outcome.
+ public Task OpenProjectAsync(string projectName)
+ {
+ return ExecuteAsync(() => projectService.LoadProjectAsync(projectName), $"Project '{projectName}' opened");
+ }
+
+ ///
+ /// Persists the active project, using its existing name when no name is supplied.
+ ///
+ /// An optional project name to use for this save.
+ /// Cancels the pending file operation.
+ /// A task that completes with the operation outcome.
+ public Task SaveProjectAsync(string? projectName = null, CancellationToken cancellationToken = default)
+ {
+ return ExecuteAsync(() => projectService.SaveProjectToFileAsync(projectName, cancellationToken), "Project saved");
+ }
+
+ ///
+ /// Builds the active project without blocking the Blazor renderer.
+ ///
+ /// Compilation options to use for the build.
+ /// Cancels the queued build before it starts.
+ /// The generated artifact path when the build succeeds.
+ public Task> BuildProjectAsync(BuildOptions options, CancellationToken cancellationToken = default)
+ {
+ return ExecuteBackgroundAsync(
+ () => Project.Build(options),
+ path => $"Project built successfully: {path}",
+ cancellationToken);
+ }
+
+ ///
+ /// Builds and runs the active project without hard debugging.
+ ///
+ /// Build options to use before execution.
+ /// Cancels the queued execution before it starts.
+ /// The process exit code, or a failure result when execution cannot start.
+ public async Task> RunProjectAsync(BuildOptions options, CancellationToken cancellationToken = default)
+ {
+ var result = await ExecuteBackgroundAsync(
+ () => Project.Run(options),
+ exitCode => $"Project exited with code {exitCode}",
+ cancellationToken);
+
+ if (result.Succeeded && result.Value is null)
+ {
+ return WorkspaceCommandResult.Failure("Project execution failed. See the debugger console for details.");
+ }
+
+ return result;
+ }
+
+ ///
+ /// Builds and runs the active project with the hard debugger attached.
+ ///
+ /// Build options to use before execution.
+ /// Cancels the queued execution before it starts.
+ /// The process exit code, or a failure result when debugging cannot start.
+ public async Task> RunWithDebugAsync(BuildOptions options, CancellationToken cancellationToken = default)
+ {
+ var result = await ExecuteBackgroundAsync(
+ () => Project.RunWithDebug(options),
+ exitCode => $"Debug session ended with code {exitCode}",
+ cancellationToken);
+
+ if (result.Succeeded && result.Value is null)
+ {
+ return WorkspaceCommandResult.Failure("Debug execution failed. See the debugger console for details.");
+ }
+
+ return result;
+ }
+
+ ///
+ /// Stops the active hard-debugging session.
+ ///
+ /// A result suitable for a status notification.
+ public WorkspaceCommandResult StopDebugging()
+ {
+ return Execute(Project.StopDebugging, "Debugging stopped");
+ }
+
+ ///
+ /// Continues the active hard-debugging session after a breakpoint pause.
+ ///
+ /// A result suitable for a status notification.
+ public WorkspaceCommandResult ResumeDebugging()
+ {
+ return Execute(Project.ContinueExecution, "Execution resumed");
+ }
+
+ ///
+ /// Toggles live-debugging mode for the active project.
+ ///
+ /// A result that describes the resulting live-debugging state.
+ public WorkspaceCommandResult ToggleLiveDebugging()
+ {
+ var isLiveDebuggingEnabled = Project.IsLiveDebuggingEnabled;
+ return Execute(() =>
+ {
+ if (isLiveDebuggingEnabled)
+ {
+ Project.StopLiveDebugging();
+ }
+ else
+ {
+ Project.StartLiveDebugging();
+ }
+ }, isLiveDebuggingEnabled ? "Live debugging stopped" : "Live debugging started");
+ }
+
+ ///
+ /// Creates a class in the active project after validating its name.
+ ///
+ /// The class name supplied by the user.
+ /// The namespace for the new class.
+ /// The created class when validation and insertion succeed.
+ public WorkspaceCommandResult CreateClass(string name, string @namespace = "MyApp")
+ {
+ return Execute(() =>
+ {
+ var nodeClass = new NodeClass(RequireName(name, "Class name"), @namespace, Project);
+ Project.AddClass(nodeClass);
+ return nodeClass;
+ }, nodeClass => $"Class '{nodeClass.Name}' created successfully");
+ }
+
+ ///
+ /// Renames a class after validating the supplied name.
+ ///
+ /// The class that belongs to the active project.
+ /// The replacement name supplied by the user.
+ /// A result that reports whether the rename was accepted.
+ public WorkspaceCommandResult RenameClass(NodeClass nodeClass, string newName)
+ {
+ return Execute(() => nodeClass.Rename(RequireName(newName, "Class name")), $"Class renamed to '{newName}'");
+ }
+
+ ///
+ /// Removes a class from the active project after core reference validation succeeds.
+ ///
+ /// The class to remove.
+ /// A result that reports reference-validation failures to the UI.
+ public WorkspaceCommandResult DeleteClass(NodeClass nodeClass)
+ {
+ return Execute(() => Project.RemoveClass(nodeClass), $"Class '{nodeClass.Name}' deleted");
+ }
+
+ ///
+ /// Creates a method with a default void return type on the specified class.
+ ///
+ /// The class that owns the new method.
+ /// The method name supplied by the user.
+ /// The created method when insertion succeeds.
+ public WorkspaceCommandResult CreateMethod(NodeClass nodeClass, string name)
+ {
+ return Execute(() =>
+ {
+ var method = new NodeClassMethod(nodeClass, RequireName(name, "Method name"), nodeClass.TypeFactory.Void);
+ nodeClass.AddMethod(method, createEntryAndReturn: true);
+ return method;
+ }, method => $"Method '{method.Name}' created successfully");
+ }
+
+ ///
+ /// Renames a method after validating the replacement name.
+ ///
+ /// The method to rename.
+ /// The replacement name supplied by the user.
+ /// A result that reports whether the rename was accepted.
+ public WorkspaceCommandResult RenameMethod(NodeClassMethod method, string newName)
+ {
+ return Execute(() => method.Rename(RequireName(newName, "Method name")), $"Method renamed to '{newName}'");
+ }
+
+ ///
+ /// Deletes a method from its owning class.
+ ///
+ /// The method to delete.
+ /// A result that reports whether deletion succeeded.
+ public WorkspaceCommandResult DeleteMethod(NodeClassMethod method)
+ {
+ return Execute(() => method.Class.RemoveMethod(method), $"Method '{method.Name}' deleted");
+ }
+
+ ///
+ /// Creates a double-typed property on the specified class.
+ ///
+ /// The class that owns the new property.
+ /// The property name supplied by the user.
+ /// The created property when insertion succeeds.
+ public WorkspaceCommandResult CreateProperty(NodeClass nodeClass, string name)
+ {
+ return Execute(() =>
+ {
+ var property = new NodeClassProperty(nodeClass, RequireName(name, "Property name"), nodeClass.TypeFactory.Get());
+ nodeClass.Properties.Add(property);
+ return property;
+ }, property => $"Property '{property.Name}' created successfully");
+ }
+
+ ///
+ /// Renames a property after validating the replacement name.
+ ///
+ /// The property to rename.
+ /// The replacement name supplied by the user.
+ /// A result that reports whether the rename was accepted.
+ public WorkspaceCommandResult RenameProperty(NodeClassProperty property, string newName)
+ {
+ return Execute(() => property.Rename(RequireName(newName, "Property name")), $"Property renamed to '{newName}'");
+ }
+
+ ///
+ /// Replaces a property's declared type.
+ ///
+ /// The property to update.
+ /// The selected type.
+ /// A result that reports whether the type change was accepted.
+ public WorkspaceCommandResult ChangePropertyType(NodeClassProperty property, TypeBase type)
+ {
+ return Execute(() => property.ChangeType(type), $"Property type changed to '{type.FriendlyName}'");
+ }
+
+ ///
+ /// Adds the core model's default parameter to a method.
+ ///
+ /// The method to update.
+ /// A result that reports whether the parameter was added.
+ public WorkspaceCommandResult AddDefaultParameter(NodeClassMethod method)
+ {
+ return Execute(method.AddDefaultParameter, "Parameter added");
+ }
+
+ ///
+ /// Renames a method parameter after validating the replacement name.
+ ///
+ /// The parameter to rename.
+ /// The replacement name supplied by the user.
+ /// A result that reports whether the rename was accepted.
+ public WorkspaceCommandResult RenameParameter(NodeClassMethodParameter parameter, string newName)
+ {
+ return Execute(() => parameter.Rename(RequireName(newName, "Parameter name")), $"Parameter renamed to '{newName}'");
+ }
+
+ ///
+ /// Changes whether a parameter is emitted as an output parameter.
+ ///
+ /// The parameter to update.
+ /// Whether the parameter should be marked .
+ /// A result that reports whether the update was accepted.
+ public WorkspaceCommandResult SetParameterIsOut(NodeClassMethodParameter parameter, bool isOut)
+ {
+ return Execute(() => parameter.SetIsOut(isOut), "Parameter updated");
+ }
+
+ ///
+ /// Moves a parameter earlier in its method signature.
+ ///
+ /// The parameter to move.
+ /// A result that reports whether the move was accepted.
+ public WorkspaceCommandResult MoveParameterUp(NodeClassMethodParameter parameter)
+ {
+ return Execute(parameter.MoveUp, "Parameter moved");
+ }
+
+ ///
+ /// Moves a parameter later in its method signature.
+ ///
+ /// The parameter to move.
+ /// A result that reports whether the move was accepted.
+ public WorkspaceCommandResult MoveParameterDown(NodeClassMethodParameter parameter)
+ {
+ return Execute(parameter.MoveDown, "Parameter moved");
+ }
+
+ ///
+ /// Removes a parameter from its method signature.
+ ///
+ /// The parameter to remove.
+ /// A result that reports whether removal was accepted.
+ public WorkspaceCommandResult RemoveParameter(NodeClassMethodParameter parameter)
+ {
+ return Execute(parameter.Remove, "Parameter removed");
+ }
+
+ ///
+ /// Replaces a method parameter's declared type.
+ ///
+ /// The parameter to update.
+ /// The selected type.
+ /// A result that reports whether the type change was accepted.
+ public WorkspaceCommandResult ChangeParameterType(NodeClassMethodParameter parameter, TypeBase type)
+ {
+ return Execute(() => parameter.ChangeType(type), $"Parameter type changed to '{type.FriendlyName}'");
+ }
+
+ ///
+ /// Runs a synchronous mutation and converts exceptions into a logged failure result.
+ ///
+ /// The mutation to run.
+ /// The message to return when the mutation completes.
+ /// A successful result, or a failure result that retains the thrown exception.
+ private WorkspaceCommandResult Execute(Action action, string successMessage)
+ {
+ try
+ {
+ action();
+ return WorkspaceCommandResult.Success(successMessage);
+ }
+ catch (Exception ex)
+ {
+ return Failure(ex);
+ }
+ }
+
+ ///
+ /// Runs a synchronous query or mutation that returns a value.
+ ///
+ /// The value returned by .
+ /// The operation to run.
+ /// The message to return when the operation completes.
+ /// The operation value in a successful result, or a logged failure result.
+ private WorkspaceCommandResult Execute(Func action, string successMessage)
+ {
+ return Execute(action, _ => successMessage);
+ }
+
+ ///
+ /// Runs a synchronous query or mutation and creates its success message from the returned value.
+ ///
+ /// The value returned by .
+ /// The operation to run.
+ /// Creates the message associated with the returned value.
+ /// The operation value in a successful result, or a logged failure result.
+ private WorkspaceCommandResult Execute(Func action, Func successMessage)
+ {
+ try
+ {
+ var value = action();
+ return WorkspaceCommandResult.Success(value, successMessage(value));
+ }
+ catch (Exception ex)
+ {
+ return Failure(ex);
+ }
+ }
+
+ ///
+ /// Awaits an asynchronous operation and converts failures into a logged result.
+ ///
+ /// The asynchronous operation to run.
+ /// The message to return when the operation completes.
+ /// A task that completes with a success or failure result.
+ private async Task ExecuteAsync(Func action, string successMessage)
+ {
+ try
+ {
+ await action();
+ return WorkspaceCommandResult.Success(successMessage);
+ }
+ catch (Exception ex)
+ {
+ return Failure(ex);
+ }
+ }
+
+ ///
+ /// Runs a blocking operation on a worker thread and converts exceptions into a logged result.
+ ///
+ /// The value returned by .
+ /// The blocking operation to run.
+ /// Creates the message associated with the returned value.
+ /// Cancels the queued worker operation before it starts.
+ /// A task that completes with the operation result.
+ private async Task> ExecuteBackgroundAsync(Func action, Func successMessage, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var value = await Task.Run(action, cancellationToken);
+ return WorkspaceCommandResult.Success(value, successMessage(value));
+ }
+ catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
+ {
+ return WorkspaceCommandResult.Failure("The operation was canceled.", ex);
+ }
+ catch (Exception ex)
+ {
+ return Failure(ex);
+ }
+ }
+
+ ///
+ /// Logs an exception and turns it into a non-generic failure result.
+ ///
+ /// The exception raised by a workspace operation.
+ /// A result that preserves the exception for callers that need to inspect it.
+ private WorkspaceCommandResult Failure(Exception exception)
+ {
+ logger.LogError(exception, "Workspace command failed");
+ return WorkspaceCommandResult.Failure(exception.Message, exception);
+ }
+
+ ///
+ /// Logs an exception and turns it into a typed failure result.
+ ///
+ /// The value type expected by the failed operation.
+ /// The exception raised by a workspace operation.
+ /// A typed result with no value and the preserved exception.
+ private WorkspaceCommandResult Failure(Exception exception)
+ {
+ logger.LogError(exception, "Workspace command failed");
+ return WorkspaceCommandResult.Failure(exception.Message, exception);
+ }
+
+ ///
+ /// Validates and normalizes a user-supplied model member name.
+ ///
+ /// The raw user input.
+ /// The user-facing name for the value being validated.
+ /// The trimmed, non-empty name.
+ /// Thrown when is empty or whitespace.
+ private static string RequireName(string name, string label)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentException($"{label} cannot be empty.", nameof(name));
+ }
+
+ return name.Trim();
+ }
+}
diff --git a/src/NodeDev.Blazor/_Imports.razor b/src/NodeDev.Blazor/_Imports.razor
index 3ef996ff..43e298ae 100644
--- a/src/NodeDev.Blazor/_Imports.razor
+++ b/src/NodeDev.Blazor/_Imports.razor
@@ -5,6 +5,7 @@
@using NodeDev.Blazor;
@using NodeDev.Blazor.Components;
+@using NodeDev.Blazor.Services;
@using MudBlazor;
diff --git a/src/NodeDev.Blazor/wwwroot/styles.css b/src/NodeDev.Blazor/wwwroot/styles.css
index d97c415e..d201d761 100644
--- a/src/NodeDev.Blazor/wwwroot/styles.css
+++ b/src/NodeDev.Blazor/wwwroot/styles.css
@@ -390,3 +390,10 @@ g.diagram-link path:not(.selection-helper) {
margin: 0;
pointer-events: all;
}
+.graph-popup-overlay {
+ background-color: var(--mud-palette-overlay-dark);
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ z-index: 1;
+}
diff --git a/src/NodeDev.EndToEndTests/Pages/HomePage.cs b/src/NodeDev.EndToEndTests/Pages/HomePage.cs
index a11c9e35..c5341887 100644
--- a/src/NodeDev.EndToEndTests/Pages/HomePage.cs
+++ b/src/NodeDev.EndToEndTests/Pages/HomePage.cs
@@ -613,23 +613,6 @@ public async Task AddMethodParameter(string paramName, string paramType)
// Project Management
- public async Task ExportProject()
- {
- var exportButton = _user.Locator("[data-test-id='export-project']");
- if (await exportButton.CountAsync() == 0)
- {
- throw new NotImplementedException($"Export project UI element not found - [data-test-id='export-project']. This feature may not be implemented yet.");
- }
-
- await exportButton.ClickAsync();
- var confirmButton = _user.Locator("[data-test-id='confirm-export']");
- if (await confirmButton.CountAsync() > 0)
- {
- await confirmButton.ClickAsync();
- }
- await Task.Delay(500);
- }
-
public async Task BuildProject()
{
var buildButton = _user.Locator("[data-test-id='build-project']");
diff --git a/src/NodeDev.EndToEndTests/Tests/DebugModeTests.cs b/src/NodeDev.EndToEndTests/Tests/DebugModeTests.cs
index b4782c06..685ff461 100644
--- a/src/NodeDev.EndToEndTests/Tests/DebugModeTests.cs
+++ b/src/NodeDev.EndToEndTests/Tests/DebugModeTests.cs
@@ -69,25 +69,21 @@ public async Task ToolbarButtons_ShouldShowStopPauseResumeWhenDebugging()
await runWithDebugButton.WaitForAsync(new() { State = Microsoft.Playwright.WaitForSelectorState.Visible });
await runWithDebugButton.ClickAsync();
- // Assert - Check that Stop/Pause/Resume buttons are visible
+ // Assert - Check that the implemented debug controls are visible
var stopButton = Page.Locator("[data-test-id='stop-debug']");
- var pauseButton = Page.Locator("[data-test-id='pause-debug']");
var resumeButton = Page.Locator("[data-test-id='resume-debug']");
var statusText = Page.Locator("[data-test-id='debug-status-text']");
await stopButton.WaitForAsync(new() { State = Microsoft.Playwright.WaitForSelectorState.Visible, Timeout = 10000 });
var isStopVisible = await stopButton.IsVisibleAsync();
- var isPauseVisible = await pauseButton.IsVisibleAsync();
var isResumeVisible = await resumeButton.IsVisibleAsync();
var isStatusVisible = await statusText.IsVisibleAsync();
Console.WriteLine($"Stop button visible: {isStopVisible}");
- Console.WriteLine($"Pause button visible: {isPauseVisible}");
Console.WriteLine($"Resume button visible: {isResumeVisible}");
Console.WriteLine($"Status text visible: {isStatusVisible}");
Assert.True(isStopVisible, "Stop button should be visible");
- Assert.True(isPauseVisible, "Pause button should be visible");
Assert.True(isResumeVisible, "Resume button should be visible");
Assert.True(isStatusVisible, "Status text should be visible");
diff --git a/src/NodeDev.EndToEndTests/Tests/ProjectManagementTests.cs b/src/NodeDev.EndToEndTests/Tests/ProjectManagementTests.cs
index 40dcf3d7..c1f06e86 100644
--- a/src/NodeDev.EndToEndTests/Tests/ProjectManagementTests.cs
+++ b/src/NodeDev.EndToEndTests/Tests/ProjectManagementTests.cs
@@ -48,29 +48,6 @@ public async Task SaveProjectAfterModifications()
await HomePage.TakeScreenshot("/tmp/modified-project-saved.png");
}
- [Fact(Timeout = 60_000)]
- public async Task ProjectExportFunctionality()
- {
- await HomePage.CreateNewProject();
-
- try
- {
- await HomePage.ExportProject();
-
- // Check for success (no error message)
- await Task.Delay(200);
- var hasError = await HomePage.HasErrorMessage();
- Assert.False(hasError, "Export failed - error message present");
-
- await HomePage.TakeScreenshot("/tmp/project-exported.png");
- Console.WriteLine("✓ Project exported successfully");
- }
- catch (NotImplementedException ex)
- {
- Console.WriteLine($"Export feature not implemented: {ex.Message}");
- }
- }
-
[Fact(Timeout = 60_000)]
public async Task BuildProjectFromUI()
{
diff --git a/src/NodeDev.Tests/WorkspaceCommandServiceTests.cs b/src/NodeDev.Tests/WorkspaceCommandServiceTests.cs
new file mode 100644
index 00000000..f4a1ed6e
--- /dev/null
+++ b/src/NodeDev.Tests/WorkspaceCommandServiceTests.cs
@@ -0,0 +1,79 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using NodeDev.Blazor.Services;
+
+namespace NodeDev.Tests;
+
+public class WorkspaceCommandServiceTests
+{
+ [Fact]
+ ///
+ /// Verifies that a successful create command returns the same class instance added to the active project.
+ ///
+ public void CreateClassReturnsEntityAndUpdatesProject()
+ {
+ var commands = CreateCommands();
+
+ var result = commands.CreateClass("Customer");
+
+ Assert.True(result.Succeeded);
+ Assert.NotNull(result.Value);
+ Assert.Contains(result.Value, commands.Project.Classes);
+ }
+
+ [Fact]
+ ///
+ /// Verifies that duplicate classes are reported as command failures without adding a second class.
+ ///
+ public void DuplicateClassIsReturnedAsFailure()
+ {
+ var commands = CreateCommands();
+ Assert.True(commands.CreateClass("Customer").Succeeded);
+
+ var result = commands.CreateClass("Customer");
+
+ Assert.False(result.Succeeded);
+ Assert.IsType(result.Exception);
+ Assert.Single(commands.Project.Classes, nodeClass => nodeClass.Name == "Customer");
+ }
+
+ [Fact]
+ ///
+ /// Verifies that command-side validation prevents blank method names from mutating the model.
+ ///
+ public void InvalidMethodNameDoesNotMutateClass()
+ {
+ var commands = CreateCommands();
+ var nodeClass = commands.Project.Classes.First();
+ var methodCount = nodeClass.Methods.Count;
+
+ var result = commands.CreateMethod(nodeClass, " ");
+
+ Assert.False(result.Succeeded);
+ Assert.Equal(methodCount, nodeClass.Methods.Count);
+ }
+
+ [Fact]
+ ///
+ /// Verifies that the project and command services share a circuit-scoped lifetime.
+ ///
+ public void WorkspaceServicesAreScoped()
+ {
+ var services = new ServiceCollection();
+ services.AddNodeDev();
+
+ Assert.Equal(ServiceLifetime.Scoped, services.Single(descriptor => descriptor.ServiceType == typeof(ProjectService)).Lifetime);
+ Assert.Equal(ServiceLifetime.Scoped, services.Single(descriptor => descriptor.ServiceType == typeof(WorkspaceCommandService)).Lifetime);
+ }
+
+ ///
+ /// Creates a command service backed by an isolated default project for each test.
+ ///
+ /// A workspace command service with a no-op logger.
+ private static WorkspaceCommandService CreateCommands()
+ {
+ var options = new AppOptionsContainer("");
+ var projectService = new ProjectService(options);
+ return new WorkspaceCommandService(projectService, NullLogger.Instance);
+ }
+}