diff --git a/docs/design/ots/avalonia.md b/docs/design/ots/avalonia.md
index b476cce..1d82c3c 100644
--- a/docs/design/ots/avalonia.md
+++ b/docs/design/ots/avalonia.md
@@ -21,6 +21,14 @@ platform-specific UI stacks.
UI.
- **Pointer and keyboard input** — supports diagram pan and zoom plus workspace
and view interactions.
+- **`ContextMenu`/`MenuItem`** — every diagram tab's right-click "Copy as
+ SysML" action (the app's first `ContextMenu` usage; see
+ `docs/design/ots/dock.md`'s "Diagram Tab 'Copy as SysML' Context Menu"
+ section).
+- **`TopLevel.Clipboard`** — writes the generated SysML snippet to the OS
+ clipboard, resolved from the diagram view's own control via
+ `TopLevel.GetTopLevel(control)`; the same API the custom view builder's own
+ "Copy as SysML" button already used.
### Integration Pattern
diff --git a/docs/design/ots/dock.md b/docs/design/ots/dock.md
index db58f95..e3c8e4d 100644
--- a/docs/design/ots/dock.md
+++ b/docs/design/ots/dock.md
@@ -134,3 +134,60 @@ with no restore path - closing a diagram tab is always a safe operation
(zero tabs open is a supported, first-class state), and reopening one is one
click away via the catalog or the "+ New Diagram Tab" button, so no
restore mechanism is needed.
+
+### Diagram Tab "Copy as SysML" Context Menu
+
+Every `DiagramDocumentView` - one per open `WorkbenchTab`, and therefore
+covering all six predefined view kinds (General, Interconnection, State
+Transition, Action Flow, Sequence, Grid) plus a custom-view-builder preview
+tab, since all of them are rendered through this one shared view/view-model
+pair - hosts a single shared `ContextMenu` on its diagram `Border`, with one
+`MenuItem` ("Copy as SysML"). This is the app's first `ContextMenu` usage.
+
+Clicking the item copies that tab's whole-diagram SysML `view { ... }` text
+(view kind, every `expose` clause, and any `filter` expression) to the OS
+clipboard, reusing the existing `SysmlSnippetGenerator` - the same unit the
+custom view builder's own "Copy as SysML" button already used - so there is
+exactly one snippet-generation code path regardless of which tab kind
+triggered it. `MainWindowShell.WorkbenchTab.SourceDefinition` (a
+`ViewDefinitionModel?`) carries the concrete definition each tab's diagram
+was rendered from: for a custom-view-preview tab it is the definition passed
+to `PreviewCustomView`; for a predefined-view tab it is derived by
+`ViewCatalogPresenter.BuildViewDefinition`, since a predefined view's
+`SysmlViewNode` in the loaded workspace carries the same kind/expose/filter
+shape but had previously only been surfaced as a `ViewDescriptor` (display
+metadata, not a definition).
+
+The menu item's `IsEnabled` is bound to `DiagramDocumentViewModel.CanCopyAsSysml`,
+which mirrors `MainWindowShell.CanExportTabAsSysml(TabId)` - `false` (and so
+the item is disabled, never crashes) when a tab has no derivable definition,
+which happens in two disclosed cases: a brand-new custom-preview tab that
+has not rendered anything yet, and an unscoped predefined view (zero
+`expose` members - a real, valid "expose everything" SysML v2 view with no
+finite expose list for `SysmlSnippetGenerator` to serialize). Both cases are
+logged at `Info` level via the existing `RollingFileLogger` rather than
+thrown, consistent with every other expected-but-unavailable shell state.
+
+Clicking the item invokes `DiagramDocumentViewModel.CopyAsSysmlAsync()`,
+which asks `MainWindowShell.ExportTabAsSysmlSnippet` for the snippet text and
+writes it via an injected `IClipboardService` - a small seam (mirroring the
+existing `IUiDispatcher` seam pattern) so the generate-and-copy
+orchestration can be unit tested with a fake clipboard rather than needing a
+live UI. `DiagramDocumentView`'s code-behind constructs the real
+`AvaloniaClipboardService` (wrapping `TopLevel.GetTopLevel(this)?.Clipboard`)
+once it has a view model, and assigns it to the view model's
+`ClipboardService` property; production code never touches
+`TopLevel.Clipboard` directly from the view model, keeping the view model
+UI-framework-agnostic aside from that one injected seam.
+
+Adding this `ContextMenu` exposed a pre-existing pan-gesture bug: the same
+`Border`'s pointer handlers previously started a pan on *any* pointer button,
+including the right button. Since opening a `ContextMenu` on right-button
+release consumes that release before it reaches the `Border`'s own
+`PointerReleased` handler, panning was left permanently "stuck on" after
+every right-click - the very next pointer move (even a plain mouse move with
+no button held) dragged the diagram. `DiagramDocumentView` now only starts a
+pan on `PointerPressed` when `PointerPoint.Properties.IsLeftButtonPressed` is
+true, so the right button is reserved exclusively for the context menu, and
+a `PointerCaptureLost` handler resets the panning flag defensively in case
+capture is ever stolen mid-drag for some other reason.
diff --git a/docs/design/sysml2-workbench/app-shell-subsystem/main-window-shell.md b/docs/design/sysml2-workbench/app-shell-subsystem/main-window-shell.md
index 3bc5d12..49fbc12 100644
--- a/docs/design/sysml2-workbench/app-shell-subsystem/main-window-shell.md
+++ b/docs/design/sysml2-workbench/app-shell-subsystem/main-window-shell.md
@@ -37,6 +37,12 @@ own focus-change signal via `NotifyActiveDiagramTab`.
WorkspacePanel to build its tree without owning its own `WorkspaceSourceSet`
instance.
+Each `WorkbenchTab` also carries a `SourceDefinition: ViewDefinitionModel?` —
+the definition that produced the tab's currently rendered diagram, or `null`
+when none could be derived (an unscoped predefined view with zero expose
+members, or a brand-new custom-preview tab that has not rendered anything
+yet). This backs the tab's "Copy as SysML" context-menu action.
+
#### Key Methods
**AddFileSource**: Adds a single file as a new workspace source.
@@ -129,6 +135,26 @@ UI focus.
when Dock reports a focus change onto a diagram document - never by
`MainWindowShell` itself, which stays Dock-agnostic.
+**CanExportTabAsSysml**: Reports whether an open diagram tab has a derivable
+source definition and can export its diagram as a SysML snippet.
+
+- *Parameters*: `string tabId` — identifier of an open tab.
+- *Returns*: `bool` — `true` when the tab exists and its `SourceDefinition` is
+ ready to export.
+- *Postconditions*: None (read-only). Backs the enabled/disabled state of
+ every diagram tab's "Copy as SysML" context-menu item.
+
+**ExportTabAsSysmlSnippet**: Generates copy-pasteable SysML `view` text for
+the diagram currently rendered in an open tab.
+
+- *Parameters*: `string tabId` — identifier of an open tab.
+- *Returns*: `string?` — the SysML snippet, or `null` when
+ `CanExportTabAsSysml` would report `false` for that tab.
+- *Postconditions*: A `null` result is logged at `Info` level with the reason
+ (an expected, valid outcome, not a failure) rather than thrown; a
+ successful export is also logged at `Info` level, mirroring
+ `ExportCustomViewSnippet`'s existing style.
+
#### Error Handling
MainWindowShell handles user-cancelled operations, empty workspace selections,
diff --git a/docs/design/sysml2-workbench/view-builder-subsystem/view-definition-model.md b/docs/design/sysml2-workbench/view-builder-subsystem/view-definition-model.md
index 2d5da4f..1b5469e 100644
--- a/docs/design/sysml2-workbench/view-builder-subsystem/view-definition-model.md
+++ b/docs/design/sysml2-workbench/view-builder-subsystem/view-definition-model.md
@@ -55,6 +55,23 @@ a named snippet.
only four possible recursion kinds, at most four selections can ever exist
for one qualified name; once all four are taken, this remains a safe no-op.
+**AddExposeTarget (selection overload)**: Adds a fully-specified
+`ExposeTargetSelection` - qualified name, recursion kind, and optional
+bracket-filter expression - directly, in a single call.
+
+- *Parameters*: `ExposeTargetSelection selection` — fully-specified target
+ selection.
+- *Returns*: `void` — state is updated in place.
+- *Preconditions*: `selection` is not null.
+- *Postconditions*: Deduplicates by the exact (`QualifiedName`,
+ `RecursionKind`) pair, mirroring the string overload's own dedupe contract:
+ a matching existing selection is preserved (including its own bracket
+ filter) rather than overwritten. Lets a caller (for example
+ `ViewCatalogPresenter.BuildViewDefinition`) reconstruct an arbitrary real
+ `ExposeMember` - any of the four recursion kinds plus an optional bracket
+ filter - without the multi-call add-then-retarget-then-filter sequence the
+ string overload would otherwise require.
+
**RemoveExposeTarget**: Removes a qualified name/recursion kind pair from the
`expose` target set.
@@ -131,3 +148,4 @@ the two recursive expose forms.
- **MainWindowShell**
- **SysmlSnippetGenerator**
- **LayoutInvoker**
+- **ViewCatalogPresenter**
diff --git a/docs/design/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.md b/docs/design/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.md
index 478ccd2..d0a5846 100644
--- a/docs/design/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.md
+++ b/docs/design/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.md
@@ -49,6 +49,27 @@ underlying workspace snapshot has changed and the catalog must be rebuilt.
- *Postconditions*: The returned descriptor matches the current catalog
revision.
+**BuildViewDefinition**: Derives a `ViewDefinitionModel` that faithfully
+reconstructs a predefined view's real `view` declaration from the loaded
+workspace.
+
+- *Parameters*: `SemanticWorkspace workspace` — loaded model content;
+ `string viewId` — qualified name of the predefined view, as published in
+ `AvailableViews`.
+- *Returns*: `ViewDefinitionModel?` — a populated definition (view kind, every
+ expose member with its own recursion kind and optional bracket-filter
+ expression, filter expression, display name), or `null` when `viewId` is
+ not in the current catalog, does not resolve to a view node, its render
+ target does not map to a supported `ViewKind`, or it declares zero expose
+ members.
+- *Preconditions*: `RefreshCatalog` has been called against `workspace`.
+- *Postconditions*: The zero-expose-members case ("expose everything, no
+ scoping" - valid SysML v2) intentionally yields `null` rather than an
+ empty-but-technically-valid definition, since `SysmlSnippetGenerator` has
+ no finite expose list to serialize for it. Used by `MainWindowShell` to
+ populate a predefined-view diagram tab's `SourceDefinition` for its
+ "Copy as SysML" context-menu action.
+
#### Error Handling
ViewCatalogPresenter handles invalid or disappearing selections locally by
@@ -63,6 +84,9 @@ resolved before exposing the catalog.
- **WorkspaceModel** — provides the semantic workspace snapshot used for
discovery.
- **LayoutInvoker** — consumes the selected descriptor to generate a layout.
+- **ViewDefinitionModel** — populated by `BuildViewDefinition` for consumers
+ that need a concrete, reusable view definition rather than just a
+ `ViewDescriptor`.
- **SysML2Tools** — supplies the view usage concepts and semantic model types.
- **AppShellSubsystem** — hosts the UI surface that binds the catalog output.
diff --git a/docs/reqstream/ots/avalonia.yaml b/docs/reqstream/ots/avalonia.yaml
index c05b90d..f334eab 100644
--- a/docs/reqstream/ots/avalonia.yaml
+++ b/docs/reqstream/ots/avalonia.yaml
@@ -17,3 +17,10 @@ sections:
The workbench depends on Avalonia to compose the major UI regions around the rendered SVG viewer.
tests:
- 'MainWindow_HostsDiagramAndDiagnosticsPanels'
+
+ - id: 'Avalonia-CopyDiagramAsSysmlViaContextMenu'
+ title: 'The SysML2Workbench shall use Avalonia''s ContextMenu and TopLevel.Clipboard APIs to let a user right-click any open diagram tab and copy that diagram''s SysML view definition to the OS clipboard.'
+ justification: |
+ Every rendered diagram tab needs a discoverable, in-place way to copy its underlying SysML view text without navigating away to the custom view builder panel, and Avalonia's real ContextMenu/Clipboard integration is the genuine end-to-end behavior being qualified rather than a mocked UI harness.
+ tests:
+ - 'DiagramContextMenu_CopyAsSysml_CopiesSnippetToClipboard'
diff --git a/docs/reqstream/sysml2-workbench/app-shell-subsystem/main-window-shell.yaml b/docs/reqstream/sysml2-workbench/app-shell-subsystem/main-window-shell.yaml
index e7e17e2..df8a783 100644
--- a/docs/reqstream/sysml2-workbench/app-shell-subsystem/main-window-shell.yaml
+++ b/docs/reqstream/sysml2-workbench/app-shell-subsystem/main-window-shell.yaml
@@ -50,3 +50,14 @@ sections:
- 'Construction_EstablishesValidEmptySnapshot'
- 'RemoveSourceAsync_DownToZeroSources_ProducesEmptySnapshotAndUnwatchesEverything'
- 'SelectPredefinedView_NoWorkspaceOpened_ThrowsInvalidOperationException'
+
+ - id: 'SysML2Workbench-AppShellSubsystem-MainWindowShell-ExportTabAsSysml'
+ title: 'The MainWindowShell shall derive and export a SysML view snippet for any open diagram tab whose originating view definition can be determined, and shall report that a tab has no derivable definition rather than throwing.'
+ justification: |
+ Every diagram tab - predefined view or custom-view-builder preview - supports a "Copy as SysML" context-menu action that reuses the existing SysmlSnippetGenerator, and a tab whose definition cannot be derived (for example an unscoped predefined view with zero expose members, or an unrendered custom-preview tab) must disable that action gracefully instead of crashing.
+ tests:
+ - 'ExportTabAsSysmlSnippet_PredefinedViewTab_ReturnsSnippet'
+ - 'ExportTabAsSysmlSnippet_CustomPreviewTab_ReturnsSnippet'
+ - 'ExportTabAsSysmlSnippet_UnknownTabId_ReturnsNull'
+ - 'ExportTabAsSysmlSnippet_PredefinedViewWithNoExposeMembers_ReturnsNull'
+ - 'CanExportTabAsSysml_MirrorsExportability'
diff --git a/docs/reqstream/sysml2-workbench/view-builder-subsystem/view-definition-model.yaml b/docs/reqstream/sysml2-workbench/view-builder-subsystem/view-definition-model.yaml
index 54d3919..d951ea9 100644
--- a/docs/reqstream/sysml2-workbench/view-builder-subsystem/view-definition-model.yaml
+++ b/docs/reqstream/sysml2-workbench/view-builder-subsystem/view-definition-model.yaml
@@ -47,3 +47,11 @@ sections:
The shell and snippet generator need to know when a user's current selections form a usable custom view definition.
tests:
- 'DefinitionState_ReportsRenderAndExportReadiness'
+
+ - id: 'SysML2Workbench-ViewBuilderSubsystem-ViewDefinitionModel-AddExposeTargetSelectionDirectly'
+ title: 'The ViewDefinitionModel shall accept a fully-specified ExposeTargetSelection (qualified name, recursion kind, and optional bracket-filter expression) in a single call, deduplicated by the exact (qualified name, recursion kind) pair.'
+ justification: |
+ Reconstructing an arbitrary real expose member - any of the four recursion kinds plus an optional bracket filter - from a workspace's predefined view requires a single-call add that does not risk colliding with an already-present sibling selection, unlike the string overload's fixed default recursion kind plus a separate retarget/filter call sequence.
+ tests:
+ - 'AddExposeTarget_SelectionOverload_AppendsAndDedupesByQualifiedNameAndRecursionKind'
+ - 'AddExposeTarget_SelectionOverload_NullSelection_ThrowsArgumentNullException'
diff --git a/docs/reqstream/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.yaml b/docs/reqstream/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.yaml
index 6e52088..b981cab 100644
--- a/docs/reqstream/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.yaml
+++ b/docs/reqstream/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.yaml
@@ -26,3 +26,12 @@ sections:
Rendering begins from a single active catalog selection, so the presenter must expose that selection change.
tests:
- 'SelectView_PublishesCurrentSelection'
+
+ - id: 'SysML2Workbench-ViewCatalogSubsystem-ViewCatalogPresenter-BuildViewDefinitionFromWorkspaceView'
+ title: 'The ViewCatalogPresenter shall derive a ViewDefinitionModel that faithfully reconstructs a predefined view''s view kind, every expose member (recursion kind and bracket filter included), filter expression, and display name from the loaded workspace, and shall return null when no finite expose list can be determined.'
+ justification: |
+ Predefined-view diagram tabs need a concrete, reusable view definition - not just a display descriptor - so the shared "Copy as SysML" export path can generate a snippet for them the same way it does for custom-view-builder previews.
+ tests:
+ - 'BuildViewDefinition_ResolvesKindExposeAndFilter'
+ - 'BuildViewDefinition_UnknownViewId_ReturnsNull'
+ - 'BuildViewDefinition_NoExposeMembers_ReturnsNull'
diff --git a/docs/user_guide/getting_started.md b/docs/user_guide/getting_started.md
index 28495ee..5b92ba7 100644
--- a/docs/user_guide/getting_started.md
+++ b/docs/user_guide/getting_started.md
@@ -79,6 +79,13 @@ The rendered diagram appears as an interactive SVG in its tab's canvas:
Each diagram tab has its own independent pan/zoom state, so switching
between tabs never disturbs another tab's view of its diagram.
+Every open diagram tab - whether it renders a predefined view or a custom
+view preview - supports right-click > **Copy as SysML** to copy that tab's
+`view { ... }` SysML v2 text to the clipboard. The option is disabled when
+no concrete view definition can be derived for the tab, for example an
+unscoped predefined view or a custom-view preview tab that has not yet been
+rendered.
+
## Building a Custom View
The **Custom View Builder** panel (right side, dockable) lets you construct an
@@ -113,6 +120,9 @@ ad-hoc view without writing SysML syntax:
regardless of whether a custom view name was supplied. Paste this into a
`.sysml` file to promote the
ephemeral preview into a permanent, version-controlled view definition.
+ The same right-click **Copy as SysML** context menu described in
+ "Browsing Predefined Views" is also available on this preview's diagram
+ tab, not just the panel's own button.
Custom views built in the GUI are **session-only** - they are not saved to
disk by SysML2Workbench itself. The "Copy as SysML" snippet is the only way
diff --git a/docs/verification/ots/avalonia.md b/docs/verification/ots/avalonia.md
index 52a6321..b313b7b 100644
--- a/docs/verification/ots/avalonia.md
+++ b/docs/verification/ots/avalonia.md
@@ -9,3 +9,5 @@ OTS integration tests in `test/OtsSoftwareTests/AvaloniaTests.cs` verify the rea
**Startup_HostsDesktopShellControls**: A real `MainWindowView` is constructed over a fully composed `MainWindowShell` and shown under the headless platform. The dependency proves it hosts the menu, predefined-views list, and custom-view builder controls (ComboBox, multi-select ListBox, buttons) as real, discoverable Avalonia controls attached to the window's visual tree.
**MainWindow_HostsDiagramAndDiagnosticsPanels**: The same window's diagram `Image` control and diagnostics `ListBox` are confirmed present before any workspace is opened; after opening a workspace and selecting a predefined view through the shell, the shared canvas state backing the diagram control reports loaded content, proving Avalonia genuinely hosts the interactive diagram surface alongside the diagnostics panel.
+
+**DiagramContextMenu_CopyAsSysml_CopiesSnippetToClipboard**: After opening a workspace and rendering a predefined view, the real diagram tab's `ContextMenu` is opened on its `Border`, and the "Copy as SysML" `MenuItem` is clicked, both discovered as genuine Avalonia controls in the visual tree rather than invoked programmatically. The headless platform's own `TopLevel.Clipboard` is then read back via `TryGetTextAsync`, and its text is asserted to match the snippet `MainWindowShell.ExportTabAsSysmlSnippet` independently generates for the same tab - proving the whole click-to-clipboard path (context menu, view-model command, `IClipboardService` seam, and the real Avalonia clipboard) works end-to-end through the actual framework, not a mock.
diff --git a/docs/verification/sysml2-workbench/app-shell-subsystem/main-window-shell.md b/docs/verification/sysml2-workbench/app-shell-subsystem/main-window-shell.md
index f6047e2..0c3cbcc 100644
--- a/docs/verification/sysml2-workbench/app-shell-subsystem/main-window-shell.md
+++ b/docs/verification/sysml2-workbench/app-shell-subsystem/main-window-shell.md
@@ -85,3 +85,23 @@ source's file watcher. Verified by
**SelectPredefinedView_NoWorkspaceOpened_ThrowsInvalidOperationException**: Selecting a predefined view while zero
workspace sources are open throws `InvalidOperationException` rather than rendering against an empty workspace.
Verified by `MainWindowShellTests.SelectPredefinedView_NoWorkspaceOpened_ThrowsInvalidOperationException`.
+
+**ExportTabAsSysmlSnippet_PredefinedViewTab_ReturnsSnippet**: A rendered predefined-view diagram tab exports a
+SysML snippet reflecting its derived view definition, and `CanExportTabAsSysml` reports it as exportable. Verified
+by `MainWindowShellTests.ExportTabAsSysmlSnippet_PredefinedViewTab_ReturnsSnippet`.
+
+**ExportTabAsSysmlSnippet_CustomPreviewTab_ReturnsSnippet**: A rendered custom-view-preview diagram tab exports a
+SysML snippet reflecting the definition it was previewed from. Verified by
+`MainWindowShellTests.ExportTabAsSysmlSnippet_CustomPreviewTab_ReturnsSnippet`.
+
+**ExportTabAsSysmlSnippet_UnknownTabId_ReturnsNull**: An unknown tab id is reported as not exportable and returns
+`null` rather than throwing. Verified by `MainWindowShellTests.ExportTabAsSysmlSnippet_UnknownTabId_ReturnsNull`.
+
+**ExportTabAsSysmlSnippet_PredefinedViewWithNoExposeMembers_ReturnsNull**: A predefined view with zero expose
+members (a valid, unscoped "expose everything" view) cannot be exported, since there is no finite expose list to
+serialize - it is reported gracefully rather than throwing. Verified by
+`MainWindowShellTests.ExportTabAsSysmlSnippet_PredefinedViewWithNoExposeMembers_ReturnsNull`.
+
+**CanExportTabAsSysml_MirrorsExportability**: `CanExportTabAsSysml` mirrors the same true/false outcomes as
+`ExportTabAsSysmlSnippet` across the exportable and unknown-tab cases. Verified by
+`MainWindowShellTests.CanExportTabAsSysml_MirrorsExportability`.
diff --git a/docs/verification/sysml2-workbench/view-builder-subsystem/view-definition-model.md b/docs/verification/sysml2-workbench/view-builder-subsystem/view-definition-model.md
index 2a3d121..52488c6 100644
--- a/docs/verification/sysml2-workbench/view-builder-subsystem/view-definition-model.md
+++ b/docs/verification/sysml2-workbench/view-builder-subsystem/view-definition-model.md
@@ -85,3 +85,15 @@ is reported as a diagnostic. Verified by
**DefinitionState_ReportsRenderAndExportReadiness**: The definition reports whether it has enough information to render
a preview or export a snippet. Verified by `ViewDefinitionModelTests.DefinitionState_ReportsRenderAndExportReadiness`.
+
+**AddExposeTarget_SelectionOverload_AppendsAndDedupesByQualifiedNameAndRecursionKind**: The `ExposeTargetSelection`
+overload appends a fully-specified selection (qualified name, recursion kind, and bracket-filter expression) in a
+single call, is a no-op when the exact (qualified name, recursion kind) pair is already present (preserving the
+originally-added selection's bracket filter), and allows the same qualified name to be added again under a
+different recursion kind. Verified by
+`ViewDefinitionModelTests.AddExposeTarget_SelectionOverload_AppendsAndDedupesByQualifiedNameAndRecursionKind`.
+
+**AddExposeTarget_SelectionOverload_NullSelection_ThrowsArgumentNullException**: Passing a `null` selection to the
+`ExposeTargetSelection` overload throws `ArgumentNullException` rather than silently ignoring it or throwing a
+`NullReferenceException`. Verified by
+`ViewDefinitionModelTests.AddExposeTarget_SelectionOverload_NullSelection_ThrowsArgumentNullException`.
diff --git a/docs/verification/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.md b/docs/verification/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.md
index 639ddd2..072e67a 100644
--- a/docs/verification/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.md
+++ b/docs/verification/sysml2-workbench/view-catalog-subsystem/view-catalog-presenter.md
@@ -33,3 +33,14 @@ Verified by `ViewCatalogPresenterTests.LoadedModel_ListsSupportedViewDefinitions
**SelectView_PublishesCurrentSelection**: Selecting a predefined view publishes it as the current selection. Verified by
`ViewCatalogPresenterTests.SelectView_PublishesCurrentSelection`.
+
+**BuildViewDefinition_ResolvesKindExposeAndFilter**: `BuildViewDefinition` faithfully reconstructs a predefined
+view's kind, every expose member (recursion kind and bracket filter included), filter expression, and display
+name. Verified by `ViewCatalogPresenterTests.BuildViewDefinition_ResolvesKindExposeAndFilter`.
+
+**BuildViewDefinition_UnknownViewId_ReturnsNull**: An unknown view identifier produces `null` rather than
+throwing. Verified by `ViewCatalogPresenterTests.BuildViewDefinition_UnknownViewId_ReturnsNull`.
+
+**BuildViewDefinition_NoExposeMembers_ReturnsNull**: A predefined view with zero expose members (a valid,
+unscoped "expose everything" view) produces `null`, since there is no finite expose list to reconstruct. Verified
+by `ViewCatalogPresenterTests.BuildViewDefinition_NoExposeMembers_ReturnsNull`.
diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/AvaloniaClipboardService.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/AvaloniaClipboardService.cs
new file mode 100644
index 0000000..fa4a788
--- /dev/null
+++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/AvaloniaClipboardService.cs
@@ -0,0 +1,53 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input.Platform;
+using DemaConsulting.SysML2Workbench.LoggingSubsystem;
+
+namespace DemaConsulting.SysML2Workbench.AppShellSubsystem;
+
+///
+/// implementation that writes to the live Avalonia clipboard, resolved via
+/// from a control anchored in the visual tree.
+///
+///
+/// The owning is intentionally resolved fresh on every call
+/// rather than cached at construction, since the anchor control may not yet be attached to a window at the
+/// point this service is constructed (for example, if a diagram document view model is bound before its view
+/// is attached to the Dock layout) - by the time a user actually triggers a clipboard copy, the control is
+/// expected to be attached, but resolving lazily is a safe, zero-cost precaution either way.
+///
+public sealed class AvaloniaClipboardService : IClipboardService
+{
+ private readonly Visual _anchor;
+ private readonly RollingFileLogger? _logger;
+
+ ///
+ /// Creates a clipboard service anchored to a control in the visual tree.
+ ///
+ /// Control used to resolve the owning at write time.
+ ///
+ /// Optional logger used to record the case where no /clipboard is available (for
+ /// example under the XAML designer). When omitted, that case is silently ignored rather than throwing.
+ ///
+ /// Thrown when is null.
+ public AvaloniaClipboardService(Visual anchor, RollingFileLogger? logger = null)
+ {
+ _anchor = anchor ?? throw new ArgumentNullException(nameof(anchor));
+ _logger = logger;
+ }
+
+ ///
+ public async Task SetTextAsync(string text)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+
+ 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);
+ }
+}
diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml
index 8553f26..64edc2b 100644
--- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml
+++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml
@@ -1,10 +1,20 @@
+
+
+
+
+
diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml.cs
index 5f1f83a..8246e47 100644
--- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml.cs
+++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentView.axaml.cs
@@ -1,6 +1,7 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
+using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Svg.Skia;
@@ -32,6 +33,7 @@ public DiagramDocumentView()
DiagramBorder.PointerPressed += OnDiagramPointerPressed;
DiagramBorder.PointerMoved += OnDiagramPointerMoved;
DiagramBorder.PointerReleased += OnDiagramPointerReleased;
+ DiagramBorder.PointerCaptureLost += OnDiagramPointerCaptureLost;
DataContextChanged += OnDataContextChanged;
@@ -53,10 +55,21 @@ private void OnDataContextChanged(object? sender, EventArgs e)
if (_viewModel is not null)
{
_viewModel.DiagramChanged += OnDiagramChanged;
+ _viewModel.ClipboardService ??= new AvaloniaClipboardService(this);
LoadCurrentDiagram();
}
}
+ private async void OnCopyAsSysmlMenuItemClick(object? sender, RoutedEventArgs e)
+ {
+ if (_viewModel is null)
+ {
+ return;
+ }
+
+ await _viewModel.CopyAsSysmlAsync();
+ }
+
private void OnDiagramChanged(object? sender, EventArgs e)
{
LoadCurrentDiagram();
@@ -75,9 +88,16 @@ private void OnDiagramPointerWheelChanged(object? sender, PointerWheelEventArgs
e.Handled = true;
}
+ ///
+ /// Begins a left-button pan gesture. The right button is deliberately excluded: it opens the diagram's
+ /// context menu (the "Copy as SysML" item) instead, and a right-button press was previously starting a
+ /// pan here too - since opening the context menu on release consumes that pointer release before
+ /// could observe it, panning was left stuck on until the next
+ /// unrelated pointer press, making the diagram appear to drag itself after every right-click.
+ ///
private void OnDiagramPointerPressed(object? sender, PointerPressedEventArgs e)
{
- if (_viewModel is null || !_viewModel.Canvas.IsContentLoaded)
+ if (_viewModel is null || !_viewModel.Canvas.IsContentLoaded || !e.GetCurrentPoint(DiagramBorder).Properties.IsLeftButtonPressed)
{
return;
}
@@ -106,6 +126,16 @@ private void OnDiagramPointerReleased(object? sender, PointerReleasedEventArgs e
_isPanning = false;
}
+ ///
+ /// Defensive backstop: if pointer capture is lost mid-pan for any reason other than a normal release
+ /// (for example another control or popup stealing capture), panning must still stop rather than staying
+ /// stuck on until the next pointer press.
+ ///
+ private void OnDiagramPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
+ {
+ _isPanning = false;
+ }
+
///
/// Loads this tab's currently active diagram SVG into the on-screen image control.
///
diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentViewModel.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentViewModel.cs
index 30f983e..18b50f3 100644
--- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentViewModel.cs
+++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/DiagramDocumentViewModel.cs
@@ -56,11 +56,43 @@ public DiagramDocumentViewModel(MainWindowShell shell, string tabId)
///
public event EventHandler? DiagramChanged;
+ ///
+ /// Clipboard write seam used by . Left unset ()
+ /// until the owning attaches to the visual tree and assigns a real
+ /// ; unit tests instead assign a fake test double directly so the
+ /// generate-and-copy orchestration can be verified without any live UI/OS clipboard.
+ ///
+ public IClipboardService? ClipboardService { get; set; }
+
+ ///
+ /// Reports whether this tab currently has enough information to export its diagram as a SysML snippet,
+ /// backing the enabled/disabled state of the tab's "Copy as SysML" context-menu item.
+ ///
+ public bool CanCopyAsSysml => Shell.CanExportTabAsSysml(TabId);
+
+ ///
+ /// Generates this tab's SysML view snippet and copies it to the clipboard via
+ /// . A safe no-op - not an exception - when no snippet can be derived for
+ /// this tab ( already logs the reason) or when no
+ /// is available yet.
+ ///
+ public async Task CopyAsSysmlAsync()
+ {
+ var snippet = Shell.ExportTabAsSysmlSnippet(TabId);
+ if (snippet is null || ClipboardService is null)
+ {
+ return;
+ }
+
+ await ClipboardService.SetTextAsync(snippet);
+ }
+
///
/// Notifies the diagram view that it should reload this tab's current diagram.
///
public void RaiseDiagramChanged()
{
DiagramChanged?.Invoke(this, EventArgs.Empty);
+ OnPropertyChanged(nameof(CanCopyAsSysml));
}
}
diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/IClipboardService.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/IClipboardService.cs
new file mode 100644
index 0000000..dddf0dd
--- /dev/null
+++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/IClipboardService.cs
@@ -0,0 +1,19 @@
+namespace DemaConsulting.SysML2Workbench.AppShellSubsystem;
+
+///
+/// Seam for writing text to the OS clipboard, so view models that need to copy generated text (for example a
+/// diagram tab's "Copy as SysML" action) can be exercised in unit tests with a fake implementation rather than
+/// requiring a live Avalonia /TopLevel. Mirrors the
+/// existing seam pattern: a small
+/// interface owned alongside its real implementation, with production wiring supplying the live implementation
+/// and tests supplying a lightweight double.
+///
+public interface IClipboardService
+{
+ ///
+ /// Writes to the OS clipboard.
+ ///
+ /// Text to place on the clipboard.
+ /// A task that completes once the clipboard write has finished.
+ Task SetTextAsync(string text);
+}
diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowShell.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowShell.cs
index bb6455b..2a7c481 100644
--- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowShell.cs
+++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowShell.cs
@@ -28,13 +28,20 @@ public enum WorkbenchTabKind
/// User-facing tab label.
/// Content category shown by the tab.
/// This tab's own diagram surface state.
+///
+/// The that produced this tab's currently rendered diagram, or
+/// when no concrete definition could be derived for it (an unscoped predefined view
+/// with zero expose members, or a brand-new custom-preview tab that has not rendered anything yet). Used by
+/// / to
+/// back the diagram tab's "Copy as SysML" context-menu action.
+///
///
/// Because is a mutable reference type, two instances are
/// no longer meaningfully value-equal to each other once its state diverges. Nothing in
/// compares instances by equality; all lookups are
/// by .
///
-public sealed record WorkbenchTab(string Id, string Title, WorkbenchTabKind Kind, SvgCanvasHost Canvas);
+public sealed record WorkbenchTab(string Id, string Title, WorkbenchTabKind Kind, SvgCanvasHost Canvas, ViewDefinitionModel? SourceDefinition = null);
///
/// MainWindowShell is the desktop composition root that coordinates workspace lifecycle, view selection,
@@ -444,7 +451,14 @@ public string SelectPredefinedView(string viewId)
try
{
var svg = _layoutInvoker.RenderPredefinedView(CurrentWorkspace.Workspace, descriptor);
+ var definition = _viewCatalogPresenter.BuildViewDefinition(CurrentWorkspace.Workspace, descriptor.QualifiedName);
var tab = EnsureTabOpen(descriptor.QualifiedName, descriptor.DisplayName, WorkbenchTabKind.PredefinedView);
+ if (tab.SourceDefinition != definition)
+ {
+ tab = tab with { SourceDefinition = definition };
+ _openTabs[_openTabs.FindIndex(t => t.Id == tab.Id)] = tab;
+ }
+
tab.Canvas.LoadSvg(svg);
ActivePredefinedView = descriptor;
ActiveCustomView = null;
@@ -496,13 +510,13 @@ public string PreviewCustomView(ViewDefinitionModel definition)
WorkbenchTab tab;
if (ActiveTab is { Kind: WorkbenchTabKind.CustomViewPreview } activeTab)
{
- // Re-render in place: same tab identity and canvas, refreshed title.
- tab = activeTab with { Title = title };
+ // Re-render in place: same tab identity and canvas, refreshed title and source definition.
+ tab = activeTab with { Title = title, SourceDefinition = definition };
_openTabs[_openTabs.FindIndex(t => t.Id == activeTab.Id)] = tab;
}
else
{
- tab = CreateTab(NextCustomPreviewTabId(), title, WorkbenchTabKind.CustomViewPreview);
+ tab = CreateTab(NextCustomPreviewTabId(), title, WorkbenchTabKind.CustomViewPreview) with { SourceDefinition = definition };
_openTabs.Add(tab);
}
@@ -603,6 +617,50 @@ public string ExportCustomViewSnippet(ViewDefinitionModel definition)
return snippet;
}
+ ///
+ /// Reports whether the given open diagram tab has a derivable source definition and can therefore export
+ /// its diagram as a SysML view snippet via . Backs the
+ /// enabled/disabled state of every diagram tab's "Copy as SysML" context-menu item.
+ ///
+ /// Identifier of an open tab.
+ ///
+ /// when the tab is open and its is
+ /// ready to export; when the tab is unknown, has no source definition (for
+ /// example an empty custom-preview tab, or an unscoped predefined view with zero expose members), or that
+ /// definition is not yet ready to export.
+ ///
+ public bool CanExportTabAsSysml(string tabId)
+ {
+ var tab = _openTabs.FirstOrDefault(t => t.Id == tabId);
+ return tab?.SourceDefinition?.IsReadyToExport == true;
+ }
+
+ ///
+ /// Generates copy-pasteable SysML view text for the diagram currently rendered in the given open
+ /// tab, reusing whichever produced that diagram - whether it came from a
+ /// predefined view or a custom-view-builder preview - so every diagram tab funnels through the same
+ /// snippet-generation path rather than duplicating it per tab kind.
+ ///
+ /// Identifier of an open tab.
+ ///
+ /// Complete SysML view snippet, or when
+ /// would report for -
+ /// this is an expected, valid outcome (not a failure), so it is reported rather than thrown.
+ ///
+ public string? ExportTabAsSysmlSnippet(string tabId)
+ {
+ var tab = _openTabs.FirstOrDefault(t => t.Id == tabId);
+ if (tab?.SourceDefinition is not { IsReadyToExport: true } definition)
+ {
+ _logger.Log(LogLevel.Info, $"Tab '{tabId}' has no derivable SysML view definition to copy.");
+ return null;
+ }
+
+ var snippet = _snippetGenerator.GenerateSnippet(definition);
+ _logger.Log(LogLevel.Info, $"Diagram tab '{tabId}' copied as a SysML snippet.");
+ return snippet;
+ }
+
///
/// Applies a freshly loaded or reloaded workspace snapshot to all workspace-derived shell state.
///
diff --git a/src/DemaConsulting.SysML2Workbench/ViewBuilderSubsystem/ViewDefinitionModel.cs b/src/DemaConsulting.SysML2Workbench/ViewBuilderSubsystem/ViewDefinitionModel.cs
index 8ef12fa..26ad3c1 100644
--- a/src/DemaConsulting.SysML2Workbench/ViewBuilderSubsystem/ViewDefinitionModel.cs
+++ b/src/DemaConsulting.SysML2Workbench/ViewBuilderSubsystem/ViewDefinitionModel.cs
@@ -144,6 +144,38 @@ public void AddExposeTarget(string qualifiedName)
_exposeTargets.Add(new ExposeTargetSelection(qualifiedName));
}
+ ///
+ /// Adds a fully-specified expose target selection - qualified name, recursion kind, and optional
+ /// bracket-filter expression - directly, without the string overload's fixed
+ /// default.
+ ///
+ ///
+ /// Deduplicates by the exact (,
+ /// ) pair, mirroring 's
+ /// own dedupe contract: if a selection already exists for that exact pair, this is a no-op and the existing
+ /// selection (including its own bracket filter) is preserved rather than overwritten by
+ /// 's. This lets a caller (for example
+ /// ViewCatalogPresenter.BuildViewDefinition) reconstruct an arbitrary real
+ /// - any of the four recursion kinds
+ /// plus an optional bracket filter - in a single call, without the multi-call
+ /// add-then-retarget-then-filter sequence the string overload plus /
+ /// would otherwise require (which risks colliding with an
+ /// already-present sibling selection under the target recursion kind).
+ ///
+ /// Fully-specified target selection to add.
+ /// Thrown when is null.
+ public void AddExposeTarget(ExposeTargetSelection selection)
+ {
+ ArgumentNullException.ThrowIfNull(selection);
+
+ if (_exposeTargets.Any(t => t.QualifiedName == selection.QualifiedName && t.RecursionKind == selection.RecursionKind))
+ {
+ return;
+ }
+
+ _exposeTargets.Add(selection);
+ }
+
///
/// Removes a qualified name/recursion kind pair from the expose target set.
///
diff --git a/src/DemaConsulting.SysML2Workbench/ViewCatalogSubsystem/ViewCatalogPresenter.cs b/src/DemaConsulting.SysML2Workbench/ViewCatalogSubsystem/ViewCatalogPresenter.cs
index ed1b800..5bf5ef5 100644
--- a/src/DemaConsulting.SysML2Workbench/ViewCatalogSubsystem/ViewCatalogPresenter.cs
+++ b/src/DemaConsulting.SysML2Workbench/ViewCatalogSubsystem/ViewCatalogPresenter.cs
@@ -2,6 +2,7 @@
using DemaConsulting.SysML2Tools.Semantic;
using DemaConsulting.SysML2Tools.Semantic.Model;
using DemaConsulting.SysML2Workbench.LayoutRenderingSubsystem;
+using DemaConsulting.SysML2Workbench.ViewBuilderSubsystem;
namespace DemaConsulting.SysML2Workbench.ViewCatalogSubsystem;
@@ -140,4 +141,67 @@ public ViewDescriptor SelectView(string viewId)
{
return SelectedViewId is null ? null : _availableViews.FirstOrDefault(d => d.QualifiedName == SelectedViewId);
}
+
+ ///
+ /// Derives a that faithfully reconstructs a predefined view's real
+ /// view declaration - view kind, every expose member (with its own recursion kind and
+ /// optional bracket-filter expression), filter expression, and display name - from the loaded workspace.
+ ///
+ ///
+ /// This is the shared derivation path used both by the view catalog (should it ever need a definition
+ /// rather than just a ) and by "Copy as SysML" for a predefined-view diagram
+ /// tab, so a single unit owns "convert a workspace's predefined view usage into UI-ready data" rather than
+ /// splitting that responsibility across the catalog and the shell.
+ ///
+ /// Loaded model content.
+ /// Qualified name of the predefined view, as published in .
+ ///
+ /// A populated , or when
+ /// does not resolve to a in , its render target
+ /// does not map to a supported , or it has zero ExposeMembers (the "expose
+ /// everything, no scoping" case - valid SysML v2, but with no finite expose list for
+ /// SysmlSnippetGenerator to serialize).
+ ///
+ /// Thrown when is null.
+ /// Thrown when is null or whitespace.
+ public ViewDefinitionModel? BuildViewDefinition(SysmlWorkspace workspace, string viewId)
+ {
+ ArgumentNullException.ThrowIfNull(workspace);
+ ArgumentException.ThrowIfNullOrWhiteSpace(viewId);
+
+ var descriptor = _availableViews.FirstOrDefault(d => d.QualifiedName == viewId);
+ if (descriptor is null)
+ {
+ return null;
+ }
+
+ if (!workspace.Declarations.TryGetValue(viewId, out var node) || node is not SysmlViewNode viewNode)
+ {
+ return null;
+ }
+
+ var kind = ViewKindExtensions.FromRenderTargetName(viewNode.RenderTargetName);
+ if (kind is null)
+ {
+ return null;
+ }
+
+ if (viewNode.ExposeMembers.Count == 0)
+ {
+ return null;
+ }
+
+ var definition = new ViewDefinitionModel();
+ definition.SetViewKind(kind.Value);
+
+ foreach (var (member, resolvedQualifiedName) in viewNode.ResolvedExposeMembers)
+ {
+ definition.AddExposeTarget(new ExposeTargetSelection(resolvedQualifiedName, member.RecursionKind, member.BracketFilterExpressionText));
+ }
+
+ definition.SetFilterExpression(viewNode.FilterExpressionText);
+ definition.SetDisplayName(descriptor.DisplayName);
+
+ return definition;
+ }
}
diff --git a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/DiagramDocumentViewModelTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/DiagramDocumentViewModelTests.cs
new file mode 100644
index 0000000..4368525
--- /dev/null
+++ b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/DiagramDocumentViewModelTests.cs
@@ -0,0 +1,157 @@
+using DemaConsulting.SysML2Workbench.AppShellSubsystem;
+using DemaConsulting.SysML2Workbench.DiagnosticsPanelSubsystem;
+using DemaConsulting.SysML2Workbench.LayoutRenderingSubsystem;
+using DemaConsulting.SysML2Workbench.LoggingSubsystem;
+using DemaConsulting.SysML2Workbench.ViewBuilderSubsystem;
+using DemaConsulting.SysML2Workbench.ViewCatalogSubsystem;
+using DemaConsulting.SysML2Workbench.WorkspaceSubsystem;
+
+namespace DemaConsulting.SysML2Workbench.Tests.AppShellSubsystem;
+
+///
+/// Unit tests for .
+///
+public sealed class DiagramDocumentViewModelTests : IDisposable
+{
+ ///
+ /// Temporary workspace root folder created fresh for each test and removed on disposal.
+ ///
+ private readonly string _tempRoot = Directory.CreateTempSubdirectory("sysml2workbench-tests-").FullName;
+
+ ///
+ /// Temporary log directory created fresh for each test and removed on disposal.
+ ///
+ private readonly string _tempLogRoot = Directory.CreateTempSubdirectory("sysml2workbench-tests-logs-").FullName;
+
+ ///
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempRoot))
+ {
+ Directory.Delete(_tempRoot, recursive: true);
+ }
+
+ if (Directory.Exists(_tempLogRoot))
+ {
+ Directory.Delete(_tempLogRoot, recursive: true);
+ }
+ }
+
+ ///
+ /// Fake test double that captures the last copied text instead of
+ /// touching any real OS clipboard.
+ ///
+ private sealed class FakeClipboardService : IClipboardService
+ {
+ public string? LastCopiedText { get; private set; }
+
+ public Task SetTextAsync(string text)
+ {
+ LastCopiedText = text;
+ return Task.CompletedTask;
+ }
+ }
+
+ ///
+ /// Builds a shell wired with real (non-mocked) subsystem units, with a small workspace containing one
+ /// predefined view already loaded.
+ ///
+ private async Task CreateShellWithSampleWorkspaceAsync()
+ {
+ await File.WriteAllTextAsync(
+ Path.Combine(_tempRoot, "Sample.sysml"),
+ "package Sample {\n"
+ + " part def Engine;\n"
+ + " view PredefinedView {\n"
+ + " expose Engine;\n"
+ + " render asGeneralDiagram;\n"
+ + " }\n"
+ + "}\n");
+
+ var shell = new MainWindowShell(
+ new WorkspaceModel(),
+ new FileWatcher(TimeSpan.FromMilliseconds(1)),
+ new DiagnosticsAggregator(),
+ new ViewCatalogPresenter(),
+ new LayoutInvoker(),
+ new DiagnosticsListView(),
+ new SysmlSnippetGenerator(),
+ new RollingFileLogger(_tempLogRoot));
+
+ await shell.AddFolderSourceAsync(_tempRoot);
+ return shell;
+ }
+
+ ///
+ /// Validates that a diagram document view model whose tab has a derivable definition reports
+ /// as and copies the
+ /// generated snippet to the injected clipboard service.
+ ///
+ [Fact]
+ public async Task CopyAsSysmlAsync_ExportableTab_CopiesSnippetToClipboard()
+ {
+ // Arrange
+ using var shell = await CreateShellWithSampleWorkspaceAsync();
+ var view = shell.ViewCatalog.AvailableViews[0];
+ shell.SelectPredefinedView(view.QualifiedName);
+ var tabId = shell.ActiveTabId!;
+
+ var viewModel = new DiagramDocumentViewModel(shell, tabId);
+ var clipboard = new FakeClipboardService();
+ viewModel.ClipboardService = clipboard;
+
+ // Act
+ var canCopy = viewModel.CanCopyAsSysml;
+ await viewModel.CopyAsSysmlAsync();
+
+ // Assert
+ Assert.True(canCopy);
+ Assert.NotNull(clipboard.LastCopiedText);
+ Assert.Contains("view PredefinedView {", clipboard.LastCopiedText);
+ Assert.Contains("expose Sample::Engine;", clipboard.LastCopiedText);
+ }
+
+ ///
+ /// Validates that a diagram document view model for a tab with no derivable definition (a brand-new,
+ /// unrendered custom-preview tab) reports as
+ /// and leaves the clipboard untouched.
+ ///
+ [Fact]
+ public async Task CopyAsSysmlAsync_NonExportableTab_LeavesClipboardUntouched()
+ {
+ // Arrange
+ using var shell = await CreateShellWithSampleWorkspaceAsync();
+ var tab = shell.OpenNewCustomPreviewTab();
+
+ var viewModel = new DiagramDocumentViewModel(shell, tab.Id);
+ var clipboard = new FakeClipboardService();
+ viewModel.ClipboardService = clipboard;
+
+ // Act
+ var canCopy = viewModel.CanCopyAsSysml;
+ await viewModel.CopyAsSysmlAsync();
+
+ // Assert
+ Assert.False(canCopy);
+ Assert.Null(clipboard.LastCopiedText);
+ }
+
+ ///
+ /// Validates that copying as SysML is a safe no-op - not an exception - when no
+ /// has been assigned yet.
+ ///
+ [Fact]
+ public async Task CopyAsSysmlAsync_NoClipboardServiceAssigned_DoesNotThrow()
+ {
+ // Arrange
+ using var shell = await CreateShellWithSampleWorkspaceAsync();
+ var view = shell.ViewCatalog.AvailableViews[0];
+ shell.SelectPredefinedView(view.QualifiedName);
+ var tabId = shell.ActiveTabId!;
+
+ var viewModel = new DiagramDocumentViewModel(shell, tabId);
+
+ // Act / Assert
+ await viewModel.CopyAsSysmlAsync();
+ }
+}
diff --git a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/MainWindowShellTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/MainWindowShellTests.cs
index 1411d28..658536e 100644
--- a/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/MainWindowShellTests.cs
+++ b/test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/MainWindowShellTests.cs
@@ -190,6 +190,131 @@ public async Task CustomViewWorkflow_PreviewsAndExportsFromShell()
Assert.True(shell.Canvas.IsContentLoaded);
}
+ ///
+ /// Validates that a rendered predefined-view diagram tab can export its diagram as a SysML snippet, and
+ /// that reports readiness consistently.
+ ///
+ [Fact]
+ public async Task ExportTabAsSysmlSnippet_PredefinedViewTab_ReturnsSnippet()
+ {
+ // Arrange
+ await WriteSampleWorkspaceAsync();
+ using var shell = CreateShell();
+ await shell.AddFolderSourceAsync(_tempRoot);
+ var view = shell.ViewCatalog.AvailableViews[0];
+ shell.SelectPredefinedView(view.QualifiedName);
+ var tabId = shell.ActiveTabId!;
+
+ // Act
+ var canExport = shell.CanExportTabAsSysml(tabId);
+ var snippet = shell.ExportTabAsSysmlSnippet(tabId);
+
+ // Assert
+ Assert.True(canExport);
+ Assert.NotNull(snippet);
+ Assert.Contains("view PredefinedView {", snippet);
+ Assert.Contains("expose Sample::Engine;", snippet);
+ }
+
+ ///
+ /// Validates that a custom-view-preview diagram tab can export its diagram as a SysML snippet.
+ ///
+ [Fact]
+ public async Task ExportTabAsSysmlSnippet_CustomPreviewTab_ReturnsSnippet()
+ {
+ // Arrange
+ await WriteSampleWorkspaceAsync();
+ using var shell = CreateShell();
+ await shell.AddFolderSourceAsync(_tempRoot);
+
+ var definition = new ViewDefinitionModel();
+ definition.SetViewKind(ViewKind.General);
+ definition.AddExposeTarget("Sample::Engine");
+ definition.SetDisplayName("EngineOnly");
+ shell.PreviewCustomView(definition);
+ var tabId = shell.ActiveTabId!;
+
+ // Act
+ var canExport = shell.CanExportTabAsSysml(tabId);
+ var snippet = shell.ExportTabAsSysmlSnippet(tabId);
+
+ // Assert
+ Assert.True(canExport);
+ Assert.NotNull(snippet);
+ Assert.Contains("view EngineOnly {", snippet);
+ }
+
+ ///
+ /// Validates that an unknown tab id reports no export readiness and returns rather
+ /// than throwing.
+ ///
+ [Fact]
+ public async Task ExportTabAsSysmlSnippet_UnknownTabId_ReturnsNull()
+ {
+ // Arrange
+ await WriteSampleWorkspaceAsync();
+ using var shell = CreateShell();
+ await shell.AddFolderSourceAsync(_tempRoot);
+
+ // Act / Assert
+ Assert.False(shell.CanExportTabAsSysml("does-not-exist"));
+ Assert.Null(shell.ExportTabAsSysmlSnippet("does-not-exist"));
+ }
+
+ ///
+ /// Validates that a predefined view with zero expose members (a valid, unscoped "expose everything" view)
+ /// cannot be exported, since there is no finite expose list to serialize - it is reported gracefully
+ /// rather than throwing.
+ ///
+ [Fact]
+ public async Task ExportTabAsSysmlSnippet_PredefinedViewWithNoExposeMembers_ReturnsNull()
+ {
+ // Arrange: a workspace whose predefined view declares no expose members at all
+ await File.WriteAllTextAsync(
+ Path.Combine(_tempRoot, "Sample.sysml"),
+ "package Sample {\n"
+ + " part def Engine;\n"
+ + " view UnscopedView {\n"
+ + " render asGeneralDiagram;\n"
+ + " }\n"
+ + "}\n",
+ TestContext.Current.CancellationToken);
+ using var shell = CreateShell();
+ await shell.AddFolderSourceAsync(_tempRoot);
+ var view = shell.ViewCatalog.AvailableViews[0];
+ shell.SelectPredefinedView(view.QualifiedName);
+ var tabId = shell.ActiveTabId!;
+
+ // Act / Assert
+ Assert.False(shell.CanExportTabAsSysml(tabId));
+ Assert.Null(shell.ExportTabAsSysmlSnippet(tabId));
+ }
+
+ ///
+ /// Validates that mirrors the same true/false outcomes
+ /// as across the exportable, unknown-tab, and
+ /// no-expose-members cases.
+ ///
+ [Fact]
+ public async Task CanExportTabAsSysml_MirrorsExportability()
+ {
+ // Arrange: an exportable predefined-view tab
+ await WriteSampleWorkspaceAsync();
+ using var shell = CreateShell();
+ await shell.AddFolderSourceAsync(_tempRoot);
+ var view = shell.ViewCatalog.AvailableViews[0];
+ shell.SelectPredefinedView(view.QualifiedName);
+ var exportableTabId = shell.ActiveTabId!;
+
+ // Assert: exportable tab
+ Assert.True(shell.CanExportTabAsSysml(exportableTabId));
+ Assert.NotNull(shell.ExportTabAsSysmlSnippet(exportableTabId));
+
+ // Assert: unknown tab
+ Assert.False(shell.CanExportTabAsSysml("unknown-tab"));
+ Assert.Null(shell.ExportTabAsSysmlSnippet("unknown-tab"));
+ }
+
///
/// Validates that selecting a predefined view is rejected while the workspace has zero sources.
///
diff --git a/test/DemaConsulting.SysML2Workbench.Tests/ViewBuilderSubsystem/ViewDefinitionModelTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/ViewBuilderSubsystem/ViewDefinitionModelTests.cs
index ae5c245..3f25073 100644
--- a/test/DemaConsulting.SysML2Workbench.Tests/ViewBuilderSubsystem/ViewDefinitionModelTests.cs
+++ b/test/DemaConsulting.SysML2Workbench.Tests/ViewBuilderSubsystem/ViewDefinitionModelTests.cs
@@ -335,6 +335,51 @@ public void DefinitionState_ReportsRenderAndExportReadiness()
Assert.True(definition.IsReadyToExport);
}
+ ///
+ /// Validates that the overload appends a fully-specified selection
+ /// and is a no-op when the exact (qualified name, recursion kind) pair is already present.
+ ///
+ [Fact]
+ public void AddExposeTarget_SelectionOverload_AppendsAndDedupesByQualifiedNameAndRecursionKind()
+ {
+ // Arrange: a definition with one selection added via the selection overload
+ var definition = new ViewDefinitionModel();
+ var selection = new ExposeTargetSelection("Sample::Engine", ExposeRecursionKind.NamespaceRecursive, "@Safety");
+ definition.AddExposeTarget(selection);
+
+ // Assert: the selection was appended verbatim
+ var stored = Assert.Single(definition.ExposeTargets);
+ Assert.Equal("Sample::Engine", stored.QualifiedName);
+ Assert.Equal(ExposeRecursionKind.NamespaceRecursive, stored.RecursionKind);
+ Assert.Equal("@Safety", stored.BracketFilterExpression);
+
+ // Act: re-add the exact same (qualified name, recursion kind) pair but with a different bracket filter
+ definition.AddExposeTarget(new ExposeTargetSelection("Sample::Engine", ExposeRecursionKind.NamespaceRecursive, "@Different"));
+
+ // Assert: the re-add was a no-op, preserving the originally-added selection's bracket filter
+ var afterReAdd = Assert.Single(definition.ExposeTargets);
+ Assert.Equal("@Safety", afterReAdd.BracketFilterExpression);
+
+ // Act: add the same qualified name under a different recursion kind
+ definition.AddExposeTarget(new ExposeTargetSelection("Sample::Engine", ExposeRecursionKind.MembershipExact));
+
+ // Assert: both selections are now present, one per recursion kind
+ Assert.Equal(2, definition.ExposeTargets.Count);
+ Assert.Contains(definition.ExposeTargets, t => t.RecursionKind == ExposeRecursionKind.NamespaceRecursive);
+ Assert.Contains(definition.ExposeTargets, t => t.RecursionKind == ExposeRecursionKind.MembershipExact);
+ }
+
+ ///
+ /// Validates that the overload rejects a null selection.
+ ///
+ [Fact]
+ public void AddExposeTarget_SelectionOverload_NullSelection_ThrowsArgumentNullException()
+ {
+ var definition = new ViewDefinitionModel();
+
+ Assert.Throws(() => definition.AddExposeTarget((ExposeTargetSelection)null!));
+ }
+
///
/// Validates that a definition targeting real workspace elements validates without diagnostics.
///
diff --git a/test/DemaConsulting.SysML2Workbench.Tests/ViewCatalogSubsystem/ViewCatalogPresenterTests.cs b/test/DemaConsulting.SysML2Workbench.Tests/ViewCatalogSubsystem/ViewCatalogPresenterTests.cs
index 53dd08e..7c7d1fa 100644
--- a/test/DemaConsulting.SysML2Workbench.Tests/ViewCatalogSubsystem/ViewCatalogPresenterTests.cs
+++ b/test/DemaConsulting.SysML2Workbench.Tests/ViewCatalogSubsystem/ViewCatalogPresenterTests.cs
@@ -1,3 +1,4 @@
+using DemaConsulting.SysML2Tools.Semantic.Model;
using DemaConsulting.SysML2Workbench.LayoutRenderingSubsystem;
using DemaConsulting.SysML2Workbench.ViewCatalogSubsystem;
using DemaConsulting.SysML2Workbench.WorkspaceSubsystem;
@@ -150,4 +151,107 @@ await File.WriteAllTextAsync(
Assert.Null(presenter.SelectedViewId);
Assert.Null(presenter.GetSelectedView());
}
+
+ ///
+ /// Loads a small workspace containing a view with multiple expose members (differing recursion kinds and
+ /// a bracket filter on the recursive one) and a filter expression, so
+ /// has real fidelity to check.
+ ///
+ private async Task LoadRichViewWorkspaceAsync()
+ {
+ var filePath = Path.Combine(_tempRoot, "Sample.sysml");
+ await File.WriteAllTextAsync(
+ filePath,
+ "package Sample {\n"
+ + " part def Engine;\n"
+ + " part def Wheel;\n"
+ + " view MyView {\n"
+ + " expose Engine;\n"
+ + " expose Wheel::**[@Safety];\n"
+ + " render asGeneralDiagram;\n"
+ + " filter @Critical;\n"
+ + " }\n"
+ + "}\n",
+ TestContext.Current.CancellationToken);
+
+ var model = new WorkspaceModel();
+ var sourceSet = new WorkspaceSourceSet();
+ sourceSet.AddFolder(_tempRoot);
+ return await model.LoadWorkspaceAsync(sourceSet.Sources, sourceSet.Resolve());
+ }
+
+ ///
+ /// Validates that faithfully reconstructs a
+ /// predefined view's kind, every expose member (recursion kind and bracket filter included), filter
+ /// expression, and display name.
+ ///
+ [Fact]
+ public async Task BuildViewDefinition_ResolvesKindExposeAndFilter()
+ {
+ // Arrange
+ var snapshot = await LoadRichViewWorkspaceAsync();
+ var presenter = new ViewCatalogPresenter();
+ presenter.RefreshCatalog(snapshot.Workspace, snapshot.RevisionId);
+
+ // Act
+ var definition = presenter.BuildViewDefinition(snapshot.Workspace, "Sample::MyView");
+
+ // Assert
+ Assert.NotNull(definition);
+ Assert.Equal(ViewKind.General, definition!.ViewKind);
+ Assert.Equal("MyView", definition.DisplayName);
+ Assert.Equal("@Critical", definition.FilterExpression);
+ Assert.Equal(2, definition.ExposeTargets.Count);
+ Assert.Contains(definition.ExposeTargets, t => t.QualifiedName == "Sample::Engine" && t.RecursionKind == ExposeRecursionKind.MembershipExact);
+ Assert.Contains(definition.ExposeTargets, t =>
+ t.QualifiedName == "Sample::Wheel"
+ && t.RecursionKind == ExposeRecursionKind.NamespaceRecursive
+ && t.BracketFilterExpression == "@Safety");
+ }
+
+ ///
+ /// Validates that an unknown view identifier produces rather than throwing.
+ ///
+ [Fact]
+ public async Task BuildViewDefinition_UnknownViewId_ReturnsNull()
+ {
+ var snapshot = await LoadSingleViewWorkspaceAsync();
+ var presenter = new ViewCatalogPresenter();
+ presenter.RefreshCatalog(snapshot.Workspace, snapshot.RevisionId);
+
+ var definition = presenter.BuildViewDefinition(snapshot.Workspace, "Sample::DoesNotExist");
+
+ Assert.Null(definition);
+ }
+
+ ///
+ /// Validates that a predefined view with zero expose members - a valid, unscoped "expose everything" view
+ /// - produces since there is no finite expose list to reconstruct.
+ ///
+ [Fact]
+ public async Task BuildViewDefinition_NoExposeMembers_ReturnsNull()
+ {
+ var filePath = Path.Combine(_tempRoot, "Sample.sysml");
+ await File.WriteAllTextAsync(
+ filePath,
+ "package Sample {\n"
+ + " part def Engine;\n"
+ + " view MyView {\n"
+ + " render asGeneralDiagram;\n"
+ + " }\n"
+ + "}\n",
+ TestContext.Current.CancellationToken);
+
+ var model = new WorkspaceModel();
+ var sourceSet = new WorkspaceSourceSet();
+ sourceSet.AddFolder(_tempRoot);
+ var snapshot = await model.LoadWorkspaceAsync(sourceSet.Sources, sourceSet.Resolve());
+
+ var presenter = new ViewCatalogPresenter();
+ presenter.RefreshCatalog(snapshot.Workspace, snapshot.RevisionId);
+
+ var definition = presenter.BuildViewDefinition(snapshot.Workspace, "Sample::MyView");
+
+ Assert.Null(definition);
+ }
}
diff --git a/test/OtsSoftwareTests/AvaloniaTests.cs b/test/OtsSoftwareTests/AvaloniaTests.cs
index e7d17b9..29963f3 100644
--- a/test/OtsSoftwareTests/AvaloniaTests.cs
+++ b/test/OtsSoftwareTests/AvaloniaTests.cs
@@ -1,6 +1,8 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
+using Avalonia.Input.Platform;
+using Avalonia.Interactivity;
using Avalonia.Threading;
using Avalonia.VisualTree;
using DemaConsulting.SysML2Workbench.AppShellSubsystem;
@@ -128,6 +130,62 @@ await File.WriteAllTextAsync(
window.Close();
}
+ ///
+ /// Validates the real end-to-end "Copy as SysML" diagram-tab context-menu integration: opening a
+ /// workspace, rendering a predefined view into a real hosted by
+ /// , invoking its "Copy as SysML" MenuItem, and confirming the
+ /// headless platform's real TopLevel.Clipboard now holds the exact SysML snippet
+ /// produces for that view's definition.
+ ///
+ [AvaloniaFact]
+ public async Task DiagramContextMenu_CopyAsSysml_CopiesSnippetToClipboard()
+ {
+ // Arrange
+ await File.WriteAllTextAsync(
+ Path.Combine(_tempRoot, "Sample.sysml"),
+ "package Sample {\n"
+ + " part def Engine;\n"
+ + " view PredefinedView {\n"
+ + " expose Engine;\n"
+ + " render asGeneralDiagram;\n"
+ + " }\n"
+ + "}\n");
+ using var shell = CreateShell();
+ var window = new MainWindowView(shell);
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+
+ await shell.AddFolderSourceAsync(_tempRoot);
+ var view = shell.ViewCatalog.AvailableViews[0];
+ shell.SelectPredefinedView(view.QualifiedName);
+ Dispatcher.UIThread.RunJobs();
+
+ var expectedSnippet = shell.ExportTabAsSysmlSnippet(shell.ActiveTabId!);
+ Assert.NotNull(expectedSnippet);
+
+ // Act: find the real diagram border, open its context menu, and click the "Copy as SysML" menu item
+ var diagramBorder = FindByName(window, "DiagramBorder");
+ Assert.NotNull(diagramBorder);
+ var contextMenu = diagramBorder!.ContextMenu;
+ Assert.NotNull(contextMenu);
+ contextMenu!.Open(diagramBorder);
+ Dispatcher.UIThread.RunJobs();
+
+ var copyMenuItem = FindByName