diff --git a/.cspell.yaml b/.cspell.yaml index 6477d62..bd9e83a 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -11,6 +11,7 @@ version: '0.2' language: en words: + - APFS - Argb - axaml - BGRA @@ -21,12 +22,15 @@ words: - Dema - doctitle - docversion + - Dedupe + - dedupe - Dockable - Dockables - fileassert - Hmmssfff - LOCALAPPDATA - Mvvm + - ordinally - Pandoc - pagetitle - pasteable @@ -46,8 +50,19 @@ words: - sonar - sonarmark - Stdlib + - Subfolders + - subfolders - sysml - timeslice + - TOCTOU + - uncontended + - unioned + - Unwatch + - unwatch + - Unwatches + - unwatches + - Unwatching + - unwatching - versionmark - Weasyprint - xunit diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 32346b8..3ef6c55 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -118,6 +118,20 @@ reviews: - docs/verification/sysml2-workbench/workspace-subsystem/diagnostics-aggregator.md - src/**/WorkspaceSubsystem/DiagnosticsAggregator.cs - test/**/WorkspaceSubsystem/DiagnosticsAggregatorTests.cs + - id: SysML2Workbench-WorkspaceSubsystem-WorkspaceSourceSet + title: Review that SysML2Workbench WorkspaceSubsystem WorkspaceSourceSet Implementation + is Correct + context: + - docs/design/sysml2-workbench.md + - docs/reqstream/sysml2-workbench.yaml + - docs/design/sysml2-workbench/workspace-subsystem.md + - docs/reqstream/sysml2-workbench/workspace-subsystem.yaml + paths: + - docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-source-set.yaml + - docs/design/sysml2-workbench/workspace-subsystem/workspace-source-set.md + - docs/verification/sysml2-workbench/workspace-subsystem/workspace-source-set.md + - src/**/WorkspaceSubsystem/WorkspaceSourceSet.cs + - test/**/WorkspaceSubsystem/WorkspaceSourceSetTests.cs - id: SysML2Workbench-ViewCatalogSubsystem title: Review that SysML2Workbench ViewCatalogSubsystem Satisfies Subsystem Requirements context: @@ -292,6 +306,22 @@ reviews: - docs/verification/sysml2-workbench/app-shell-subsystem/main-window-shell.md - src/**/AppShellSubsystem/MainWindowShell.cs - test/**/AppShellSubsystem/MainWindowShellTests.cs + - id: SysML2Workbench-AppShellSubsystem-WorkspacePanel + title: Review that SysML2Workbench AppShellSubsystem WorkspacePanel Implementation + is Correct + context: + - docs/design/sysml2-workbench.md + - docs/reqstream/sysml2-workbench.yaml + - docs/design/sysml2-workbench/app-shell-subsystem.md + - docs/reqstream/sysml2-workbench/app-shell-subsystem.yaml + paths: + - docs/reqstream/sysml2-workbench/app-shell-subsystem/workspace-panel.yaml + - docs/design/sysml2-workbench/app-shell-subsystem/workspace-panel.md + - docs/verification/sysml2-workbench/app-shell-subsystem/workspace-panel.md + - src/**/AppShellSubsystem/WorkspacePanelToolViewModel.cs + - src/**/AppShellSubsystem/WorkspacePanelToolView.axaml + - src/**/AppShellSubsystem/WorkspacePanelToolView.axaml.cs + - test/**/AppShellSubsystem/WorkspacePanelToolViewModelTests.cs - id: OTS-DemaConsultingSysML2Tools title: Review that DemaConsulting.SysML2Tools Provides Required Functionality paths: diff --git a/docs/design/sysml2-workbench/app-shell-subsystem.md b/docs/design/sysml2-workbench/app-shell-subsystem.md index c22447c..e724ee2 100644 --- a/docs/design/sysml2-workbench/app-shell-subsystem.md +++ b/docs/design/sysml2-workbench/app-shell-subsystem.md @@ -6,9 +6,10 @@ AppShellSubsystem is the composition root for the desktop application. It owns the main window, navigation flow, and tabbed viewing experience that tie the -other subsystems into one coherent user workflow. Its only documented unit is -MainWindowShell. The thin desktop platform head remains outside the documented -unit set because it is only a bootstrap and lifetime host. +other subsystems into one coherent user workflow. Its documented units are +MainWindowShell and WorkspacePanel. The thin desktop platform head remains +outside the documented unit set because it is only a bootstrap and lifetime +host. ### Interfaces @@ -51,18 +52,28 @@ environment. user workflow without pushing UI-specific code into those subsystems. 5. The desktop platform head initializes the shell and application lifetime but remains a bootstrap concern rather than a separately documented unit. -6. The Avalonia UI layer presents MainWindowShell's panels (predefined - views, custom view builder, diagnostics, and diagram) as a resizable, - floatable Dock layout, composed by a Dock factory from thin panel views - and view models that hold no logic beyond forwarding to MainWindowShell - and binding its state. The three Tool panels (predefined views, custom - view builder, diagnostics) are closable and restorable via a View menu, - which reuses the same long-lived panel view model instance so its - in-progress state survives the close/restore cycle. The diagram area - hosts zero or more independently closable diagram Documents - one per - open `WorkbenchTab`, each bound to its own `SvgCanvasHost` - and the - Dock `DocumentDock` container itself remains visibly present even when - no diagram tab is open, so an empty diagram area is a normal, supported - state rather than a dead end. MainWindowShell itself has no dependency - on Avalonia or Dock and is unaware of how its panels are arranged on - screen. +6. The Avalonia UI layer presents MainWindowShell's panels (workspace, + predefined views, custom view builder, diagnostics, and diagram) as a + resizable, floatable Dock layout, composed by a Dock factory from thin + panel views and view models that hold no logic beyond forwarding to + MainWindowShell and binding its state. The four Tool panels (workspace, + predefined views, custom view builder, diagnostics) are closable and + restorable via a View menu, which reuses the same long-lived panel view + model instance so its in-progress state survives the close/restore + cycle. The diagram area hosts zero or more independently closable diagram + Documents - one per open `WorkbenchTab`, each bound to its own + `SvgCanvasHost` - and the Dock `DocumentDock` container itself remains + visibly present even when no diagram tab is open, so an empty diagram + area is a normal, supported state rather than a dead end. MainWindowShell + itself has no dependency on Avalonia or Dock and is unaware of how its + panels are arranged on screen. +7. A workspace with zero sources open is a first-class, non-error state, not + a precondition failure: `MainWindowShell.CurrentWorkspace` is never null + and starts as a valid empty snapshot at construction. Every panel that + depends on workspace content - the workspace panel, predefined views, + custom view builder, and diagnostics - presents a distinct, friendly + empty-state message when `Sources.Count == 0` rather than rendering + against an empty `SysmlWorkspace` or leaving a blank pane; the diagnostics + panel in particular distinguishes "no diagnostics because the workspace is + empty" from "no diagnostics because everything is clean," since those are + different facts the user needs to tell apart. 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 061f3a1..3bc5d12 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 @@ -10,8 +10,11 @@ snippet export within a single windowed user experience. #### Data Model -**CurrentWorkspace**: `WorkspaceSnapshot?` — currently loaded workspace and its -revision metadata. +**CurrentWorkspace**: `WorkspaceSnapshot` — currently loaded workspace and its +revision metadata. Never `null`: initialized at construction to a valid empty +snapshot (zero sources, zero files, stdlib-only) rather than left unset until +the first source is added, so every consumer can safely read +`CurrentWorkspace.Sources` without a null guard. **ActivePredefinedView**: `ViewDescriptor?` — selected catalog view, if the user is in predefined-view mode. @@ -29,27 +32,55 @@ active/focused, or `null` when no diagram tab is open. Updated both by shell operations that open or focus a tab, and by the UI layer forwarding Dock's own focus-change signal via `NotifyActiveDiagramTab`. -#### Key Methods +**CurrentSourceIdToFiles**: `IReadOnlyDictionary>` +— per-source file lists from the most recent workspace resolution, used by +WorkspacePanel to build its tree without owning its own `WorkspaceSourceSet` +instance. -**OpenWorkspace**: Loads a new workspace into the shell. +#### Key Methods -- *Parameters*: `string rootPath` — user-selected folder. -- *Returns*: `void` — shell state updates in place. -- *Preconditions*: `rootPath` exists and is readable. -- *Postconditions*: WorkspaceSubsystem is initialized, the view catalog is - refreshed, diagnostics are displayed, and every open tab is closed - (`ActiveTabId` becomes `null`) since a reloaded workspace invalidates every - currently-rendered diagram. The file watcher is retargeted to `rootPath` on - every call, not only the first: any previously watched root is torn down and - its pending change state discarded, so a second or later workspace open - never leaves the watcher bound to a stale folder. +**AddFileSource**: Adds a single file as a new workspace source. + +- *Parameters*: `string path` — file to add. +- *Returns*: `WorkspaceSnapshot` — the reloaded workspace snapshot. +- *Postconditions*: The path is registered on the shell's owned + `WorkspaceSourceSet`, the source set is re-resolved, `WorkspaceModel` is + reloaded against the new resolution, the file watcher begins watching the + new source (a non-recursive, filtered watcher for a file source), the + resulting snapshot is applied (existing tabs are cleared, since a reload + invalidates every currently-rendered diagram), and `SourcesChanged` is + raised. Idempotent: adding the same path twice does not duplicate the + source. + +**AddFolderSource**: Adds a folder as a new workspace source. + +- *Parameters*: `string path` — folder to add. +- *Returns*: `WorkspaceSnapshot` — the reloaded workspace snapshot. +- *Postconditions*: Identical to `AddFileSource`, except the source is a + recursively watched folder and its files are discovered via + `WorkspaceSourceSet.Resolve()`'s default glob options. + +**RemoveSource**: Removes a previously registered source. + +- *Parameters*: `string sourceId` — id of the source to remove. +- *Returns*: `WorkspaceSnapshot` — the reloaded workspace snapshot. +- *Postconditions*: The source is removed from the source set (a no-op for + an unknown id), the set is re-resolved, `WorkspaceModel` is reloaded, the + now-unwatched source's watcher is disposed, the resulting snapshot is + applied, and `SourcesChanged` is raised. Removing the last remaining source + produces a valid empty snapshot (zero sources, zero files) - the same + first-class empty state the shell starts in at construction - and still + correctly clears every open tab, not only when shrinking to a smaller + nonzero file set. + +#### Additional Key Methods **SelectPredefinedView**: Renders a predefined view selected by the user. - *Parameters*: `string viewId` — identifier from ViewCatalogPresenter. - *Returns*: `void` — active diagram state updates in place. -- *Preconditions*: A workspace is loaded and the view identifier is present in - the current catalog. +- *Preconditions*: `CurrentWorkspace.Sources.Count > 0` and the view + identifier is present in the current catalog. - *Postconditions*: `ActivePredefinedView` is updated; a tab identified by the view's qualified name is opened (or, if already open, reused) and rendered into; that tab becomes `ActiveTabId` either way, so selecting an @@ -61,7 +92,8 @@ own focus-change signal via `NotifyActiveDiagramTab`. - *Parameters*: `ViewDefinitionModel definition` — normalized custom-view state. - *Returns*: `void` — active diagram state updates in place. -- *Preconditions*: The definition validates against the current workspace. +- *Preconditions*: `CurrentWorkspace.Sources.Count > 0` and the definition + validates against the current workspace. - *Postconditions*: `ActiveCustomView` is updated. If the currently active tab is itself a custom-view-preview tab, it is re-rendered in place (same tab identity and canvas); otherwise a brand-new custom-view-preview tab is @@ -116,13 +148,23 @@ workspace is reloaded (which clears every tab). The Avalonia-aware UI layer add or remove Dock dockables directly. The notification is raised through an injected `IUiDispatcher`, so subscribers are guaranteed to observe it on the dispatcher's target thread even though methods that trigger it - most notably -`OpenWorkspace`, which awaits workspace loading with `ConfigureAwait(false)` - -may themselves resume on a background thread pool thread once the load -completes. +`AddFolderSourceAsync`, which awaits workspace loading with +`ConfigureAwait(false)` - may themselves resume on a background thread pool +thread once the load completes. + +**SourcesChanged**: Raised whenever the set of workspace sources changes - a +source is added or removed and the resulting resolution has been applied. +Parallel in structure and marshaling behavior to `TabsChanged`: raised through +the same injected `IUiDispatcher`. `WorkspacePanelToolViewModel` subscribes to +this to rebuild its tree, and `CurrentSourceIdToFiles` reflects the change by +the time subscribers observe the notification. #### Dependencies - **WorkspaceSubsystem** — loads and refreshes the workspace state. +- **WorkspaceSourceSet** — owned by the shell; mutated by + `AddFileSourceAsync`/`AddFolderSourceAsync`/`RemoveSourceAsync` and + re-resolved before every `WorkspaceModel` load, reload, and watcher diff. - **ViewCatalogPresenter** — supplies predefined view choices. - **ViewDefinitionModel** — captures custom-view authoring state. - **SysmlSnippetGenerator** — exports custom-view definitions as SysML text. @@ -130,9 +172,9 @@ completes. - **SvgCanvasHost** — displays the active diagram. - **DiagnosticsListView** — displays workspace diagnostics. - **RollingFileLogger** — records shell-level operational failures. -- **IUiDispatcher** — marshals `TabsChanged` notifications onto the thread - required by UI-facing subscribers; defaults to an immediate, synchronous - dispatcher when none is supplied. +- **IUiDispatcher** — marshals `TabsChanged` and `SourcesChanged` + notifications onto the thread required by UI-facing subscribers; defaults + to an immediate, synchronous dispatcher when none is supplied. - **Avalonia** — provides the window, tab, and application-lifetime framework. #### Callers diff --git a/docs/design/sysml2-workbench/app-shell-subsystem/workspace-panel.md b/docs/design/sysml2-workbench/app-shell-subsystem/workspace-panel.md new file mode 100644 index 0000000..8ac3bb8 --- /dev/null +++ b/docs/design/sysml2-workbench/app-shell-subsystem/workspace-panel.md @@ -0,0 +1,127 @@ +### WorkspacePanel + +![AppShellSubsystem Structure](AppShellSubsystemView.svg) + +#### Purpose + +WorkspacePanel is the Dock tool panel that lets the user see, add, and remove +the workspace's file and folder sources as a tree, and accepts drag-and-drop +of files/folders as an alternative to picker dialogs. It is documented as one +unit covering both `WorkspacePanelToolViewModel` (the presentation/command +logic) and `WorkspacePanelToolView` (the Avalonia `TreeView`-based Dock tool +view), matching the pairing convention used by the other three tool panels +(`PredefinedViewsToolViewModel`/`View`, `CustomViewBuilderToolViewModel`/ +`View`, `DiagnosticsToolViewModel`/`View`). + +#### Data Model + +**RootNodes**: `IReadOnlyList` — one `WorkspaceSourceNode` +per workspace source, in the same order as `MainWindowShell.CurrentWorkspace.Sources`. + +**WorkspaceTreeNode**: abstract base with `required string Id { get; init; }` +— the tree control's item key — and `abstract MaterialIconKind IconKind`, the +icon shown alongside the node. + +**WorkspaceSourceNode**: `WorkspaceTreeNode` with `required WorkspaceSource Source` +and `required IReadOnlyList Children` — a source's node. +`Children` is empty for a `File`-kind source (a leaf, no expand arrow); for a +`Folder`-kind source it holds one entry per top-level subfolder +(`WorkspaceFolderNode`) or file (`WorkspaceFileNode`) directly under that +folder, preserving its on-disk hierarchy rather than flattening every +discovered file into a single list. + +**WorkspaceFolderNode**: `WorkspaceTreeNode` with `required string Name` (the +folder's own last path segment, not its full path) and +`required IReadOnlyList Children` — an intermediate, +purely-display grouping node for one subfolder discovered under a +`Folder`-kind source. It carries no identity used for removal (only whole +sources are removable) or file-watching (the owning source's watch scope +already covers everything beneath it). + +**WorkspaceFileNode**: `WorkspaceTreeNode` with `required string FilePath`, +`required string SourceId`, and a computed `Name` (`Path.GetFileName(FilePath)`, +shown in the tree since ancestor nodes already convey the file's directory) — +a leaf node for one file. `FilePath` is a stable identity intended for a +future, out-of-scope, double-click read-only file viewer; it is preserved +even though nothing else currently reads it beyond display. + +**SelectedNode**: `WorkspaceTreeNode?` — the tree node currently selected by +the user, used to resolve which source `RemoveSelected` acts on. + +**IsEmpty**: `bool` — `true` when `RootNodes` is empty (zero workspace +sources), used to show the panel's root-level empty-state message. + +**StatusMessage**: `string?` — set when a command (currently only Remove) +fails, so the view can surface the failure inline without a modal dialog. + +#### Key Methods + +**RebuildTree**: Rebuilds `RootNodes` from the shell's current workspace +state. + +- *Parameters*: `None` — reads `MainWindowShell.CurrentWorkspace.Sources` and + `MainWindowShell.CurrentSourceIdToFiles`. +- *Returns*: `void` — `RootNodes` and `IsEmpty` update in place. +- *Postconditions*: One `WorkspaceSourceNode` exists per current source, each + with the correct `Children` shape for its kind; overlap dedupe already + resolved upstream by `WorkspaceSourceSet.Resolve()` is reflected as a single + attribution in the tree shape (a deduplicated file appears once, under its + attributed owning source, not once per overlapping source). A folder + source's flat, per-source file list is grouped by each file's position + relative to that source's own path, via a private `BuildFolderChildren` + helper, so the resulting `Children` mirror the folder's on-disk + hierarchy — subfolders (as `WorkspaceFolderNode`s, sorted before files) and + files (as `WorkspaceFileNode`s), each level sorted alphabetically by name. + Called eagerly from the constructor and every time + `MainWindowShell.SourcesChanged` fires. + +**AddFile** / **AddFolder**: Commands that raise `RequestAddFile` / +`RequestAddFolder` so the Avalonia-aware view can fulfill them with a real +picker (this view model has no direct `StorageProvider` access) and call back +into `MainWindowShell.AddFileSourceAsync` / `AddFolderSourceAsync`. + +- *Parameters*: `None`. +- *Returns*: `void` — an event is raised; the view performs the actual add. + +**RemoveSelected**: Removes the source owning `SelectedNode`. + +- *Parameters*: `None` — uses `SelectedNode`. +- *Returns*: `Task` — completes once the shell has removed the source and + reapplied the resulting snapshot. +- *Postconditions*: If nothing is selected, this is a no-op. Otherwise + resolves the owning source id (`WorkspaceSourceNode.Source.Id` or + `WorkspaceFileNode.SourceId`) and calls + `MainWindowShell.RemoveSourceAsync(sourceId)`; a thrown failure is caught + and surfaced via `StatusMessage` rather than propagated, since a failed + remove should not crash the shell. + +#### Error Handling + +WorkspacePanel treats a failed `RemoveSelectedAsync` as a locally recoverable +condition surfaced through `StatusMessage`, since the shell's workspace state +remains valid regardless of whether the remove succeeded. It does not perform +its own file-system validation for drag-and-drop drops: the view checks +`File.Exists`/`Directory.Exists` per dropped path and calls +`MainWindowShell.AddFileSourceAsync`/`AddFolderSourceAsync` exactly as the +picker-driven commands do, so add-time failures (for example a folder that +disappears between the drop and the add call) surface through the same shell +exception path as picker-driven adds. + +#### Dependencies + +- **MainWindowShell** — sole source of truth for `CurrentWorkspace.Sources` + and `CurrentSourceIdToFiles`; owns `AddFileSourceAsync`, + `AddFolderSourceAsync`, `RemoveSourceAsync`, and `SourcesChanged`, all of + which this unit calls or subscribes to but never reimplements. +- **Avalonia** — `WorkspacePanelToolView` is a `TreeView`-based Dock tool + view; the view (not the view model) owns `StorageProvider` pickers and + `DragDrop` handling. +- **Dock** — `WorkspacePanelToolViewModel` is a `Dock.Model.Mvvm.Controls.Tool`, + hosted in `WorkbenchDockFactory`'s layout alongside the other three panels. + +#### Callers + +- **WorkbenchDockFactory** — constructs the Dock layout tab hosting this + panel. +- **MainWindowView** — constructs the view model instance shared with the + Dock factory and wires `RequestAddFile`/`RequestAddFolder` to real pickers. diff --git a/docs/design/sysml2-workbench/workspace-subsystem.md b/docs/design/sysml2-workbench/workspace-subsystem.md index 4a62683..0065522 100644 --- a/docs/design/sysml2-workbench/workspace-subsystem.md +++ b/docs/design/sysml2-workbench/workspace-subsystem.md @@ -4,13 +4,14 @@ ### Overview -WorkspaceSubsystem owns the live folder-backed model workspace. Its boundary -starts at a user-selected root folder and ends at a normalized in-memory -representation of the discovered files, resolved imports, and aggregated -diagnostics that other subsystems can safely consume. It contains -WorkspaceModel, FileWatcher, and DiagnosticsAggregator. View selection, -rendering, and window composition are outside this subsystem and consume its -outputs through in-process interfaces. +WorkspaceSubsystem owns the live folder-and-file-backed model workspace. Its +boundary starts at an ordered set of user-added file and folder sources and +ends at a normalized in-memory representation of the merged, deduplicated +files, resolved imports, and aggregated diagnostics that other subsystems can +safely consume. It contains WorkspaceSourceSet, WorkspaceModel, FileWatcher, +and DiagnosticsAggregator. View selection, rendering, and window composition +are outside this subsystem and consume its outputs through in-process +interfaces. ### Interfaces @@ -19,21 +20,26 @@ querying the current workspace. - *Type*: In-process .NET API. - *Role*: Provider. -- *Contract*: Accepts a root path and reload requests, returns normalized - workspace state, and exposes file-level parse and diagnostic results to other - subsystems. +- *Contract*: Accepts additions and removals of individual file/folder + sources, resolves them into a merged file set, reloads on request, and + exposes file-level parse and diagnostic results to other subsystems. A + zero-source workspace is a valid, non-error state rather than a precondition + failure. - *Constraints*: Must present a consistent snapshot to callers and must keep partial reload failures localized to the affected files. **File System Notification Stream**: The operating-system change feed for files -under the opened workspace root. +under the opened workspace sources. - *Type*: File system watcher. - *Role*: Consumer. -- *Contract*: Consumes create, change, rename, and delete notifications and - converts them into incremental reload requests. +- *Contract*: Consumes create, change, rename, and delete notifications for + every currently watched source (one recursive watcher per folder source, + one filtered watcher per file source) and converts them into incremental + reload requests. - *Constraints*: Notifications may arrive out of order or in bursts and must be - debounced before triggering reparses. + debounced before triggering reparses; a change under one source must never + be attributed to another watched source. **Diagnostics Feed**: The subsystem output used by UI consumers to display current workspace health. @@ -43,19 +49,23 @@ current workspace health. - *Contract*: Exposes an aggregated list of `SysmlDiagnostic` instances plus enough file context for display and future navigation features. - *Constraints*: Must remain available even when the workspace contains syntax - or reference-resolution failures. + or reference-resolution failures, and even when zero sources are open. ### Design -1. WorkspaceModel performs initial discovery, tracks the known file set, and - stores per-file parse and semantic state. -2. FileWatcher subscribes to operating-system notifications for the opened root - and normalizes noisy change bursts into discrete reload requests. -3. WorkspaceModel reparses only the affected files, re-evaluates import - relationships, and publishes the updated file state. -4. DiagnosticsAggregator collects the per-file `SysmlDiagnostic` results and +1. WorkspaceSourceSet maintains the ordered list of file and folder sources the + user has added, and resolves them on demand into a merged, deduplicated + file list with per-file and per-source attribution. +2. WorkspaceModel loads that resolution, tracks the known file set, and stores + per-file parse and semantic state. +3. FileWatcher subscribes to operating-system notifications scoped to each + individually watched source and normalizes noisy change bursts into + discrete reload requests. +4. WorkspaceModel reparses against a freshly re-resolved file set, re-evaluates + import relationships, and publishes the updated file state. +5. DiagnosticsAggregator collects the per-file `SysmlDiagnostic` results and publishes a stable workspace-wide view for AppShellSubsystem and DiagnosticsPanelSubsystem. -5. The subsystem reports workspace changes as structured state rather than by +6. The subsystem reports workspace changes as structured state rather than by directly manipulating UI controls, keeping presentation concerns outside the boundary. diff --git a/docs/design/sysml2-workbench/workspace-subsystem/file-watcher.md b/docs/design/sysml2-workbench/workspace-subsystem/file-watcher.md index f52192f..a71ccf1 100644 --- a/docs/design/sysml2-workbench/workspace-subsystem/file-watcher.md +++ b/docs/design/sysml2-workbench/workspace-subsystem/file-watcher.md @@ -10,46 +10,70 @@ with edits made outside the application. #### Data Model -**WatchedRootPath**: `string` — workspace root currently monitored for external -changes. +**WatchedSourceIds**: `IReadOnlySet` — identifiers of every +`WorkspaceSource` currently monitored for external changes. -**Watcher**: `FileSystemWatcher` — operating-system watcher configured for -recursive observation of the workspace root. +**Watchers**: `Dictionary` — one operating-system +watcher per watched source, keyed by source id. A `Folder`-kind source gets a +single recursive watcher rooted at the folder; a `File`-kind source gets a +single non-recursive watcher rooted at the file's containing directory, with +its `Filter` set to that file's name so only changes to that specific file are +observed. **DebounceWindow**: `TimeSpan` — minimum delay used to merge rapid change -bursts into a single reload batch. +bursts, across all active watchers, into a single reload batch. **PendingChanges**: `IReadOnlySet` — normalized paths queued for the -next incremental refresh cycle. +next incremental refresh cycle, merged across every currently watched source. #### Key Methods -**StartWatching**: Begins monitoring the given workspace root, retargeting an -already-active watcher if one exists. +**WatchSource**: Begins monitoring the given source, retargeting an +already-watched source with the same id if one exists. -- *Parameters*: `string rootPath` — folder to observe. +- *Parameters*: `WorkspaceSource source` — the file or folder source to + observe. - *Returns*: `void` — watcher state is updated in place. -- *Preconditions*: `rootPath` exists. -- *Postconditions*: File-system notifications are subscribed and queued into the - local debounce buffer. If a watcher was already active for a different (or - the same) root, it is disposed and any pending change state accumulated - against that previous root is discarded first, so calling `StartWatching` - again retargets monitoring to the new root instead of throwing. +- *Preconditions*: The source's folder (or, for a file source, its containing + directory) exists. +- *Postconditions*: A dedicated `FileSystemWatcher` for `source.Id` is created + and subscribed - recursive for a `Folder` source, filtered to the file's + name for a `File` source. If a watcher already existed for this exact + `source.Id`, it is disposed and replaced, but other still-watched sources' + watchers and pending change state are left untouched - unlike a global + retarget, watching or re-watching one source never disturbs any other + independently watched source. + +**UnwatchSource**: Stops monitoring a previously watched source. + +- *Parameters*: `string sourceId` — identifier of the source to stop watching. +- *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 + by other still-watched sources must survive an unrelated source being + removed. **QueueChange**: Records a changed path from an operating-system event. - *Parameters*: `string path` — file or folder path reported by the platform. - *Returns*: `void` — the change is added to the pending set. -- *Preconditions*: Monitoring has already started. -- *Postconditions*: Duplicate or bursty notifications for the same path - collapse into a single pending change entry. +- *Preconditions*: None - zero watched sources (an empty workspace, or a stale + notification racing an unwatch) is a valid, first-class state. +- *Postconditions*: When at least one source is watched, duplicate or bursty + 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. **FlushPendingChanges**: Dispatches the current batch to the workspace reload pipeline. - *Parameters*: `None` — operates on the accumulated pending changes. -- *Returns*: `IReadOnlyList` — normalized paths requiring reload. -- *Preconditions*: Monitoring has started. +- *Returns*: `IReadOnlyList` — normalized paths requiring reload, + 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 ready for `WorkspaceModel.ReloadFiles`. @@ -59,13 +83,29 @@ FileWatcher handles duplicate, missing, and transiently locked files locally by retaining the change notification and letting a later reload attempt decide whether the workspace can be refreshed. Platform-level watcher startup failures or disposal errors are propagated because they prevent the subsystem from -honoring the live-reload design. Logging of watcher faults is best-effort and -does not replace propagation to the shell. +honoring the live-reload design. A change reported for one watched source is +never merged into or attributed to another watched source's isolation +boundary - each `FileSystemWatcher` only reports events from its own root +(and, for file sources, its own `Filter`). Logging of watcher faults is +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`. +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 +callback thread itself. `FileWatcher` therefore guards all reads and writes of +its internal pending-changes and watcher-registry state with a single +internal lock, so a change notification racing a concurrent call from another +thread can never corrupt that state. #### Dependencies - **WorkspaceModel** — applies the incremental reload once a stable change batch is available. +- **WorkspaceSourceSet** — supplies the `WorkspaceSource` instances that define + each watcher's scope. - **RollingFileLogger** — records watcher startup failures and repeated reload faults. - **Avalonia** — provides dispatcher integration when reload notifications must diff --git a/docs/design/sysml2-workbench/workspace-subsystem/workspace-model.md b/docs/design/sysml2-workbench/workspace-subsystem/workspace-model.md index 8d8cf08..a4d6f9d 100644 --- a/docs/design/sysml2-workbench/workspace-subsystem/workspace-model.md +++ b/docs/design/sysml2-workbench/workspace-subsystem/workspace-model.md @@ -10,44 +10,65 @@ semantic state needed by view selection, rendering, and diagnostics. #### Data Model -**RootPath**: `string` — absolute path to the workspace folder currently -loaded. +**Sources**: `IReadOnlyList` — the file and folder sources +that produced the currently loaded file set, as supplied by the caller's most +recent `LoadWorkspaceAsync` call. **Files**: `IReadOnlyDictionary` — maps normalized file paths to the latest known parse result, semantic model, and file metadata. -**ImportGraph**: `IReadOnlyDictionary>` — tracks -the direct import relationships between files so incremental reload can -invalidate only the affected dependents. - -**LoadOptions**: `WorkspaceLoadOptions` — captures glob patterns, standard -library inclusion, and reload behavior that must remain consistent for the life -of the loaded workspace. +**LoadOptions**: not owned by WorkspaceModel. Glob patterns and file-discovery +options are exclusively a `WorkspaceSourceSet` concern; WorkspaceModel loads +whatever merged file list a `WorkspaceSourceResolution` hands it and performs +no discovery of its own. #### Key Methods -**LoadWorkspace**: Creates a workspace snapshot from a root folder. - -- *Parameters*: `string rootPath` — folder to scan for `.sysml` content. -- *Returns*: `WorkspaceSnapshot` — normalized state for the full discovered - workspace. -- *Preconditions*: `rootPath` exists and is accessible to the current process. -- *Postconditions*: `RootPath`, `Files`, and `ImportGraph` are synchronized to - the discovered content, even if some files contain diagnostics. - -The method discovers candidate files, loads the SysML standard library inputs -needed by the parser pipeline, parses each file, resolves imports, and records -diagnostics per file before publishing the initial snapshot. - -**ReloadFiles**: Incrementally refreshes a subset of files. +**LoadWorkspace**: Creates a workspace snapshot from a resolved set of +sources. + +- *Parameters*: `IReadOnlyList sources` — the sources that + produced the resolution, retained for display and re-resolution by callers; + `WorkspaceSourceResolution resolution` — the merged, deduplicated file list + (and its per-source attribution) to load. +- *Returns*: `WorkspaceSnapshot` — normalized state for the resolved file set. +- *Preconditions*: None beyond both parameters being non-null - a resolution + with zero files (including one produced by a zero-source set) is valid. +- *Postconditions*: `Sources` and `Files` are synchronized to + `resolution.MergedFiles`, even if some files contain diagnostics. A + zero-file resolution produces a valid, non-throwing, stdlib-only workspace + with no diagnostics, calling straight through to the underlying semantic + loader rather than special-casing the empty case. + +The method loads the SysML standard library inputs needed by the parser +pipeline, parses each file in `resolution.MergedFiles`, resolves imports, and +records diagnostics per file before publishing the snapshot. File discovery +itself (globbing folders, deduping overlaps) has already happened upstream in +`WorkspaceSourceSet.Resolve()` by the time this method is called. + +**ReloadFiles**: Incrementally refreshes the workspace against the current or +a freshly supplied resolution. - *Parameters*: `IReadOnlyList changedPaths` — normalized files - affected by an external change. -- *Returns*: `WorkspaceSnapshot` — updated workspace state after recomputation. -- *Preconditions*: A workspace has already been loaded and the paths belong to - the current root or to known imported files. -- *Postconditions*: Updated file entries replace stale ones and impacted import - relationships are recomputed. + affected by an external change (used for logging/diagnostics context, not + for narrowing which files are recomputed - see Error Handling); + `WorkspaceSourceResolution? updatedResolution` — optional freshly + recomputed resolution to adopt before reloading. When supplied, it replaces + the resolution stored from the last `LoadWorkspaceAsync`/`ReloadFiles` call; + when omitted, the stored resolution is reused unchanged. +- *Returns*: `WorkspaceSnapshot` — updated workspace state after + recomputation. +- *Preconditions*: None - recomputing against an empty resolution (0 sources, + 0 files) is valid and produces the same stdlib-only snapshot as + `LoadWorkspace`. +- *Postconditions*: Updated file entries replace stale ones; unaffected file + entries retain their prior `LoadedUtc`. Callers that watch a folder source + for external changes are expected to pass a freshly resolved + `updatedResolution` (via `WorkspaceSourceSet.Resolve()`) so that a file + created or deleted externally under a still-registered folder source is + actually picked up - reloading against a stale, previously stored + resolution would silently miss such changes, since discovery no longer + happens inside `WorkspaceModel` itself. **GetSemanticWorkspace**: Returns the current semantic view for downstream consumers. @@ -64,9 +85,13 @@ consumers. WorkspaceModel treats malformed files, unresolved imports, and semantic failures as diagnostic-producing states rather than fatal conditions. Invalid file contents are captured inside the affected `WorkspaceFileState` and -surfaced to DiagnosticsAggregator. Root-folder access failures, unrecoverable -I/O exceptions, or parser initialization failures are propagated to the caller -because they prevent a coherent workspace from being established. +surfaced to DiagnosticsAggregator. Since file discovery moved to +`WorkspaceSourceSet`, `WorkspaceModel` itself no longer touches the file +system to enumerate candidates - a missing folder source surfaces at +`WorkspaceSourceSet.AddFolder`/`Resolve` time, not here. Unrecoverable I/O +exceptions reading an individual file, or parser initialization failures, are +propagated to the caller because they prevent a coherent workspace from being +established. #### Dependencies @@ -74,6 +99,8 @@ because they prevent a coherent workspace from being established. loading, and view discovery primitives. - **WorkspaceSubsystem** — defines the boundary within which the model is owned and refreshed. +- **WorkspaceSourceSet** — resolves the registered sources into the merged + file list and per-source attribution that this unit loads. - **FileWatcher** — supplies the changed file set that drives incremental reload. - **DiagnosticsAggregator** — consumes the per-file diagnostics emitted by the diff --git a/docs/design/sysml2-workbench/workspace-subsystem/workspace-source-set.md b/docs/design/sysml2-workbench/workspace-subsystem/workspace-source-set.md new file mode 100644 index 0000000..03268ed --- /dev/null +++ b/docs/design/sysml2-workbench/workspace-subsystem/workspace-source-set.md @@ -0,0 +1,114 @@ +### WorkspaceSourceSet + +![WorkspaceSubsystem Structure](WorkspaceSubsystemView.svg) + +#### Purpose + +WorkspaceSourceSet owns the ordered collection of file and folder sources the +user has added to the workspace and resolves them into the merged, deduplicated +file list and per-source attribution that WorkspaceModel loads and the +Workspace panel displays as a tree. + +#### Data Model + +**Sources**: `IReadOnlyList` — ordered, as added, file/folder +sources currently registered with the workspace. + +**WorkspaceSource**: `record(string Id, WorkspaceSourceKind Kind, string Path)` +— a single registered source. `Id` is a stable `Guid`-derived string that +survives reorder/refresh and is used as the file-watcher key and the Workspace +panel's tree-node key. `Path` is absolute (via `Path.GetFullPath`) and further +corrected, segment by segment, to its actual on-disk casing (see `AddFile`/ +`AddFolder`), so every identity/dedupe comparison elsewhere in this unit can +safely use an ordinal (case-sensitive) comparison. + +**WorkspaceSourceKind**: `enum { File, Folder }` — distinguishes a single-file +source from a recursively-globbed folder source. + +**WorkspaceLoadOptions**: `record(IReadOnlyList GlobPatterns, IReadOnlyList FileExtensions)` +— glob patterns and extension filters applied uniformly to every `Folder` +source's discovery pass. `WorkspaceLoadOptions.Default` is `(["**/*.sysml"], [])` +and is not configurable per source. + +**WorkspaceSourceResolution**: `record(IReadOnlyList MergedFiles, IReadOnlyDictionary FileToSourceId, IReadOnlyDictionary> SourceIdToFiles)` +— the deduplicated union of every source's files (`MergedFiles`), the owning +source id per file (`FileToSourceId`, first-registered-source-wins on +overlap), and the per-source file list used to build the Workspace panel's +tree (`SourceIdToFiles`; a `File` source maps to a singleton list containing +itself, a `Folder` source maps to its discovered files). + +#### Key Methods + +**AddFile**: Registers a single file as a workspace source. + +- *Parameters*: `string path` — file path to add. +- *Returns*: `WorkspaceSource` — the newly created source, or the existing + `File`-kind source if the exact same normalized path is already registered. +- *Preconditions*: None — existence is not enforced at registration time. +- *Postconditions*: Idempotent: adding the same normalized path twice returns + the original source rather than creating a duplicate entry. Path identity is + compared ordinally (case-sensitively) against each source's already-corrected + on-disk casing, so this is correct on both case-insensitive filesystems + (Windows, default macOS) and case-sensitive ones (Linux, case-sensitive + APFS) alike. + +**AddFolder**: Registers a folder as a workspace source. + +- *Parameters*: `string path` — folder path to add. +- *Returns*: `WorkspaceSource` — the newly created source, or the existing + `Folder`-kind source if the exact same normalized path is already + registered. +- *Preconditions*: `path` exists as a directory. +- *Postconditions*: Idempotent, identically to `AddFile`. Throws + `DirectoryNotFoundException` if `path` does not exist. + +**RemoveSource**: Removes a previously registered source. + +- *Parameters*: `string sourceId` — id of the source to remove. +- *Returns*: `bool` — whether a matching source was found and removed. +- *Postconditions*: `Sources` no longer contains an entry with `sourceId`. + Removing an unknown id is a no-op that returns `false` rather than + throwing. + +**Resolve**: Computes the merged, deduplicated file resolution across every +registered source. + +- *Parameters*: `None` — operates on the current `Sources`. +- *Returns*: `WorkspaceSourceResolution` — merged files, per-file attribution, + and per-source file lists. +- *Postconditions*: Every `Folder` source is globbed with + `WorkspaceLoadOptions.Default`; every `File` source contributes itself. + Files are unioned in source-registration order; on overlap (a file inside a + registered folder, or a folder nested inside another registered folder) the + first-registered source silently wins attribution in `FileToSourceId` — no + error and no visible "overlap" flag is produced. File identity for both + `FileToSourceId` and `SourceIdToFiles` keys is compared ordinally + (case-sensitively), matching each discovered file's actual on-disk casing + (as `GlobFileCollector` and the already-corrected `WorkspaceSource.Path` + both preserve it), so two files differing only by case on a case-sensitive + filesystem are never conflated. A zero-source set resolves + to `MergedFiles = []` and empty maps; this is a valid, non-error result. + +#### Error Handling + +WorkspaceSourceSet does not treat overlapping sources as an error condition: +overlap is deduplicated silently and attributed to whichever source was +registered first, which is a display-only tie-break rather than a workspace +fault. `AddFolder` propagates `DirectoryNotFoundException` for a missing +folder because a source that can never resolve any files is a caller mistake +that should be visible immediately rather than silently producing an empty +source. `AddFile` does not perform existence checks, since a `File` source +representing a not-yet-created file is a legitimate, if unusual, registration +that simply contributes zero files until the file appears. + +#### Dependencies + +- **SysML2Tools** — supplies `GlobFileCollector`, used to discover files under + each `Folder` source with `WorkspaceLoadOptions.Default`. +- **WorkspaceModel** — consumes `Resolve()`'s output as the file list to load. + +#### Callers + +- **MainWindowShell** — owns the single `WorkspaceSourceSet` instance, mutates + it via Add/Remove, and re-resolves it before every `WorkspaceModel` load or + reload and before rebuilding the Workspace panel's per-source file lists. 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 55170bd..e7e17e2 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 @@ -33,3 +33,20 @@ sections: Users need shell regions to stay in sync as they browse the workspace and interact with rendered or custom views. tests: - 'SessionStateChanges_SynchronizeVisibleRegions' + + - id: 'SysML2Workbench-AppShellSubsystem-MainWindowShell-ManageMultipleSources' + title: 'The MainWindowShell shall support adding and removing individual file and folder workspace sources, additively watching and unwatching each source, and raising SourcesChanged whenever the source set changes.' + justification: | + Users build up a workspace from an arbitrary combination of files and folders rather than a single root folder, and every panel needs a reliable notification to stay synchronized as sources come and go. + tests: + - 'AddFolderSourceAsync_SecondDistinctFolder_IsAdditiveAndWatchesBothSources' + - 'SourcesChanged_RaisedOnAdd_NotRaisedAtConstruction' + + - id: 'SysML2Workbench-AppShellSubsystem-MainWindowShell-SupportEmptyWorkspace' + title: 'The MainWindowShell shall start with a valid, non-null empty workspace snapshot at construction, and shall correctly return to that empty state - clearing every open tab - when the last remaining source is removed.' + justification: | + Zero sources open is a first-class, valid application state rather than an error, both before the user has opened anything and after removing everything they had opened. + tests: + - 'Construction_EstablishesValidEmptySnapshot' + - 'RemoveSourceAsync_DownToZeroSources_ProducesEmptySnapshotAndUnwatchesEverything' + - 'SelectPredefinedView_NoWorkspaceOpened_ThrowsInvalidOperationException' diff --git a/docs/reqstream/sysml2-workbench/app-shell-subsystem/workspace-panel.yaml b/docs/reqstream/sysml2-workbench/app-shell-subsystem/workspace-panel.yaml new file mode 100644 index 0000000..db4ac85 --- /dev/null +++ b/docs/reqstream/sysml2-workbench/app-shell-subsystem/workspace-panel.yaml @@ -0,0 +1,42 @@ +--- +sections: + - title: 'SysML2Workbench Requirements' + sections: + - title: 'AppShellSubsystem Requirements' + sections: + - title: 'WorkspacePanel Requirements' + requirements: + - id: 'SysML2Workbench-AppShellSubsystem-WorkspacePanel-BuildSourceTree' + title: 'The WorkspacePanel shall present the current workspace sources as a tree, with folder sources expanding to their discovered files preserving on-disk subfolder hierarchy, and file sources as leaves.' + justification: | + Users need a visual inventory of every open source and, for folders, the files contributed by each one arranged the way they are actually organized on disk (not flattened), distinguishing expandable folder nodes from non-expandable file nodes. + tests: + - 'Construction_ZeroSources_ReportsEmptyTree' + - 'RebuildTree_FolderSource_ProducesSourceNodeWithFileChildren' + - 'RebuildTree_FileSource_ProducesLeafSourceNodeWithNoChildren' + - 'RebuildTree_FolderSourceWithSubfolders_PreservesOnDiskHierarchy' + + - id: 'SysML2Workbench-AppShellSubsystem-WorkspacePanel-ReflectDedupeInTreeShape' + title: 'The WorkspacePanel shall reflect WorkspaceSourceSet overlap dedupe in its tree shape, showing an overlapping file exactly once under its attributed owning source.' + justification: | + Presenting an overlapping file under more than one source node would misrepresent the single, deduplicated file resolution the rest of the workspace already operates on. + tests: + - 'RebuildTree_OverlappingFileAndFolder_DedupeReflectedInTreeShape' + + - id: 'SysML2Workbench-AppShellSubsystem-WorkspacePanel-RefreshOnSourcesChanged' + title: 'The WorkspacePanel shall rebuild its tree whenever MainWindowShell reports that the set of workspace sources has changed, including removal down to zero sources.' + justification: | + The panel must stay synchronized with the shell as the user adds and removes sources, and must correctly return to its empty state rather than showing stale nodes. + tests: + - 'SourcesChanged_TriggersRebuild_AndRemovalRestoresEmptyState' + + - id: 'SysML2Workbench-AppShellSubsystem-WorkspacePanel-WireAddRemoveCommands' + title: 'The WorkspacePanel shall wire its Add File, Add Folder, and Remove commands to MainWindowShell, resolving Remove against the selected node''s owning source.' + justification: | + Add/Remove are the panel''s only mutation entry points, and Remove must correctly resolve the owning source whether a source node or one of its file children is selected. + tests: + - 'AddFileCommand_RaisesRequestAddFile' + - 'AddFolderCommand_RaisesRequestAddFolder' + - 'RemoveSelectedCommand_WithSourceNodeSelected_RemovesOwningSource' + - 'RemoveSelectedCommand_WithFileNodeSelected_RemovesOwningSource' + - 'RemoveSelectedCommand_NoSelection_IsNoOp' diff --git a/docs/reqstream/sysml2-workbench/workspace-subsystem/file-watcher.yaml b/docs/reqstream/sysml2-workbench/workspace-subsystem/file-watcher.yaml index ba480d2..8e441ff 100644 --- a/docs/reqstream/sysml2-workbench/workspace-subsystem/file-watcher.yaml +++ b/docs/reqstream/sysml2-workbench/workspace-subsystem/file-watcher.yaml @@ -19,3 +19,28 @@ sections: Editors and source-control operations often emit multiple low-level events, and the workspace refresh pipeline needs stable, actionable reload requests. tests: - 'NotificationBurst_CoalescesIntoSingleReloadTrigger' + - 'FlushPendingChanges_WithinDebounceWindow_RetainsPathForLaterFlush' + + - id: 'SysML2Workbench-WorkspaceSubsystem-FileWatcher-WatchPerSourceScope' + title: 'The FileWatcher shall watch each workspace source independently, using a recursive watcher for a folder source and a single-file-filtered watcher for a file source, and shall isolate changes so a change under one source is never attributed to another.' + justification: | + Folder sources need broad recursive coverage while file sources must not trigger reloads for unrelated sibling files, and users adding multiple independent sources rely on their watch scopes never leaking into one another. + tests: + - 'WatchSource_TwoDistinctSources_BothTrackedIndependently' + - 'WatchSource_TwoFolders_ChangeUnderOneIsNotAttributedToTheOther' + - 'WatchSource_CalledTwiceForSameSourceId_RetargetsWithoutThrowing' + + - id: 'SysML2Workbench-WorkspaceSubsystem-FileWatcher-UnwatchSource' + title: 'The FileWatcher shall stop watching a source by id without disturbing other still-watched sources'' watchers or pending change state, and shall treat unwatching an unknown id as a no-op.' + justification: | + Removing one workspace source must not silently drop pending changes accumulated for other sources that remain open, and a stale or already-removed source id must not surface as an error. + tests: + - 'UnwatchSource_RemovesOnlyThatWatcher_OthersContinueReporting' + - 'UnwatchSource_UnknownSourceId_ReturnsFalse' + + - id: 'SysML2Workbench-WorkspaceSubsystem-FileWatcher-RequireWatchBeforeQueue' + title: 'The FileWatcher shall silently ignore queuing a change when no source is currently being watched.' + justification: | + Zero watched sources is a first-class, valid empty-workspace state, and a change notification can legitimately race an unwatch; queuing must not accumulate unattributable pending state or throw. + tests: + - 'QueueChange_WithNoWatchedSources_IsIgnored' diff --git a/docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-model.yaml b/docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-model.yaml index 726ab9f..bdc693e 100644 --- a/docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-model.yaml +++ b/docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-model.yaml @@ -7,18 +7,18 @@ sections: - title: 'WorkspaceModel Requirements' requirements: - id: 'SysML2Workbench-WorkspaceSubsystem-WorkspaceModel-MaintainWorkspaceTree' - title: 'The WorkspaceModel shall maintain an in-memory representation of the open workspace folder and its tracked SysML files.' + title: 'The WorkspaceModel shall maintain an in-memory representation of the currently resolved workspace sources and their tracked SysML files.' justification: | - Users interact with a folder-based workspace, so the unit must keep the current file tree available to the rest of the application. + Users interact with a multi-source workspace, so the unit must keep the current file tree available to the rest of the application. tests: - - 'OpenWorkspace_BuildsTrackedFileTree' + - 'LoadWorkspaceAsync_BuildsTrackedFileTree' - id: 'SysML2Workbench-WorkspaceSubsystem-WorkspaceModel-ResolveWorkspaceInputs' - title: 'The WorkspaceModel shall determine the set of model files to load by combining workspace discovery rules with SysML import resolution.' + title: 'The WorkspaceModel shall load the set of model files handed to it by a WorkspaceSourceResolution, including SysML import resolution.' justification: | - The workbench must mirror the multi-file loading behavior users expect from existing SysML2Tools workflows. + The workbench must mirror the multi-file loading behavior users expect from existing SysML2Tools workflows, while leaving file discovery itself to WorkspaceSourceSet. tests: - - 'ResolveInputs_FindsDiscoveredAndImportedFiles' + - 'LoadWorkspaceAsync_FindsDiscoveredAndImportedFiles' - id: 'SysML2Workbench-WorkspaceSubsystem-WorkspaceModel-TrackPerFileLoadState' title: 'The WorkspaceModel shall retain per-file load state, including successful parses and file-specific failures, so reloads can update only affected content.' @@ -26,3 +26,11 @@ sections: Incremental reload behavior depends on knowing the current state of each tracked file rather than recomputing the entire workspace blindly. tests: - 'ReloadFile_UpdatesOnlyAffectedFileState' + + - id: 'SysML2Workbench-WorkspaceSubsystem-WorkspaceModel-SupportEmptyResolution' + title: 'The WorkspaceModel shall produce a valid, non-throwing, stdlib-only snapshot when loaded or reloaded against a zero-file resolution.' + justification: | + A workspace with zero sources open is a first-class, valid state rather than a failure, and every downstream consumer relies on WorkspaceModel never throwing for that case. + tests: + - 'LoadWorkspaceAsync_EmptyResolution_ProducesValidStdlibOnlySnapshot' + - 'ReloadFilesAsync_AfterResolutionBecomesEmpty_ProducesValidEmptySnapshot' diff --git a/docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-source-set.yaml b/docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-source-set.yaml new file mode 100644 index 0000000..f51954b --- /dev/null +++ b/docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-source-set.yaml @@ -0,0 +1,46 @@ +--- +sections: + - title: 'SysML2Workbench Requirements' + sections: + - title: 'WorkspaceSubsystem Requirements' + sections: + - title: 'WorkspaceSourceSet Requirements' + requirements: + - id: 'SysML2Workbench-WorkspaceSubsystem-WorkspaceSourceSet-MaintainOrderedSources' + title: 'The WorkspaceSourceSet shall maintain an ordered, idempotent list of file and folder sources.' + justification: | + The Workspace panel and file watcher both need a stable, registration-ordered list of sources, and adding the same source twice must not create duplicate entries. + tests: + - 'AddFile_SamePathTwice_ReturnsSameSourceAndDoesNotDuplicate' + - 'AddFolder_SamePathTwice_ReturnsSameSourceAndDoesNotDuplicate' + - 'AddFolder_MissingFolder_ThrowsDirectoryNotFoundException' + - 'Sources_PreservesRegistrationOrder' + + - id: 'SysML2Workbench-WorkspaceSubsystem-WorkspaceSourceSet-RemoveSource' + title: 'The WorkspaceSourceSet shall remove a registered source by id, and treat removal of an unknown id as a no-op.' + justification: | + Users must be able to remove a previously added source, and a stale or already-removed id must not surface as an error. + tests: + - 'RemoveSource_RegisteredThenUnknownId_ReturnsTrueThenFalse' + + - id: 'SysML2Workbench-WorkspaceSubsystem-WorkspaceSourceSet-ResolveEmptySet' + title: 'The WorkspaceSourceSet shall resolve a zero-source set to an empty, non-error resolution.' + justification: | + A workspace with no sources open is a first-class, valid state rather than a failure, so resolving it must not throw or require a special-case caller guard. + tests: + - 'Resolve_ZeroSources_ReturnsEmptyResolution' + + - id: 'SysML2Workbench-WorkspaceSubsystem-WorkspaceSourceSet-DiscoverFolderFiles' + title: 'The WorkspaceSourceSet shall discover every matching model file recursively under each folder source using the default glob options.' + justification: | + Folder sources are the primary way users add whole model trees, and discovery must find every nested `.sysml` file, not only top-level files. + tests: + - 'Resolve_FolderSource_DiscoversAllSysmlFilesRecursively' + + - id: 'SysML2Workbench-WorkspaceSubsystem-WorkspaceSourceSet-DedupeOverlap' + title: 'The WorkspaceSourceSet shall dedupe files reachable through more than one source and attribute each merged file to whichever source registered it first, without raising an error.' + justification: | + Users may add an individual file that also falls under a folder source, or add two overlapping folders, and the merged file list must contain each file exactly once with a deterministic, silent attribution. + tests: + - 'Resolve_FileOverlappingFolder_DedupesAndFirstRegisteredSourceWinsAttribution' + - 'Resolve_NestedFolderOverlap_DedupesAndFirstRegisteredSourceWinsAttribution' diff --git a/docs/sysml2/model/sysml2-workbench/app-shell-subsystem.sysml b/docs/sysml2/model/sysml2-workbench/app-shell-subsystem.sysml index c3a61fe..7e2af57 100644 --- a/docs/sysml2/model/sysml2-workbench/app-shell-subsystem.sysml +++ b/docs/sysml2/model/sysml2-workbench/app-shell-subsystem.sysml @@ -9,6 +9,7 @@ package SysML2WorkbenchArchitecture { comment reqRef /* Requirements: docs/reqstream/sysml2-workbench/app-shell-subsystem.yaml */ part mainWindowShell: MainWindowShell; + part workspacePanel: WorkspacePanel; } } } diff --git a/docs/sysml2/model/sysml2-workbench/app-shell-subsystem/workspace-panel.sysml b/docs/sysml2/model/sysml2-workbench/app-shell-subsystem/workspace-panel.sysml new file mode 100644 index 0000000..03b6b93 --- /dev/null +++ b/docs/sysml2/model/sysml2-workbench/app-shell-subsystem/workspace-panel.sysml @@ -0,0 +1,13 @@ +package SysML2WorkbenchArchitecture { + package SysML2Workbench { + part def WorkspacePanel { + doc /* Dock tool panel presenting workspace sources as a tree, with Add File/Add Folder/Remove and drag-and-drop. */ + + comment sourceRef /* Source: src/DemaConsulting.SysML2Workbench/AppShellSubsystem/WorkspacePanelToolViewModel.cs */ + comment testRef /* Test: test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/WorkspacePanelToolViewModelTests.cs */ + comment designRef /* Design: docs/design/sysml2-workbench/app-shell-subsystem/workspace-panel.md */ + comment verificationRef /* Verification: docs/verification/sysml2-workbench/app-shell-subsystem/workspace-panel.md */ + comment reqRef /* Requirements: docs/reqstream/sysml2-workbench/app-shell-subsystem/workspace-panel.yaml */ + } + } +} diff --git a/docs/sysml2/model/sysml2-workbench/workspace-subsystem.sysml b/docs/sysml2/model/sysml2-workbench/workspace-subsystem.sysml index 19abd8e..d006941 100644 --- a/docs/sysml2/model/sysml2-workbench/workspace-subsystem.sysml +++ b/docs/sysml2/model/sysml2-workbench/workspace-subsystem.sysml @@ -9,6 +9,7 @@ package SysML2WorkbenchArchitecture { comment reqRef /* Requirements: docs/reqstream/sysml2-workbench/workspace-subsystem.yaml */ part workspaceModel: WorkspaceModel; + part workspaceSourceSet: WorkspaceSourceSet; part fileWatcher: FileWatcher; part diagnosticsAggregator: DiagnosticsAggregator; } diff --git a/docs/sysml2/model/sysml2-workbench/workspace-subsystem/workspace-source-set.sysml b/docs/sysml2/model/sysml2-workbench/workspace-subsystem/workspace-source-set.sysml new file mode 100644 index 0000000..c2b12d3 --- /dev/null +++ b/docs/sysml2/model/sysml2-workbench/workspace-subsystem/workspace-source-set.sysml @@ -0,0 +1,13 @@ +package SysML2WorkbenchArchitecture { + package SysML2Workbench { + part def WorkspaceSourceSet { + doc /* Ordered file/folder source list; resolves to a merged, deduplicated file set with per-source attribution. */ + + comment sourceRef /* Source: src/DemaConsulting.SysML2Workbench/WorkspaceSubsystem/WorkspaceSourceSet.cs */ + comment testRef /* Test: test/DemaConsulting.SysML2Workbench.Tests/WorkspaceSubsystem/WorkspaceSourceSetTests.cs */ + comment designRef /* Design: docs/design/sysml2-workbench/workspace-subsystem/workspace-source-set.md */ + comment verificationRef /* Verification: docs/verification/sysml2-workbench/workspace-subsystem/workspace-source-set.md */ + comment reqRef /* Requirements: docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-source-set.yaml */ + } + } +} diff --git a/docs/user_guide/getting_started.md b/docs/user_guide/getting_started.md index 269fa6a..28495ee 100644 --- a/docs/user_guide/getting_started.md +++ b/docs/user_guide/getting_started.md @@ -12,25 +12,50 @@ for setup prerequisites. 1. Launch the application (`dotnet run --project src/DemaConsulting.SysML2Workbench.Desktop`, or run the published executable). -2. Choose **File > Open Workspace...** and pick the folder containing your - `.sysml` files. -3. SysML2Workbench discovers every `.sysml` file under the folder (matching - the same glob-based discovery the SysML2Tools CLI uses), parses them, and - resolves `import` relationships across files, exactly as the CLI would. -4. Once loaded, the **Predefined Views** list (left panel) is populated with - every view usage declared in the workspace, and the **Diagnostics** panel +2. Build up your workspace from any combination of files and folders: + - Choose **File > Open Folder...** and pick a folder containing `.sysml` + files, or + - Choose **File > Open File...** and pick a single `.sysml` file, or + - Drag files and/or folders from your file manager onto the main window + or the **Workspace** panel. + + Each addition is additive - adding a second folder does not replace the + first, so a workspace can be assembled from several unrelated folders + and/or individual files at once. If a file happens to fall both inside an + added folder and is also added individually, it is only loaded once; + overlapping folders are deduplicated the same way. +3. For each added folder, SysML2Workbench discovers every `.sysml` file + under it (matching the same glob-based discovery the SysML2Tools CLI + uses); each added file is loaded directly. All discovered/added files are + merged, parsed, and `import` relationships resolved across the whole set, + exactly as the CLI would. +4. Once loaded, the **Workspace** panel (left side, dockable) lists every + added source as a tree - folders expand to show the files discovered + under them, files appear as non-expandable entries - and lets you add + more sources or remove one via its toolbar or a right-click/selection + plus **Remove**. The **Predefined Views** list is populated with every + view usage declared in the workspace, and the **Diagnostics** panel (bottom) lists any parser or reference-resolution problems found across the whole workspace. Panels are docked in this default arrangement but can be resized, floated, or closed like any other dockable panel. If you - close the **Predefined Views**, **Custom View Builder**, or + close the **Workspace**, **Predefined Views**, **Custom View Builder**, or **Diagnostics** panel, reopen it from the **View** menu - selecting it there restores the panel to its original dock without losing any in-progress state (for example, a partially-built custom view). - -While a workspace is open, SysML2Workbench watches the folder for external -changes. If you edit a `.sysml` file in another editor, or `git pull` new +5. Removing every source returns the workspace to its empty starting state: + the **Predefined Views**, **Custom View Builder**, and **Diagnostics** + panels each show a friendly "workspace is empty" message instead of + rendering against nothing, and any open diagram tabs are closed. This is + the same state the application starts in before any source is added, not + an error condition. + +While a workspace is open, SysML2Workbench watches every added file and +folder independently for external changes. If you edit a `.sysml` file in +another editor, add a new file under a watched folder, or `git pull` new changes, the workspace is incrementally reloaded and the active diagram and -diagnostics list refresh automatically - no need to reopen the folder. +diagnostics list refresh automatically - no need to reopen anything. A +change under one added folder never affects another added folder's watch +scope. ## Browsing Predefined Views diff --git a/docs/user_guide/introduction.md b/docs/user_guide/introduction.md index 0d39b58..975b31f 100644 --- a/docs/user_guide/introduction.md +++ b/docs/user_guide/introduction.md @@ -22,7 +22,7 @@ This guide currently covers: - Installation expectations for the phase-0 desktop viewer -- Opening a workspace folder and browsing predefined views +- Opening a workspace from one or more file/folder sources and browsing predefined views - Building custom views by selecting a view type, multiple expose targets, and an optional filter 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 6822664..f6047e2 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 @@ -63,3 +63,25 @@ Verified by `MainWindowShellTests.SelectPredefinedView_TabsHaveIndependentCanvas **SessionStateChanges_SynchronizeVisibleRegions**: Reloading the workspace after an external change resynchronizes visible shell regions (diagnostics and catalog) and resets stale active-view state. Verified by `MainWindowShellTests.SessionStateChanges_SynchronizeVisibleRegions`. + +**AddFolderSourceAsync_SecondDistinctFolder_IsAdditiveAndWatchesBothSources**: Adding a second, distinct folder +source is additive - both folders' files are merged into the workspace and both sources are independently watched, +without disturbing the first source. Verified by +`MainWindowShellTests.AddFolderSourceAsync_SecondDistinctFolder_IsAdditiveAndWatchesBothSources`. + +**SourcesChanged_RaisedOnAdd_NotRaisedAtConstruction**: `SourcesChanged` is raised when a source is added, but is +not raised merely by constructing the shell (which establishes its initial empty snapshot without any source +mutation). Verified by `MainWindowShellTests.SourcesChanged_RaisedOnAdd_NotRaisedAtConstruction`. + +**Construction_EstablishesValidEmptySnapshot**: Constructing the shell establishes a valid, non-null +`CurrentWorkspace` with zero sources and zero files, rather than leaving workspace state unset until the first +source is added. Verified by `MainWindowShellTests.Construction_EstablishesValidEmptySnapshot`. + +**RemoveSourceAsync_DownToZeroSources_ProducesEmptySnapshotAndUnwatchesEverything**: Removing the last remaining +source produces a valid empty snapshot (zero sources, zero files), clears every open tab, and unwatches every +source's file watcher. Verified by +`MainWindowShellTests.RemoveSourceAsync_DownToZeroSources_ProducesEmptySnapshotAndUnwatchesEverything`. + +**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`. diff --git a/docs/verification/sysml2-workbench/app-shell-subsystem/workspace-panel.md b/docs/verification/sysml2-workbench/app-shell-subsystem/workspace-panel.md new file mode 100644 index 0000000..73943ac --- /dev/null +++ b/docs/verification/sysml2-workbench/app-shell-subsystem/workspace-panel.md @@ -0,0 +1,80 @@ +### WorkspacePanel + +#### Verification Approach + +Tests in `test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/WorkspacePanelToolViewModelTests.cs` exercise +`WorkspacePanelToolViewModel` against a real `MainWindowShell` harness (matching the shell test project's +`CreateShell()` pattern), driving actual `AddFileSourceAsync`/`AddFolderSourceAsync`/`RemoveSourceAsync` calls rather +than mocks, so tree rebuilds and command wiring are verified end-to-end. The scenario list below follows the +authoritative mappings in `docs/reqstream/sysml2-workbench/app-shell-subsystem/workspace-panel.yaml` and describes +the implemented tests in present tense. Drag-and-drop acceptance in `WorkspacePanelToolView`'s code-behind and +`MainWindowView`'s code-behind is not independently unit-tested: both handlers only branch on `File.Exists`/ +`Directory.Exists` for each dropped path and then call the exact same `MainWindowShell.AddFileSourceAsync`/ +`AddFolderSourceAsync` methods already covered by `MainWindowShellTests`, so drag-and-drop is verified by code +review rather than a dedicated Avalonia UI-automation test. + +#### Test Environment + +Tests run under the standard .NET test runner with a real `MainWindowShell` constructed over temporary workspace +folders and files, plus real collaborator units. No external services are required. + +#### Acceptance Criteria + +- All implemented tests in + `test/DemaConsulting.SysML2Workbench.Tests/AppShellSubsystem/WorkspacePanelToolViewModelTests.cs` that correspond + to the scenarios below pass with zero failures. +- The assertions exercised by these scenarios continue to verify the behavior traced from + `docs/reqstream/sysml2-workbench/app-shell-subsystem/workspace-panel.yaml` using the real paths and collaborators + described above. +- Any regression in the covered normal, boundary, or error flows produces a failing xUnit assertion rather than a + speculative or placeholder verification statement. + +#### Test Scenarios + +**Construction_ZeroSources_ReportsEmptyTree**: Constructing the panel over a shell with zero workspace sources +produces an empty `RootNodes` list and `IsEmpty == true`. Verified by +`WorkspacePanelToolViewModelTests.Construction_ZeroSources_ReportsEmptyTree`. + +**RebuildTree_FolderSource_ProducesSourceNodeWithFileChildren**: Adding a folder source and rebuilding produces a +`WorkspaceSourceNode` whose `Children` list contains one `WorkspaceFileNode` per file discovered under that folder. +Verified by `WorkspacePanelToolViewModelTests.RebuildTree_FolderSource_ProducesSourceNodeWithFileChildren`. + +**RebuildTree_FolderSourceWithSubfolders_PreservesOnDiskHierarchy**: Adding a folder source whose discovered files +span multiple nested subfolders produces intermediate `WorkspaceFolderNode`s mirroring that on-disk hierarchy +(subfolders before files, each level sorted alphabetically) instead of flattening every file directly under the +source node. Verified by +`WorkspacePanelToolViewModelTests.RebuildTree_FolderSourceWithSubfolders_PreservesOnDiskHierarchy`. + +**RebuildTree_FileSource_ProducesLeafSourceNodeWithNoChildren**: Adding a file source and rebuilding produces a +`WorkspaceSourceNode` with an empty `Children` list (a leaf, no expand arrow). Verified by +`WorkspacePanelToolViewModelTests.RebuildTree_FileSource_ProducesLeafSourceNodeWithNoChildren`. + +**RebuildTree_OverlappingFileAndFolder_DedupeReflectedInTreeShape**: When a file source overlaps a folder source, +the overlapping file appears exactly once in the tree, as a child of whichever source `WorkspaceSourceSet.Resolve()` +attributed it to, not duplicated under both. Verified by +`WorkspacePanelToolViewModelTests.RebuildTree_OverlappingFileAndFolder_DedupeReflectedInTreeShape`. + +**SourcesChanged_TriggersRebuild_AndRemovalRestoresEmptyState**: The panel rebuilds its tree automatically when +`MainWindowShell.SourcesChanged` fires, and removing the last remaining source restores `IsEmpty == true` with an +empty `RootNodes` list. Verified by +`WorkspacePanelToolViewModelTests.SourcesChanged_TriggersRebuild_AndRemovalRestoresEmptyState`. + +**AddFileCommand_RaisesRequestAddFile**: Invoking the Add File command raises `RequestAddFile` so the Avalonia-aware +view can fulfill it with a real file picker. Verified by +`WorkspacePanelToolViewModelTests.AddFileCommand_RaisesRequestAddFile`. + +**AddFolderCommand_RaisesRequestAddFolder**: Invoking the Add Folder command raises `RequestAddFolder` so the +Avalonia-aware view can fulfill it with a real folder picker. Verified by +`WorkspacePanelToolViewModelTests.AddFolderCommand_RaisesRequestAddFolder`. + +**RemoveSelectedCommand_WithSourceNodeSelected_RemovesOwningSource**: Invoking Remove with a `WorkspaceSourceNode` +selected removes that node's source via `MainWindowShell.RemoveSourceAsync`. Verified by +`WorkspacePanelToolViewModelTests.RemoveSelectedCommand_WithSourceNodeSelected_RemovesOwningSource`. + +**RemoveSelectedCommand_WithFileNodeSelected_RemovesOwningSource**: Invoking Remove with a `WorkspaceFileNode` +selected resolves and removes that file's owning source via `MainWindowShell.RemoveSourceAsync`. Verified by +`WorkspacePanelToolViewModelTests.RemoveSelectedCommand_WithFileNodeSelected_RemovesOwningSource`. + +**RemoveSelectedCommand_NoSelection_IsNoOp**: Invoking Remove with no node selected does not call +`MainWindowShell.RemoveSourceAsync` and leaves the tree unchanged. Verified by +`WorkspacePanelToolViewModelTests.RemoveSelectedCommand_NoSelection_IsNoOp`. diff --git a/docs/verification/sysml2-workbench/workspace-subsystem/file-watcher.md b/docs/verification/sysml2-workbench/workspace-subsystem/file-watcher.md index 123fff6..33db548 100644 --- a/docs/verification/sysml2-workbench/workspace-subsystem/file-watcher.md +++ b/docs/verification/sysml2-workbench/workspace-subsystem/file-watcher.md @@ -10,8 +10,10 @@ tense. #### Test Environment -Tests run under the standard .NET test runner with deterministic timestamps and manually queued file-change -notifications. No real OS watcher, network, or external services are required. +Tests run under the standard .NET test runner. Most scenarios use deterministic timestamps and manually queued +file-change notifications and require no real OS watcher. The per-source isolation scenario +(`WatchSource_TwoFolders_ChangeUnderOneIsNotAttributedToTheOther`) uses real `FileSystemWatcher` instances over two +temporary folders to prove isolation end-to-end. No network or other external services are required. #### Acceptance Criteria @@ -32,3 +34,33 @@ returned by a flush once its debounce window has elapsed. Verified by **NotificationBurst_CoalescesIntoSingleReloadTrigger**: Repeated notifications for the same path within the debounce window collapse into a single reload trigger instead of one trigger per notification. Verified by `FileWatcherTests.NotificationBurst_CoalescesIntoSingleReloadTrigger`. + +**FlushPendingChanges_WithinDebounceWindow_RetainsPathForLaterFlush**: A pending change flushed before its debounce +window has elapsed is retained rather than discarded, and is returned by a later flush once the window elapses. +Verified by `FileWatcherTests.FlushPendingChanges_WithinDebounceWindow_RetainsPathForLaterFlush`. + +**WatchSource_TwoDistinctSources_BothTrackedIndependently**: Watching two distinct sources tracks both independently +in `WatchedSourceIds`, and each source's watcher operates without disturbing the other. Verified by +`FileWatcherTests.WatchSource_TwoDistinctSources_BothTrackedIndependently`. + +**WatchSource_TwoFolders_ChangeUnderOneIsNotAttributedToTheOther**: With two independently watched folder sources, a +real file-system change under one folder is reported as pending, while the other folder's watcher contributes +nothing for that change - direct proof that per-source watch scope is isolated. Verified by +`FileWatcherTests.WatchSource_TwoFolders_ChangeUnderOneIsNotAttributedToTheOther`. + +**WatchSource_CalledTwiceForSameSourceId_RetargetsWithoutThrowing**: Calling `WatchSource` twice for the same source +id disposes and replaces that source's watcher without throwing and without disturbing any other still-watched +source. Verified by `FileWatcherTests.WatchSource_CalledTwiceForSameSourceId_RetargetsWithoutThrowing`. + +**UnwatchSource_RemovesOnlyThatWatcher_OthersContinueReporting**: Unwatching one source disposes only that source's +watcher and removes it from `WatchedSourceIds`, while other still-watched sources continue reporting changes and +their previously queued pending state survives. Verified by +`FileWatcherTests.UnwatchSource_RemovesOnlyThatWatcher_OthersContinueReporting`. + +**UnwatchSource_UnknownSourceId_ReturnsFalse**: Unwatching an id that is not currently watched returns `false` +without throwing. Verified by `FileWatcherTests.UnwatchSource_UnknownSourceId_ReturnsFalse`. + +**QueueChange_WithNoWatchedSources_IsIgnored**: Queuing a change when no source is currently watched (before any +source has been watched, or after the last one has been unwatched) is silently ignored rather than throwing or +accumulating unattributable pending state, since zero watched sources is a first-class, valid empty-workspace +state. Verified by `FileWatcherTests.QueueChange_WithNoWatchedSources_IsIgnored`. diff --git a/docs/verification/sysml2-workbench/workspace-subsystem/workspace-model.md b/docs/verification/sysml2-workbench/workspace-subsystem/workspace-model.md index 359ff2b..0c2ec42 100644 --- a/docs/verification/sysml2-workbench/workspace-subsystem/workspace-model.md +++ b/docs/verification/sysml2-workbench/workspace-subsystem/workspace-model.md @@ -25,13 +25,22 @@ No external services are required. #### Test Scenarios -**OpenWorkspace_BuildsTrackedFileTree**: Loading a workspace folder builds a tracked file tree covering every discovered -file. Verified by `WorkspaceModelTests.OpenWorkspace_BuildsTrackedFileTree`. +**OpenWorkspace_BuildsTrackedFileTree**: Loading a workspace resolution builds a tracked file tree covering every +file in the resolution. Verified by `WorkspaceModelTests.LoadWorkspaceAsync_BuildsTrackedFileTree`. -**ResolveInputs_FindsDiscoveredAndImportedFiles**: Resolving workspace inputs combines glob-discovered files with SysML +**ResolveInputs_FindsDiscoveredAndImportedFiles**: Loading a resolution combines glob-discovered files with SysML import resolution across those discovered files. Verified by -`WorkspaceModelTests.ResolveInputs_FindsDiscoveredAndImportedFiles`. +`WorkspaceModelTests.LoadWorkspaceAsync_FindsDiscoveredAndImportedFiles`. **ReloadFile_UpdatesOnlyAffectedFileState**: Reloading the workspace only replaces the per-file state of files whose diagnostics actually changed, leaving unaffected file state instances untouched. Verified by `WorkspaceModelTests.ReloadFile_UpdatesOnlyAffectedFileState`. + +**LoadWorkspaceAsync_EmptyResolution_ProducesValidStdlibOnlySnapshot**: Loading a zero-source, zero-file resolution +produces a valid, non-throwing, stdlib-only snapshot with no diagnostics rather than a special-cased empty +placeholder. Verified by `WorkspaceModelTests.LoadWorkspaceAsync_EmptyResolution_ProducesValidStdlibOnlySnapshot`. + +**ReloadFilesAsync_AfterResolutionBecomesEmpty_ProducesValidEmptySnapshot**: Reloading against a resolution that has +since become empty (for example after every source was removed) produces the same valid, non-throwing, stdlib-only +snapshot as loading empty from scratch. Verified by +`WorkspaceModelTests.ReloadFilesAsync_AfterResolutionBecomesEmpty_ProducesValidEmptySnapshot`. diff --git a/docs/verification/sysml2-workbench/workspace-subsystem/workspace-source-set.md b/docs/verification/sysml2-workbench/workspace-subsystem/workspace-source-set.md new file mode 100644 index 0000000..13ec5e5 --- /dev/null +++ b/docs/verification/sysml2-workbench/workspace-subsystem/workspace-source-set.md @@ -0,0 +1,63 @@ +### WorkspaceSourceSet + +#### Verification Approach + +Tests in `test/DemaConsulting.SysML2Workbench.Tests/WorkspaceSubsystem/WorkspaceSourceSetTests.cs` exercise +`WorkspaceSourceSet` directly. The suite adds and removes file and folder sources against temporary directories and +asserts the resulting `Sources` list and `Resolve()` output. The scenario list below follows the authoritative +mappings in `docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-source-set.yaml` and describes the +implemented tests in present tense. + +#### Test Environment + +Tests run under the standard .NET test runner with temporary folders and files created per test. No external +services are required. + +#### Acceptance Criteria + +- All implemented tests in `test/DemaConsulting.SysML2Workbench.Tests/WorkspaceSubsystem/WorkspaceSourceSetTests.cs` + that correspond to the scenarios below pass with zero failures. +- The assertions exercised by these scenarios continue to verify the behavior traced from + `docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-source-set.yaml` using the real paths and + collaborators described above. +- Any regression in the covered normal, boundary, or error flows produces a failing xUnit assertion rather than a + speculative or placeholder verification statement. + +#### Test Scenarios + +**AddFile_SamePathTwice_ReturnsSameSourceAndDoesNotDuplicate**: Adding the same normalized file path twice returns +the original source instance both times and does not add a second entry to `Sources`. Verified by +`WorkspaceSourceSetTests.AddFile_SamePathTwice_ReturnsSameSourceAndDoesNotDuplicate`. + +**AddFolder_SamePathTwice_ReturnsSameSourceAndDoesNotDuplicate**: Adding the same normalized folder path twice +returns the original source instance both times and does not add a second entry to `Sources`. Verified by +`WorkspaceSourceSetTests.AddFolder_SamePathTwice_ReturnsSameSourceAndDoesNotDuplicate`. + +**AddFolder_MissingFolder_ThrowsDirectoryNotFoundException**: Adding a folder path that does not exist throws +`DirectoryNotFoundException` rather than silently registering an unusable source. Verified by +`WorkspaceSourceSetTests.AddFolder_MissingFolder_ThrowsDirectoryNotFoundException`. + +**Sources_PreservesRegistrationOrder**: `Sources` reflects sources in the exact order they were registered, +regardless of kind. Verified by `WorkspaceSourceSetTests.Sources_PreservesRegistrationOrder`. + +**RemoveSource_RegisteredThenUnknownId_ReturnsTrueThenFalse**: Removing a registered source id returns `true` and +drops it from `Sources`; removing that same id again, or any unknown id, returns `false` without throwing. +Verified by `WorkspaceSourceSetTests.RemoveSource_RegisteredThenUnknownId_ReturnsTrueThenFalse`. + +**Resolve_ZeroSources_ReturnsEmptyResolution**: Resolving a `WorkspaceSourceSet` with zero registered sources +produces an empty `MergedFiles` list and empty attribution maps, with no exception thrown. Verified by +`WorkspaceSourceSetTests.Resolve_ZeroSources_ReturnsEmptyResolution`. + +**Resolve_FolderSource_DiscoversAllSysmlFilesRecursively**: Resolving a folder source discovers every `.sysml` file +nested under it, including files in subdirectories, using the default glob options. Verified by +`WorkspaceSourceSetTests.Resolve_FolderSource_DiscoversAllSysmlFilesRecursively`. + +**Resolve_FileOverlappingFolder_DedupesAndFirstRegisteredSourceWinsAttribution**: When a file explicitly added as a +`File` source also falls under a registered `Folder` source, resolving the set contains that file exactly once in +`MergedFiles`, attributed in `FileToSourceId` to whichever of the two sources was registered first. Verified by +`WorkspaceSourceSetTests.Resolve_FileOverlappingFolder_DedupesAndFirstRegisteredSourceWinsAttribution`. + +**Resolve_NestedFolderOverlap_DedupesAndFirstRegisteredSourceWinsAttribution**: When two registered folder sources +overlap (one nested inside the other), resolving the set contains each shared file exactly once, attributed to +whichever folder source was registered first. Verified by +`WorkspaceSourceSetTests.Resolve_NestedFolderOverlap_DedupesAndFirstRegisteredSourceWinsAttribution`. diff --git a/requirements.yaml b/requirements.yaml index 68c817d..ce558ce 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -4,6 +4,7 @@ includes: - docs/reqstream/sysml2-workbench/platform-requirements.yaml - docs/reqstream/sysml2-workbench/workspace-subsystem.yaml - docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-model.yaml + - docs/reqstream/sysml2-workbench/workspace-subsystem/workspace-source-set.yaml - docs/reqstream/sysml2-workbench/workspace-subsystem/file-watcher.yaml - docs/reqstream/sysml2-workbench/workspace-subsystem/diagnostics-aggregator.yaml - docs/reqstream/sysml2-workbench/view-catalog-subsystem.yaml @@ -20,6 +21,7 @@ includes: - docs/reqstream/sysml2-workbench/logging-subsystem/rolling-file-logger.yaml - docs/reqstream/sysml2-workbench/app-shell-subsystem.yaml - docs/reqstream/sysml2-workbench/app-shell-subsystem/main-window-shell.yaml + - docs/reqstream/sysml2-workbench/app-shell-subsystem/workspace-panel.yaml - docs/reqstream/ots/sysml2-tools.yaml - docs/reqstream/ots/rendering.yaml - docs/reqstream/ots/avalonia.yaml diff --git a/src/DemaConsulting.SysML2Workbench/App.axaml b/src/DemaConsulting.SysML2Workbench/App.axaml index 97178d6..d750ae0 100644 --- a/src/DemaConsulting.SysML2Workbench/App.axaml +++ b/src/DemaConsulting.SysML2Workbench/App.axaml @@ -1,6 +1,7 @@ @@ -11,6 +12,7 @@ + diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/CustomViewBuilderToolView.axaml b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/CustomViewBuilderToolView.axaml index 8f1b33a..41446d7 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/CustomViewBuilderToolView.axaml +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/CustomViewBuilderToolView.axaml @@ -4,65 +4,69 @@ x:Class="DemaConsulting.SysML2Workbench.AppShellSubsystem.CustomViewBuilderToolView" x:DataType="local:CustomViewBuilderToolViewModel"> - - + + + - - + - + + - - -