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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# SysML2Workbench

[![GitHub forks][badge-forks]][link-forks]
[![GitHub stars][badge-stars]][link-stars]
[![GitHub contributors][badge-contributors]][link-contributors]
[![License][badge-license]][link-license]
[![Build][badge-build]][link-build]
[![Quality Gate][badge-quality]][link-quality]
[![Security][badge-security]][link-security]
[![Release][badge-release]][link-release]

A cross-platform desktop viewer and GUI custom-view builder for SysML v2
models, built on the SysML2Tools rendering engine.

Expand Down Expand Up @@ -79,3 +88,21 @@ the Desktop application, starts and polls a local Appium server, runs the
tests, and always stops the server afterward; on Linux it publishes the
Desktop application and delegates the whole test run to the pre-installed
`selenium-webdriver-at-spi-run` wrapper (see `docs/design/ots/appium.md`).

[badge-forks]: https://img.shields.io/github/forks/demaconsulting/SysML2Workbench?style=plastic
[badge-stars]: https://img.shields.io/github/stars/demaconsulting/SysML2Workbench?style=plastic
[badge-contributors]: https://img.shields.io/github/contributors/demaconsulting/SysML2Workbench?style=plastic
[badge-license]: https://img.shields.io/github/license/demaconsulting/SysML2Workbench?style=plastic
[badge-build]: https://img.shields.io/github/actions/workflow/status/demaconsulting/SysML2Workbench/build_on_push.yaml?style=plastic
[badge-quality]: https://sonarcloud.io/api/project_badges/measure?project=demaconsulting_SysML2Workbench&metric=alert_status
[badge-security]: https://sonarcloud.io/api/project_badges/measure?project=demaconsulting_SysML2Workbench&metric=security_rating
[badge-release]: https://img.shields.io/github/v/release/demaconsulting/SysML2Workbench?style=plastic

[link-forks]: https://github.com/demaconsulting/SysML2Workbench/network/members
[link-stars]: https://github.com/demaconsulting/SysML2Workbench/stargazers
[link-contributors]: https://github.com/demaconsulting/SysML2Workbench/graphs/contributors
[link-license]: https://github.com/demaconsulting/SysML2Workbench/blob/main/LICENSE
[link-build]: https://github.com/demaconsulting/SysML2Workbench/actions/workflows/build_on_push.yaml
[link-quality]: https://sonarcloud.io/dashboard?id=demaconsulting_SysML2Workbench
[link-security]: https://sonarcloud.io/dashboard?id=demaconsulting_SysML2Workbench
[link-release]: https://github.com/demaconsulting/SysML2Workbench/releases/latest
20 changes: 14 additions & 6 deletions docs/design/sysml2-workbench/workspace-subsystem/file-watcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,18 @@ observed.
**DebounceWindow**: `TimeSpan` — minimum delay used to merge rapid change
bursts, across all active watchers, into a single reload batch.

**PendingChanges**: `IReadOnlySet<string>` — normalized paths queued for the
#### Key Methods

**GetPendingChanges**: Returns the normalized paths currently queued for the
next incremental refresh cycle, merged across every currently watched source.

#### Key Methods
- *Parameters*: `None` — reads the accumulated pending-change state.
- *Returns*: `IReadOnlySet<string>` — a defensive copy of the currently
pending, normalized paths. Implemented as a method rather than a property
because it allocates a new set on every call.
- *Preconditions*: None.
- *Postconditions*: The internal pending-change state is left unchanged;
callers must not assume subsequent calls return the same set instance.

**WatchSource**: Begins monitoring the given source, retargeting an
already-watched source with the same id if one exists.
Expand All @@ -50,7 +58,7 @@ already-watched source with the same id if one exists.
- *Returns*: `bool` — whether a matching watcher was found and disposed.
- *Postconditions*: The matching watcher, if any, is disposed and removed from
`WatchedSourceIds`. Unlike the old single-watcher `StartWatching` retarget
behavior, this does **not** clear `PendingChanges` - pending changes queued
behavior, this does **not** clear the pending-change set - pending changes queued
by other still-watched sources must survive an unrelated source being
removed.

