Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
05e4210
Fix the Save/Save As menu items hardcoded IsEnabled="False" in XAML
edwin-vasquez-ucaldas Jul 28, 2026
302d8c9
Adding unit tests
edwin-vasquez-ucaldas Jul 29, 2026
9db01b0
Fix unit test
edwin-vasquez-ucaldas Jul 29, 2026
eb2fb46
call OnWorkspaceOpened/raise RequestEnableShortcutBarItems(true) fro…
edwin-vasquez-ucaldas Jul 29, 2026
acdb691
Sync Save/Save As enablement across menu, hotkey, and toolbar via sha…
edwin-vasquez-ucaldas Jul 30, 2026
91f0700
Correctly reverted — this file is now staged with exactly the inverse…
edwin-vasquez-ucaldas Jul 30, 2026
7d50b3e
Merge branch 'master' into DYN-10717_Fix_disabled_Save_and_Save_As_op…
RobertGlobant20 Jul 30, 2026
aed88c5
Fixing tests were failing. Add new test focus into the Guided Tour
edwin-vasquez-ucaldas Jul 31, 2026
b59c24b
Removed the duplicated isGuidedTourActive field and SetGuidedTourActi…
edwin-vasquez-ucaldas Aug 3, 2026
f93e6f3
Fixing unit tests. Stopped relying on ICommandSource's built-in coerc…
edwin-vasquez-ucaldas Aug 3, 2026
48702f4
Fix: Check for whether the model's position/size/any property actuall…
edwin-vasquez-ucaldas Aug 5, 2026
313ef50
Fix: passing each path segment separately, which resolves the analyze…
edwin-vasquez-ucaldas Aug 5, 2026
cd63a7b
Fix: raw stack depth conflated "position in undo history" with "conte…
edwin-vasquez-ucaldas Aug 5, 2026
f4ecaa5
Fix SonarQube analysis issues.
edwin-vasquez-ucaldas Aug 5, 2026
f886a97
Fix SonarQube analysis issues.
edwin-vasquez-ucaldas Aug 6, 2026
e2395b8
Apply minor fixes
edwin-vasquez-ucaldas Aug 6, 2026
936d742
Fix: UpdateHasUnsavedChangesFromSavedStateAffectingDepth() overwrites…
edwin-vasquez-ucaldas Aug 6, 2026
fc8f8d0
Apply all fixes for the independentDirtyFlag/MarkAsIndependentlyModif…
edwin-vasquez-ucaldas Aug 6, 2026
828a04a
Removing some comments
edwin-vasquez-ucaldas Aug 6, 2026
d648411
Merge remote-tracking branch 'origin/master' into DYN-10717_Fix_disab…
edwin-vasquez-ucaldas Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/DynamoCore/Core/CustomNodeManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -984,7 +984,7 @@ private bool InitializeCustomNode(
newWorkspace.Category = workspaceInfo.Category;
// Mark the custom node workspace as having no changes - when we set the category on the above line
// this marks the workspace as changed.
newWorkspace.HasUnsavedChanges = false;
newWorkspace.MarkAsSaved();
}
}

Expand Down Expand Up @@ -1549,7 +1549,7 @@ from output in outputs
IsVisibleInDynamoLibrary = true
});

newWorkspace.HasUnsavedChanges = true;
newWorkspace.MarkAsIndependentlyModified();

RegisterCustomNodeWorkspace(newWorkspace);

Expand Down
50 changes: 48 additions & 2 deletions src/DynamoCore/Core/UndoRedoRecorder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ internal interface IUndoRedoRecorderClient
/// Notifies the UI that the undo/redo state has changed so that undo/redo buttons can be enabled/disabled.
/// </summary>
void UpdateUndoRedoStack();

/// <summary>
/// UndoRedoRecorder calls this method right after recording a fresh user
/// modification (e.g. a node/note/group being moved or resized) so the
/// client can mark itself as having unsaved changes. Not called during
/// undo/redo replay itself, which goes through DeleteModel/CreateModel/
/// ReloadModel instead.
/// </summary>
void MarkAsModified();
}

internal class UndoRedoRecorder : LogSourceBase
Expand All @@ -75,6 +84,7 @@ public enum UserAction

