diff --git a/src/NodeDev.Blazor/Components/ClassExplorer.razor b/src/NodeDev.Blazor/Components/ClassExplorer.razor index 1af670d3..9947fb67 100644 --- a/src/NodeDev.Blazor/Components/ClassExplorer.razor +++ b/src/NodeDev.Blazor/Components/ClassExplorer.razor @@ -106,6 +106,9 @@ [Parameter] public EventCallback SelectedMethodChanged { get; set; } + [Parameter] + public EventCallback MethodDeleted { get; set; } + private TreeItem? SelectedTreeItem; private List> Items { get; } = []; @@ -267,10 +270,24 @@ if (confirm == true) { - // Note: There's no public API to remove methods, so we just remove from UI - Items.First(x => x.Value!.Type == TreeItemType.MethodsFolder).Children!.RemoveAll(x => x.Value?.Method == item.Method); - Snackbar.Add($"Method '{item.Method.Name}' removed from view (Note: No API to actually delete)", Severity.Warning); - StateHasChanged(); + try + { + Class.RemoveMethod(item.Method); + Items.First(x => x.Value!.Type == TreeItemType.MethodsFolder).Children!.RemoveAll(x => x.Value?.Method == item.Method); + if (SelectedTreeItem == item) + { + SelectedTreeItem = null; + SelectedMethod = null; + 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); + } } } @@ -377,7 +394,8 @@ { // remove the textbox if (IsNew) - Items.First().Children!.RemoveAll(x => x.Value == CurrentlyEditingItem); + Items.First(x => x.Value?.Type == (CurrentlyEditingItem.Type == TreeItemType.Method ? TreeItemType.MethodsFolder : TreeItemType.PropertiesFolder)) + .Children!.RemoveAll(x => x.Value == CurrentlyEditingItem); CurrentlyEditingItem = null; Text = null; @@ -398,4 +416,4 @@ } -} \ No newline at end of file +} diff --git a/src/NodeDev.Blazor/Components/GraphCanvas.razor b/src/NodeDev.Blazor/Components/GraphCanvas.razor index 2f524656..ebf2449f 100644 --- a/src/NodeDev.Blazor/Components/GraphCanvas.razor +++ b/src/NodeDev.Blazor/Components/GraphCanvas.razor @@ -2,22 +2,22 @@
- @if (IsShowingNodeSelection) + @if (PopupState.IsShowingNodeSelection) {
- +
} - @if (IsShowingGenericTypeSelection) + @if (PopupState.IsShowingGenericTypeSelection) {
- +
} - @if (IsShowingOverloadSelection && PopupNode != null) + @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 ea03b259..ab7549da 100644 --- a/src/NodeDev.Blazor/Components/GraphCanvas.razor.cs +++ b/src/NodeDev.Blazor/Components/GraphCanvas.razor.cs @@ -17,11 +17,10 @@ using NodeDev.Core.Nodes.Delegates; using NodeDev.Core.Types; using System.Numerics; -using System.Reactive.Linq; namespace NodeDev.Blazor.Components; -public partial class GraphCanvas : ComponentBase, IDisposable, IGraphCanvas +public partial class GraphCanvas : ComponentBase, IDisposable { [Parameter, EditorRequired] public Graph Graph { get; set; } = null!; @@ -34,14 +33,11 @@ public partial class GraphCanvas : ComponentBase, IDisposable, IGraphCanvas private GraphManagerService GraphManagerService => Graph.Manager; - private int PopupX = 0; - private int PopupY = 0; - private Vector2 PopupNodePosition; - private Connection? PopupNodeConnection; - private Node? PopupNode; - private string? PopupCallableScopeId; - private BlazorDiagram Diagram { get; set; } = null!; + private GraphDiagramProjection DiagramProjection { get; set; } = null!; + private GraphDiagramSynchronizer DiagramSynchronizer { get; set; } = null!; + private GraphPopupState PopupState { get; } = new(); + private GraphDebugVisualizer DebugVisualizer { get; set; } = null!; #region OnInitialized @@ -50,8 +46,6 @@ protected override void OnInitialized() base.OnInitialized(); _ = NodeProvider.WarmExtensionMethodCatalogAsync(); - Graph.GraphCanvas = this; - var options = new BlazorDiagramOptions { GridSize = 30, @@ -79,6 +73,11 @@ protected override void OnInitialized() Diagram.Links.Added += x => OnConnectionAdded(x, false); Diagram.Links.Removed += OnConnectionRemoved; Diagram.SelectionChanged += SelectionChanged; + + DiagramSynchronizer = new GraphDiagramSynchronizer(Graph, action => InvokeAsync(action)); + DiagramProjection = new GraphDiagramProjection(Graph, Diagram, DiagramSynchronizer, OnConnectionAdded); + DebugVisualizer = new GraphDebugVisualizer(Graph, Diagram, action => InvokeAsync(action)); + IndexPage.RegisterGraphCanvas(this); } #endregion @@ -92,121 +91,27 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (firstRender) { await Task.Delay(100); - Diagram.Batch(InitializeCanvasWithGraphNodes); - - GraphChangedSubscription = Graph.SelfClass.Project.GraphChanged.Where(x => x.RequireUIRefresh && x.Graph == Graph).AcceptThenSample(TimeSpan.FromMilliseconds(250)).Subscribe(OnGraphChangedFromCore); - NodeExecutingSubscription = Graph.SelfClass.Project.GraphNodeExecuting.Where(x => x.Executor.Graph == Graph).Buffer(TimeSpan.FromMilliseconds(250)).Subscribe(OnGraphNodeExecuting); - NodeExecutedSubscription = Graph.SelfClass.Project.GraphNodeExecuted.Where(x => x.Executor.Graph == Graph).Sample(TimeSpan.FromMilliseconds(250)).Subscribe(OnGraphNodeExecuted); + Diagram.Batch(DiagramProjection.Initialize); + DiagramSynchronizer.Start(DiagramProjection, StateHasChanged); + DebugVisualizer.Start(); } } #endregion - #region OnGraphNodeExecuting / OnGraphNodeExecuted - - private void OnGraphNodeExecuting(IList<(GraphExecutor Executor, Node Node, Connection Exec)> options) - { - InvokeAsync(() => - { - foreach (var option in options.DistinctBy(x => x.Exec)) - { - var nodeModel = Diagram.Nodes.OfType().FirstOrDefault(x => x.Node == option.Node); - if (nodeModel == null) - return; - - _ = nodeModel.OnNodeExecuting(option.Exec); - } - }); - } - - private void OnGraphNodeExecuted((GraphExecutor Executor, Node Node, Connection Exec) options) - { - InvokeAsync(() => - { - var nodeModel = Diagram.Nodes.OfType().FirstOrDefault(x => x.Node == options.Node); - if (nodeModel == null) - return; - - nodeModel.OnNodeExecuted(options.Exec); - }); - } - - #endregion - - #region OnGraphChangedFromCore / RefreshAll - - private void OnGraphChangedFromCore((Graph, bool) _) - { - InvokeAsync(() => - { - UpdateNodes(); // update all the nodes - - StateHasChanged(); - }); - } - - #endregion - - #region UpdateConnectionType - - public void UpdatePortColor(Connection connection) - { - var port = FindPort(connection); - if (port == null) - return; - - var color = GetTypeShapeColor(connection.Type, connection.Parent.TypeFactory); - foreach (var link in port.Links.Cast()) - link.Color = color; - - Diagram.Refresh(); - } - - #endregion - #region UpdateNodes - private void UpdateNodes() - { - Diagram.Batch(() => - { - DisableConnectionUpdate = true; - DisableNodeRemovedUpdate = true; - - Diagram.Links.Clear(); - Diagram.Groups.Clear(); - Diagram.Nodes.Clear(); - - InitializeCanvasWithGraphNodes(); - - DisableNodeRemovedUpdate = false; - DisableConnectionUpdate = false; - }); - } - private NodeModel? FindNodeModel(Node node) { - return (NodeModel?)Diagram.Nodes.OfType().FirstOrDefault(x => x.Node == node) - ?? Diagram.Groups.OfType().FirstOrDefault(x => x.DelegateNode == node); + return DiagramProjection.FindNodeModel(node); } private GraphPortModel? FindPort(Connection connection) { - var nodePort = FindNodeModel(connection.Parent)?.Ports - .OfType() - .FirstOrDefault(x => x.Connection == connection); - if (nodePort != null) - return nodePort; - - return Diagram.Groups - .SelectMany(x => x.Ports) - .OfType() - .FirstOrDefault(x => x.Connection == connection); + return DiagramProjection.FindPort(connection); } - private LambdaGroupModel? FindLambdaGroup(string? bodyScopeId) => Diagram.Groups - .OfType() - .FirstOrDefault(x => x.DelegateNode.BodyScopeId == bodyScopeId); + private LambdaGroupModel? FindLambdaGroup(string? bodyScopeId) => DiagramProjection.FindLambdaGroup(bodyScopeId); #endregion @@ -254,11 +159,9 @@ private static bool HasSelectedLambdaAncestor(LambdaGroupModel group, HashSet public void OnConnectionAdded(BaseLinkModel baseLinkModel, bool force) { - if (DisableConnectionUpdate && !force) + if (DiagramSynchronizer.IsConnectionUpdateSuppressed && !force) return; baseLinkModel.SourceChanged += OnConnectionUpdated; @@ -342,7 +245,7 @@ public void OnConnectionAdded(BaseLinkModel baseLinkModel, bool force) /// This is because vertices are stored for the port, and execs conveniently only have one output connection while other types only have one input connection. /// /// - private static Connection GetConnectionContainingVertices(Connection source, Connection destination) + internal static Connection GetConnectionContainingVertices(Connection source, Connection destination) { if (source.Type.IsExec) // execs can only have one connection, therefor they always contains the vertex information return source; @@ -360,7 +263,6 @@ private static void UpdateVerticesInConnection(Connection source, Connection des other.UpdateVertices([]); // make sure there's no leftover vertices } - private bool DisableVertexAddDuringLoading = false; private void BaseLinkModel_VertexRemoved(BaseLinkModel baseLinkModel, LinkVertexModel vertex) { if (baseLinkModel is LinkModel link && link.Source.Model is GraphPortModel source && link.Target.Model is GraphPortModel destination) @@ -373,7 +275,7 @@ private void BaseLinkModel_VertexAdded(BaseLinkModel baseLinkModel, LinkVertexMo { vertex.Moved += _ => Vertex_Moved(link); - if (!DisableVertexAddDuringLoading) + if (!DiagramSynchronizer.IsLoadingVertices) UpdateVerticesInConnection(source.Connection, destination.Connection, link); } } @@ -390,7 +292,7 @@ private static void Vertex_Moved(LinkModel link) /// public void OnConnectionRemoved(BaseLinkModel baseLinkModel) { - if (DisableConnectionUpdate) + if (DiagramSynchronizer.IsConnectionUpdateSuppressed) return; var source = ((GraphPortModel?)baseLinkModel.Source.Model)?.Connection; @@ -424,37 +326,20 @@ public static void OnNodeMoved(MovableModel movableModel) decoration.Position = new((float)movableModel.Position.X, (float)movableModel.Position.Y); } - private static void OnLambdaGroupMoved(MovableModel movableModel) - { - if (movableModel is not LambdaGroupModel group) - return; - - var groupDecoration = group.DelegateNode.GetOrAddDecoration(() => new(Vector2.Zero)); - groupDecoration.Position = new((float)group.Position.X, (float)group.Position.Y); - - foreach (var nodeModel in group.GetDescendantNodeModels()) - { - var decoration = nodeModel.Node.GetOrAddDecoration(() => new(Vector2.Zero)); - decoration.Position = new((float)nodeModel.Position.X, (float)nodeModel.Position.Y); - } - } - #endregion #region OnPortDroppedOnCanvas - private bool IsShowingNodeSelection = false; - public void OnPortDroppedOnCanvas(Connection connection, global::Blazor.Diagrams.Core.Geometry.Point point) { - PopupNode = connection.Parent; - PopupNodeConnection = connection; - PopupCallableScopeId = connection.Parent.CallableScopeId; var screenPosition = Diagram.GetScreenPoint(point.X, point.Y) - Diagram.Container!.NorthWest; - PopupX = (int)screenPosition.X; - PopupY = (int)screenPosition.Y; - PopupNodePosition = new((float)point.X, (float)point.Y); - IsShowingNodeSelection = true; + PopupState.ShowNodeSelection( + (int)screenPosition.X, + (int)screenPosition.Y, + new((float)point.X, (float)point.Y), + connection, + connection.Parent, + connection.Parent.CallableScopeId); StateHasChanged(); } @@ -463,38 +348,38 @@ private void OnNewNodeTypeSelected(NodeProvider.NodeSearchResult searchResult) { var node = GraphManagerService.AddNode(searchResult, node => { - node.AddDecoration(new NodeDecorationPosition(new(PopupNodePosition.X, PopupNodePosition.Y))); - }, PopupCallableScopeId); + node.AddDecoration(new NodeDecorationPosition(PopupState.NodePosition)); + }, PopupState.CallableScopeId); Diagram.Batch(() => { - if (PopupNodeConnection != null && PopupNode != null) + if (PopupState.NodeConnection is { } popupConnection && PopupState.Node is { } popupNode) { // check if the source was an input or output and choose the proper destination based on that List sources, destinations; - bool isPopupNodeInput = PopupNodeConnection.IsInput; + bool isPopupNodeInput = popupConnection.IsInput; if (isPopupNodeInput) { - sources = PopupNode.Inputs; + sources = popupNode.Inputs; destinations = node.Outputs; } else { - sources = PopupNode.Outputs; + sources = popupNode.Outputs; destinations = node.Inputs; } Connection? destination = null; - if (PopupNodeConnection.Type is UndefinedGenericType) // can connect to anything except exec + if (popupConnection.Type is UndefinedGenericType) // can connect to anything except exec destination = destinations.FirstOrDefault(x => !x.Type.IsExec); else // can connect to anything that is assignable to the type - destination = destinations.FirstOrDefault(x => PopupNodeConnection.Type.IsAssignableTo(x.Type, out _, out _) || (x.Type is UndefinedGenericType && !PopupNodeConnection.Type.IsExec)); + destination = destinations.FirstOrDefault(x => popupConnection.Type.IsAssignableTo(x.Type, out _, out _) || (x.Type is UndefinedGenericType && !popupConnection.Type.IsExec)); // if we found a connection, connect them together if (destination != null) { - var source = isPopupNodeInput ? destination : PopupNodeConnection; - var target = isPopupNodeInput ? PopupNodeConnection : destination; + var source = isPopupNodeInput ? destination : popupConnection; + var target = isPopupNodeInput ? popupConnection : destination; GraphManagerService.AddNewConnectionBetween(source, target); } @@ -509,26 +394,19 @@ private void OnNewNodeTypeSelected(NodeProvider.NodeSearchResult searchResult) #region OnOverloadSelectionRequested / OnNewOverloadSelected - private bool IsShowingOverloadSelection = false; - public void OnOverloadSelectionRequested(GraphNodeModel graphNode) { - PopupNode = graphNode.Node; - IsShowingOverloadSelection = true; + PopupState.ShowOverloadSelection(graphNode.Node); StateHasChanged(); } private void OnNewOverloadSelected(Node.AlternateOverload overload) { - if (PopupNode == null) + if (PopupState.Node == null) return; - GraphManagerService.SelectNodeOverload(PopupNode, overload); - - // Refresh the node visually after overload selection - // The node's ports have changed, so we need to update the UI - Refresh(PopupNode); + GraphManagerService.SelectNodeOverload(PopupState.Node, overload); CancelPopup(); } @@ -537,54 +415,37 @@ private void OnNewOverloadSelected(Node.AlternateOverload overload) #region OnGenericTypeSelectionMenuAsked - private bool IsShowingGenericTypeSelection = false; - private string? GenericTypeSelectionMenuGeneric; - private Action? PopupTypeSelectedAction; - public void OnGenericTypeSelectionMenuAsked(GraphNodeModel nodeModel, string undefinedGenericType) { - PopupTypeSelectedAction = null; - PopupNode = nodeModel.Node; var p = Diagram.GetScreenPoint(nodeModel.Position.X, nodeModel.Position.Y) - Diagram.Container!.NorthWest; - PopupX = (int)p.X; - PopupY = (int)p.Y; - GenericTypeSelectionMenuGeneric = undefinedGenericType; - IsShowingGenericTypeSelection = true; + PopupState.ShowGenericTypeSelection((int)p.X, (int)p.Y, nodeModel.Node, undefinedGenericType); StateHasChanged(); } private void OnGenericTypeSelected(TypeBase type) { - if (PopupTypeSelectedAction != null) + if (PopupState.TypeSelectedAction != null) { - PopupTypeSelectedAction(type); + PopupState.TypeSelectedAction(type); CancelPopup(); return; } - if (PopupNode == null || GenericTypeSelectionMenuGeneric == null) + if (PopupState.Node == null || PopupState.GenericTypeName == null) return; - GraphManagerService.PropagateNewGeneric(PopupNode, new Dictionary() { [GenericTypeSelectionMenuGeneric] = type }, false, null, overrideInitialTypes: true); - - // Prefer updating the nodes directly instead of calling Graph.RaiseGraphChanged(true) to be sure it is called as soon as possible - //UpdateNodes(Graph.Nodes.Values.ToList()); + GraphManagerService.PropagateNewGeneric(PopupState.Node, new Dictionary() { [PopupState.GenericTypeName] = type }, false, null, overrideInitialTypes: true); CancelPopup(); } public void ShowLambdaTypeSelector(LambdaGroupModel group, Action onTypeSelected) { - PopupNode = group.DelegateNode; - GenericTypeSelectionMenuGeneric = null; - PopupTypeSelectedAction = onTypeSelected; var point = Diagram.GetScreenPoint(group.Position.X + group.Padding, group.Position.Y + 30); if (Diagram.Container != null) point -= Diagram.Container.NorthWest; - PopupX = (int)point.X; - PopupY = (int)point.Y; - IsShowingGenericTypeSelection = true; + PopupState.ShowGenericTypeSelection((int)point.X, (int)point.Y, group.DelegateNode, null, onTypeSelected); StateHasChanged(); } @@ -695,24 +556,21 @@ public void ShowAddNodeForScope(LambdaGroupModel group) private void ShowAddNodeAtScope(string? callableScopeId, Vector2 position) { - PopupNode = null; - PopupNodeConnection = null; - PopupCallableScopeId = callableScopeId; - PopupNodePosition = position; - + int x; + int y; if (Diagram.Container != null) { var screenPosition = Diagram.GetScreenPoint(position.X, position.Y) - Diagram.Container.NorthWest; - PopupX = (int)screenPosition.X; - PopupY = (int)screenPosition.Y; + x = (int)screenPosition.X; + y = (int)screenPosition.Y; } else { - PopupX = (int)position.X; - PopupY = (int)position.Y; + x = (int)position.X; + y = (int)position.Y; } - IsShowingNodeSelection = true; + PopupState.ShowNodeSelection(x, y, position, null, null, callableScopeId); StateHasChanged(); } @@ -728,12 +586,7 @@ public void ShowAddNodeDialog() private void CancelPopup() { - IsShowingGenericTypeSelection = IsShowingNodeSelection = IsShowingOverloadSelection = false; - PopupNode = null; - PopupNodeConnection = null; - PopupCallableScopeId = null; - PopupTypeSelectedAction = null; - GenericTypeSelectionMenuGeneric = null; + PopupState.Reset(); } #endregion @@ -764,353 +617,8 @@ public void ToggleBreakpointOnSelectedNode() #endregion - #region RemoveNode - - public void RemoveNode(Node node) - { - if (node is LambdaReturnNode { IsImplicit: true } boundaryReturn) - { - var group = FindLambdaGroup(boundaryReturn.CallableScopeId); - if (group != null) - { - foreach (var port in group.Ports - .OfType() - .Where(x => x.Connection.Parent == boundaryReturn) - .ToList()) - { - group.RemovePort(port); - } - group.Refresh(); - } - return; - } - - var nodeModel = FindNodeModel(node); - if (nodeModel == null) - return; - - var previousNodeSuppression = DisableNodeRemovedUpdate; - var previousConnectionSuppression = DisableConnectionUpdate; - DisableNodeRemovedUpdate = true; - DisableConnectionUpdate = true; - try - { - if (nodeModel is LambdaGroupModel group) - Diagram.Groups.Remove(group); - else - Diagram.Nodes.Remove(nodeModel); - } - finally - { - DisableNodeRemovedUpdate = previousNodeSuppression; - DisableConnectionUpdate = previousConnectionSuppression; - } - } - - #endregion - - #region AddLink / RemoveLink - - public void RemoveLinkFromGraphCanvas(Connection source, Connection destination) - { - var previousConnectionSuppression = DisableConnectionUpdate; - DisableConnectionUpdate = true; - try - { - var link = Diagram.Links.FirstOrDefault(x => (x.Source.Model as GraphPortModel)?.Connection == source && (x.Target.Model as GraphPortModel)?.Connection == destination); - if (link != null) - { - Diagram.Links.Remove(link); - } - } - finally - { - DisableConnectionUpdate = previousConnectionSuppression; - } - } - - public void AddLinkToGraphCanvas(Connection source, Connection destination) - { - var previousConnectionSuppression = DisableConnectionUpdate; - DisableConnectionUpdate = true; - try - { - if (source.IsInput) - (destination, source) = (source, destination); - - var sourcePort = FindPort(source) ?? throw new InvalidOperationException($"No canvas port exists for {source.Parent.Name}.{source.Name}."); - var destinationPort = FindPort(destination) ?? throw new InvalidOperationException($"No canvas port exists for {destination.Parent.Name}.{destination.Name}."); - - // Make sure there isn't already an existing identical link - if (Diagram.Links.OfType().Any(x => (x.Source as SinglePortAnchor)?.Port == sourcePort && (x.Target as SinglePortAnchor)?.Port == destinationPort)) - return; - - var link = Diagram.Links.Add(new LinkModel(sourcePort, destinationPort)); - - OnConnectionAdded(link, true); - } - finally - { - DisableConnectionUpdate = previousConnectionSuppression; - } - } - - #endregion - - #region AddNode - - public void AddNode(Node node) - { - if (node is CreateDelegateNode delegateNode) - AddLambdaGroupModel(delegateNode); - else if (node is LambdaReturnNode { IsImplicit: true } boundaryReturn) - AddBoundaryReturnToGroup(boundaryReturn); - else - AddGraphNodeModel(node); - - ReparentScopedModels(); - } - - private GraphNodeModel AddGraphNodeModel(Node node) - { - EnsureInitialScopedPosition(node); - var nodeModel = Diagram.Nodes.Add(new GraphNodeModel(node)); - foreach (var connection in node.InputsAndOutputs) - nodeModel.AddPort(new GraphPortModel(nodeModel, connection, node.Inputs.Contains(connection))); - - nodeModel.Moved += OnNodeMoved; - return nodeModel; - } - - private void EnsureInitialScopedPosition(Node node) - { - if (node.CallableScopeId == null || node.HasDecoration()) - return; - - var owner = Graph.GetOwningLambda(node.CallableScopeId); - if (owner == null) - return; - - var ownerPosition = owner.GetOrAddDecoration(() => new(Vector2.Zero)).Position; - var groupPadding = FindLambdaGroup(owner.BodyScopeId)?.Padding ?? LambdaGroupModel.MinimumPadding; - var existingNodesInScope = Diagram.Nodes - .OfType() - .Count(x => x.Node.CallableScopeId == node.CallableScopeId); - var offset = new Vector2(groupPadding + existingNodesInScope * 220, groupPadding); - node.AddDecoration(new NodeDecorationPosition(ownerPosition + offset)); - } - - private LambdaGroupModel AddLambdaGroupModel(CreateDelegateNode node) - { - var group = Diagram.Groups.Add(new LambdaGroupModel(node)); - foreach (var capture in node.CaptureInputs) - group.AddPort(new GraphPortModel(group, capture, true)); - group.AddPort(new GraphPortModel(group, node.DelegateOutput, false)); - if (group.BoundaryReturn is { } boundaryReturn) - AddBoundaryReturnPorts(group, boundaryReturn); - - group.Moved += OnLambdaGroupMoved; - return group; - } - - private void AddBoundaryReturnToGroup(LambdaReturnNode boundaryReturn) - { - var group = FindLambdaGroup(boundaryReturn.CallableScopeId); - if (group == null) - return; - - AddBoundaryReturnPorts(group, boundaryReturn); - group.Refresh(); - } - - private static void AddBoundaryReturnPorts(LambdaGroupModel group, LambdaReturnNode boundaryReturn) - { - foreach (var connection in boundaryReturn.Inputs) - { - if (group.Ports.OfType().All(x => x.Connection != connection)) - group.AddPort(new GraphPortModel(group, connection, true)); - } - } - - private void ReparentScopedModels() - { - var groupsByScope = Diagram.Groups - .OfType() - .ToDictionary(x => x.DelegateNode.BodyScopeId); - - foreach (var nodeModel in Diagram.Nodes.OfType()) - AttachToScope(nodeModel, nodeModel.Node.CallableScopeId, groupsByScope); - - foreach (var group in Diagram.Groups.OfType()) - AttachToScope(group, group.DelegateNode.CallableScopeId, groupsByScope); - - foreach (var rootGroup in Diagram.Groups.OfType().Where(x => x.Group == null)) - Diagram.SendToBack(rootGroup); - } - - private static void AttachToScope(NodeModel model, string? scopeId, IReadOnlyDictionary groupsByScope) - { - groupsByScope.TryGetValue(scopeId ?? string.Empty, out var desiredGroup); - if (model.Group == desiredGroup) - return; - - model.Group?.RemoveChild(model); - desiredGroup?.AddChild(model); - } - - #endregion - - #region AddNodeLinks - - private void AddNodeLinks(Node node, bool onlyOutputs) - { - var addedConnections = new HashSet<(string Source, string Target)>(); - foreach (var connection in onlyOutputs ? node.Outputs : node.InputsAndOutputs) // just process the outputs so we don't connect "input to output" and "output to input" on the same connections - { - var portModel = FindPort(connection) ?? throw new InvalidOperationException($"No canvas port exists for {node.Name}.{connection.Name}."); - foreach (var other in connection.Connections) - { - var connectionKey = connection.IsOutput - ? (connection.Id, other.Id) - : (other.Id, connection.Id); - if (!addedConnections.Add(connectionKey)) - continue; - - var otherPortModel = FindPort(other) ?? throw new InvalidOperationException($"No canvas port exists for {other.Parent.Name}.{other.Name}."); - - var source = portModel; - var target = otherPortModel; - - // if we're processing the inputs, we need to swap the source and target to reflect the proper direction - if (!onlyOutputs && node.Inputs.Contains(connection)) - { - source = otherPortModel; - target = portModel; - } - - // disable the connection update while adding the link so we can call it ourself and 'force' it to be sure it actually runs - // if we don't do that, we'll have to call it again after adding the link and put the 'force' parameter to true, but then - // it might be run twice, resulting in all callbacks being called twice! - var previousConnectionSuppression = DisableConnectionUpdate; - DisableConnectionUpdate = true; - LinkModel link; - try - { - link = Diagram.Links.Add(new LinkModel(source, target)); - } - finally - { - DisableConnectionUpdate = previousConnectionSuppression; - } - OnConnectionAdded(link, true); - - var connectionWithVertices = GetConnectionContainingVertices(source.Connection, target.Connection); - - if (connectionWithVertices.Vertices.Count != 0) - { - Diagram.Batch(() => - { - DisableVertexAddDuringLoading = true; - - foreach (var vertex in connectionWithVertices.Vertices) - link.AddVertex(new(vertex.X, vertex.Y)); - - DisableVertexAddDuringLoading = false; - }); - } - - - - } - } - } - - - #endregion - - #region Refresh - - public void Refresh(Node node) - { - if (node is CreateDelegateNode) - { - UpdateNodes(); - return; - } - if (node is LambdaReturnNode { IsImplicit: true } boundaryReturn) - { - var group = FindLambdaGroup(boundaryReturn.CallableScopeId); - group?.Refresh(); - return; - } - - var nodeModel = FindNodeModel(node) as GraphNodeModel; - if (nodeModel == null) - return; - - var oldPorts = nodeModel.Ports.ToList(); - var expectedPorts = node.InputsAndOutputs - .Select(connection => (Connection: connection, IsInput: node.Inputs.Contains(connection))) - .ToList(); - var portsAreUnchanged = oldPorts.Count == expectedPorts.Count && expectedPorts.All(expected => - oldPorts.OfType().Any(port => - port.Connection == expected.Connection && - (port.Alignment == PortAlignment.Left) == expected.IsInput)); - - // Type-only updates keep the same Connection instances. Reusing their ports is - // important because existing diagram links are anchored to those port objects. - if (portsAreUnchanged) - { - nodeModel.Refresh(); - return; - } - - Diagram.Batch(() => - { - var previousConnectionSuppression = DisableConnectionUpdate; - DisableConnectionUpdate = true; - try - { - // Links must be removed before their old ports. The core connections remain - // intact and are re-rendered against the replacement ports below. - foreach (var link in oldPorts.SelectMany(port => port.Links).Distinct().ToList()) - Diagram.Links.Remove(link); - - foreach (var port in oldPorts) - nodeModel.RemovePort(port); - - foreach (var expectedPort in expectedPorts) - nodeModel.AddPort(new GraphPortModel(nodeModel, expectedPort.Connection, expectedPort.IsInput)); - } - finally - { - DisableConnectionUpdate = previousConnectionSuppression; - } - - AddNodeLinks(node, onlyOutputs: false); - nodeModel.Refresh(); - }); - } - - #endregion - #region Initialize - private void InitializeCanvasWithGraphNodes() - { - foreach (var delegateNode in Graph.Nodes.Values.OfType()) - AddLambdaGroupModel(delegateNode); - - foreach (var node in Graph.Nodes.Values.Where(x => x is not CreateDelegateNode and not LambdaReturnNode { IsImplicit: true })) - AddGraphNodeModel(node); - - ReparentScopedModels(); - - // add links - foreach (var node in Graph.Nodes.Values) - AddNodeLinks(node, true); - } - public static string GetTypeShapeColor(TypeBase type, TypeFactory typeFactory) { if (type.HasUndefinedGenerics) @@ -1131,22 +639,13 @@ public static string GetTypeShapeColor(TypeBase type, TypeFactory typeFactory) #region Dispose - private IDisposable? GraphChangedSubscription; - private IDisposable? NodeExecutingSubscription; - private IDisposable? NodeExecutedSubscription; public void Dispose() { GC.SuppressFinalize(this); + IndexPage.UnregisterGraphCanvas(this); - if (Graph.GraphCanvas == this) - Graph.GraphCanvas = null; - - GraphChangedSubscription?.Dispose(); - NodeExecutingSubscription?.Dispose(); - NodeExecutedSubscription?.Dispose(); - GraphChangedSubscription = null; - NodeExecutingSubscription = null; - NodeExecutedSubscription = null; + DebugVisualizer.Dispose(); + DiagramSynchronizer.Dispose(); } #endregion diff --git a/src/NodeDev.Blazor/Components/GraphDebugVisualizer.cs b/src/NodeDev.Blazor/Components/GraphDebugVisualizer.cs new file mode 100644 index 00000000..6a97daea --- /dev/null +++ b/src/NodeDev.Blazor/Components/GraphDebugVisualizer.cs @@ -0,0 +1,71 @@ +using Blazor.Diagrams; +using NodeDev.Blazor.DiagramsModels; +using NodeDev.Core; +using NodeDev.Core.Connections; +using NodeDev.Core.Nodes; +using System.Reactive.Linq; + +namespace NodeDev.Blazor.Components; + +/// +/// Projects graph execution notifications onto the diagram as short-lived node/connection animations. +/// Events are filtered to this visualizer's graph and buffered to reduce renderer work during rapid execution. +/// +internal sealed class GraphDebugVisualizer : IDisposable +{ + private readonly Graph Graph; + private readonly BlazorDiagram Diagram; + private readonly Func InvokeAsync; + private IDisposable? NodeExecutingSubscription; + + /// + /// Creates a visualizer for one graph and its diagram. must dispatch + /// diagram updates through the owning Blazor component. + /// + public GraphDebugVisualizer(Graph graph, BlazorDiagram diagram, Func invokeAsync) + { + Graph = graph; + Diagram = diagram; + InvokeAsync = invokeAsync; + } + + /// + /// Starts observing execution events. Events are buffered briefly and dispatched as one renderer update. + /// + public void Start() + { + NodeExecutingSubscription = Graph.Project.GraphNodeExecuting + .Where(change => change.Executor.Graph == Graph) + .Buffer(TimeSpan.FromMilliseconds(250)) + .Where(changes => changes.Count != 0) + .Subscribe(changes => + { + _ = InvokeAsync(() => ShowExecutingConnections(changes)); + }); + } + + /// + /// Finds the projected model for each executed node and starts its animation. Duplicate execution + /// connections within the same buffer are collapsed, and removed/unprojected nodes are ignored. + /// + private void ShowExecutingConnections(IList<(GraphExecutor Executor, Node Node, Connection Exec)> changes) + { + foreach (var change in changes.DistinctBy(item => item.Exec)) + { + var nodeModel = Diagram.Nodes.OfType().FirstOrDefault(model => model.Node == change.Node); + if (nodeModel == null) + continue; + + _ = nodeModel.OnNodeExecuting(change.Exec); + } + } + + /// + /// Stops execution events from updating the diagram after its canvas has been disposed. + /// + public void Dispose() + { + NodeExecutingSubscription?.Dispose(); + NodeExecutingSubscription = null; + } +} diff --git a/src/NodeDev.Blazor/Components/GraphDiagramProjection.cs b/src/NodeDev.Blazor/Components/GraphDiagramProjection.cs new file mode 100644 index 00000000..bba5fa94 --- /dev/null +++ b/src/NodeDev.Blazor/Components/GraphDiagramProjection.cs @@ -0,0 +1,442 @@ +using Blazor.Diagrams; +using Blazor.Diagrams.Core.Anchors; +using Blazor.Diagrams.Core.Models; +using Blazor.Diagrams.Core.Models.Base; +using NodeDev.Blazor.DiagramsModels; +using NodeDev.Blazor.NodeAttributes; +using NodeDev.Core; +using NodeDev.Core.Connections; +using NodeDev.Core.Nodes; +using NodeDev.Core.Nodes.Delegates; +using System.Numerics; + +namespace NodeDev.Blazor.Components; + +/// +/// Materializes the domain as Blazor.Diagrams nodes, groups, ports, and links. +/// The domain graph remains authoritative; this class owns only the projection and the position +/// decorations that are needed to place projected models on the canvas. +/// +internal sealed class GraphDiagramProjection +{ + private readonly Graph Graph; + private readonly BlazorDiagram Diagram; + private readonly GraphDiagramSynchronizer Synchronizer; + private readonly Action ConfigureConnection; + + /// + /// Creates a projection for one graph and one diagram. A projection is not reusable for another + /// graph because all lookups and event handlers are tied to these instances. + /// + public GraphDiagramProjection( + Graph graph, + BlazorDiagram diagram, + GraphDiagramSynchronizer synchronizer, + Action configureConnection) + { + Graph = graph; + Diagram = diagram; + Synchronizer = synchronizer; + ConfigureConnection = configureConnection; + } + + /// + /// Finds the diagram model representing a domain node. Delegate nodes are represented by groups, + /// while ordinary nodes are represented by instances. + /// + public NodeModel? FindNodeModel(Node node) + { + return (NodeModel?)Diagram.Nodes.OfType().FirstOrDefault(model => model.Node == node) + ?? Diagram.Groups.OfType().FirstOrDefault(group => group.DelegateNode == node); + } + + /// + /// Finds the projected port for a domain connection, including ports rendered directly on lambda groups. + /// + public GraphPortModel? FindPort(Connection connection) + { + var nodePort = FindNodeModel(connection.Parent)?.Ports + .OfType() + .FirstOrDefault(port => port.Connection == connection); + if (nodePort != null) + return nodePort; + + return Diagram.Groups + .SelectMany(group => group.Ports) + .OfType() + .FirstOrDefault(port => port.Connection == connection); + } + + /// + /// Finds the lambda group whose body owns the supplied callable scope. + /// + public LambdaGroupModel? FindLambdaGroup(string? bodyScopeId) => Diagram.Groups + .OfType() + .FirstOrDefault(group => group.DelegateNode.BodyScopeId == bodyScopeId); + + /// + /// Recreates the complete diagram projection. Canvas-originated callbacks are suppressed while + /// models are cleared and restored so the rebuild is not mistaken for a user domain mutation. + /// + public void Rebuild() + { + Diagram.Batch(() => + { + using var suppression = Synchronizer.SuppressCanvasMutations(); + Diagram.Links.Clear(); + Diagram.Groups.Clear(); + Diagram.Nodes.Clear(); + Initialize(); + }); + } + + /// + /// Projects a newly added domain node using the specialized model required by its node type, + /// then restores its parent lambda relationship. + /// + public void AddNode(Node node) + { + if (FindNodeModel(node) != null) + return; + + if (node is CreateDelegateNode delegateNode) + AddLambdaGroupModel(delegateNode); + else if (node is LambdaReturnNode { IsImplicit: true } boundaryReturn) + AddBoundaryReturnToGroup(boundaryReturn); + else + AddGraphNodeModel(node); + + ReparentScopedModels(); + } + + /// + /// Removes the model representing a domain node without allowing diagram callbacks to repeat the domain mutation. + /// + public void RemoveNode(Node node) + { + if (node is LambdaReturnNode { IsImplicit: true } boundaryReturn) + { + var group = FindLambdaGroup(boundaryReturn.CallableScopeId); + if (group == null) + return; + + foreach (var port in group.Ports + .OfType() + .Where(port => port.Connection.Parent == boundaryReturn) + .ToList()) + { + group.RemovePort(port); + } + group.Refresh(); + return; + } + + var nodeModel = FindNodeModel(node); + if (nodeModel == null) + return; + + using var suppression = Synchronizer.SuppressCanvasMutations(); + if (nodeModel is LambdaGroupModel groupModel) + Diagram.Groups.Remove(groupModel); + else + Diagram.Nodes.Remove(nodeModel); + } + + /// + /// Adds a projected link if the diagram does not already contain it. + /// + public void AddLink(Connection source, Connection destination) + { + if (source.IsInput) + (destination, source) = (source, destination); + + var sourcePort = FindPort(source) ?? throw new InvalidOperationException($"No canvas port exists for {source.Parent.Name}.{source.Name}."); + var destinationPort = FindPort(destination) ?? throw new InvalidOperationException($"No canvas port exists for {destination.Parent.Name}.{destination.Name}."); + if (Diagram.Links.Any(link => + (link.Source.Model as GraphPortModel)?.Connection == source && + (link.Target.Model as GraphPortModel)?.Connection == destination)) + { + return; + } + + LinkModel link; + using (Synchronizer.SuppressConnectionUpdates()) + link = Diagram.Links.Add(new LinkModel(new SinglePortAnchor(sourcePort), new SinglePortAnchor(destinationPort))); + + ConfigureConnection(link, true); + } + + /// + /// Removes a projected link if it still exists. + /// + public void RemoveLink(Connection source, Connection destination) + { + if (source.IsInput) + (destination, source) = (source, destination); + + using var suppression = Synchronizer.SuppressConnectionUpdates(); + var link = Diagram.Links.FirstOrDefault(candidate => + (candidate.Source.Model as GraphPortModel)?.Connection == source && + (candidate.Target.Model as GraphPortModel)?.Connection == destination); + if (link != null) + Diagram.Links.Remove(link); + } + + /// + /// Recomputes the visual state of a changed domain connection and its links. + /// + public void RefreshConnection(Connection connection) + { + var port = FindPort(connection); + if (port == null) + return; + + var color = GraphCanvas.GetTypeShapeColor(connection.Type, connection.Parent.TypeFactory); + foreach (var link in port.Links.Cast()) + link.Color = color; + + port.Parent.Refresh(); + Diagram.Refresh(); + } + + /// + /// Refreshes an existing projected node. Port topology is rebuilt only when the domain node's + /// inputs or outputs changed; otherwise the cheaper model refresh path is used. + /// + public void Refresh(Node node) + { + if (node is CreateDelegateNode) + { + Rebuild(); + return; + } + if (node is LambdaReturnNode { IsImplicit: true } boundaryReturn) + { + FindLambdaGroup(boundaryReturn.CallableScopeId)?.Refresh(); + return; + } + + var nodeModel = FindNodeModel(node) as GraphNodeModel; + if (nodeModel == null) + return; + + var oldPorts = nodeModel.Ports.ToList(); + var expectedPorts = node.InputsAndOutputs + .Select(connection => (Connection: connection, IsInput: node.Inputs.Contains(connection))) + .ToList(); + var portsAreUnchanged = oldPorts.Count == expectedPorts.Count && expectedPorts.All(expected => + oldPorts.OfType().Any(port => + port.Connection == expected.Connection && + (port.Alignment == PortAlignment.Left) == expected.IsInput)); + + if (portsAreUnchanged) + { + nodeModel.Refresh(); + return; + } + + Diagram.Batch(() => + { + using (Synchronizer.SuppressConnectionUpdates()) + { + foreach (var link in oldPorts.SelectMany(port => port.Links).Distinct().ToList()) + Diagram.Links.Remove(link); + + foreach (var port in oldPorts) + nodeModel.RemovePort(port); + + foreach (var expectedPort in expectedPorts) + nodeModel.AddPort(new GraphPortModel(nodeModel, expectedPort.Connection, expectedPort.IsInput)); + } + + AddNodeLinks(node, onlyOutputs: false); + nodeModel.Refresh(); + }); + } + + /// + /// Builds the initial projection in dependency order: lambda groups first, ordinary nodes second, + /// scope parenting third, and links last after every possible endpoint exists. + /// + public void Initialize() + { + foreach (var delegateNode in Graph.Nodes.Values.OfType()) + AddLambdaGroupModel(delegateNode); + + foreach (var node in Graph.Nodes.Values.Where(node => node is not CreateDelegateNode and not LambdaReturnNode { IsImplicit: true })) + AddGraphNodeModel(node); + + ReparentScopedModels(); + + foreach (var node in Graph.Nodes.Values) + AddNodeLinks(node, onlyOutputs: true); + } + + /// + /// Creates an ordinary diagram node and its ports, and wires position persistence back to the domain node. + /// + private GraphNodeModel AddGraphNodeModel(Node node) + { + EnsureInitialScopedPosition(node); + var nodeModel = Diagram.Nodes.Add(new GraphNodeModel(node)); + foreach (var connection in node.InputsAndOutputs) + nodeModel.AddPort(new GraphPortModel(nodeModel, connection, node.Inputs.Contains(connection))); + + nodeModel.Moved += GraphCanvas.OnNodeMoved; + return nodeModel; + } + + /// + /// Gives a new scoped node a usable position inside its owning lambda without overwriting a saved position. + /// + private void EnsureInitialScopedPosition(Node node) + { + if (node.CallableScopeId == null || node.HasDecoration()) + return; + + var owner = Graph.GetOwningLambda(node.CallableScopeId); + if (owner == null) + return; + + var ownerPosition = owner.GetOrAddDecoration(() => new(Vector2.Zero)).Position; + var groupPadding = FindLambdaGroup(owner.BodyScopeId)?.Padding ?? LambdaGroupModel.MinimumPadding; + var existingNodesInScope = Diagram.Nodes + .OfType() + .Count(model => model.Node.CallableScopeId == node.CallableScopeId); + var offset = new Vector2(groupPadding + existingNodesInScope * 220, groupPadding); + node.AddDecoration(new NodeDecorationPosition(ownerPosition + offset)); + } + + /// + /// Creates the group used to visualize a delegate body, including capture, delegate, and return-boundary ports. + /// + private LambdaGroupModel AddLambdaGroupModel(CreateDelegateNode node) + { + var group = Diagram.Groups.Add(new LambdaGroupModel(node)); + foreach (var capture in node.CaptureInputs) + group.AddPort(new GraphPortModel(group, capture, true)); + group.AddPort(new GraphPortModel(group, node.DelegateOutput, false)); + if (group.BoundaryReturn is { } boundaryReturn) + AddBoundaryReturnPorts(group, boundaryReturn); + + group.Moved += OnLambdaGroupMoved; + return group; + } + + /// + /// Adds a newly created implicit lambda return node to the group that represents its callable scope. + /// + private void AddBoundaryReturnToGroup(LambdaReturnNode boundaryReturn) + { + var group = FindLambdaGroup(boundaryReturn.CallableScopeId); + if (group == null) + return; + + AddBoundaryReturnPorts(group, boundaryReturn); + group.Refresh(); + } + + /// + /// Adds any missing return-boundary ports while preserving ports that were already projected. + /// + private static void AddBoundaryReturnPorts(LambdaGroupModel group, LambdaReturnNode boundaryReturn) + { + foreach (var connection in boundaryReturn.Inputs) + { + if (group.Ports.OfType().All(port => port.Connection != connection)) + group.AddPort(new GraphPortModel(group, connection, true)); + } + } + + /// + /// Reconciles diagram parent/child relationships with callable-scope ownership in the domain graph. + /// This is performed after nodes and groups exist because nested lambdas may reference another group. + /// + private void ReparentScopedModels() + { + var groupsByScope = Diagram.Groups + .OfType() + .ToDictionary(group => group.DelegateNode.BodyScopeId); + + foreach (var nodeModel in Diagram.Nodes.OfType()) + AttachToScope(nodeModel, nodeModel.Node.CallableScopeId, groupsByScope); + + foreach (var group in Diagram.Groups.OfType()) + AttachToScope(group, group.DelegateNode.CallableScopeId, groupsByScope); + + foreach (var rootGroup in Diagram.Groups.OfType().Where(group => group.Group == null)) + Diagram.SendToBack(rootGroup); + } + + /// + /// Moves a projected model between lambda groups when its desired scope differs from its current parent. + /// + private static void AttachToScope(NodeModel model, string? scopeId, IReadOnlyDictionary groupsByScope) + { + groupsByScope.TryGetValue(scopeId ?? string.Empty, out var desiredGroup); + if (model.Group == desiredGroup) + return; + + model.Group?.RemoveChild(model); + desiredGroup?.AddChild(model); + } + + /// + /// Projects links for a node and restores their saved bend vertices. During initial projection only + /// outputs are visited so each domain connection is added once; refreshes may inspect both directions. + /// + private void AddNodeLinks(Node node, bool onlyOutputs) + { + var addedConnections = new HashSet<(string Source, string Target)>(); + foreach (var connection in onlyOutputs ? node.Outputs : node.InputsAndOutputs) + { + var portModel = FindPort(connection) ?? throw new InvalidOperationException($"No canvas port exists for {node.Name}.{connection.Name}."); + foreach (var other in connection.Connections) + { + var connectionKey = connection.IsOutput ? (connection.Id, other.Id) : (other.Id, connection.Id); + if (!addedConnections.Add(connectionKey)) + continue; + + var otherPortModel = FindPort(other) ?? throw new InvalidOperationException($"No canvas port exists for {other.Parent.Name}.{other.Name}."); + var source = portModel; + var target = otherPortModel; + if (!onlyOutputs && node.Inputs.Contains(connection)) + (source, target) = (otherPortModel, portModel); + + LinkModel link; + using (Synchronizer.SuppressConnectionUpdates()) + link = Diagram.Links.Add(new LinkModel(source, target)); + ConfigureConnection(link, true); + + var connectionWithVertices = GraphCanvas.GetConnectionContainingVertices(source.Connection, target.Connection); + if (connectionWithVertices.Vertices.Count == 0) + continue; + + Diagram.Batch(() => + { + using var vertexLoading = Synchronizer.SuppressVertexLoading(); + foreach (var vertex in connectionWithVertices.Vertices) + link.AddVertex(new(vertex.X, vertex.Y)); + }); + } + } + } + + /// + /// Persists a moved lambda group's position and the absolute positions of all descendant nodes. + /// + private static void OnLambdaGroupMoved(MovableModel movableModel) + { + if (movableModel is not LambdaGroupModel group) + return; + + var groupDecoration = group.DelegateNode.GetOrAddDecoration(() => new(Vector2.Zero)); + groupDecoration.Position = new((float)group.Position.X, (float)group.Position.Y); + + foreach (var nodeModel in group.GetDescendantNodeModels()) + { + var decoration = nodeModel.Node.GetOrAddDecoration(() => new(Vector2.Zero)); + decoration.Position = new((float)nodeModel.Position.X, (float)nodeModel.Position.Y); + } + } +} diff --git a/src/NodeDev.Blazor/Components/GraphDiagramSynchronizer.cs b/src/NodeDev.Blazor/Components/GraphDiagramSynchronizer.cs new file mode 100644 index 00000000..07a54526 --- /dev/null +++ b/src/NodeDev.Blazor/Components/GraphDiagramSynchronizer.cs @@ -0,0 +1,142 @@ +using NodeDev.Core; + +namespace NodeDev.Blazor.Components; + +/// +/// Coordinates graph-to-diagram refreshes and prevents programmatic diagram changes from flowing back +/// into the domain as if they were user edits. Suppression uses counters so nested operations restore +/// the previous state correctly when their disposable scopes exit. +/// +internal sealed class GraphDiagramSynchronizer : IDisposable +{ + private readonly Graph Graph; + private readonly Func InvokeAsync; + private IDisposable? GraphChangeSubscription; + private int ConnectionSuppressionCount; + private int NodeRemovalSuppressionCount; + private int VertexLoadingCount; + + /// + /// Creates a synchronizer for a single graph. must marshal callbacks + /// onto the owning Blazor component's renderer context. + /// + public GraphDiagramSynchronizer(Graph graph, Func invokeAsync) + { + Graph = graph; + InvokeAsync = invokeAsync; + } + + public bool IsConnectionUpdateSuppressed => ConnectionSuppressionCount != 0; + public bool IsNodeRemovalSuppressed => NodeRemovalSuppressionCount != 0; + public bool IsLoadingVertices => VertexLoadingCount != 0; + + /// + /// Starts listening for domain changes and applies them to this synchronizer's diagram projection. + /// + public void Start(GraphDiagramProjection projection, Action stateHasChanged) + { + GraphChangeSubscription = Graph.Changes + .Subscribe(change => + { + _ = InvokeAsync(() => + { + ApplyChange(projection, change); + stateHasChanged(); + }); + }); + } + + private static void ApplyChange(GraphDiagramProjection projection, GraphChange change) + { + switch (change) + { + case GraphChange.NodeAdded added: + projection.AddNode(added.Node); + break; + case GraphChange.NodeRemoved removed: + projection.RemoveNode(removed.Node); + break; + case GraphChange.NodeChanged changed: + projection.Refresh(changed.Node); + break; + case GraphChange.LinkAdded added: + projection.AddLink(added.Source, added.Destination); + break; + case GraphChange.LinkRemoved removed: + projection.RemoveLink(removed.Source, removed.Destination); + break; + case GraphChange.ConnectionChanged changed: + projection.RefreshConnection(changed.Connection); + break; + case GraphChange.ProjectionReset: + projection.Rebuild(); + break; + } + } + + /// + /// Prevents diagram link callbacks from adding or removing domain connections during programmatic updates. + /// + public IDisposable SuppressConnectionUpdates() => Suppress( + () => ConnectionSuppressionCount++, + () => ConnectionSuppressionCount--); + + /// + /// Prevents diagram node-removal callbacks from deleting domain nodes during programmatic updates. + /// + public IDisposable SuppressNodeRemovals() => Suppress( + () => NodeRemovalSuppressionCount++, + () => NodeRemovalSuppressionCount--); + + /// + /// Marks link vertices as being restored from saved domain data instead of being created by the user. + /// + public IDisposable SuppressVertexLoading() => Suppress( + () => VertexLoadingCount++, + () => VertexLoadingCount--); + + /// + /// Suppresses both connection and node-removal callbacks for bulk projection operations such as rebuilds. + /// + public IDisposable SuppressCanvasMutations() + { + ConnectionSuppressionCount++; + NodeRemovalSuppressionCount++; + return new CallbackScope(() => + { + NodeRemovalSuppressionCount--; + ConnectionSuppressionCount--; + }); + } + + /// + /// Enters a suppression state and returns an idempotent scope that restores it on disposal. + /// + private static IDisposable Suppress(Action enter, Action exit) + { + enter(); + return new CallbackScope(exit); + } + + /// + /// Stops graph notifications from reaching the diagram after the owning canvas is disposed. + /// + public void Dispose() + { + GraphChangeSubscription?.Dispose(); + GraphChangeSubscription = null; + } + + /// + /// Runs a restoration callback at most once, allowing suppression scopes to be safely disposed repeatedly. + /// + private sealed class CallbackScope(Action callback) : IDisposable + { + private Action? Callback = callback; + + public void Dispose() + { + Interlocked.Exchange(ref Callback, null)?.Invoke(); + } + } +} diff --git a/src/NodeDev.Blazor/Components/GraphPopupState.cs b/src/NodeDev.Blazor/Components/GraphPopupState.cs new file mode 100644 index 00000000..3619fded --- /dev/null +++ b/src/NodeDev.Blazor/Components/GraphPopupState.cs @@ -0,0 +1,83 @@ +using NodeDev.Core.Connections; +using NodeDev.Core.Nodes; +using NodeDev.Core.Types; +using System.Numerics; + +namespace NodeDev.Blazor.Components; + +/// +/// Holds the transient state shared by GraphCanvas popup overlays. The show methods always reset the +/// previous state first, ensuring that node selection, overload selection, and generic type selection +/// remain mutually exclusive. +/// +internal sealed class GraphPopupState +{ + public bool IsShowingNodeSelection { get; private set; } + public bool IsShowingGenericTypeSelection { get; private set; } + public bool IsShowingOverloadSelection { get; private set; } + + public int X { get; private set; } + public int Y { get; private set; } + public Vector2 NodePosition { get; private set; } + public Connection? NodeConnection { get; private set; } + public Node? Node { get; private set; } + public string? CallableScopeId { get; private set; } + public string? GenericTypeName { get; private set; } + public Action? TypeSelectedAction { get; private set; } + + /// + /// Opens node selection at the requested screen and graph positions, optionally carrying the connection + /// and callable scope that the new node should be attached to. + /// + public void ShowNodeSelection(int x, int y, Vector2 nodePosition, Connection? connection, Node? node, string? callableScopeId) + { + Reset(); + X = x; + Y = y; + NodePosition = nodePosition; + NodeConnection = connection; + Node = node; + CallableScopeId = callableScopeId; + IsShowingNodeSelection = true; + } + + /// + /// Opens overload selection for a node whose callable target can be replaced by another overload. + /// + public void ShowOverloadSelection(Node node) + { + Reset(); + Node = node; + IsShowingOverloadSelection = true; + } + + /// + /// Opens generic type selection for a node. A callback may override the canvas's default generic + /// propagation behavior for specialized callers such as lambda group configuration. + /// + public void ShowGenericTypeSelection(int x, int y, Node node, string? genericTypeName, Action? onTypeSelected = null) + { + Reset(); + X = x; + Y = y; + Node = node; + GenericTypeName = genericTypeName; + TypeSelectedAction = onTypeSelected; + IsShowingGenericTypeSelection = true; + } + + /// + /// Closes every popup and releases all references associated with the previous selection operation. + /// + public void Reset() + { + IsShowingNodeSelection = false; + IsShowingGenericTypeSelection = false; + IsShowingOverloadSelection = false; + NodeConnection = null; + Node = null; + CallableScopeId = null; + GenericTypeName = null; + TypeSelectedAction = null; + } +} diff --git a/src/NodeDev.Blazor/Components/OpenProjectDialog.razor b/src/NodeDev.Blazor/Components/OpenProjectDialog.razor index a1d24c8e..954b7d78 100644 --- a/src/NodeDev.Blazor/Components/OpenProjectDialog.razor +++ b/src/NodeDev.Blazor/Components/OpenProjectDialog.razor @@ -1,8 +1,5 @@ @using System.Text.Json @using NodeDev.Blazor.Services -@using NodeDev.Core -@inject AppOptionsContainer AppOptionsContainer -@inject IDialogService DialogService @inject ProjectService ProjectService @inject ISnackbar Snackbar @@ -32,25 +29,20 @@ private string? ProjectName { get; set; } private List RecentProjects { get; set; } = new List(); - private void Close() => MudDialog.Close(DialogResult.Ok(true)); + private void Close() => MudDialog.Cancel(); protected override void OnInitialized() { base.OnInitialized(); - RecentProjects = ListRecentProjects(); - } - - private List ListRecentProjects() - { - if (AppOptionsContainer.AppOptions.ProjectsDirectory is null) + try { - return []; + RecentProjects = ProjectService.GetSavedProjectNames().ToList(); } - if (!Directory.Exists(AppOptionsContainer.AppOptions.ProjectsDirectory)) + catch (Exception ex) { - return []; + Snackbar.Add(ex.Message, Severity.Error); + RecentProjects = []; } - return Directory.EnumerateFiles(AppOptionsContainer.AppOptions.ProjectsDirectory, "*.ndproj").Select(Path.GetFileNameWithoutExtension).ToList()!; } private async Task LoadProject() @@ -61,8 +53,8 @@ } try { - var projectPath = Path.Combine(AppOptionsContainer.AppOptions.ProjectsDirectory!, $"{ProjectName}.ndproj"); - await ProjectService.LoadProjectFromFileAsync(projectPath); + await ProjectService.LoadProjectAsync(ProjectName); + MudDialog.Close(DialogResult.Ok(ProjectName)); } catch (Exception ex) { @@ -70,6 +62,5 @@ Snackbar.Configuration.PositionClass = Defaults.Classes.Position.TopCenter; Snackbar.Add(ex.Message, Severity.Error); } - MudDialog.Close(DialogResult.Ok(true)); } } diff --git a/src/NodeDev.Blazor/Components/ProjectExplorer.razor b/src/NodeDev.Blazor/Components/ProjectExplorer.razor index 51a38746..0dbe1c0b 100644 --- a/src/NodeDev.Blazor/Components/ProjectExplorer.razor +++ b/src/NodeDev.Blazor/Components/ProjectExplorer.razor @@ -54,6 +54,9 @@ [Parameter] public EventCallback SelectedClassChanged { get; set; } + [Parameter] + public EventCallback ClassDeleted { get; set; } + private TreeItem? Selected = null; private TreeItem? HoveredClass = null; @@ -150,10 +153,17 @@ if (result != null && !result.Canceled && result.Data is string newName) { var oldName = item.Class.Name; - item.Class.Name = newName; // Update the class name - item.Name = newName; // Update the tree item name for display - Snackbar.Add($"Class renamed from '{oldName}' to '{newName}'", Severity.Success); - StateHasChanged(); + try + { + 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); + } } } @@ -170,30 +180,43 @@ if (confirm == true) { - // Note: There's no public API to remove classes, so we just remove from UI - // Remove from tree - TreeItemData? Remove(List> items) + try { - foreach (var i in items.ToList()) + Project.RemoveClass(item.Class); + + TreeItemData? Remove(List> items) { - if (i.Value == item) + foreach (var i in items.ToList()) { - items.Remove(i); - return i; - } - if (i.Children != null) - { - var found = Remove(i.Children); - if (found != null) - return found; + if (i.Value == item) + { + items.Remove(i); + return i; + } + if (i.Children != null) + { + var found = Remove(i.Children); + if (found != null) + return found; + } } + return null; } - return null; - } - Remove(Items); - Snackbar.Add($"Class '{item.Class.Name}' removed from view (Note: No API to actually delete)", Severity.Warning); - StateHasChanged(); + Remove(Items); + if (Selected == item) + { + Selected = null; + 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 c7d292be..2a1a86f1 100644 --- a/src/NodeDev.Blazor/Components/ProjectToolbar.razor +++ b/src/NodeDev.Blazor/Components/ProjectToolbar.razor @@ -88,24 +88,23 @@ else return DialogService.ShowAsync("Open Project", DialogOptions); } - private Task Save() + private async Task Save() { if (string.IsNullOrWhiteSpace(Project.Settings.ProjectName)) { - return SaveAs(); + await SaveAs(); + return; } try { - ProjectService.SaveProjectToFile(); + await ProjectService.SaveProjectToFileAsync(); Snackbar.Add("Project saved", Severity.Success); } catch (Exception ex) { Snackbar.Add(ex.Message, Severity.Error); } - - return Task.CompletedTask; } private Task SaveAs() diff --git a/src/NodeDev.Blazor/Components/SaveAsProjectDialog.razor b/src/NodeDev.Blazor/Components/SaveAsProjectDialog.razor index 3558e3af..1f7cb1b0 100644 --- a/src/NodeDev.Blazor/Components/SaveAsProjectDialog.razor +++ b/src/NodeDev.Blazor/Components/SaveAsProjectDialog.razor @@ -1,8 +1,5 @@ @using System.Text.Json @using NodeDev.Blazor.Services -@using NodeDev.Core -@inject AppOptionsContainer AppOptionsContainer -@inject IDialogService DialogService @inject ProjectService ProjectService @inject ISnackbar Snackbar @@ -22,7 +19,7 @@ public string? ProjectName { get; set; } - private void Close() => MudDialog.Close(DialogResult.Ok(true)); + private void Close() => MudDialog.Cancel(); protected override void OnInitialized() { @@ -30,12 +27,11 @@ ProjectName = ProjectService.Project.Settings.ProjectName; } - private void SaveProject() + private async Task SaveProject() { try { - ProjectService.Project.Settings.ProjectName = ProjectName!; - ProjectService.SaveProjectToFile(); + await ProjectService.SaveProjectToFileAsync(ProjectName!); Snackbar.Add("Project saved", Severity.Success); MudDialog.Close(DialogResult.Ok(ProjectName)); } diff --git a/src/NodeDev.Blazor/Components/SourceViewer.razor b/src/NodeDev.Blazor/Components/SourceViewer.razor index 947294dc..14477866 100644 --- a/src/NodeDev.Blazor/Components/SourceViewer.razor +++ b/src/NodeDev.Blazor/Components/SourceViewer.razor @@ -1,6 +1,9 @@ @using NodeDev.Core; @using NodeDev.Core.Class; @using System.Reactive.Linq; +@using NodeDev.Blazor.Services +@implements IDisposable +@inject SourceGenerationService SourceGenerationService @using BlazorMonaco @using BlazorMonaco.Editor @@ -40,6 +43,8 @@ else private IDisposable? GraphChangedSubscription { get; set; } + private CancellationTokenSource? GenerationCancellation { get; set; } + private string? CodeCs; private StandaloneEditorConstructionOptions EditorConstructionOptions(StandaloneCodeEditor editor, string code, string language) @@ -62,49 +67,56 @@ else base.OnParametersSet(); if (!IsVisible) + { + GraphChangedSubscription?.Dispose(); + GraphChangedSubscription = null; + GenerationCancellation?.Cancel(); return; + } - // Either the target method changed or it previously failed to compile - if (Method != PreviousMethod) + if (Method != PreviousMethod || (Method != null && GraphChangedSubscription == null)) { - if (Method?.Graph.Project != PreviousMethod?.Graph.Project) - { - GraphChangedSubscription?.Dispose(); - - if (Method != null) - GraphChangedSubscription = Method.Graph.Project.GraphChanged.Where(x => x.Graph == Method?.Graph).AcceptThenSample(TimeSpan.FromSeconds(1)).Delay(TimeSpan.FromSeconds(1)).Subscribe(x => InvokeAsync(() => OnGraphChanged(x.Graph))); - } + GraphChangedSubscription?.Dispose(); + GraphChangedSubscription = null; + GenerationCancellation?.Cancel(); + GenerationCancellation?.Dispose(); + GenerationCancellation = null; PreviousMethod = Method; - CodeCs = null; // we don't want to leave the code from the previous method visible + CodeCs = null; if (Method != null) - OnGraphChanged(Method.Graph); + { + var method = Method; + GraphChangedSubscription = method.Graph.Project.GraphChanged + .Where(change => change.Graph == method.Graph) + .AcceptThenSample(TimeSpan.FromSeconds(1)) + .Subscribe(change => + { + _ = InvokeAsync(() => QueueSourceRefresh(method)); + }); + QueueSourceRefresh(method); + } } } - private void OnGraphChanged(Graph graph) + private void QueueSourceRefresh(NodeClassMethod method) { - ArgumentNullException.ThrowIfNull(Method); + GenerationCancellation?.Cancel(); + GenerationCancellation?.Dispose(); + GenerationCancellation = new CancellationTokenSource(); + _ = GenerateSourceAsync(method, GenerationCancellation.Token); + } + private async Task GenerateSourceAsync(NodeClassMethod method, CancellationToken cancellationToken) + { try { - var temp = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - var assemblyPath = Method.Graph.Project.Build(Core.BuildOptions.Debug with { OutputPath = temp }); - - // Get the generated C# code from the project - CodeCs = Method.Graph.Project.GetGeneratedCSharpCode(Method); - - if (CodeCs != null) - { - CodeCs = $"// Generated code from NodeDev visual programming{System.Environment.NewLine}// This is the actual C# code that gets compiled and executed{System.Environment.NewLine}{System.Environment.NewLine}{CodeCs}"; - } - else - { - CodeCs = "// Unable to retrieve generated code for this method"; - } - - StateHasChanged(); + var generatedCode = await SourceGenerationService.GenerateCSharpAsync(method, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + CodeCs = generatedCode == null + ? "// Unable to retrieve generated code for this method" + : $"// Generated code from NodeDev visual programming{System.Environment.NewLine}// This is the actual C# code that gets compiled and executed{System.Environment.NewLine}{System.Environment.NewLine}{generatedCode}"; } catch (BuildError buildError) { @@ -115,9 +127,22 @@ else CodeCs += $"{System.Environment.NewLine}*/"; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } catch (Exception ex) { CodeCs = $"/* Error during code generation: {System.Environment.NewLine}{ex}{System.Environment.NewLine}*/"; } + + await InvokeAsync(StateHasChanged); + } + + public void Dispose() + { + GraphChangedSubscription?.Dispose(); + GenerationCancellation?.Cancel(); + GenerationCancellation?.Dispose(); } } diff --git a/src/NodeDev.Blazor/DiagramsModels/GraphNodeModel.cs b/src/NodeDev.Blazor/DiagramsModels/GraphNodeModel.cs index c62e750a..5bf4c375 100644 --- a/src/NodeDev.Blazor/DiagramsModels/GraphNodeModel.cs +++ b/src/NodeDev.Blazor/DiagramsModels/GraphNodeModel.cs @@ -24,11 +24,6 @@ public class GraphNodeModel : NodeModel public GraphPortModel GetPort(Connection connection) => Ports.OfType().First(x => x.Connection == connection); - internal void OnNodeExecuted(Connection exec) - { - - } - internal void OnConnectionPathHighlighted(Connection connection) { var port = GetPort(connection); diff --git a/src/NodeDev.Blazor/Index.razor b/src/NodeDev.Blazor/Index.razor index fb9517b7..b0a08cdb 100644 --- a/src/NodeDev.Blazor/Index.razor +++ b/src/NodeDev.Blazor/Index.razor @@ -21,12 +21,12 @@ - + @if (SelectedClass != null) { - + } @@ -86,6 +86,8 @@ private List OpenedMethods { get; } = new(); + private Dictionary GraphCanvases { get; } = new(); + private MudTabs Tabs { get; set; } = null!; private int ActivePanelIndex = 0; @@ -165,18 +167,57 @@ StateHasChanged(); } + private void CloseMethod(Core.Class.NodeClassMethod method) + { + var removedIndex = OpenedMethods.IndexOf(method); + if (removedIndex < 0) + return; + + OpenedMethods.RemoveAt(removedIndex); + if (OpenedMethods.Count == 0) + ActivePanelIndex = 0; + else if (removedIndex < ActivePanelIndex) + ActivePanelIndex--; + else if (removedIndex == ActivePanelIndex) + ActivePanelIndex = Math.Min(ActivePanelIndex, OpenedMethods.Count - 1); + } + + private void OnClassDeleted(Core.Class.NodeClass nodeClass) + { + var activeMethod = ActivePanelIndex < OpenedMethods.Count ? OpenedMethods[ActivePanelIndex] : null; + var previousActiveIndex = ActivePanelIndex; + OpenedMethods.RemoveAll(method => method.Class == nodeClass); + if (SelectedClass == nodeClass) + SelectedClass = null; + ActivePanelIndex = activeMethod != null && OpenedMethods.Contains(activeMethod) + ? OpenedMethods.IndexOf(activeMethod) + : OpenedMethods.Count == 0 ? 0 : Math.Min(previousActiveIndex, OpenedMethods.Count - 1); + } + public void ToggleBreakpointOnSelectedNode() { if (OpenedMethods.Count > 0 && ActivePanelIndex < OpenedMethods.Count) { var method = OpenedMethods[ActivePanelIndex]; - var graphCanvas = method.Graph.GraphCanvas as GraphCanvas; - graphCanvas?.ToggleBreakpointOnSelectedNode(); + if (GraphCanvases.TryGetValue(method.Graph, out var graphCanvas)) + graphCanvas.ToggleBreakpointOnSelectedNode(); } } + internal void RegisterGraphCanvas(GraphCanvas graphCanvas) + { + GraphCanvases[graphCanvas.Graph] = graphCanvas; + } + + internal void UnregisterGraphCanvas(GraphCanvas graphCanvas) + { + if (GraphCanvases.TryGetValue(graphCanvas.Graph, out var registered) && registered == graphCanvas) + GraphCanvases.Remove(graphCanvas.Graph); + } + public void Dispose() { ProjectService.ProjectChanged -= OnProjectChanged; + GraphCanvases.Clear(); } -} \ No newline at end of file +} diff --git a/src/NodeDev.Blazor/Properties/AssemblyInfo.cs b/src/NodeDev.Blazor/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..086ea698 --- /dev/null +++ b/src/NodeDev.Blazor/Properties/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("NodeDev.Tests")] diff --git a/src/NodeDev.Blazor/Services/ProjectService.cs b/src/NodeDev.Blazor/Services/ProjectService.cs index 4bfa5314..c15754ca 100644 --- a/src/NodeDev.Blazor/Services/ProjectService.cs +++ b/src/NodeDev.Blazor/Services/ProjectService.cs @@ -46,13 +46,68 @@ public async Task LoadProjectFromFileAsync(string file) ChangeProject(project); } - public void SaveProjectToFile() + public Task LoadProjectAsync(string projectName) { - ArgumentNullException.ThrowIfNullOrWhiteSpace(Project.Settings.ProjectName); - var projectPath = Path.Combine(AppOptionsContainer.AppOptions.ProjectsDirectory!, $"{Project.Settings.ProjectName}.ndproj"); - string content = Project.Serialize(); - File.WriteAllText(projectPath, content); + return LoadProjectFromFileAsync(GetProjectFilePath(projectName)); + } + + public IReadOnlyList GetSavedProjectNames() + { + var projectsDirectory = GetProjectsDirectory(); + if (!Directory.Exists(projectsDirectory)) + return []; + + return Directory.EnumerateFiles(projectsDirectory, "*.ndproj") + .Select(Path.GetFileNameWithoutExtension) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToList()!; + } + + public async Task SaveProjectToFileAsync(string? projectName = null, CancellationToken cancellationToken = default) + { + projectName ??= Project.Settings.ProjectName; + var projectPath = GetProjectFilePath(projectName); + var projectsDirectory = Path.GetDirectoryName(projectPath)!; + Directory.CreateDirectory(projectsDirectory); + + var temporaryPath = Path.Combine(projectsDirectory, $".{Guid.NewGuid():N}.tmp"); + try + { + var serializedProject = Project.Serialize(Project.Settings with { ProjectName = projectName }); + await File.WriteAllTextAsync(temporaryPath, serializedProject, cancellationToken); + File.Move(temporaryPath, projectPath, overwrite: true); + Project.Settings.ProjectName = projectName; + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } + + private string GetProjectFilePath(string projectName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectName); + if (projectName is "." or ".." || projectName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || projectName.Contains('/') || projectName.Contains('\\')) + throw new ArgumentException("Project name contains invalid filename characters.", nameof(projectName)); + + var projectsDirectory = GetProjectsDirectory(); + var projectPath = Path.GetFullPath(Path.Combine(projectsDirectory, $"{projectName}.ndproj")); + var relativePath = Path.GetRelativePath(projectsDirectory, projectPath); + if (Path.IsPathRooted(relativePath) || relativePath == ".." || relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + throw new InvalidOperationException("The project path must remain inside the configured projects directory."); + + return projectPath; + } + + private string GetProjectsDirectory() + { + var configuredDirectory = AppOptionsContainer.AppOptions.ProjectsDirectory; + if (string.IsNullOrWhiteSpace(configuredDirectory)) + throw new InvalidOperationException("Configure a projects directory in Options before opening or saving projects."); + return Path.GetFullPath(configuredDirectory); } } } diff --git a/src/NodeDev.Blazor/Services/ServicesExtension.cs b/src/NodeDev.Blazor/Services/ServicesExtension.cs index e08d7250..ff95d363 100644 --- a/src/NodeDev.Blazor/Services/ServicesExtension.cs +++ b/src/NodeDev.Blazor/Services/ServicesExtension.cs @@ -11,6 +11,7 @@ public static IServiceCollection AddNodeDev(this IServiceCollection services) services .AddMudServices() .AddScoped() + .AddScoped() .AddSingleton() .AddSingleton(new AppOptionsContainer("AppOptions.json")); diff --git a/src/NodeDev.Blazor/Services/SourceGenerationService.cs b/src/NodeDev.Blazor/Services/SourceGenerationService.cs new file mode 100644 index 00000000..5406caf0 --- /dev/null +++ b/src/NodeDev.Blazor/Services/SourceGenerationService.cs @@ -0,0 +1,63 @@ +using NodeDev.Core; +using NodeDev.Core.Class; + +namespace NodeDev.Blazor.Services; + +/// +/// Generates the C# preview shown by SourceViewer without blocking the Blazor renderer. Generation is +/// serialized because building mutates project compiler state, and every build receives an isolated output directory. +/// +internal sealed class SourceGenerationService +{ + private readonly SemaphoreSlim GenerationGate = new(1, 1); + + /// + /// Builds the method's project on a worker thread and returns the generated C# for the requested method. + /// Cancellation prevents queued work and suppresses stale results, although an active compiler invocation + /// must finish before its temporary output can be cleaned up. + /// + public async Task GenerateCSharpAsync(NodeClassMethod method, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(method); + await GenerationGate.WaitAsync(cancellationToken); + try + { + return await Task.Run(() => GenerateCSharp(method, cancellationToken), cancellationToken); + } + finally + { + GenerationGate.Release(); + } + } + + /// + /// Performs generation in a unique temporary directory and removes all generated artifacts afterward. + /// + private static string? GenerateCSharp(NodeClassMethod method, CancellationToken cancellationToken) + { + var outputPath = Path.Combine(Path.GetTempPath(), $"nodedev-source-{Guid.NewGuid():N}"); + try + { + cancellationToken.ThrowIfCancellationRequested(); + method.Graph.Project.Build(BuildOptions.Debug with { OutputPath = outputPath }); + cancellationToken.ThrowIfCancellationRequested(); + return method.Graph.Project.GetGeneratedCSharpCode(method); + } + finally + { + try + { + if (Directory.Exists(outputPath)) + Directory.Delete(outputPath, recursive: true); + } + catch (IOException) + { + // Best-effort cleanup. A process may briefly retain a generated output file. + } + catch (UnauthorizedAccessException) + { + // Best-effort cleanup. The generated source result should still be usable. + } + } + } +} diff --git a/src/NodeDev.Core/Class/NodeClass.cs b/src/NodeDev.Core/Class/NodeClass.cs index 2232b6b7..f99df45c 100644 --- a/src/NodeDev.Core/Class/NodeClass.cs +++ b/src/NodeDev.Core/Class/NodeClass.cs @@ -11,7 +11,7 @@ public class NodeClass(string name, string @namespace, Project project) public TypeBase ClassTypeBase => Project.GetNodeClassType(this); - public string Name { get; set; } = name; + public string Name { get; private set; } = name; public string Namespace { get; set; } = @namespace; @@ -24,6 +24,12 @@ public class NodeClass(string name, string @namespace, Project project) public void AddMethod(NodeClassMethod nodeClassMethod, bool createEntryAndReturn) { + ArgumentNullException.ThrowIfNull(nodeClassMethod); + if (nodeClassMethod.Class != this) + throw new ArgumentException("The method belongs to a different class.", nameof(nodeClassMethod)); + if (_Methods.Any(method => method.Name == nodeClassMethod.Name && method.Parameters.Select(parameter => parameter.ParameterType).SequenceEqual(nodeClassMethod.Parameters.Select(parameter => parameter.ParameterType)))) + throw new InvalidOperationException($"A method named '{nodeClassMethod.Name}' with the same signature already exists."); + _Methods.Add(nodeClassMethod); if (!createEntryAndReturn) @@ -40,6 +46,30 @@ public void AddMethod(NodeClassMethod nodeClassMethod, bool createEntryAndReturn nodeClassMethod.Manager.AddNewConnectionBetween(entry.Outputs[0], returnNode.Inputs[0]); } + public void RemoveMethod(NodeClassMethod method) + { + ArgumentNullException.ThrowIfNull(method); + if (!_Methods.Contains(method)) + throw new InvalidOperationException("The method does not belong to this class."); + + var referencingCall = Project.GetNodes().FirstOrDefault(call => call.TargetMethod == method); + if (referencingCall != null) + throw new InvalidOperationException($"Method '{method.Name}' is still used by node '{referencingCall.Name}'. Remove those calls first."); + + _Methods.Remove(method); + } + + public void Rename(string newName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(newName); + if (Project.Classes.Any(nodeClass => nodeClass != this && nodeClass.Namespace == Namespace && nodeClass.Name == newName)) + throw new InvalidOperationException($"A class named '{Namespace}.{newName}' already exists."); + + Name = newName; + foreach (var method in Project.Classes.SelectMany(nodeClass => nodeClass.Methods)) + method.Graph.RaiseGraphChanged(true); + } + #endregion #region Serialisation diff --git a/src/NodeDev.Core/Connections/Connection.cs b/src/NodeDev.Core/Connections/Connection.cs index 016ab16b..a90aed3c 100644 --- a/src/NodeDev.Core/Connections/Connection.cs +++ b/src/NodeDev.Core/Connections/Connection.cs @@ -161,7 +161,7 @@ public void UpdateTypeAndTextboxVisibility(TypeBase newType, bool overrideInitia else TextboxValue = null; - Parent.Graph.GraphCanvas?.Refresh(Parent); + Parent.Graph.Notify(new GraphChange.NodeChanged(Parent)); } public void UpdateTextboxText(string? text) diff --git a/src/NodeDev.Core/Graph.cs b/src/NodeDev.Core/Graph.cs index 523adb4d..31454736 100644 --- a/src/NodeDev.Core/Graph.cs +++ b/src/NodeDev.Core/Graph.cs @@ -5,6 +5,8 @@ using NodeDev.Core.Nodes.Delegates; using NodeDev.Core.Nodes.Flow; using System.Linq.Expressions; +using System.Reactive.Linq; +using System.Reactive.Subjects; namespace NodeDev.Core; @@ -18,26 +20,17 @@ public class Graph(NodeClassMethod selfMethod) public Project Project => SelfMethod.Class.Project; - private IGraphCanvas? _graphCanvas; + private GraphManagerService? _graphManagerService; /// - /// The canvas that this graph is displayed on. Null if the graph is not displayed on a canvas. + /// Gets the domain service used to mutate this graph. /// - public IGraphCanvas? GraphCanvas - { - get => _graphCanvas; - set - { - _graphCanvas = value; - _graphManagerService = null; - } - } + public GraphManagerService Manager => _graphManagerService ??= new(this); - private GraphManagerService? _graphManagerService; + private readonly Subject GraphChangeSubject = new(); /// - /// Get the GraphManagerService for this graph and its associated graph canvas. - /// This property should be used all the time as it will keep itself up to date with the graph canvas. + /// Domain changes emitted by this graph. Each UI projection subscribes to the graph it displays. /// - public GraphManagerService Manager => _graphManagerService ??= new(_graphCanvas ?? new GraphCanvasNoUI(this)); + public IObservable Changes => GraphChangeSubject.AsObservable(); static Graph() { @@ -76,7 +69,19 @@ public bool IsScopeAncestorOf(string? ancestorScopeId, string? descendantScopeId return false; } - public void RaiseGraphChanged(bool requireUIRefresh) => Project.GraphChangedSubject.OnNext((this, requireUIRefresh)); + internal void Notify(GraphChange change) + { + GraphChangeSubject.OnNext(change); + Project.GraphChangedSubject.OnNext((this, false)); + } + + public void RaiseGraphChanged(bool requireUIRefresh) + { + if (requireUIRefresh) + GraphChangeSubject.OnNext(new GraphChange.ProjectionReset()); + + Project.GraphChangedSubject.OnNext((this, requireUIRefresh)); + } #region GetChunks @@ -479,10 +484,7 @@ internal static void Deserialize(SerializedGraph serializedGraphObj, Graph graph foreach (var serializedNode in serializedGraphObj.Nodes) { var node = Node.Deserialize(graph, serializedNode); - - var noUi = new GraphCanvasNoUI(graph); - var manager = new GraphManagerService(noUi); - manager.AddNode(node); + graph.Manager.AddNode(node); } foreach (var node in graph.Nodes.Values) diff --git a/src/NodeDev.Core/GraphChange.cs b/src/NodeDev.Core/GraphChange.cs new file mode 100644 index 00000000..b60f3037 --- /dev/null +++ b/src/NodeDev.Core/GraphChange.cs @@ -0,0 +1,24 @@ +using NodeDev.Core.Connections; +using NodeDev.Core.Nodes; + +namespace NodeDev.Core; + +/// +/// Describes a domain graph change that projections can apply incrementally. +/// +public abstract record GraphChange +{ + public sealed record NodeAdded(Node Node) : GraphChange; + + public sealed record NodeRemoved(Node Node) : GraphChange; + + public sealed record NodeChanged(Node Node) : GraphChange; + + public sealed record LinkAdded(Connection Source, Connection Destination) : GraphChange; + + public sealed record LinkRemoved(Connection Source, Connection Destination) : GraphChange; + + public sealed record ConnectionChanged(Connection Connection) : GraphChange; + + public sealed record ProjectionReset : GraphChange; +} diff --git a/src/NodeDev.Core/ManagerServices/GraphCanvasNoUI.cs b/src/NodeDev.Core/ManagerServices/GraphCanvasNoUI.cs deleted file mode 100644 index e63c637a..00000000 --- a/src/NodeDev.Core/ManagerServices/GraphCanvasNoUI.cs +++ /dev/null @@ -1,41 +0,0 @@ -using NodeDev.Core.Connections; -using NodeDev.Core.Nodes; - -namespace NodeDev.Core.ManagerServices; - -/// -/// Represents a GraphCanvas that doesn't have a UI associated. -/// This is used when we need to update stuff in a graph but we don't have a UI to update. -/// -internal class GraphCanvasNoUI(Graph graph) : IGraphCanvas -{ - public Graph Graph { get; } = graph; - - public void AddLinkToGraphCanvas(Connection source, Connection destination) - { - } - - public void AddNode(Node node) - { - } - - public void Refresh(Node node) - { - } - - public void RefreshAll() - { - } - - public void RemoveLinkFromGraphCanvas(Connection source, Connection destination) - { - } - - public void RemoveNode(Node node) - { - } - - public void UpdatePortColor(Connection connection) - { - } -} diff --git a/src/NodeDev.Core/ManagerServices/GraphManagerService.cs b/src/NodeDev.Core/ManagerServices/GraphManagerService.cs index d2993c8c..10e76617 100644 --- a/src/NodeDev.Core/ManagerServices/GraphManagerService.cs +++ b/src/NodeDev.Core/ManagerServices/GraphManagerService.cs @@ -11,20 +11,18 @@ namespace NodeDev.Core.ManagerServices; /// public class GraphManagerService { - private readonly IGraphCanvas GraphCanvas; + private readonly Graph Graph; - private Graph Graph => GraphCanvas.Graph; - - internal GraphManagerService(IGraphCanvas graphCanvas) + internal GraphManagerService(Graph graph) { - GraphCanvas = graphCanvas; + Graph = graph; } #region Nodes /// /// Add the node from the search result. - /// The is used to add required UI information before adding it to the UI + /// The callback can attach persisted decorations before the node-added notification is published. /// /// /// @@ -80,7 +78,7 @@ public void AddNode(Node node) ValidateNodePlacement(node); ((IDictionary)Graph.Nodes)[node.Id] = node; - GraphCanvas.AddNode(node); + Graph.Notify(new GraphChange.NodeAdded(node)); } @@ -105,10 +103,8 @@ public void RemoveNode(Node node) foreach (var removedNode in removalOrder) { Graph._Nodes.Remove(removedNode.Id); - GraphCanvas.RemoveNode(removedNode); + Graph.Notify(new GraphChange.NodeRemoved(removedNode)); } - - Graph.RaiseGraphChanged(false); } private List GetRecursiveRemovalOrder(Node root) @@ -200,7 +196,7 @@ public void AddNewConnectionBetweenOrCapture(Connection source, Connection desti foreach (var owner in owners) { owner.AddCapture(captureName, scopedSource.Type); - GraphCanvas.Refresh(owner); + Graph.Notify(new GraphChange.NodeChanged(owner)); var captureIndex = owner.Captures.Count - 1; var entry = Graph.GetNodesInScope(owner.BodyScopeId).OfType().Single(); @@ -258,7 +254,7 @@ public void MergeRemovedConnectionsWithNewConnections(IEnumerable ne } foreach (var parent in newConnectionsList.Concat(removedConnectionsList).Select(connection => connection.Parent).Distinct()) - GraphCanvas.Refresh(parent); + Graph.Notify(new GraphChange.NodeChanged(parent)); foreach (var (oldLink, newConnection) in reconnections) AddNewConnectionBetween(oldLink, newConnection); @@ -292,10 +288,9 @@ public void AddNewConnectionBetween(Connection source, Connection destination) PropagateNewGeneric(destination.Parent, newTypesRight, usedInitialTypes, source, false); } - GraphCanvas.AddLinkToGraphCanvas(source, destination); - - GraphCanvas.UpdatePortColor(source); - GraphCanvas.UpdatePortColor(destination); + Graph.Notify(new GraphChange.LinkAdded(source, destination)); + Graph.Notify(new GraphChange.ConnectionChanged(source)); + Graph.Notify(new GraphChange.ConnectionChanged(destination)); // we have to disconnect the previously connected exec, since exec outputs can only have one connection if (source.Type.IsExec && source.Connections.Count > 1) @@ -303,7 +298,6 @@ public void AddNewConnectionBetween(Connection source, Connection destination) else if (!destination.Type.IsExec && destination.Connections.Count > 1) // non-exec inputs can only have one connection DisconnectConnectionBetween(destination.Connections.First(x => x != source), destination); - Graph.RaiseGraphChanged(false); // any change in the graph should trigger a UI refresh already, lets just trigger at least one non-ui refresh to be sure } public void DisconnectConnectionBetween(Connection source, Connection destination) @@ -316,12 +310,9 @@ public void DisconnectConnectionBetween(Connection source, Connection destinatio source._Connections.Remove(destination); destination._Connections.Remove(source); - GraphCanvas.RemoveLinkFromGraphCanvas(source, destination); - - GraphCanvas.UpdatePortColor(source); - GraphCanvas.UpdatePortColor(destination); - - Graph.RaiseGraphChanged(false); // no ui refresh needed as we already took care of it through the GraphCanvas directly + Graph.Notify(new GraphChange.LinkRemoved(source, destination)); + Graph.Notify(new GraphChange.ConnectionChanged(source)); + Graph.Notify(new GraphChange.ConnectionChanged(destination)); } /// @@ -343,7 +334,7 @@ public void PropagateNewGeneric(Node node, IReadOnlyDictionary // update port.Type property as well as the textbox visibility if necessary port.UpdateTypeAndTextboxVisibility(previousType.ReplaceUndefinedGeneric(changedGenerics), overrideInitialType: overrideInitialTypes); hadAnyChanges |= node.GenericConnectionTypeDefined(port).Count != 0; - GraphCanvas.UpdatePortColor(port); + Graph.Notify(new GraphChange.ConnectionChanged(port)); var isPortInput = port.IsInput; // cache for performance, IsInput is slow // check if other connections had their own generics and if we just solved them @@ -367,7 +358,7 @@ public void PropagateNewGeneric(Node node, IReadOnlyDictionary } if (hadAnyChanges) - Graph.RaiseGraphChanged(false); + Graph.Notify(new GraphChange.NodeChanged(node)); } public void SelectNodeOverload(Node popupNode, Node.AlternateOverload overload) diff --git a/src/NodeDev.Core/ManagerServices/IGraphCanvas.cs b/src/NodeDev.Core/ManagerServices/IGraphCanvas.cs deleted file mode 100644 index 39ecb52f..00000000 --- a/src/NodeDev.Core/ManagerServices/IGraphCanvas.cs +++ /dev/null @@ -1,23 +0,0 @@ -using NodeDev.Core.Connections; -using NodeDev.Core.Nodes; - -namespace NodeDev.Core.ManagerServices; - -public interface IGraphCanvas -{ - Graph Graph { get; } - - void UpdatePortColor(Connection connection); - - void RemoveLinkFromGraphCanvas(Connection source, Connection destination); - - void AddLinkToGraphCanvas(Connection source, Connection destination); - - void RemoveNode(Node node); - - void AddNode(Node node); - - void Refresh(Node node); - - void RefreshAll() => Graph.RaiseGraphChanged(true); -} diff --git a/src/NodeDev.Core/Project.cs b/src/NodeDev.Core/Project.cs index 9965b87e..97ddf2db 100644 --- a/src/NodeDev.Core/Project.cs +++ b/src/NodeDev.Core/Project.cs @@ -147,9 +147,54 @@ public static Project CreateNewDefaultProject(out NodeClassMethod main) public void AddClass(NodeClass nodeClass) { + ArgumentNullException.ThrowIfNull(nodeClass); + if (nodeClass.Project != this) + throw new ArgumentException("The class belongs to a different project.", nameof(nodeClass)); + if (_Classes.Any(existing => existing.Namespace == nodeClass.Namespace && existing.Name == nodeClass.Name)) + throw new InvalidOperationException($"A class named '{nodeClass.Namespace}.{nodeClass.Name}' already exists."); + _Classes.Add(nodeClass); } + public void RemoveClass(NodeClass nodeClass) + { + ArgumentNullException.ThrowIfNull(nodeClass); + if (!_Classes.Contains(nodeClass)) + throw new InvalidOperationException("The class does not belong to this project."); + + var referencingMember = _Classes + .Where(existing => existing != nodeClass) + .SelectMany(existing => existing.Properties.Select(property => (Name: $"{existing.Name}.{property.Name}", Type: property.PropertyType)) + .Concat(existing.Methods.Select(method => (Name: $"{existing.Name}.{method.Name} return type", Type: method.ReturnType))) + .Concat(existing.Methods.SelectMany(method => method.Parameters.Select(parameter => (Name: $"{existing.Name}.{method.Name}.{parameter.Name}", Type: parameter.ParameterType))))) + .FirstOrDefault(member => ReferencesClass(member.Type, nodeClass)); + if (referencingMember != default) + throw new InvalidOperationException($"Class '{nodeClass.Name}' is still referenced by '{referencingMember.Name}'. Remove that reference first."); + + var referencingNode = _Classes + .Where(existing => existing != nodeClass) + .SelectMany(existing => existing.Methods) + .SelectMany(method => method.Graph.Nodes.Values) + .FirstOrDefault(node => + node.InputsAndOutputs.Any(connection => ReferencesClass(connection.Type, nodeClass)) || + node is MethodCall { TargetMethod: NodeClassMethod targetMethod } && targetMethod.Class == nodeClass); + if (referencingNode != null) + throw new InvalidOperationException($"Class '{nodeClass.Name}' is still referenced by node '{referencingNode.Name}'. Remove that node first."); + + _Classes.Remove(nodeClass); + NodeClassTypes.Remove(nodeClass); + } + + private static bool ReferencesClass(TypeBase type, NodeClass nodeClass) + { + return type switch + { + NodeClassType nodeClassType => nodeClassType.NodeClass == nodeClass, + NodeClassArrayType arrayType => arrayType.InnerNodeClassType.NodeClass == nodeClass, + _ => type.Generics.Any(generic => ReferencesClass(generic, nodeClass)) + }; + } + #endregion #region Build @@ -923,7 +968,13 @@ public NodeClassType GetNodeClassType(NodeClass nodeClass, TypeBase[]? generics public string Serialize() { - var serializedProject = new SerializedProject(Id, NodeDevVersion, Classes.Select(x => x.Serialize()).ToList(), Settings); + return Serialize(Settings); + } + + public string Serialize(ProjectSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + var serializedProject = new SerializedProject(Id, NodeDevVersion, Classes.Select(x => x.Serialize()).ToList(), settings); return JsonSerializer.Serialize(serializedProject, new JsonSerializerOptions() { @@ -942,6 +993,7 @@ public static Project Deserialize(string serialized) var serializedProject = document.Deserialize() ?? throw new Exception("Unable to deserialize project"); var project = new Project(serializedProject.Id == default ? Guid.NewGuid() : serializedProject.Id); + project.Settings = serializedProject.Settings ?? new ProjectSettings(); var nodeClasses = new Dictionary(); foreach (var nodeClassSerializedObj in serializedProject.Classes) diff --git a/src/NodeDev.Core/ProjectSettings.cs b/src/NodeDev.Core/ProjectSettings.cs index ce2c687b..c51ff732 100644 --- a/src/NodeDev.Core/ProjectSettings.cs +++ b/src/NodeDev.Core/ProjectSettings.cs @@ -3,5 +3,5 @@ public record class ProjectSettings() { public string ProjectName { get; set; } = string.Empty; - public static ProjectSettings Default { get; } = new(); + public static ProjectSettings Default => new(); } diff --git a/src/NodeDev.EndToEndTests/Fixtures/AppServerFixture.cs b/src/NodeDev.EndToEndTests/Fixtures/AppServerFixture.cs index b4fb1ddd..f5a7f8fd 100644 --- a/src/NodeDev.EndToEndTests/Fixtures/AppServerFixture.cs +++ b/src/NodeDev.EndToEndTests/Fixtures/AppServerFixture.cs @@ -2,19 +2,24 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; +using NodeDev.Blazor.Services; namespace NodeDev.EndToEndTests.Fixtures; public class AppServerFixture : IAsyncLifetime { private BlazorWebAppFactory? _factory; + private string? TestDataDirectory; public Uri BaseUrl => _factory?.ClientOptions.BaseAddress ?? throw new InvalidOperationException("BlazorWebAppFactory not initialized"); public async Task InitializeAsync() { - _factory = new BlazorWebAppFactory(); + TestDataDirectory = Path.Combine(Path.GetTempPath(), "NodeDev.EndToEndTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(TestDataDirectory); + _factory = new BlazorWebAppFactory(TestDataDirectory); _factory.UseKestrel(); _factory.StartServer(); @@ -24,10 +29,13 @@ public async Task DisposeAsync() { if (_factory != null) await _factory.DisposeAsync(); + + if (TestDataDirectory != null && Directory.Exists(TestDataDirectory)) + Directory.Delete(TestDataDirectory, recursive: true); } } -internal class BlazorWebAppFactory : WebApplicationFactory +internal class BlazorWebAppFactory(string testDataDirectory) : WebApplicationFactory { protected override void ConfigureWebHost(IWebHostBuilder builder) { @@ -38,6 +46,16 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) logging.AddConsole(); }); builder.ConfigureServices(services => - services.AddDataProtection().UseEphemeralDataProtectionProvider()); + { + services.AddDataProtection().UseEphemeralDataProtectionProvider(); + + services.RemoveAll(); + var optionsContainer = new AppOptionsContainer(Path.Combine(testDataDirectory, "AppOptions.json")); + optionsContainer.AppOptions = new AppOptions + { + ProjectsDirectory = Path.Combine(testDataDirectory, "Projects") + }; + services.AddSingleton(optionsContainer); + }); } } diff --git a/src/NodeDev.Tests/GraphDiagramSynchronizationTests.cs b/src/NodeDev.Tests/GraphDiagramSynchronizationTests.cs new file mode 100644 index 00000000..8b9df6d8 --- /dev/null +++ b/src/NodeDev.Tests/GraphDiagramSynchronizationTests.cs @@ -0,0 +1,86 @@ +using Blazor.Diagrams; +using NodeDev.Blazor.Components; +using NodeDev.Core; +using NodeDev.Core.Nodes; + +namespace NodeDev.Tests; + +public class GraphDiagramSynchronizationTests +{ + [Fact] + public void MultipleProjectionsSubscribeToTheSameGraphIndependently() + { + Project.CreateNewDefaultProject(out var main); + var first = CreateProjection(main.Graph); + var second = CreateProjection(main.Graph); + var initialNodeCount = first.Diagram.Nodes.Count; + + var firstNode = new MethodCall(main.Graph); + main.Graph.Manager.AddNode(firstNode); + + Assert.Equal(initialNodeCount + 1, first.Diagram.Nodes.Count); + Assert.Equal(initialNodeCount + 1, second.Diagram.Nodes.Count); + + first.Synchronizer.Dispose(); + var secondNode = new MethodCall(main.Graph); + main.Graph.Manager.AddNode(secondNode); + + Assert.Equal(initialNodeCount + 1, first.Diagram.Nodes.Count); + Assert.Equal(initialNodeCount + 2, second.Diagram.Nodes.Count); + + second.Synchronizer.Dispose(); + } + + [Fact] + public void DomainConnectionReplacementIsAppliedToProjection() + { + Project.CreateNewDefaultProject(out var main); + var projection = CreateProjection(main.Graph); + Assert.Single(projection.Diagram.Links); + + var methodCall = new MethodCall(main.Graph); + main.Graph.Manager.AddNode(methodCall); + main.Graph.Manager.AddNewConnectionBetween(main.EntryNode!.Outputs[0], methodCall.Inputs[0]); + + var link = Assert.Single(projection.Diagram.Links); + Assert.Equal(main.EntryNode.Outputs[0], projection.Projection.FindPort(main.EntryNode.Outputs[0])?.Connection); + Assert.Equal(methodCall.Inputs[0], projection.Projection.FindPort(methodCall.Inputs[0])?.Connection); + Assert.Equal(main.EntryNode.Outputs[0], ((NodeDev.Blazor.DiagramsModels.GraphPortModel?)link.Source.Model)?.Connection); + Assert.Equal(methodCall.Inputs[0], ((NodeDev.Blazor.DiagramsModels.GraphPortModel?)link.Target.Model)?.Connection); + + projection.Synchronizer.Dispose(); + } + + [Fact] + public void ProjectionResetRebuildsFromCurrentDomainState() + { + Project.CreateNewDefaultProject(out var main); + var projection = CreateProjection(main.Graph); + projection.Diagram.Nodes.Clear(); + Assert.Empty(projection.Diagram.Nodes); + + main.Graph.RaiseGraphChanged(true); + + Assert.Equal(main.Graph.Nodes.Count, projection.Diagram.Nodes.Count); + projection.Synchronizer.Dispose(); + } + + private static ProjectionFixture CreateProjection(Graph graph) + { + var diagram = new BlazorDiagram(); + var synchronizer = new GraphDiagramSynchronizer(graph, action => + { + action(); + return Task.CompletedTask; + }); + var projection = new GraphDiagramProjection(graph, diagram, synchronizer, (_, _) => { }); + projection.Initialize(); + synchronizer.Start(projection, () => { }); + return new(diagram, projection, synchronizer); + } + + private sealed record ProjectionFixture( + BlazorDiagram Diagram, + GraphDiagramProjection Projection, + GraphDiagramSynchronizer Synchronizer); +} diff --git a/src/NodeDev.Tests/GraphManagerServiceTests.cs b/src/NodeDev.Tests/GraphManagerServiceTests.cs index 64bb4951..f8661c0b 100644 --- a/src/NodeDev.Tests/GraphManagerServiceTests.cs +++ b/src/NodeDev.Tests/GraphManagerServiceTests.cs @@ -1,9 +1,7 @@ using NodeDev.Core; -using NodeDev.Core.Connections; -using NodeDev.Core.ManagerServices; using NodeDev.Core.Nodes; using NodeDev.Core.Nodes.Flow; -using NSubstitute; +using System.Reactive.Linq; namespace NodeDev.Tests; @@ -13,9 +11,8 @@ public class GraphManagerServiceTests : NodeDevTestsBase public void SelectingNewListOverloadRefreshesPortsAndKeepsExecConnected() { var project = Project.CreateNewDefaultProject(out var main); - var graphCanvas = Substitute.For(); - graphCanvas.Graph.Returns(main.Graph); - main.Graph.GraphCanvas = graphCanvas; + var changes = new List(); + using var subscription = main.Graph.Changes.Subscribe(changes.Add); var newNode = new New(main.Graph); main.Graph.Manager.AddNode(newNode); @@ -33,33 +30,30 @@ public void SelectingNewListOverloadRefreshesPortsAndKeepsExecConnected() var capacityOverload = Assert.Single( newNode.AlternatesOverloads, overload => overload.Parameters.Count == 1 && overload.Parameters[0].Name == "capacity"); - graphCanvas.ClearReceivedCalls(); + changes.Clear(); main.Graph.Manager.SelectNodeOverload(newNode, capacityOverload); Assert.Contains(main.EntryNode.Outputs[0], newNode.Inputs[0].Connections); Assert.Equal("capacity", newNode.Inputs[1].Name); - graphCanvas.Received(1).Refresh(newNode); + Assert.Contains(changes, change => change is GraphChange.NodeChanged nodeChanged && nodeChanged.Node == newNode); } [Fact] - public void AddEnumerableRange_ShouldExposeAllPortsWhenAddedToCanvas() + public void AddEnumerableRange_ShouldExposeAllPortsWhenNodeAddedIsPublished() { var project = Project.CreateNewDefaultProject(out var main); - var graphCanvas = Substitute.For(); - graphCanvas.Graph.Returns(main.Graph); - main.Graph.GraphCanvas = graphCanvas; - - string[]? inputsSeenByCanvas = null; - string[]? outputsSeenByCanvas = null; - graphCanvas - .When(canvas => canvas.AddNode(Arg.Any())) - .Do(call => + + string[]? inputsSeenBySubscriber = null; + string[]? outputsSeenBySubscriber = null; + using var subscription = main.Graph.Changes.Subscribe(change => + { + if (change is GraphChange.NodeAdded added) { - var addedNode = call.Arg(); - inputsSeenByCanvas = addedNode.Inputs.Select(connection => connection.Name).ToArray(); - outputsSeenByCanvas = addedNode.Outputs.Select(connection => connection.Name).ToArray(); - }); + inputsSeenBySubscriber = added.Node.Inputs.Select(connection => connection.Name).ToArray(); + outputsSeenBySubscriber = added.Node.Outputs.Select(connection => connection.Name).ToArray(); + } + }); var rangeSearchResult = Assert.Single( NodeProvider.Search(main.Graph, "Enumerable.Range", null, null) @@ -67,12 +61,12 @@ public void AddEnumerableRange_ShouldExposeAllPortsWhenAddedToCanvas() var rangeNode = Assert.IsType(main.Graph.Manager.AddNode(rangeSearchResult, _ => { })); - Assert.NotNull(inputsSeenByCanvas); - Assert.NotNull(outputsSeenByCanvas); - Assert.Equal(["Exec", "start", "count"], inputsSeenByCanvas); - Assert.Equal(["Exec", "Result"], outputsSeenByCanvas); - Assert.Equal(inputsSeenByCanvas, rangeNode.Inputs.Select(connection => connection.Name)); - Assert.Equal(outputsSeenByCanvas, rangeNode.Outputs.Select(connection => connection.Name)); + Assert.NotNull(inputsSeenBySubscriber); + Assert.NotNull(outputsSeenBySubscriber); + Assert.Equal(["Exec", "start", "count"], inputsSeenBySubscriber); + Assert.Equal(["Exec", "Result"], outputsSeenBySubscriber); + Assert.Equal(inputsSeenBySubscriber, rangeNode.Inputs.Select(connection => connection.Name)); + Assert.Equal(outputsSeenBySubscriber, rangeNode.Outputs.Select(connection => connection.Name)); } [Fact] @@ -83,11 +77,9 @@ public void ConnectTwoExecInOneOutput_ShouldDisconnectFirstExec() Assert.Single(main.ReturnNodes); Assert.Equal(main.EntryNode.Outputs[0].Connections[0], main.ReturnNodes.Single().Inputs[0]); - // create fake IGraphCanvas - var graphCanvas = Substitute.For(); - graphCanvas.Graph.Returns(main.Graph); - - var graphManager = new GraphManagerService(graphCanvas); + var graphManager = main.Graph.Manager; + var changes = new List(); + using var subscription = main.Graph.Changes.Subscribe(changes.Add); // create a random method call used to test the connection var methodCall = new MethodCall(main.Graph); @@ -104,13 +96,8 @@ public void ConnectTwoExecInOneOutput_ShouldDisconnectFirstExec() // return node is not connected to anything Assert.Empty(main.ReturnNodes.Single().Inputs[0].Connections); - // check that each connection was updated - graphCanvas.Received().UpdatePortColor(Arg.Is(main.EntryNode.Outputs[0].Connections[0])); - graphCanvas.Received().UpdatePortColor(Arg.Is(methodCall.Inputs[0])); - graphCanvas.Received().UpdatePortColor(Arg.Is(main.ReturnNodes.Single().Inputs[0])); - - // check that the old connection was removed from the graph canvas - graphCanvas.Received().RemoveLinkFromGraphCanvas(Arg.Is(main.EntryNode.Outputs[0]), Arg.Is(main.ReturnNodes.Single().Inputs[0])); + Assert.Contains(changes, change => change is GraphChange.LinkAdded added && added.Source == main.EntryNode.Outputs[0] && added.Destination == methodCall.Inputs[0]); + Assert.Contains(changes, change => change is GraphChange.LinkRemoved removed && removed.Source == main.EntryNode.Outputs[0] && removed.Destination == main.ReturnNodes.Single().Inputs[0]); } [Fact] @@ -121,11 +108,9 @@ public void ConnectTwoOutputsInOneInput_ShouldDisconnectFirstOutput() Assert.Single(main.ReturnNodes); Assert.Equal(main.EntryNode.Outputs[0].Connections[0], main.ReturnNodes.Single().Inputs[0]); - // create fake IGraphCanvas - var graphCanvas = Substitute.For(); - graphCanvas.Graph.Returns(main.Graph); - - var graphManager = new GraphManagerService(graphCanvas); + var graphManager = main.Graph.Manager; + var changes = new List(); + using var subscription = main.Graph.Changes.Subscribe(changes.Add); var addNode1 = AddNewAddNodeToGraph(main.Graph); var addNode2 = AddNewAddNodeToGraph(main.Graph); @@ -133,23 +118,19 @@ public void ConnectTwoOutputsInOneInput_ShouldDisconnectFirstOutput() // connect output of addNode1 to input of addNode3 graphManager.AddNewConnectionBetween(addNode1.Outputs[0], addNode3.Inputs[0]); - graphCanvas.Received().UpdatePortColor(Arg.Is(addNode1.Outputs[0])); - graphCanvas.Received().UpdatePortColor(Arg.Is(addNode3.Inputs[0])); Assert.Single(addNode1.Outputs[0].Connections); Assert.Single(addNode3.Inputs[0].Connections); Assert.Equal(addNode1.Outputs[0].Connections[0], addNode3.Inputs[0]); + changes.Clear(); // connect output of addNode2 to input of addNode3. It should disconnect the existing connection graphManager.AddNewConnectionBetween(addNode2.Outputs[0], addNode3.Inputs[0]); - graphCanvas.Received(2).UpdatePortColor(Arg.Is(addNode1.Outputs[0])); // when first adding, then when disconnecting - graphCanvas.Received(1).UpdatePortColor(Arg.Is(addNode2.Outputs[0])); - graphCanvas.Received(3).UpdatePortColor(Arg.Is(addNode3.Inputs[0])); // when first adding, adding a second time, then disconnecting Assert.Empty(addNode1.Outputs[0].Connections); Assert.Single(addNode2.Outputs[0].Connections); Assert.Single(addNode3.Inputs[0].Connections); Assert.Equal(addNode2.Outputs[0].Connections[0], addNode3.Inputs[0]); - graphCanvas.Received().RemoveLinkFromGraphCanvas(Arg.Is(addNode1.Outputs[0]), Arg.Is(addNode3.Inputs[0])); + Assert.Contains(changes, change => change is GraphChange.LinkRemoved removed && removed.Source == addNode1.Outputs[0] && removed.Destination == addNode3.Inputs[0]); } [Fact] @@ -160,11 +141,9 @@ public void ConnectTwoExecOutputsInOneInput_ShouldAllow() Assert.Single(main.ReturnNodes); Assert.Equal(main.EntryNode.Outputs[0].Connections[0], main.ReturnNodes.Single().Inputs[0]); - // create fake IGraphCanvas - var graphCanvas = Substitute.For(); - graphCanvas.Graph.Returns(main.Graph); - - var graphManager = new GraphManagerService(graphCanvas); + var graphManager = main.Graph.Manager; + var changes = new List(); + using var subscription = main.Graph.Changes.Subscribe(changes.Add); // create a random method call used to test the connection var methodCall = new MethodCall(main.Graph); @@ -172,13 +151,11 @@ public void ConnectTwoExecOutputsInOneInput_ShouldAllow() // connect output of addNode1 to input of addNode3 graphManager.AddNewConnectionBetween(methodCall.Outputs[0], main.ReturnNodes.Single().Inputs[0]); - graphCanvas.Received().UpdatePortColor(Arg.Is(methodCall.Outputs[0])); - graphCanvas.Received().UpdatePortColor(Arg.Is(main.ReturnNodes.Single().Inputs[0])); Assert.Single(main.EntryNode.Outputs[0].Connections); Assert.Equal(2, main.ReturnNodes.Single().Inputs[0].Connections.Count); Assert.Single(methodCall.Outputs[0].Connections); Assert.Equal(methodCall.Outputs[0].Connections[0], main.ReturnNodes.Single().Inputs[0]); - graphCanvas.DidNotReceiveWithAnyArgs().RemoveLinkFromGraphCanvas(Arg.Any(), Arg.Any()); + Assert.DoesNotContain(changes, change => change is GraphChange.LinkRemoved); } [Fact] @@ -191,11 +168,7 @@ public void ConnectArrayToIEnumerableT_ShouldAllow() var typeFactory = main.TypeFactory; - // create fake IGraphCanvas - var graphCanvas = Substitute.For(); - graphCanvas.Graph.Returns(main.Graph); - - var graphManager = new GraphManagerService(graphCanvas); + var graphManager = main.Graph.Manager; // create a random method call used to test the connection var methodCall = AddMethodCall(main.Graph, typeFactory.Get(), nameof(Array.Empty)); @@ -206,9 +179,6 @@ public void ConnectArrayToIEnumerableT_ShouldAllow() // connect output of Array.Empty() to input of foreachNode graphManager.AddNewConnectionBetween(methodCall.Outputs[1], foreachNode.Inputs[1]); - graphCanvas.Received().UpdatePortColor(Arg.Is(methodCall.Outputs[1])); - graphCanvas.Received().UpdatePortColor(Arg.Is(foreachNode.Inputs[1])); - graphCanvas.Received().UpdatePortColor(Arg.Is(foreachNode.Outputs[1])); Assert.Equal(typeFactory.Get>(), foreachNode.Inputs[1].Type); Assert.Equal(typeFactory.Get(), foreachNode.Outputs[1].Type); } @@ -223,11 +193,7 @@ public void ConnectListArrayToForeach_ShouldPropagateChange() var typeFactory = main.TypeFactory; - // create fake IGraphCanvas - var graphCanvas = Substitute.For(); - graphCanvas.Graph.Returns(main.Graph); - - var graphManager = new GraphManagerService(graphCanvas); + var graphManager = main.Graph.Manager; // create a random method call used to test the connection var newListArray = new New(main.Graph); @@ -245,11 +211,6 @@ public void ConnectListArrayToForeach_ShouldPropagateChange() // connect output of new List to input of foreachNode graphManager.AddNewConnectionBetween(newListArray.Outputs[1], foreachNode.Inputs[1]); - graphCanvas.Received().UpdatePortColor(Arg.Is(newListArray.Outputs[1])); - graphCanvas.Received().UpdatePortColor(Arg.Is(foreachNode.Inputs[1])); - graphCanvas.Received().UpdatePortColor(Arg.Is(foreachNode.Outputs[1])); - graphCanvas.Received().UpdatePortColor(Arg.Is(foreachNode2.Inputs[1])); - graphCanvas.Received().UpdatePortColor(Arg.Is(foreachNode2.Outputs[1])); // Input of foreach node should be IEnumerable, output should be string[] Assert.Equal(typeFactory.Get>(), foreachNode.Inputs[1].Type); @@ -270,11 +231,7 @@ public void ConnectArrayToArrayT_ShouldPropagateChange() var typeFactory = main.TypeFactory; - // create fake IGraphCanvas - var graphCanvas = Substitute.For(); - graphCanvas.Graph.Returns(main.Graph); - - var graphManager = new GraphManagerService(graphCanvas); + var graphManager = main.Graph.Manager; // output string[] var newArray = new New(main.Graph); @@ -287,10 +244,6 @@ public void ConnectArrayToArrayT_ShouldPropagateChange() // connect output of foreachNode into input of foreachNode2 graphManager.AddNewConnectionBetween(newArray.Outputs[1], arrayGet.Inputs[0]); - graphCanvas.Received().UpdatePortColor(Arg.Is(newArray.Outputs[1])); - graphCanvas.Received().UpdatePortColor(Arg.Is(arrayGet.Inputs[0])); - graphCanvas.Received().UpdatePortColor(Arg.Is(arrayGet.Outputs[0])); - // Input of arrayGet should be string[], output should be string Assert.Equal(typeFactory.Get(), arrayGet.Inputs[0].Type); Assert.Equal(typeFactory.Get(), arrayGet.Outputs[0].Type); diff --git a/src/NodeDev.Tests/NodeDev.Tests.csproj b/src/NodeDev.Tests/NodeDev.Tests.csproj index 8f71a11f..12f9a1c2 100644 --- a/src/NodeDev.Tests/NodeDev.Tests.csproj +++ b/src/NodeDev.Tests/NodeDev.Tests.csproj @@ -11,7 +11,6 @@ - runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/NodeDev.Tests/NodeProviderTests.cs b/src/NodeDev.Tests/NodeProviderTests.cs index d7890582..60195746 100644 --- a/src/NodeDev.Tests/NodeProviderTests.cs +++ b/src/NodeDev.Tests/NodeProviderTests.cs @@ -11,9 +11,7 @@ public class NodeProviderTests public void TestsNodeMethod() { var graph = GraphExecutorTests.CreateSimpleAddGraph(out _, out _, out _); - var project = new Project(Guid.NewGuid()); - - project.AddClass(graph.SelfClass); + var project = graph.SelfClass.Project; var methods = NodeProvider.Search(project, graph.SelfMethod.Name, null); diff --git a/src/NodeDev.Tests/ProjectMutationTests.cs b/src/NodeDev.Tests/ProjectMutationTests.cs new file mode 100644 index 00000000..0a97d84b --- /dev/null +++ b/src/NodeDev.Tests/ProjectMutationTests.cs @@ -0,0 +1,65 @@ +using NodeDev.Core; +using NodeDev.Core.Class; +using NodeDev.Core.Nodes; + +namespace NodeDev.Tests; + +public class ProjectMutationTests +{ + [Fact] + public void RemoveMethodRemovesItFromTheSerializedDomainModel() + { + var project = Project.CreateNewDefaultProject(); + var nodeClass = project.Classes.Single(); + var method = new NodeClassMethod(nodeClass, "Temporary", project.TypeFactory.Void); + nodeClass.AddMethod(method, createEntryAndReturn: true); + + nodeClass.RemoveMethod(method); + + Assert.DoesNotContain(method, nodeClass.Methods); + Assert.DoesNotContain("Temporary", project.Serialize()); + } + + [Fact] + public void RemoveMethodRejectsDanglingMethodCalls() + { + var project = Project.CreateNewDefaultProject(out var main); + var nodeClass = project.Classes.Single(); + var method = new NodeClassMethod(nodeClass, "UsedMethod", project.TypeFactory.Void); + nodeClass.AddMethod(method, createEntryAndReturn: true); + var call = new MethodCall(main.Graph); + call.SetMethodTarget(method); + main.Manager.AddNode(call); + + Assert.Throws(() => nodeClass.RemoveMethod(method)); + Assert.Contains(method, nodeClass.Methods); + } + + [Fact] + public void RemoveClassRejectsReferencedClassTypes() + { + var project = Project.CreateNewDefaultProject(); + var referencedClass = new NodeClass("Referenced", "Tests", project); + project.AddClass(referencedClass); + project.Classes[0].Properties.Add(new NodeClassProperty(project.Classes[0], "Reference", referencedClass.ClassTypeBase)); + + Assert.Throws(() => project.RemoveClass(referencedClass)); + Assert.Contains(referencedClass, project.Classes); + } + + [Fact] + public void RemoveClassRejectsStaticMethodCallsWithoutClassTypedConnections() + { + var project = Project.CreateNewDefaultProject(out var main); + var referencedClass = new NodeClass("Referenced", "Tests", project); + project.AddClass(referencedClass); + var referencedMethod = new NodeClassMethod(referencedClass, "StaticMethod", project.TypeFactory.Void, isStatic: true); + referencedClass.AddMethod(referencedMethod, createEntryAndReturn: true); + var call = new MethodCall(main.Graph); + call.SetMethodTarget(referencedMethod); + main.Manager.AddNode(call); + + Assert.Throws(() => project.RemoveClass(referencedClass)); + Assert.Contains(referencedClass, project.Classes); + } +} diff --git a/src/NodeDev.Tests/ProjectServiceTest.cs b/src/NodeDev.Tests/ProjectServiceTest.cs index a9b46f06..16bf4534 100644 --- a/src/NodeDev.Tests/ProjectServiceTest.cs +++ b/src/NodeDev.Tests/ProjectServiceTest.cs @@ -28,5 +28,44 @@ public void TestsProjectChangedEvent() Assert.True(isEventTriggered); } + + [Fact] + public async Task SaveProjectUsesConfiguredDirectoryAndCommitsNameAfterSuccess() + { + var projectsDirectory = Path.Combine(Path.GetTempPath(), $"nodedev-project-service-{Guid.NewGuid():N}"); + try + { + var options = new AppOptionsContainer(""); + options.AppOptions.ProjectsDirectory = projectsDirectory; + var projectService = new ProjectService(options); + + await projectService.SaveProjectToFileAsync("SavedProject"); + + Assert.Equal("SavedProject", projectService.Project.Settings.ProjectName); + var projectPath = Path.Combine(projectsDirectory, "SavedProject.ndproj"); + Assert.True(File.Exists(projectPath)); + var savedProject = Project.Deserialize(await File.ReadAllTextAsync(projectPath)); + Assert.Equal("SavedProject", savedProject.Settings.ProjectName); + Assert.Empty(Directory.EnumerateFiles(projectsDirectory, "*.tmp")); + } + finally + { + if (Directory.Exists(projectsDirectory)) + Directory.Delete(projectsDirectory, recursive: true); + } + } + + [Fact] + public async Task SaveProjectRejectsTraversalWithoutChangingProjectName() + { + var options = new AppOptionsContainer(""); + options.AppOptions.ProjectsDirectory = Path.GetTempPath(); + var projectService = new ProjectService(options); + var originalName = projectService.Project.Settings.ProjectName; + + await Assert.ThrowsAsync(() => projectService.SaveProjectToFileAsync("../escaped")); + + Assert.Equal(originalName, projectService.Project.Settings.ProjectName); + } }