Expand All @@ -64,7 +72,7 @@ already-watched source with the same id if one exists.
notifications for the same path collapse into a single pending change
entry, regardless of which watched source's watcher raised the underlying
event. When no source is watched, the notification is silently ignored and
`PendingChanges` is left unchanged.
the pending-change set is left unchanged.

**FlushPendingChanges**: Dispatches the current batch to the workspace reload
pipeline.
Expand All @@ -74,7 +82,7 @@ pipeline.
merged across every watcher that reported a change since the last flush.
- *Preconditions*: None - zero watched sources is a valid state and simply
yields an empty result.
- *Postconditions*: Returned paths are removed from `PendingChanges` and are
- *Postconditions*: Returned paths are removed from the pending-change set and are
ready for `WorkspaceModel.ReloadFiles`.

#### Error Handling
Expand All @@ -91,7 +99,7 @@ best-effort and does not replace propagation to the shell.

Every `FileSystemWatcher` raises its `Changed`/`Created`/`Deleted`/`Renamed`
events on an operating-system callback thread, independent of whatever thread
calls `WatchSource`/`UnwatchSource`/`FlushPendingChanges`/`PendingChanges`.
calls `WatchSource`/`UnwatchSource`/`FlushPendingChanges`/`GetPendingChanges`.
Production usage marshals those callbacks onto the UI thread first, making
this uncontended in practice, but a caller that supplies (or defaults to) an
immediate, non-marshaling dispatcher runs them synchronously on the OS
Expand Down
2 changes: 1 addition & 1 deletion src/DemaConsulting.SysML2Workbench/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public override void OnFrameworkInitializationCompleted()

if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var logDirectory = Path.Combine(
var logDirectory = PathHelpers.SafePathCombine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"SysML2Workbench",
"logs");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,26 @@ public sealed class AboutDialogViewModel
{
private const string UnknownVersion = "Unknown";

/// <summary>
/// License name shared by every entry in <see cref="Dependencies" />; every key OSS dependency this
/// application bundles happens to use the same license, so this avoids repeating the literal string.
/// </summary>
private const string MitLicense = "MIT License";

/// <summary>
/// The application's display name.
/// </summary>
public string ApplicationName => "SysML2Workbench";
public static string ApplicationName => "SysML2Workbench";

/// <summary>
/// Short description of what the application does.
/// </summary>
public string Tagline => "Cross-platform desktop viewer and IDE for SysML v2 models";
public static string Tagline => "Cross-platform desktop viewer and IDE for SysML v2 models";

/// <summary>
/// Application copyright text, matching the format used by the repository's own <c>LICENSE</c> file.
/// </summary>
public string Copyright => "Copyright (c) 2026 DEMA Consulting";
public static string Copyright => "Copyright (c) 2026 DEMA Consulting";

/// <summary>
/// The running assembly's build-stamped version, read at runtime so it always reflects the actual build
Expand All @@ -51,12 +57,12 @@ public sealed class AboutDialogViewModel
/// </summary>
public IReadOnlyList<DependencyInfo> Dependencies { get; } =
[
new DependencyInfo("Avalonia", "MIT License"),
new DependencyInfo("Dock.Avalonia", "MIT License"),
new DependencyInfo("Material.Icons.Avalonia", "MIT License"),
new DependencyInfo("CommunityToolkit.Mvvm", "MIT License"),
new DependencyInfo("DemaConsulting.SysML2Tools", "MIT License"),
new DependencyInfo("DemaConsulting.Rendering", "MIT License"),
new DependencyInfo("Avalonia", MitLicense),
new DependencyInfo("Dock.Avalonia", MitLicense),
new DependencyInfo("Material.Icons.Avalonia", MitLicense),
new DependencyInfo("CommunityToolkit.Mvvm", MitLicense),
new DependencyInfo("DemaConsulting.SysML2Tools", MitLicense),
new DependencyInfo("DemaConsulting.Rendering", MitLicense),
];

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@ public static MainWindowShell Create()
new LayoutInvoker(),
new DiagnosticsListView(),
new SysmlSnippetGenerator(),
new RollingFileLogger(Path.Combine(Path.GetTempPath(), "SysML2Workbench-DesignTime")));
new RollingFileLogger(PathHelpers.SafePathCombine(Path.GetTempPath(), "SysML2Workbench-DesignTime")));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ public partial class DiagnosticsToolViewModel : Dock.Model.Mvvm.Controls.Tool
private readonly MainWindowShell _shell;