private const string UserActionAttrib = "UserAction";
private const string ActionGroup = "ActionGroup";
private const string AffectsSavedStateAttrib = "AffectsSavedState";

private readonly IUndoRedoRecorderClient undoClient;
private readonly XmlDocument document = new XmlDocument();
Expand Down Expand Up @@ -201,6 +211,7 @@ public void RecordCreationForUndo(ModelBase model)
RecordActionInternal(currentActionGroup,
model, UserAction.Creation);

currentActionGroup.SetAttribute(AffectsSavedStateAttrib, bool.TrueString);
redoStack.Clear(); // Wipe out the redo-stack.
}

Expand All @@ -216,22 +227,35 @@ public void RecordDeletionForUndo(ModelBase model)
RecordActionInternal(currentActionGroup,
model, UserAction.Deletion);

currentActionGroup.SetAttribute(AffectsSavedStateAttrib, bool.TrueString);
redoStack.Clear(); // Wipe out the redo-stack.
}

/// <summary>
/// Record the given model right before it is modified. This results
/// in a modification action to be recorded under the current action
/// in a modification action to be recorded under the current action
/// group. Undoing this action will result in the model being reverted
/// to the states that it was in before the modification took place.
/// </summary>
/// <param name="model">The model to be recorded.</param>
public void RecordModificationForUndo(ModelBase model)
/// <param name="markAsModified">
/// Whether this recording represents a real content change that should mark the
/// workspace dirty (e.g. a node/note/group drag or resize). Pass false for recordings
/// that exist purely to make an incidental action (like a selection change) undoable,
/// since that state is never written to the saved file (DYN-10717).
/// </param>
public void RecordModificationForUndo(ModelBase model, bool markAsModified = true)
{
RecordActionInternal(currentActionGroup,
model, UserAction.Modification);

redoStack.Clear(); // Wipe out the redo-stack.

if (markAsModified)
{
currentActionGroup.SetAttribute(AffectsSavedStateAttrib, bool.TrueString);
undoClient.MarkAsModified();
}
}

/// <summary>
Expand Down Expand Up @@ -270,6 +294,22 @@ public XmlElement PopFromUndoGroup()
public bool CanUndo { get { return undoStack.Count > 0; } }
public bool CanRedo { get { return redoStack.Count > 0; } }

/// <summary>
/// The number of action groups currently on the undo stack that actually affect
/// what would be written to the saved file (i.e. were recorded via
/// RecordCreationForUndo/RecordDeletionForUndo, or RecordModificationForUndo with
/// markAsModified: true) -- action groups recorded purely to make an incidental,
/// non-persisted action undoable (e.g. a selection change) are excluded. Used by
/// the owning workspace to detect when Undo/Redo has returned to the exact content
/// position it was at when last saved, so it can correctly report having no unsaved
/// changes again (DYN-10717) instead of a one-way "dirty" flag that Undo never
/// clears, without being fooled by undoing/redoing a non-content action group.
/// </summary>
internal int SavedStateAffectingUndoDepth
{
get { return undoStack.Count(group => group.GetAttribute(AffectsSavedStateAttrib) == bool.TrueString); }
}

#endregion

#region Private Class Helper Methods
Expand Down Expand Up @@ -421,6 +461,9 @@ private void UndoActionGroup(XmlElement actionGroup)
}
}

if (actionGroup.GetAttribute(AffectsSavedStateAttrib) == bool.TrueString)
newGroup.SetAttribute(AffectsSavedStateAttrib, bool.TrueString);

redoStack.Push(newGroup); // Place the states on the redo-stack.
}

Expand Down Expand Up @@ -467,6 +510,9 @@ private void RedoActionGroup(XmlElement actionGroup)
}
}

if (actionGroup.GetAttribute(AffectsSavedStateAttrib) == bool.TrueString)
newGroup.SetAttribute(AffectsSavedStateAttrib, bool.TrueString);

undoStack.Push(newGroup);
}

