diff --git a/.agents/skills/coding-standard/SKILL.md b/.agents/skills/coding-standard/SKILL.md new file mode 100644 index 00000000..266ef74f --- /dev/null +++ b/.agents/skills/coding-standard/SKILL.md @@ -0,0 +1,65 @@ +--- +name: coding-standard +description: > + Apply NodeDev coding standards. Use when writing, editing, reviewing, or formatting any code in this repository. +--- + +# Coding Standard + +Apply these rules to all C# code created or modified in this repository. + +## Braces and control flow + +Always put control-flow bodies on their own lines and enclose them in braces. +Do not use single-line `if`, `else`, loop, `lock`, `try`, `catch`, or +similar statements. + +```csharp +if (condition) +{ + DoWork(); +} +``` + +## Usings +Always use the new `using var xxx = ...` syntax whenever possible. Avoid `using(...) { ... }` blocks. + +## Methods + +Use block-bodied methods. Do not use expression-bodied methods, including +single-line methods such as `public void Something() => SomethingElse();`. + +Every method must have XML documentation using triple-slash syntax. The +documentation must explain intent, behavior, constraints, side effects, or +other useful context; do not restate the method name or obvious information. Add relevant ``, +``, ``, or `` elements when they add useful +information. + +```csharp +/// +/// Rebuilds the graph index so connection lookups reflect the current nodes. +/// +/// +/// Call this after bulk graph mutations, before resolving connections. +/// +public void RebuildIndex() +{ + _index = BuildIndex(_nodes); +} +``` + +Avoid meaningless descriptions such as `Clears the values` for a method named +`ClearValues`; they do not provide information beyond the identifier. + +## Properties + +Expression-bodied syntax is allowed only for small, simple properties. + +```csharp +public int Prop => _otherThing; +``` + +## Blazor + +Do not put services and general classes alongside Blazor components. +For example, "Components" and "Services" should be separate folders in the project structure. \ No newline at end of file diff --git a/.agents/skills/skill-writing/SKILL.md b/.agents/skills/skill-writing/SKILL.md new file mode 100644 index 00000000..0d225e14 --- /dev/null +++ b/.agents/skills/skill-writing/SKILL.md @@ -0,0 +1,141 @@ +--- +name: skill-writing +description: > + Covers how to create and edit Agent Skills (SKILL.md files and supporting assets). + Use this skill when asked to write a new skill, update an existing skill, improve skill instructions, + or restructure skill content. Keywords: skill, SKILL.md, agent skill, skill writing, skill editing, create skill, update skill +--- + +# Skill: Skill Writing & Editing + +## Overview + +Agent Skills are directories containing a `SKILL.md` file with YAML frontmatter and Markdown instructions. They may also include `scripts/`, `references/`, and `assets/` subdirectories. + +Skills in this repository live under `.github/skills//`. + +--- + +## Creating a New Skill + +### 1. Pick a name + +- Lowercase letters, numbers, and hyphens only +- No leading/trailing/consecutive hyphens (`--`) +- Must match the directory name exactly +- Example: `skill-writing`, `pdf-processing`, `data-analysis` + +### 2. Create the directory + +``` +.github/skills// +``` + +### 3. Write `SKILL.md` + +Every skill requires this frontmatter: + +```yaml +--- +name: skill-name +description: > + What this skill does and when to use it. Include action keywords (verbs) and + domain nouns so the agent can recognize relevant prompts. + Max 1024 characters. +--- +``` + +Optional fields: + +| Field | Use when | +|---|---| +| `compatibility` | Requires specific tools/env (e.g. `Requires Python 3.11+`) | +| `metadata` | Storing author, version, or other key-value info | +| `allowed-tools` | Pre-approving specific tools (experimental) | + +### 4. Write the body + +After the frontmatter, write Markdown instructions. Use these proven patterns: + +**Step-by-step workflow** — numbered lists for sequential tasks +**Gotchas section** — list non-obvious facts specific to this environment that the agent would get wrong otherwise +**Output template** — provide a concrete template when output format matters +**Validation loop** — instruct the agent to verify its own work before proceeding +**Progressive disclosure** — keep `SKILL.md` under 500 lines; move deep reference material to `references/` + +### 5. Add supporting files (optional) + +``` +scripts/ # Executable scripts the agent can run +references/ # Detailed reference docs loaded on demand +assets/ # Templates, data files, static resources +``` + +When referencing these in `SKILL.md`, tell the agent *when* to load them: + +```markdown +If the API returns a non-200 status, read `references/error-codes.md`. +``` + +--- + +## Editing an Existing Skill + +1. **Read the existing `SKILL.md`** fully before making any edits. +2. **Identify the gap**: Is the description too vague? Are instructions missing a step? Is there a gotcha that should be added? Did the content become outdated due to changes in the project? +3. **Prefer targeted edits** over rewrites — preserve working sections. +4. **Update the `description`** if the skill's trigger conditions have changed. The description is what the agent reads at startup to decide whether to activate the skill. +5. **Move bulk content to `references/`** if `SKILL.md` is growing beyond ~500-600 lines. + +--- + +## Quality Checklist + +Before finalizing any skill: + +- [ ] `name` matches the directory name exactly +- [ ] `description` describes both *what* the skill does and *when* to use it, with action keywords +- [ ] Body covers steps the agent would otherwise get wrong (not generic advice) +- [ ] Output format defined with a concrete template if the skill produces structured output +- [ ] `SKILL.md` is under 500-600 lines; large reference material moved to `references/` +- [ ] File references use relative paths and specify *when* to load them +- [ ] No duplicate information between frontmatter description and body + +--- + +## Gotchas + +- **Don't explain general knowledge.** The agent already knows how HTTP works or what a PDF is, omit these general explanations. Focus on what is specific to this project or workflow. +- **Description is for activation, not documentation.** It must be keyword-rich and describe trigger conditions, not a paragraph of prose. +- **`name` must match directory exactly.** A mismatch will cause the skill to fail validation. +- **Avoid menus of equal options.** Pick a default approach and briefly mention alternatives as escape hatches. +- **Generic LLM-generated skills are low value.** Ground every instruction in real project context — conventions, actual APIs, known failure modes. +- **Don't go into excessive detail.** Focus on the high-level flow of features. Mentioning class names and project names is acceptable, but skills should not enumerate individual methods, properties, or endpoint signatures — that level of detail belongs in the code itself. + +--- + +## Example Minimal Skill + +```markdown +--- +name: csv-import +description: > + Import CSV files into the database. Use when the user provides a CSV file + to load, asks to import data, or mentions bulk data upload. +--- + +# CSV Import + +## Steps + +1. Validate the CSV: `python scripts/validate_csv.py ` +2. If validation fails, fix the reported issues and re-validate. +3. Import: `python scripts/import_csv.py --table ` +4. Confirm row count matches source: `python scripts/verify_import.py ` + +## Gotchas + +- Column names in the CSV must match the database schema exactly (case-sensitive). +- Empty strings are imported as NULL — warn the user if this is unexpected. +- The import script does not deduplicate; run `scripts/check_duplicates.py` first if the source may have duplicates. +``` diff --git a/src/NodeDev.Blazor/Components/ClassExplorer.razor b/src/NodeDev.Blazor/Components/ClassExplorer.razor index 9947fb67..ace13543 100644 --- a/src/NodeDev.Blazor/Components/ClassExplorer.razor +++ b/src/NodeDev.Blazor/Components/ClassExplorer.razor @@ -1,5 +1,6 @@ @inject IDialogService DialogService @inject ISnackbar Snackbar +@inject NodeDev.Blazor.Services.WorkspaceCommandService Commands
@@ -19,14 +20,6 @@ @if (context.Value == null) { } - else if (context.Value == CurrentlyEditingItem) - { - - - - - - } else if (context.Value.Type == TreeItemType.MethodsFolder || context.Value.Type == TreeItemType.PropertiesFolder) { @@ -70,8 +63,7 @@ @Item.Value!.Property!.PropertyType.FriendlyName @Item.Value!.Name
- - +
@@ -115,10 +107,9 @@ private TreeItem? Hovered = null; - private TreeItem? CurrentlyEditingItem = null; - private string? Text = null; - public bool IsNew = false; - + /// + /// Builds the tree from the class members that exist when this explorer is created. + /// protected override void OnInitialized() { base.OnInitialized(); @@ -169,110 +160,129 @@ } } - private void ShowAddMethodMenu() + /// + /// Prompts for a method name and adds the created method to the methods branch. + /// + private async Task ShowCreateMethodDialog() { - var newItem = new TreeItemData() - { - Value = new TreeItem(TreeItemType.Method, null, null) - { - Name = "NewMethod" - } - }; - Items.First(x => x.Value?.Type == TreeItemType.MethodsFolder).Children!.Add(newItem); - - CurrentlyEditingItem = newItem.Value; - IsNew = true; - Text = CurrentlyEditingItem.Name; + var dialogReference = await DialogService.ShowAsync("Create New Method", new() + { + [nameof(NameDialog.InitialValue)] = "NewMethod", + [nameof(NameDialog.Label)] = "Method Name", + [nameof(NameDialog.ConfirmText)] = "Create", + [nameof(NameDialog.InputTestId)] = "method-name-input", + [nameof(NameDialog.ConfirmTestId)] = "confirm-create-method" + }, DialogDefaults.SmallForm); - } + var result = await dialogReference.Result; - private void ShowNewProperty() - { - var newItem = new TreeItemData() + if (result is { Canceled: false, Data: string methodName }) + { + var commandResult = Commands.CreateMethod(Class, methodName); + Snackbar.ShowCommandResult(commandResult); + if (commandResult is { Succeeded: true, Value: not null }) { - Value = new(TreeItemType.Property, null, null) + Items.First(x => x.Value!.Type == TreeItemType.MethodsFolder).Children!.Add(new() { - Name = "NewProperty" - } - }; - Items.First(x => x.Value!.Type == TreeItemType.PropertiesFolder).Children!.Add(newItem); - - CurrentlyEditingItem = newItem.Value; - IsNew = true; - Text = CurrentlyEditingItem.Name; - + Value = new(TreeItemType.Method, commandResult.Value, null) + { + Name = commandResult.Value.Name + } + }); + StateHasChanged(); + } + } } - private async Task ShowCreateMethodDialog() + /// + /// Prompts for a property name and adds the created property to the properties branch. + /// + private async Task ShowNewProperty() { - var dialogReference = await DialogService.ShowAsync("Create New Method", new() + var dialogReference = await DialogService.ShowAsync("Create New Property", new() { - [nameof(CreateMethodDialog.Class)] = Class - }, new DialogOptions() - { - MaxWidth = MaxWidth.Small, - FullWidth = true - }); - + [nameof(NameDialog.InitialValue)] = "NewProperty", + [nameof(NameDialog.Label)] = "Property Name", + [nameof(NameDialog.ConfirmText)] = "Create" + }, DialogDefaults.SmallForm); var result = await dialogReference.Result; - if (result != null && !result.Canceled && result.Data is NodeDev.Core.Class.NodeClassMethod method) + if (result is { Canceled: false, Data: string propertyName }) { - Items.First(x => x.Value!.Type == TreeItemType.MethodsFolder).Children!.Add(new() + var commandResult = Commands.CreateProperty(Class, propertyName); + Snackbar.ShowCommandResult(commandResult); + if (commandResult is { Succeeded: true, Value: not null }) { - Value = new(TreeItemType.Method, method, null) + Items.First(x => x.Value!.Type == TreeItemType.PropertiesFolder).Children!.Add(new() { - Name = method.Name - } - }); - Snackbar.Add($"Method '{method.Name}' created successfully", Severity.Success); - StateHasChanged(); + Value = new(TreeItemType.Property, null, commandResult.Value) + { + Name = commandResult.Value.Name + } + }); + StateHasChanged(); + } } } + /// + /// Prompts for and applies a replacement name for a method tree item. + /// + /// The tree item containing the method to rename. private async Task ShowRenameMethodDialog(TreeItem item) { - if (item.Method == null) return; - - var dialogReference = await DialogService.ShowAsync("Rename Method", new() + if (item.Method == null) { - [nameof(RenameDialog.CurrentName)] = item.Method.Name, - [nameof(RenameDialog.Label)] = "Method Name", - [nameof(RenameDialog.IsMethod)] = true - }, new DialogOptions() + return; + } + + var dialogReference = await DialogService.ShowAsync("Rename Method", new() { - MaxWidth = MaxWidth.Small, - FullWidth = true - }); + [nameof(NameDialog.InitialValue)] = item.Method.Name, + [nameof(NameDialog.Label)] = "Method Name", + [nameof(NameDialog.ConfirmText)] = "Rename", + [nameof(NameDialog.InputTestId)] = "method-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.Method.Name; - item.Method.Rename(newName); - item.Name = newName; // Update the tree item name for display - Snackbar.Add($"Method renamed from '{oldName}' to '{newName}'", Severity.Success); - StateHasChanged(); + var commandResult = Commands.RenameMethod(item.Method, newName); + Snackbar.ShowCommandResult(commandResult); + if (commandResult.Succeeded) + { + item.Name = newName; + StateHasChanged(); + } } } + /// + /// Confirms and deletes a method, clearing any selected method that was removed. + /// + /// The tree item containing the method to delete. private async Task DeleteMethod(TreeItem item) { - if (item.Method == null) return; + if (item.Method == null) + { + return; + } var confirm = await DialogService.ShowMessageBox( "Delete Method", $"Are you sure you want to delete method '{item.Method.Name}'?", yesText: "Delete", cancelText: "Cancel", - options: new DialogOptions() { MaxWidth = MaxWidth.Small } + options: DialogDefaults.Confirmation ); if (confirm == true) { - try + var commandResult = Commands.DeleteMethod(item.Method); + Snackbar.ShowCommandResult(commandResult); + if (commandResult.Succeeded) { - Class.RemoveMethod(item.Method); Items.First(x => x.Value!.Type == TreeItemType.MethodsFolder).Children!.RemoveAll(x => x.Value?.Method == item.Method); if (SelectedTreeItem == item) { @@ -281,126 +291,88 @@ await SelectedMethodChanged.InvokeAsync(null); } await MethodDeleted.InvokeAsync(item.Method); - Snackbar.Add($"Method '{item.Method.Name}' deleted", Severity.Success); StateHasChanged(); } - catch (Exception ex) - { - Snackbar.Add(ex.Message, Severity.Error); - } } } - - private void ShowRenameMenu(TreeItem item) + /// + /// Prompts for and applies a replacement name for a property tree item. + /// + /// The tree item containing the property to rename. + private async Task ShowRenamePropertyDialog(TreeItem item) { - if (item == null) + if (item.Property == null) + { return; + } - Text = item.Name; - CurrentlyEditingItem = item; - IsNew = false; + var dialogReference = await DialogService.ShowAsync("Rename Property", new() + { + [nameof(NameDialog.InitialValue)] = item.Property.Name, + [nameof(NameDialog.Label)] = "Property Name", + [nameof(NameDialog.ConfirmText)] = "Rename" + }, DialogDefaults.SmallForm); + var result = await dialogReference.Result; + if (result is { Canceled: false, Data: string newName }) + { + var commandResult = Commands.RenameProperty(item.Property, newName); + Snackbar.ShowCommandResult(commandResult); + if (commandResult.Succeeded) + { + item.Name = newName; + StateHasChanged(); + } + } } + /// + /// Opens the type selector for a property and applies a selected type. + /// + /// The tree item containing the property to update. private async Task ShowPropertyTypeEdit(TreeItem item) { - var result = await DialogService.Show("", new() + if (item.Property == null) + { + return; + } + + var dialog = await DialogService.ShowAsync("Select Property Type", new() { [nameof(TypeSelectorDialog.TypeFactory)] = Class.TypeFactory - }, new DialogOptions() - { - FullScreen = true, - FullWidth = true - }).Result; - - NodeDev.Core.Types.TypeBase typeBase; - if (result?.Data is Type type) - typeBase = Class.TypeFactory.Get(type, null); - else if (result?.Data is NodeDev.Core.Types.TypeBase t) - typeBase = t; - else - return; + }, DialogDefaults.FullScreen); + var result = await dialog.Result; - item.Property!.ChangeType(typeBase); + if (result?.Data is NodeDev.Core.Types.TypeBase type) + { + Snackbar.ShowCommandResult(Commands.ChangePropertyType(item.Property, type)); + } } + /// + /// Opens the parameter editor for the selected method. + /// + /// The tree item containing the method to edit. private async Task ShowMethodEdit(TreeItem item) { - var result = await DialogService.Show("", new() - { - [nameof(EditMethodMenu.Method)] = item.Method - }, new DialogOptions() - { - FullScreen = false, - FullWidth = true, - MaxWidth = MaxWidth.Large - }).Result; - } - - private void OnEditTextKeyUp(KeyboardEventArgs args) - { - if (args.Key != "Enter" || string.IsNullOrWhiteSpace(Text) || CurrentlyEditingItem == null) - return; - - if (IsNew) - { - if (CurrentlyEditingItem!.Type == TreeItemType.Method) - { - var method = new Core.Class.NodeClassMethod(Class, Text, Class.TypeFactory.Get(typeof(void), null)); - Class.AddMethod(method, createEntryAndReturn: true); + if (item.Method == null) + { + return; + } - Items.First(x => x.Value!.Type == TreeItemType.MethodsFolder).Children!.RemoveAll(x => x.Value == CurrentlyEditingItem); - Items.First(x => x.Value!.Type == TreeItemType.MethodsFolder).Children!.Add(new() - { - Value = new(TreeItemType.Method, method, null) - { - Name = method.Name - } - }); - } - else if (CurrentlyEditingItem.Type == TreeItemType.Property) + var dialog = await DialogService.ShowAsync("Edit Method", new() { - var property = new Core.Class.NodeClassProperty(Class, Text, Class.TypeFactory.Get()); - Class.Properties.Add(property); - - Items.First(x => x.Value!.Type == TreeItemType.PropertiesFolder).Children!.RemoveAll(x => x.Value == CurrentlyEditingItem); - Items.First(x => x.Value!.Type == TreeItemType.PropertiesFolder).Children!.Add(new() - { - Value = new(TreeItemType.Property, null, property) - { - Name = property.Name - } - }); - } - } - else if (CurrentlyEditingItem.Method != null) - { - CurrentlyEditingItem.Method.Rename(Text); - CurrentlyEditingItem.Name = Text; - } - else if (CurrentlyEditingItem.Property != null) - { - CurrentlyEditingItem.Property.Rename(Text); - CurrentlyEditingItem.Name = Text; - } - - CurrentlyEditingItem = null; - Text = null; + [nameof(EditMethodMenu.Method)] = item.Method + }, DialogDefaults.LargeEditor); + await dialog.Result; } + /// + /// Publishes the selected method and clears the selection for non-method tree items. + /// + /// The newly selected tree item. private void OnSelectedItemChanged(TreeItem? tree) { - if (CurrentlyEditingItem != null) - { - // remove the textbox - if (IsNew) - Items.First(x => x.Value?.Type == (CurrentlyEditingItem.Type == TreeItemType.Method ? TreeItemType.MethodsFolder : TreeItemType.PropertiesFolder)) - .Children!.RemoveAll(x => x.Value == CurrentlyEditingItem); - - CurrentlyEditingItem = null; - Text = null; - } - SelectedTreeItem = tree; if (tree?.Type == TreeItemType.Method) diff --git a/src/NodeDev.Blazor/Components/CreateClassDialog.razor b/src/NodeDev.Blazor/Components/CreateClassDialog.razor deleted file mode 100644 index db07c190..00000000 --- a/src/NodeDev.Blazor/Components/CreateClassDialog.razor +++ /dev/null @@ -1,38 +0,0 @@ -@inject ISnackbar Snackbar - - - - - - - Cancel - Create - - - -@code { - [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = null!; - - [Parameter] - public NodeDev.Core.Project Project { get; set; } = null!; - - private string ClassName { get; set; } = "NewClass"; - - private void Cancel() => MudDialog.Cancel(); - - private void Submit() - { - if (string.IsNullOrWhiteSpace(ClassName)) - { - Snackbar.Add("Class name cannot be empty", Severity.Error); - return; - } - - // Create the new class - use proper constructor - var newClass = new NodeDev.Core.Class.NodeClass(ClassName, "MyApp", Project); - Project.AddClass(newClass); - - MudDialog.Close(DialogResult.Ok(newClass)); - } -} diff --git a/src/NodeDev.Blazor/Components/CreateMethodDialog.razor b/src/NodeDev.Blazor/Components/CreateMethodDialog.razor deleted file mode 100644 index 499c7252..00000000 --- a/src/NodeDev.Blazor/Components/CreateMethodDialog.razor +++ /dev/null @@ -1,38 +0,0 @@ -@inject ISnackbar Snackbar - - - - - - - Cancel - Create - - - -@code { - [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = null!; - - [Parameter] - public NodeDev.Core.Class.NodeClass Class { get; set; } = null!; - - private string MethodName { get; set; } = "NewMethod"; - - private void Cancel() => MudDialog.Cancel(); - - private void Submit() - { - if (string.IsNullOrWhiteSpace(MethodName)) - { - Snackbar.Add("Method name cannot be empty", Severity.Error); - return; - } - - // Create the new method - var method = new NodeDev.Core.Class.NodeClassMethod(Class, MethodName, Class.TypeFactory.Get(typeof(void), null)); - Class.AddMethod(method, createEntryAndReturn: true); - - MudDialog.Close(DialogResult.Ok(method)); - } -} diff --git a/src/NodeDev.Blazor/Components/DialogDefaults.cs b/src/NodeDev.Blazor/Components/DialogDefaults.cs new file mode 100644 index 00000000..5a12c081 --- /dev/null +++ b/src/NodeDev.Blazor/Components/DialogDefaults.cs @@ -0,0 +1,18 @@ +using MudBlazor; + +namespace NodeDev.Blazor.Components; + +internal static class DialogDefaults +{ + public static DialogOptions SmallForm { get; } = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; + + public static DialogOptions MediumForm { get; } = new() { MaxWidth = MaxWidth.Medium, FullWidth = true }; + + public static DialogOptions LargeEditor { get; } = new() { MaxWidth = MaxWidth.Large, FullWidth = true }; + + public static DialogOptions TypeSelector { get; } = new() { FullWidth = true }; + + public static DialogOptions FullScreen { get; } = new() { FullScreen = true, FullWidth = true }; + + public static DialogOptions Confirmation { get; } = new() { MaxWidth = MaxWidth.Small }; +} diff --git a/src/NodeDev.Blazor/Components/EditMethodMenu.razor b/src/NodeDev.Blazor/Components/EditMethodMenu.razor index 42603d7f..9061dda4 100644 --- a/src/NodeDev.Blazor/Components/EditMethodMenu.razor +++ b/src/NodeDev.Blazor/Components/EditMethodMenu.razor @@ -1,5 +1,6 @@ @inject IDialogService DialogService @inject ISnackbar Snackbar +@inject NodeDev.Blazor.Services.WorkspaceCommandService Commands @@ -8,7 +9,6 @@
Add parameter - Change return type
@@ -24,11 +24,11 @@ - + - + @@ -37,9 +37,9 @@
- - - + + +
@@ -63,49 +63,100 @@ [Parameter] public NodeDev.Core.Class.NodeClassMethod Method { get; set; } = null!; - void Submit() => MudDialog.Close(DialogResult.Ok(true)); + /// + /// Closes the editor after its immediate parameter mutations have been applied. + /// + private void Submit() + { + MudDialog.Close(DialogResult.Ok(true)); + } - void AddParameter() + /// + /// Adds the default parameter defined by the core model and refreshes the grid. + /// + private void AddParameter() { - Method.AddDefaultParameter(); + ShowFailure(Commands.AddDefaultParameter(Method)); StateHasChanged(); } - private async Task ShowReturnTypeEdit() + /// + /// Opens the type selector for a method parameter. + /// + /// The parameter that receives the selected type. + private async Task ShowParameterTypeEdit(NodeDev.Core.Class.NodeClassMethodParameter parameter) { - var result = await DialogService.Show("Select Return Type", new() + var dialog = await DialogService.ShowAsync("Select Parameter Type", new() { [nameof(TypeSelectorDialog.TypeFactory)] = Method.Class.TypeFactory - }, new DialogOptions() - { - FullScreen = false, - FullWidth = true - }).Result; + }, DialogDefaults.TypeSelector); + var result = await dialog.Result; - // Note: ReturnType is readonly in NodeClassMethod, so we can't actually change it - // This is a limitation of the current API - Snackbar.Add("Return type changing not yet supported in the API", Severity.Warning); + if (result?.Data is NodeDev.Core.Types.TypeBase type) + { + ShowFailure(Commands.ChangeParameterType(parameter, type)); + } } - private async Task ShowParameterTypeEdit(NodeDev.Core.Class.NodeClassMethodParameter parameter) + /// + /// Applies an inline name edit through the workspace command boundary. + /// + /// The parameter being renamed. + /// The edited name, which may be empty while editing. + private void RenameParameter(NodeDev.Core.Class.NodeClassMethodParameter parameter, string? name) { - var result = await DialogService.Show("", new() - { - [nameof(TypeSelectorDialog.TypeFactory)] = Method.Class.TypeFactory - }, new DialogOptions() - { - FullScreen = false, - FullWidth = true - }).Result; - - NodeDev.Core.Types.TypeBase typeBase; - if (result?.Data is Type type) - typeBase = Method.Class.TypeFactory.Get(type, null); - else if (result?.Data is NodeDev.Core.Types.TypeBase t) - typeBase = t; - else - return; - - parameter.ChangeType(typeBase); + ShowFailure(Commands.RenameParameter(parameter, name ?? "")); + } + + /// + /// Applies the output-parameter toggle when the grid has a valid row item. + /// + /// The optional parameter from the grid row. + /// Whether the parameter should be emitted as . + private void SetParameterIsOut(NodeDev.Core.Class.NodeClassMethodParameter? parameter, bool value) + { + if (parameter != null) + { + ShowFailure(Commands.SetParameterIsOut(parameter, value)); + } + } + + /// + /// Moves a parameter earlier in the method signature. + /// + /// The parameter to move. + private void MoveParameterUp(NodeDev.Core.Class.NodeClassMethodParameter parameter) + { + ShowFailure(Commands.MoveParameterUp(parameter)); + } + + /// + /// Moves a parameter later in the method signature. + /// + /// The parameter to move. + private void MoveParameterDown(NodeDev.Core.Class.NodeClassMethodParameter parameter) + { + ShowFailure(Commands.MoveParameterDown(parameter)); + } + + /// + /// Removes a parameter from the method signature. + /// + /// The parameter to remove. + private void RemoveParameter(NodeDev.Core.Class.NodeClassMethodParameter parameter) + { + ShowFailure(Commands.RemoveParameter(parameter)); + } + + /// + /// Displays only failed command outcomes because successful inline edits do not require a toast. + /// + /// The command outcome to inspect. + private void ShowFailure(NodeDev.Blazor.Services.WorkspaceCommandResult result) + { + if (!result.Succeeded) + { + Snackbar.ShowCommandResult(result); + } } -} \ No newline at end of file +} diff --git a/src/NodeDev.Blazor/Components/GraphCanvas.razor b/src/NodeDev.Blazor/Components/GraphCanvas.razor index ebf2449f..c69ea368 100644 --- a/src/NodeDev.Blazor/Components/GraphCanvas.razor +++ b/src/NodeDev.Blazor/Components/GraphCanvas.razor @@ -4,21 +4,21 @@ @if (PopupState.IsShowingNodeSelection) { -
+ -
+ } @if (PopupState.IsShowingGenericTypeSelection) { -
- -
+ + + } @if (PopupState.IsShowingOverloadSelection && PopupState.Node != null) { -
+ -
+ }
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 @@ +
+
+ @ChildContent +
+
+ +@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); + } +}