[ObservableProperty]
private IReadOnlyList<SysmlDiagnostic> _visibleDiagnostics = [];
public partial IReadOnlyList<SysmlDiagnostic> VisibleDiagnostics { get; set; } = [];

[ObservableProperty]
private string? _emptyStateMessage;
public partial string? EmptyStateMessage { get; set; }

[ObservableProperty]
private bool _hasEmptyStateMessage;
public partial bool HasEmptyStateMessage { get; set; }

/// <summary>
/// Creates the diagnostics tool view model.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public MainWindowView(MainWindowShell shell)
var factory = new WorkbenchDockFactory(_predefinedViewsViewModel, _diagnosticsViewModel, _workspacePanelViewModel);
var layout = factory.CreateLayout();
factory.InitLayout(layout);
WorkbenchDockControl.Layout = (IDock)layout;
WorkbenchDockControl.Layout = layout;
_dockFactory = factory;

_dockFactory.FocusedDockableChanged += OnFocusedDockableChanged;
Expand Down Expand Up @@ -288,6 +288,10 @@ private async void OnAddFileSourceClick(object? sender, RoutedEventArgs e)
}
catch (Exception ex)
{
// Intentionally broad: the storage-provider/file-source pipeline can surface a wide range of
// exception types (I/O, permission, parser, platform-picker) and this handler is a UI event
// boundary that must not let any of them escape and crash the application - reporting the
// failure to the user via the status message is the correct outcome for every cause.
_workspacePanelViewModel.StatusMessage = $"Failed to open file: {ex.Message}";
}
}
Expand Down Expand Up @@ -319,6 +323,8 @@ private async void OnAddFolderSourceClick(object? sender, RoutedEventArgs e)
}
catch (Exception ex)
{
// Intentionally broad: see OnAddFileSourceClick - this is the same kind of UI event boundary
// over the same folder-source pipeline, so any exception type must be reported, not propagated.
_workspacePanelViewModel.StatusMessage = $"Failed to open folder: {ex.Message}";
}
}
Expand All @@ -336,6 +342,8 @@ private async void OnCloseAllMenuItemClick(object? sender, RoutedEventArgs e)
}
catch (Exception ex)
{
// Intentionally broad: same UI event-boundary rationale as OnAddFileSourceClick - closing
// every source can fail for any number of reasons and must never crash the application.
_workspacePanelViewModel.StatusMessage = $"Failed to close all sources: {ex.Message}";
}
}
Expand All @@ -352,9 +360,8 @@ private async void OnWindowDrop(object? sender, DragEventArgs e)
return;
}

foreach (var item in e.DataTransfer.TryGetFiles() ?? [])
foreach (var path in (e.DataTransfer.TryGetFiles() ?? []).Select(item => item.TryGetLocalPath()))
{
var path = item.TryGetLocalPath();
if (string.IsNullOrEmpty(path))
{
continue;
Expand All @@ -373,14 +380,17 @@ private async void OnWindowDrop(object? sender, DragEventArgs e)
}
catch (Exception ex)
{
// Intentionally broad: this loop drives a drag-and-drop event boundary over a per-item
// add-source call that can fail for any reason - one bad dropped path must not abort the
// loop or crash the application, only be reported and skipped.
_workspacePanelViewModel.StatusMessage = $"Failed to open dropped path '{path}': {ex.Message}";
}
}

RefreshPanelsFromWorkspace();
}