Expand Down
4 changes: 2 additions & 2 deletions src/DynamoCore/Graph/Workspaces/CustomNodeWorkspaceModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public CustomNodeWorkspaceModel(
{
Debug.WriteLine("Creating a custom node workspace...");

HasUnsavedChanges = false;
MarkAsSaved();

CustomNodeId = Guid.Parse(info.ID);
Category = info.Category;
Expand All @@ -101,7 +101,7 @@ private void OnPropertyChanged(object sender, PropertyChangedEventArgs args)

if (args.PropertyName == "Category" || args.PropertyName == "Description")
{
HasUnsavedChanges = true;
MarkAsIndependentlyModified();
OnInfoChanged();
}
}
Expand Down
82 changes: 80 additions & 2 deletions src/DynamoCore/Graph/Workspaces/HomeWorkspaceModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,84 @@
base.DisposeNode(node);
}

/// <summary>
/// Computes the external file references by inspecting evaluated output values from
/// this workspace's running engine (only possible for a home workspace, not e.g. a
/// custom node workspace, hence this override).
/// </summary>
private protected override List<INodeLibraryDependencyInfo> ComputeExternalFileReferences()
{
var externalFiles = new Dictionary<object, DependencyInfo>();

// If an execution is in progress we'll have to wait for it to be done before we can gather the
// external file references as this implementation relies on the output values of each node.
// instead just bail to avoid blocking the UI.
if (RunSettings.RunEnabled && !RunSettings.ForceBlockRun)
{
foreach (var node in Nodes)
{
CollectExternalFileReferencesForNode(node, externalFiles);
}
}

return externalFiles.Values.ToList<INodeLibraryDependencyInfo>();
}

/// <summary>
/// Checks each output port of the given node for a file path value, recording any
/// found as an external file reference.
/// </summary>
private void CollectExternalFileReferencesForNode(NodeModel node, Dictionary<object, DependencyInfo> externalFiles)
{
externalFilesDictionary.TryGetValue(node.GUID, out var serializedDependencyInfo);

// Check for the file path string value at each of the output ports of all nodes in the workspace.
var outputIdentifierNames = node.OutPorts.Select(port => node.GetAstIdentifierForOutputIndex(port.Index)?.Name);
foreach (var id in outputIdentifierNames)
{
var mirror = EngineController.GetMirror(id);
var data = mirror?.GetData().Data;

if (data is string dataString && dataString.Contains(@"\"))
{
RecordExternalFileReference(node, dataString, serializedDependencyInfo, externalFiles);
}
}
}

/// <summary>
/// Records the given output value as an external file reference, either because it
/// exists on disk, or -- if not -- because it matches a previously serialized
/// dependency for this node.
/// </summary>
private void RecordExternalFileReference(NodeModel node, string dataString, DependencyInfo serializedDependencyInfo, Dictionary<object, DependencyInfo> externalFiles)

Check warning on line 565 in src/DynamoCore/Graph/Workspaces/HomeWorkspaceModel.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make 'RecordExternalFileReference' a static method.

See more on https://sonarcloud.io/project/issues?id=DynamoDS_Dynamo&issues=AZ_UlYhTiJf74ojWau27&open=AZ_UlYhTiJf74ojWau27&pullRequest=17255
{
// Check if the value exists on disk
DynamoUtilities.PathHelper.FileInfoAtPath(dataString, out bool fileExists, out string fileSize);
if (fileExists)
{
var externalFilePath = System.IO.Path.GetFullPath(dataString);
var externalFileName = System.IO.Path.GetFileName(dataString);

if (!externalFiles.ContainsKey(externalFilePath))
{
externalFiles[externalFilePath] = new DependencyInfo(externalFileName, dataString, ReferenceType.External);
}

externalFiles[externalFilePath].AddDependent(node.GUID);
externalFiles[externalFilePath].Size = fileSize;
}
// Read the serialized value for that node.
else if (serializedDependencyInfo != null && dataString.Contains(serializedDependencyInfo.Name))
{
if (!externalFiles.ContainsKey(serializedDependencyInfo.Name))
{
externalFiles[serializedDependencyInfo.Name] = new DependencyInfo(serializedDependencyInfo.Name, ReferenceType.External);
}
externalFiles[serializedDependencyInfo.Name].AddDependent(node.GUID);
}
}

/// <summary>
/// Called when the RequestSilenceNodeModifiedEvents event is emitted from a Node
/// </summary>
Expand Down Expand Up @@ -925,10 +1003,10 @@
cbn.ProcessCodeDirect(cbn.RecompileCodeBlockAST);
}
// This method is intended to be called only during opening of an existing workspace
// and therefore if the workspace is set as dirty on account of CBN precompilation,
// and therefore if the workspace is set as dirty on account of CBN precompilation,
// the workspace should be reverted to a clean state as it's undesirable to have unsaved
// changes for a workspace that is newly opened.
HasUnsavedChanges = false;
MarkAsSaved();
}

