Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Threading;
using DemaConsulting.SysML2Workbench.LoggingSubsystem;

namespace DemaConsulting.SysML2Workbench.AppShellSubsystem;
Expand Down Expand Up @@ -37,17 +38,30 @@ public AvaloniaClipboardService(Visual anchor, RollingFileLogger? logger = null)
}

/// <inheritdoc />
/// <remarks>
/// The actual clipboard write is always marshaled onto <see cref="Dispatcher.UIThread" /> via
/// <see cref="Dispatcher.InvokeAsync(System.Func{System.Threading.Tasks.Task})" />, even when this method
/// is itself already called from the UI thread: the native OS clipboard (Win32 <c>OpenClipboard</c> in
/// particular) can only be accessed from the single UI/STA thread, and a caller further up the awaited
/// chain may have resumed on a thread-pool thread (for example after a <c>ConfigureAwait(false)</c>
/// elsewhere in the call chain) without this method's own caller being aware of it. Marshaling
/// unconditionally here is the only way this unit can guarantee correctness regardless of the calling
/// thread.
/// </remarks>
public async Task SetTextAsync(string text)
{
ArgumentNullException.ThrowIfNull(text);

var clipboard = TopLevel.GetTopLevel(_anchor)?.Clipboard;
if (clipboard is null)
await Dispatcher.UIThread.InvokeAsync(async () =>
{
_logger?.Log(LogLevel.Error, "Unable to copy to the clipboard: no Avalonia TopLevel/clipboard is available.");
return;
}
var clipboard = TopLevel.GetTopLevel(_anchor)?.Clipboard;
if (clipboard is null)
{
_logger?.Log(LogLevel.Error, "Unable to copy to the clipboard: no Avalonia TopLevel/clipboard is available.");
return;
}

await clipboard.SetTextAsync(text);
await clipboard.SetTextAsync(text);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,13 @@ private void OnDataContextChanged(object? sender, EventArgs e)
if (_viewModel is not null)
{
_viewModel.DiagramChanged += OnDiagramChanged;
_viewModel.ClipboardService ??= new AvaloniaClipboardService(this);

// Always rebind to this view instance rather than only the first ever attached (`??=`): Dock can
// recreate/reattach a tab's DiagramDocumentView while its DiagramDocumentViewModel persists (for
// example when two custom-preview tabs are created in quick succession), which would otherwise leave
// the clipboard service anchored to a now-detached, stale view - causing TopLevel.GetTopLevel to
// resolve null and the clipboard write to silently no-op.
_viewModel.ClipboardService = new AvaloniaClipboardService(this);
LoadCurrentDiagram();
}
}
Expand Down
128 changes: 128 additions & 0 deletions test/OtsSoftwareTests/AvaloniaTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Reflection;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
Expand Down Expand Up @@ -181,6 +182,133 @@ await File.WriteAllTextAsync(
window.Close();
}

/// <summary>
/// Regression test for the "Copy as SysML always returns the last-created custom view" bug: opening the
/// View Builder's real "OK" commit sequence twice (<see cref="MainWindowShell.OpenNewCustomPreviewTab" />
/// immediately followed by <see cref="MainWindowShell.PreviewCustomView" />, exactly as
/// <c>ViewBuilderDialogViewModel.TryCommit</c> does) creates two distinct, real
/// <see cref="DiagramDocumentView" /> tabs hosted by the real Dock-composed <see cref="MainWindowView" />.
/// Selecting the first tab and copying must yield the first view's snippet, not the second (previously
/// failing because <c>DiagramDocumentView.OnDataContextChanged</c> bound the clipboard service only once
/// per view model via <c>??=</c>, so re-selecting a tab whose view had been detached and later
/// re-attached left its clipboard write silently targeting a stale, detached anchor).
/// </summary>
[AvaloniaFact]
public async Task DiagramContextMenu_CopyAsSysml_TwoCustomViewTabs_EachCopiesItsOwnSnippet()
{
// Arrange
await File.WriteAllTextAsync(
Path.Combine(_tempRoot, "Sample.sysml"),
"package Sample {\n"
+ " part def Engine;\n"
+ " part def Gearbox;\n"
+ "}\n");
using var shell = CreateShell();
var window = new MainWindowView(shell);
window.Show();
Dispatcher.UIThread.RunJobs();

await shell.AddFolderSourceAsync(_tempRoot);
Dispatcher.UIThread.RunJobs();

// Act: commit the first custom view (General diagram over Engine), mirroring ViewBuilderDialog's real
// OK-button sequence.
var firstDefinition = new ViewDefinitionModel();
firstDefinition.SetViewKind(ViewKind.General);
firstDefinition.AddExposeTarget("Sample::Engine");
shell.OpenNewCustomPreviewTab();
shell.PreviewCustomView(firstDefinition);
Dispatcher.UIThread.RunJobs();
var firstTabId = shell.ActiveTabId!;
var expectedFirstSnippet = shell.ExportTabAsSysmlSnippet(firstTabId);
Assert.NotNull(expectedFirstSnippet);

// Act: commit a second, distinct custom view (Interconnection diagram over Gearbox), which opens a
// second real tab/DiagramDocumentView rather than re-rendering the first in place.
var secondDefinition = new ViewDefinitionModel();
secondDefinition.SetViewKind(ViewKind.Interconnection);
secondDefinition.AddExposeTarget("Sample::Gearbox");
shell.OpenNewCustomPreviewTab();
shell.PreviewCustomView(secondDefinition);
Dispatcher.UIThread.RunJobs();
var secondTabId = shell.ActiveTabId!;
var expectedSecondSnippet = shell.ExportTabAsSysmlSnippet(secondTabId);
Assert.NotNull(expectedSecondSnippet);
Assert.NotEqual(firstTabId, secondTabId);
Assert.NotEqual(expectedFirstSnippet, expectedSecondSnippet);

// Act: switch back to the first tab and copy - this is the step that previously returned the second
// (last-created) tab's snippet regardless of which tab was actually active. Uses the real Dock factory's
// SetActiveDockable/SetFocusedDockable, exactly as Dock's own tab-header click handler does, so this
// faithfully reproduces a user clicking back to the first tab rather than merely poking shell state.
SelectDiagramTab(window, firstTabId);
Dispatcher.UIThread.RunJobs();
var firstCopiedSnippet = await CopyActiveTabAsSysmlAsync(window);

// Assert: the first tab's own snippet was copied, not the second tab's.
Assert.Equal(expectedFirstSnippet, firstCopiedSnippet);

// Act: switch to the second tab and copy too, confirming it still copies its own snippet correctly.
SelectDiagramTab(window, secondTabId);
Dispatcher.UIThread.RunJobs();
var secondCopiedSnippet = await CopyActiveTabAsSysmlAsync(window);

// Assert
Assert.Equal(expectedSecondSnippet, secondCopiedSnippet);

window.Close();
}

/// <summary>
/// Selects the diagram document tab with the given tab id via the real Dock factory's
/// <c>SetActiveDockable</c>/<c>SetFocusedDockable</c> - exactly what Dock's own tab-header click handler
/// invokes - so the resulting <see cref="MainWindowShell.NotifyActiveDiagramTab" /> notification (raised
/// through <see cref="MainWindowView" />'s <c>FocusedDockableChanged</c> forwarding) faithfully reflects a
/// real user tab click rather than only updating shell-side bookkeeping. Reached via reflection since
/// <see cref="MainWindowView" />'s Dock factory and per-tab view-model tracking are private implementation
/// details not otherwise exposed to tests.
/// </summary>
private static void SelectDiagramTab(MainWindowView window, string tabId)
{
var windowType = typeof(MainWindowView);
var dockFactoryField = windowType.GetField("_dockFactory", BindingFlags.NonPublic | BindingFlags.Instance);
var tabViewModelsField = windowType.GetField("_tabViewModelsByTabId", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(dockFactoryField);
Assert.NotNull(tabViewModelsField);

var dockFactory = (WorkbenchDockFactory)dockFactoryField!.GetValue(window)!;
var tabViewModels = (Dictionary<string, Dock.Model.Mvvm.Controls.Document>)tabViewModelsField!.GetValue(window)!;
var tabViewModel = tabViewModels[tabId];

dockFactory.SetActiveDockable(tabViewModel);
dockFactory.SetFocusedDockable(dockFactory.DiagramDock, tabViewModel);
}

/// <summary>
/// Drives the real "Copy as SysML" context-menu <c>MenuItem</c> for whichever <see cref="DiagramDocumentView" />
/// is currently hosted by <paramref name="window" />, and returns whatever text ends up on the headless
/// platform's real clipboard afterward.
/// </summary>
private static async Task<string?> CopyActiveTabAsSysmlAsync(MainWindowView window)
{
var diagramBorder = FindByName<Border>(window, "DiagramBorder");
Assert.NotNull(diagramBorder);
var contextMenu = diagramBorder!.ContextMenu;
Assert.NotNull(contextMenu);
contextMenu!.Open(diagramBorder);
Dispatcher.UIThread.RunJobs();

var copyMenuItem = FindByName<MenuItem>(window, "CopyAsSysmlMenuItem");
Assert.NotNull(copyMenuItem);
Assert.True(copyMenuItem!.IsEnabled);
copyMenuItem.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
Dispatcher.UIThread.RunJobs();

var clipboard = TopLevel.GetTopLevel(window)?.Clipboard;
Assert.NotNull(clipboard);
return await clipboard!.TryGetTextAsync();
}

/// <summary>
/// Finds a named control anywhere in <paramref name="root" />'s visual tree, regardless of which
/// Dock-hosted UserControl's own compiled NameScope it belongs to (unlike a control's own
Expand Down
Loading