private void OnWindowDragOver(object? sender, DragEventArgs e)
private static void OnWindowDragOver(object? sender, DragEventArgs e)
{
e.DragEffects = e.DataTransfer.Formats.Contains(DataFormat.File) ? DragDropEffects.Copy : DragDropEffects.None;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@ public partial class PredefinedViewsToolViewModel : Dock.Model.Mvvm.Controls.Too
private readonly MainWindowShell _shell;

[ObservableProperty]
private IReadOnlyList<ViewDescriptor> _availableViews = [];
public partial IReadOnlyList<ViewDescriptor> AvailableViews { get; set; } = [];

[ObservableProperty]
private ViewDescriptor? _selectedView;
public partial ViewDescriptor? SelectedView { get; set; }

[ObservableProperty]
private string? _statusMessage;
public partial string? StatusMessage { get; set; }

[ObservableProperty]
private bool _isWorkspaceEmpty;
public partial bool IsWorkspaceEmpty { get; set; }

/// <summary>
/// Creates the predefined-views tool view model.
Expand Down Expand Up @@ -63,6 +63,9 @@ partial void OnSelectedViewChanged(ViewDescriptor? value)
}
catch (Exception ex)
{
// Intentionally broad: this is a UI selection-changed event boundary over the predefined-view
// rendering pipeline, which can fail for any reason (missing element, layout error, renderer
// fault) - every cause must be reported via StatusMessage, never crash the application.
StatusMessage = $"Failed to render '{value.DisplayName}': {ex.Message}";
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,28 +75,28 @@ public sealed partial class QueryDialogViewModel : ObservableObject
new Dictionary<string, SysmlNode>(StringComparer.Ordinal);

[ObservableProperty]
private bool _includeStdlib;
public partial bool IncludeStdlib { get; set; }

[ObservableProperty]
private bool _isWorkspaceEmpty;
public partial bool IsWorkspaceEmpty { get; set; }

[ObservableProperty]
private QueryVerb _selectedQueryType = QueryVerb.List;
public partial QueryVerb SelectedQueryType { get; set; } = QueryVerb.List;

[ObservableProperty]
private string? _hierarchyDirection = "both";
public partial string? HierarchyDirection { get; set; } = "both";

[ObservableProperty]
private string? _walkDepthText;
public partial string? WalkDepthText { get; set; }

[ObservableProperty]
private QueryResult? _currentResult;
public partial QueryResult? CurrentResult { get; set; }

[ObservableProperty]
private IReadOnlyList<QueryResultRow> _currentResultRows = [];
public partial IReadOnlyList<QueryResultRow> CurrentResultRows { get; set; } = [];

[ObservableProperty]
private string? _statusMessage;
public partial string? StatusMessage { get; set; }

/// <summary>
/// Creates the dialog view model over <paramref name="shell" /> and immediately populates the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
/// <returns>The loaded, registered highlighting definition.</returns>
private static IHighlightingDefinition LoadSysMlHighlighting()
{
using var stream = AssetLoader.Open(new Uri("avares://DemaConsulting.SysML2Workbench/Assets/SysML.xshd"));

Check warning on line 70 in src/DemaConsulting.SysML2Workbench/AppShellSubsystem/SourceTextDocumentView.axaml.cs

View workflow job for this annotation

GitHub Actions / Build / Build ubuntu-latest

Refactor your code not to use hardcoded absolute paths or URIs.

Check warning on line 70 in src/DemaConsulting.SysML2Workbench/AppShellSubsystem/SourceTextDocumentView.axaml.cs

View workflow job for this annotation

GitHub Actions / Build / Build ubuntu-latest

Refactor your code not to use hardcoded absolute paths or URIs.

Check warning on line 70 in src/DemaConsulting.SysML2Workbench/AppShellSubsystem/SourceTextDocumentView.axaml.cs

View workflow job for this annotation

GitHub Actions / Build / Build macos-latest

Refactor your code not to use hardcoded absolute paths or URIs.

Check warning on line 70 in src/DemaConsulting.SysML2Workbench/AppShellSubsystem/SourceTextDocumentView.axaml.cs

View workflow job for this annotation

GitHub Actions / Build / Build macos-latest

Refactor your code not to use hardcoded absolute paths or URIs.

Check warning on line 70 in src/DemaConsulting.SysML2Workbench/AppShellSubsystem/SourceTextDocumentView.axaml.cs

View workflow job for this annotation

GitHub Actions / Build / Build windows-latest

Refactor your code not to use hardcoded absolute paths or URIs.

Check warning on line 70 in src/DemaConsulting.SysML2Workbench/AppShellSubsystem/SourceTextDocumentView.axaml.cs

View workflow job for this annotation

GitHub Actions / Build / Build windows-latest

Refactor your code not to use hardcoded absolute paths or URIs.
using var reader = XmlReader.Create(stream);
var definition = HighlightingLoader.Load(reader, HighlightingManager.Instance);

Expand All @@ -75,7 +75,9 @@
var keywordPattern = $@"\b(?:{string.Join("|", GetKeywords())})\b";
definition.MainRuleSet.Rules.Add(new HighlightingRule
{
Regex = new Regex(keywordPattern),
// A bounded timeout guards against catastrophic backtracking on pathological input,
// since the keyword pattern is applied to arbitrary user-supplied source text.
Regex = new Regex(keywordPattern, RegexOptions.None, TimeSpan.FromSeconds(2)),
Color = keywordColor,
});

Expand All @@ -95,12 +97,17 @@
{
try
{
// Reflecting a private, static, generated field is intentional and safe here: it is
// read-only (never written via reflection), scoped to a single well-known third-party
// type (SysMLv2Lexer), and wrapped in a try/catch with a hard-coded fallback list below,
// so if the field is ever renamed or removed by a future SysML2Tools release, this
// degrades gracefully instead of crashing.
var field = typeof(SysMLv2Lexer).GetField("_LiteralNames", BindingFlags.NonPublic | BindingFlags.Static);

Check warning on line 105 in src/DemaConsulting.SysML2Workbench/AppShellSubsystem/SourceTextDocumentView.axaml.cs

View workflow job for this annotation

GitHub Actions / Build / Build ubuntu-latest

Make sure that this accessibility bypass is safe here.

Check warning on line 105 in src/DemaConsulting.SysML2Workbench/AppShellSubsystem/SourceTextDocumentView.axaml.cs

View workflow job for this annotation

GitHub Actions / Build / Build ubuntu-latest

Make sure that this accessibility bypass is safe here.

Check warning on line 105 in src/DemaConsulting.SysML2Workbench/AppShellSubsystem/SourceTextDocumentView.axaml.cs

View workflow job for this annotation

GitHub Actions / Build / Build macos-latest

Make sure that this accessibility bypass is safe here.

Check warning on line 105 in src/DemaConsulting.SysML2Workbench/AppShellSubsystem/SourceTextDocumentView.axaml.cs

View workflow job for this annotation

GitHub Actions / Build / Build macos-latest

Make sure that this accessibility bypass is safe here.

Check warning on line 105 in src/DemaConsulting.SysML2Workbench/AppShellSubsystem/SourceTextDocumentView.axaml.cs

View workflow job for this annotation

GitHub Actions / Build / Build windows-latest

Make sure that this accessibility bypass is safe here.

Check warning on line 105 in src/DemaConsulting.SysML2Workbench/AppShellSubsystem/SourceTextDocumentView.axaml.cs

View workflow job for this annotation

GitHub Actions / Build / Build windows-latest

Make sure that this accessibility bypass is safe here.
if (field?.GetValue(null) is string[] literalNames)
{
var keywords = literalNames
.Where(name => name is not null)
.Select(name => name!.Trim('\''))
.Select(name => name.Trim('\''))
.Where(name => name.Length > 0 && char.IsLetter(name[0]))
.Distinct()
.ToList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ public sealed partial class ViewBuilderDialogViewModel : ObservableObject
private const string DefaultTypeFilterLabel = "part";

[ObservableProperty]
private string? _statusMessage;
public partial string? StatusMessage { get; set; }

[ObservableProperty]
private bool _isWorkspaceEmpty;
public partial bool IsWorkspaceEmpty { get; set; }

/// <summary>
/// Creates the dialog view model, refreshing the available expose-target picker list from the shell's
Expand Down Expand Up @@ -214,6 +214,9 @@ public void RenderPreview()
}
catch (Exception ex)
{
// Intentionally broad: RenderPreview is documented as "never throws" and runs after every
// single control edit, so any exception type from the render pipeline (a mid-edit definition
// is routinely incomplete/invalid) must be swallowed and reported via StatusMessage instead.
PreviewCanvas.Clear();
StatusMessage = $"Preview unavailable: {ex.Message}";
}
Expand Down Expand Up @@ -254,6 +257,9 @@ public bool TryCommit(out string? error)
}
catch (Exception ex)
{
// Intentionally broad: TryCommit's contract (see its <returns> doc) is to report every render
// failure through the out `error`/StatusMessage rather than throw, so the just-opened tab can
// always be rolled back regardless of which exception type the render pipeline raised.
Shell.CloseDiagramTab(tab.Id);
error = $"Commit failed: {ex.Message}";
StatusMessage = error;
Expand Down
Loading
Loading