internal bool TryGetMatchingWorkspaceData(string uniqueId, out Dictionary<string, string> data)
Expand Down
45 changes: 43 additions & 2 deletions src/DynamoCore/Graph/Workspaces/UndoRedo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ internal void Undo()
if (null != undoRecorder)
{
undoRecorder.Undo();
UpdateHasUnsavedChangesFromSavedStateAffectingDepth();

// http://adsk-oss.myjetbrains.com/youtrack/issue/MAGN-7883
// Request run for every undo action
Expand All @@ -72,6 +73,7 @@ internal void Redo()
if (null != undoRecorder)
{
undoRecorder.Redo();
UpdateHasUnsavedChangesFromSavedStateAffectingDepth();

// http://adsk-oss.myjetbrains.com/youtrack/issue/MAGN-7883
// Request run for every redo action
Expand All @@ -83,6 +85,31 @@ internal void ClearUndoRecorder()
{
if (null != undoRecorder)
undoRecorder.Clear();

savedUndoDepth = 0;
}

/// <summary>
/// Marks this workspace as having no unsaved changes, and remembers the current
/// position in the undo history so that Undo/Redo back to this exact point can
/// correctly report the workspace as clean again, rather than leaving Undo unable
/// to ever clear the one-way dirty flag (DYN-10717).
/// </summary>
internal void MarkAsSaved()
{
// A save clears both undo-tracked and independently-flagged (administrative)
// dirty state. independentDirtyFlag must be reset explicitly here
independentDirtyFlag = false;
HasUnsavedChanges = false;
savedUndoDepth = undoRecorder?.SavedStateAffectingUndoDepth ?? 0;
}

private void UpdateHasUnsavedChangesFromSavedStateAffectingDepth()
{
// OR in independentDirtyFlag rather than overwriting: the undo stack being back
// at its saved position only means undo-tracked content is clean again
bool undoDepthIndicatesUnsaved = undoRecorder.SavedStateAffectingUndoDepth != savedUndoDepth;
HasUnsavedChanges = undoDepthIndicatesUnsaved || independentDirtyFlag;
Comment on lines +107 to +112
}
Comment thread
edwin-vasquez-ucaldas marked this conversation as resolved.

// See RecordModelsForModification below for more details.
Expand All @@ -106,7 +133,12 @@ internal static void RecordModelForModification(ModelBase model, UndoRedoRecorde
/// </summary>
/// <param name="models">The models to be recorded for undo.</param>
/// <param name="recorder"></param>
internal static void RecordModelsForModification(List<ModelBase> models, UndoRedoRecorder recorder)
/// <param name="markAsModified">
/// Whether this recording represents a real content change that should mark the
/// workspace dirty. Pass false for recordings that exist purely to make an incidental
/// action (like a selection change) undoable (DYN-10717).
/// </param>
internal static void RecordModelsForModification(List<ModelBase> models, UndoRedoRecorder recorder, bool markAsModified = true)
{
if (null == recorder)
return;
Expand All @@ -116,7 +148,7 @@ internal static void RecordModelsForModification(List<ModelBase> models, UndoRed
using (recorder.BeginActionGroup())
{
foreach (var model in models)
recorder.RecordModificationForUndo(model);
recorder.RecordModificationForUndo(model, markAsModified);
}
}

Expand Down Expand Up @@ -852,6 +884,15 @@ public void UpdateUndoRedoStack()
{
RaisePropertyChanged("CanUndoRedoCommand");
}

// Explicit implementation (rather than matching the public/implicit style of
// the other IUndoRedoRecorderClient members above) so this stays an internal
// implementation detail of the undo/dirty-flag wiring, not new public API.
void IUndoRedoRecorderClient.MarkAsModified()
{
HasUnsavedChanges = true;
}

/// <summary>
/// Returns model by GUID
/// </summary>
Expand Down
Loading
Loading