diff --git a/.cspell.yaml b/.cspell.yaml index 9f6f9c4..2343572 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -93,6 +93,7 @@ words: - WebDriver - webdriver - novawindows + - winapi - atspi - automatable - startable diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1210d7d..b4170d5 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -391,9 +391,13 @@ jobs: node-version: 24.x - name: Install Appium and the NovaWindows driver + # appium-novawindows-driver is pinned: 1.4.2 reintroduced an inverted return value in + # trySetForegroundWindow, so every session fails with "Failed to locate window of the app." + # 1.4.1 is the last good release. Keep this in step with build.ps1's pin. See + # docs/design/ots/appium.md; remove the pin once a fixed release ships. run: | npm install -g appium - appium driver install --source=npm appium-novawindows-driver + appium driver install --source=npm appium-novawindows-driver@1.4.1 # === BUILD AND PUBLISH THE DESKTOP APPLICATION === # This section builds the solution and publishes the self-contained Desktop executable that the diff --git a/README.md b/README.md index ae81a92..333c619 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ exported as copy-pasteable SysML `view ... expose ...` text to promote them into a permanent model file, and any workspace `.sysml` file's raw source text can be opened read-only, with syntax highlighting, by double-clicking it in the workspace tree. A live-updating diagnostics panel surfaces parser -and reference-resolution problems across the whole workspace, and a local -rolling log file is written for bug-report attachments. +and reference-resolution problems across the whole workspace, a status bar +along the bottom of the window summarizes whichever document tab is active, +and a local rolling log file is written for bug-report attachments. This is a **Phase 0, read-only** release: there is no git integration, no text/structural editing, no telemetry, and custom views are session-only until diff --git a/build.ps1 b/build.ps1 index 10b2f46..e392645 100644 --- a/build.ps1 +++ b/build.ps1 @@ -83,8 +83,32 @@ if ($IntegrationTest) { npm install -g appium if ($LASTEXITCODE -ne 0) { $buildError = $true } - $driverName = $IsWindows ? "appium-novawindows-driver" : "mac2" + # appium-novawindows-driver is pinned: 1.4.2 reintroduced an inverted return value in + # trySetForegroundWindow, so every session fails with "Failed to locate window of the + # app." 1.4.1 is the last good release. See docs/design/ots/appium.md for the analysis; + # remove the pin once a fixed release ships. + $novaWindowsDriverVersion = "1.4.1" + + $driverName = $IsWindows ? "appium-novawindows-driver@$novaWindowsDriverVersion" : "mac2" $driverArgs = $IsWindows ? @("--source=npm", $driverName) : @($driverName) + + # A driver already installed at a different version makes `driver install` fail with + # "already installed", which the check below deliberately tolerates. Uninstall a + # mismatched version first so that tolerance can never silently leave the pin unapplied. + if ($IsWindows) { + $installedVersion = $null + try { + $installedVersion = (appium driver list --installed --json 2>$null | ConvertFrom-Json).novawindows.version + } catch { + $installedVersion = $null + } + + if ($installedVersion -and $installedVersion -ne $novaWindowsDriverVersion) { + Write-Host "Replacing the novawindows driver $installedVersion with the pinned $novaWindowsDriverVersion..." + appium driver uninstall novawindows + } + } + Write-Host "Installing the $driverName driver..." $driverInstallOutput = appium driver install @driverArgs 2>&1 | Out-String Write-Host $driverInstallOutput diff --git a/docs/design/ots/appium.md b/docs/design/ots/appium.md index 7155264..9078081 100644 --- a/docs/design/ots/appium.md +++ b/docs/design/ots/appium.md @@ -131,3 +131,27 @@ and fails fast with an actionable error instead of letting all tests fail slowly, one at a time, with this cryptic stack trace. The remediation is simply to **unlock the interactive console/RDP session** and re-run `-IntegrationTest`; no code change can substitute for this. + +The same error has a second, entirely different cause: a regression in +**`appium-novawindows-driver` 1.4.2**. Its `trySetForegroundWindow` +(`lib/winapi/user32.ts`) returns the result of the Win32 `EnumWindows` call +that locates the window, but `EnumWindows` returns FALSE precisely when its +callback halts enumeration - i.e. when the window *was* found and +foregrounded. The value is therefore inverted, so `attachToApplicationWindow` +takes its `focusElement` fallback branch on every successful launch; +`focusElement` issues UI Automation `SetFocus()` against a window that already +holds the foreground, that throws, and the launch loop swallows the error and +retries 20 times before reporting `Failed to locate window of the app.` The +inverted form is pre-1.4.1 code that 1.4.1's stability refactor incidentally +corrected and 1.4.2's revert of that refactor reintroduced. + +Because that failure is indistinguishable from the locked-session one at the +top-level error message, and because an unpinned `appium driver install` +silently picks up new releases, **both `build.ps1 -IntegrationTest` and +`.github/workflows/build.yaml` pin the driver to 1.4.1** - the last good +release. `build.ps1` additionally uninstalls a mismatched version before +installing, because `appium driver install` refuses to overwrite an existing +driver and both scripts deliberately tolerate its "already installed" error; +without that uninstall, a developer's stale driver would silently defeat the +pin and let local results diverge from CI. Remove both pins once a fixed +release ships. diff --git a/docs/design/ots/dock.md b/docs/design/ots/dock.md index 27220a1..4247096 100644 --- a/docs/design/ots/dock.md +++ b/docs/design/ots/dock.md @@ -122,12 +122,24 @@ the ones the Tool panels use: - **`IFactory.FocusedDockableChanged`/`OnDockableClosed`** — `MainWindowView` subscribes to `WorkbenchDockFactory`'s inherited `FocusedDockableChanged` event to forward Dock's own tab-focus tracking to - `MainWindowShell.NotifyActiveDiagramTab` whenever focus lands on a diagram - document (focus changes onto a Tool panel are ignored), and + `MainWindowShell.NotifyActiveDocumentTab` whenever focus lands on a + document, whether a diagram or a read-only source-text document (focus + changes onto a Tool panel are ignored), and `WorkbenchDockFactory` overrides `OnDockableClosed` to raise a `DiagramTabClosed` event when a diagram document closes through Dock's own chrome, which `MainWindowView` uses to call - `MainWindowShell.CloseDiagramTab`. + `MainWindowShell.CloseDiagramTab`. Selecting a document tab drives Dock's + internal `TryActivateDockable`, which activates the dockable within its own + dock and *then* focuses it from the focusable root; the activation step + already raises the focus change synchronously (via `InitActiveDockable`), so + subscribing to `FocusedDockableChanged` alone tracks the selected tab. + Headless tests therefore reproduce a tab click by calling the factory's + `SetActiveDockable` followed by `SetFocusedDockable`, which is the same + activate-then-focus pair Dock's own tab-header click handler issues. + Calling `SetFocusedDockable` alone is not sufficient: it returns without + raising anything when the focusable root's focused dockable already matches + the requested one, so a test that only focuses cannot observe a transition + it did not first activate. `Document`s and `Tool`s therefore have deliberately different close semantics in this codebase: a closed `Tool` is hidden and restorable via the diff --git a/docs/design/sysml2-workbench/app-shell-subsystem.md b/docs/design/sysml2-workbench/app-shell-subsystem.md index 4eeee89..04ee19d 100644 --- a/docs/design/sysml2-workbench/app-shell-subsystem.md +++ b/docs/design/sysml2-workbench/app-shell-subsystem.md @@ -60,7 +60,12 @@ environment. state. The three Tool panels (workspace, predefined views, 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. Custom-view composition is not one of these + the close/restore cycle. The workspace and predefined-views panels share + the left column, with Workspace as its leading tab and the one shown by + default at startup (matching the order the View menu lists them in), so a + freshly launched application presents the workspace tree first - the + panel a user needs before any view can be rendered. Custom-view + composition is not one of these docked panels: it is `ViewBuilderDialog`, a modal `Window` shown via `ShowDialog` (like `AboutDialog`), opened from the View menu and discarded on close rather than living in the persistent Dock layout. The @@ -86,3 +91,14 @@ environment. 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. +8. A status area runs along the bottom edge of the main window, below every + docked panel, summarizing whatever document tab is currently active: the + file name and full path for a source-text tab, the view kind plus the + entities being viewed (elided to the first few with a trailing "+N more" + count) for a diagram tab, and a neutral idle message when no tab is open. + The summary text itself is computed by MainWindowShell + (`GetActiveTabStatusSummary`), so the logic stays Avalonia-free and + unit-testable and the UI layer only renders the resulting string, + refreshing it whenever the open-tab set or the focused document changes. + Because the shell is the only owner of that text, the status area never + duplicates tab-presentation rules the shell already applies. 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 849b49a..b986010 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 @@ -28,10 +28,10 @@ content. Each `WorkbenchTab` owns its own `SvgCanvasHost` (unused/never loaded for a source-text tab), so multiple open diagram tabs have fully independent zoom/pan/content state. -**ActiveTabId**: `string?` — identifier of the diagram tab currently -active/focused, or `null` when no diagram tab is open. Updated both by shell +**ActiveTabId**: `string?` — identifier of the document tab currently +active/focused, or `null` when no document 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`. +own focus-change signal via `NotifyActiveDocumentTab`. **CurrentSourceIdToFiles**: `IReadOnlyDictionary>` — per-source file lists from the most recent workspace resolution, used by @@ -150,10 +150,10 @@ and makes it active, without rendering anything into it. tab, a neighboring tab becomes active, or `ActiveTabId` becomes `null` if no tabs remain. -**NotifyActiveDiagramTab**: Notifies the shell that a diagram tab has gained -UI focus. +**NotifyActiveDocumentTab**: Notifies the shell that a document tab — a +rendered diagram or a read-only source-text document — has gained UI focus. -- *Parameters*: `string? tabId` — identifier of the newly focused diagram +- *Parameters*: `string? tabId` — identifier of the newly focused document tab. - *Returns*: `void` — `ActiveTabId` updates in place. - *Postconditions*: `ActiveTabId` is updated, unless `tabId` does not refer to @@ -224,6 +224,29 @@ the diagram currently rendered in an open tab. successful export is also logged at `Info` level, mirroring `ExportCustomViewSnippet`'s existing style. +**GetActiveTabStatusSummary**: Builds a short, single-line summary of +whatever the currently active tab presents, for the main window's bottom +status area. + +- *Parameters*: none. +- *Returns*: `string` — never `null` or empty. +- *Preconditions*: None; every shell state, including "no tab open", is + valid input. +- *Postconditions*: None (read-only; no file is read and nothing is + re-rendered, so it is cheap enough to call on every tab or focus change). + The result is `Ready` when no tab is active; `{fileName} — {fullPath}` for + a `SourceText` tab (falling back to the tab's `Title` when it carries no + file path); and `{View Kind} view — {Entity1}, {Entity2}, {Entity3} + (+N more)` for a diagram tab, where the view kind is the definition's + friendly space-separated label (for example *State Transition*), each + entity is the final `::` segment of an `ExposeTargetSelection`'s qualified + name, at most three are listed and the remainder is elided with a trailing + count, and a definition exposing nothing reports + `(no exposed entities)`. A diagram tab whose `SourceDefinition` is `null` + falls back to the tab's `Title`. Returning a plain `string` computed from + already-resident shell state keeps this class free of any UI dependency - + the Avalonia layer only renders the result. + #### Supporting Types **SourceTextDocumentViewModel**/**SourceTextDocumentView**: the Dock diff --git a/docs/reqstream/sysml2-workbench.yaml b/docs/reqstream/sysml2-workbench.yaml index 26bb536..8f5019e 100644 --- a/docs/reqstream/sysml2-workbench.yaml +++ b/docs/reqstream/sysml2-workbench.yaml @@ -52,5 +52,7 @@ sections: children: - 'SysML2Workbench-LoggingSubsystem-PersistOperationalEvents' - 'SysML2Workbench-AppShellSubsystem-CoordinateSessionWorkspace' + - 'SysML2Workbench-AppShellSubsystem-DefaultSidebarPanel' + - 'SysML2Workbench-AppShellSubsystem-ShowActiveDocumentStatus' tests: - 'StartSession_OpensShellAndWritesOperationalLogs' diff --git a/docs/reqstream/sysml2-workbench/app-shell-subsystem.yaml b/docs/reqstream/sysml2-workbench/app-shell-subsystem.yaml index 7309c80..da762ec 100644 --- a/docs/reqstream/sysml2-workbench/app-shell-subsystem.yaml +++ b/docs/reqstream/sysml2-workbench/app-shell-subsystem.yaml @@ -36,3 +36,24 @@ sections: - 'SysML2Workbench-AppShellSubsystem-ViewBuilderDialog-CancelDiscardsWithoutSideEffects' tests: - 'CustomViewWorkflow_PreviewsAndExportsFromShell' + + - id: 'SysML2Workbench-AppShellSubsystem-DefaultSidebarPanel' + title: 'The AppShellSubsystem shall present the workspace panel as the sidebar panel shown by default when the application starts, ahead of the predefined-views panel in the same sidebar.' + justification: | + The workspace tree is the first thing a user needs at startup - sources must be added before any view can be + rendered - so it is the sidebar panel shown by default, matching the order the View menu already lists the + panels in. This behavior is realized by the subsystem's UI composition layer rather than by a documented + unit, so it is verified at subsystem level. + tests: + - 'Startup_ShowsWorkspacePanelAsDefaultSidebarTab' + + - id: 'SysML2Workbench-AppShellSubsystem-ShowActiveDocumentStatus' + title: 'The AppShellSubsystem shall display a status area along the bottom of the main window summarizing the currently active document.' + justification: | + Users need to tell at a glance which file or view the front tab presents - the tab label alone is truncated and + omits the file path and the entities a diagram exposes - and need a neutral idle indication when no document + is open at all. + children: + - 'SysML2Workbench-AppShellSubsystem-MainWindowShell-SummarizeActiveTab' + tests: + - 'ActiveTabChanges_ProduceStatusSummary' 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 4655c1b..29632d2 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 @@ -24,7 +24,7 @@ sections: - 'PreviewCustomView_WithNoTabsOpen_OpensNewTab' - 'OpenNewCustomPreviewTab_OpensEmptyActiveTab_AndSubsequentPreviewUpdatesIt' - 'CloseDiagramTab_RemovesTab_AndReassignsActiveTab' - - 'NotifyActiveDiagramTab_UnknownId_IsIgnored' + - 'NotifyActiveDocumentTab_UnknownId_IsIgnored' - 'SelectPredefinedView_TabsHaveIndependentCanvases' - id: 'SysML2Workbench-AppShellSubsystem-MainWindowShell-SynchronizeSessionState' @@ -85,3 +85,18 @@ sections: - 'OpenSourceTextTab_NewFile_CreatesOneNewActiveTab' - 'OpenSourceTextTab_SamePathTwice_RefocusesExistingTabWithoutDuplicating' - 'GetTabFilePath_ReturnsPathForSourceTextTab_AndNullOtherwise' + + - id: 'SysML2Workbench-AppShellSubsystem-MainWindowShell-SummarizeActiveTab' + title: 'The MainWindowShell shall produce a short single-line summary of the currently active tab - the file name and full path for a source-text tab, the view kind and exposed entities for a diagram tab, and a neutral idle message when no tab is open.' + justification: | + The main window's bottom status area needs the summary text computed in Avalonia-free, unit-testable shell + code so the UI layer only renders it; the summary must stay single-line for any number of exposed entities + and must degrade gracefully for a diagram tab whose view definition could not be derived. + tests: + - 'GetActiveTabStatusSummary_NoTabOpen_ReturnsIdleSummary' + - 'GetActiveTabStatusSummary_SourceTextTab_ReturnsFileNameAndPath' + - 'GetActiveTabStatusSummary_PredefinedViewTab_ReturnsViewKindAndExposedEntities' + - 'GetActiveTabStatusSummary_CustomPreviewTab_ReturnsViewKindAndExposedEntities' + - 'GetActiveTabStatusSummary_ManyExposedEntities_ElidesWithMoreCount' + - 'GetActiveTabStatusSummary_DiagramTabWithoutDefinition_FallsBackToTabTitle' + - 'GetActiveTabStatusSummary_UnresolvedExposeTarget_ReportsNoExposedEntities' diff --git a/docs/user_guide/getting_started.md b/docs/user_guide/getting_started.md index 51ea3c5..892f174 100644 --- a/docs/user_guide/getting_started.md +++ b/docs/user_guide/getting_started.md @@ -33,7 +33,10 @@ for setup prerequisites. 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 + plus **Remove**. **Workspace** is the sidebar tab shown by default when + the application starts, with **Predefined Views** as the second tab of + the same panel - click either tab to switch between them. 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 @@ -195,6 +198,23 @@ found anywhere in the currently loaded workspace, refreshed automatically on every reload (initial open or external file change). Each entry shows the affected file, location, severity, and message. +## Status Bar + +A status bar runs along the bottom edge of the window, below every docked +panel, showing a short summary of whichever document tab is currently +active: + +- A **source-text tab** shows the file's name followed by its full path. +- A **diagram tab** - predefined view or custom-view preview - shows the + view kind followed by the entities being viewed, for example + *Interconnection view — Vehicle, Engine*. Only the first few entities are + listed; the rest are summarized as a trailing *(+N more)* count so the + status bar stays on one line. +- With **no tab open** at all, the status bar shows *Ready*. + +The summary updates automatically as you open, close, or switch between +tabs. + ## Local Log File SysML2Workbench writes a local rolling log file recording notable operations diff --git a/docs/verification/ots/avalonia.md b/docs/verification/ots/avalonia.md index 950fa66..aac202d 100644 --- a/docs/verification/ots/avalonia.md +++ b/docs/verification/ots/avalonia.md @@ -6,7 +6,7 @@ OTS integration tests in `test/OtsSoftwareTests/AvaloniaTests.cs` verify the rea ### Test Scenarios -**Startup_HostsDesktopShellControls**: A real `MainWindowView` is constructed over a fully composed `MainWindowShell` and shown under the headless platform. The dependency proves it hosts the menu and predefined-views list as real, discoverable Avalonia controls attached to the window's visual tree. +**Startup_HostsDesktopShellControls**: A real `MainWindowView` is constructed over a fully composed `MainWindowShell` and shown under the headless platform. The dependency proves it hosts the menu and the workspace tree - the sidebar's leading, initially-active tab - as real, discoverable Avalonia controls attached to the window's visual tree. **MainWindow_HostsDiagramAndDiagnosticsPanels**: The same window's diagram `Image` control and diagnostics `ListBox` are confirmed present before any workspace is opened; after opening a workspace and selecting a predefined view through the shell, the shared canvas state backing the diagram control reports loaded content, proving Avalonia genuinely hosts the interactive diagram surface alongside the diagnostics panel. diff --git a/docs/verification/ots/dock.md b/docs/verification/ots/dock.md index 95e38f0..0233c9a 100644 --- a/docs/verification/ots/dock.md +++ b/docs/verification/ots/dock.md @@ -16,6 +16,6 @@ OTS integration tests in `test/OtsSoftwareTests/DockTests.cs` verify Dock's layo **AddDockable_And_CloseDockable_DynamicallyManageDiagramDocuments**: Two `DiagramDocumentViewModel`s are added dynamically via `AddDockable` to a real, hosted `DockControl`'s layout, confirming both are reachable and counted; one is then closed via `CloseDockable`, confirming it is removed while the other remains, and that `DiagramTabClosed` fires exactly once with the closed view model as its argument. -**FocusedDockableChanged_FiresForActiveDockableChangesOnDiagramDocuments**: Two `DiagramDocumentViewModel`s are added to a `WorkbenchDockFactory` layout, and `SetFocusedDockable` is called to move focus to the second one, confirming `FocusedDockableChanged` fires with that document as its argument - the mechanism `MainWindowView` relies on to forward Dock's own focus tracking to `MainWindowShell.NotifyActiveDiagramTab`. +**FocusedDockableChanged_FiresForActiveDockableChangesOnDiagramDocuments**: Two `DiagramDocumentViewModel`s are added to a `WorkbenchDockFactory` layout, and `SetFocusedDockable` is called to move focus to the second one, confirming `FocusedDockableChanged` fires with that document as its argument - the mechanism `MainWindowView` relies on to forward Dock's own focus tracking to `MainWindowShell.NotifyActiveDocumentTab`. **DiagramDock_EmptyArea_HasNoVisibleBorder**: A real `MainWindowView` (hosting the actual `DockControl` and its `DockControl.Styles` border-removal fix) is shown headless and rendered to a bitmap with zero diagram tabs open, then again after one trivial diagram tab is added via `AddDockable`, sampling pixel colors at the diagram `DocumentControl`'s `PART_Border` location in both frames. Confirms the border pixel is indistinguishable from its surrounding blank content when zero tabs are open (no visible border), while remaining visibly distinct from its content when a tab is open (the with-tab-open appearance is unchanged). diff --git a/docs/verification/sysml2-workbench/app-shell-subsystem.md b/docs/verification/sysml2-workbench/app-shell-subsystem.md index 6a8eb5f..921ff11 100644 --- a/docs/verification/sysml2-workbench/app-shell-subsystem.md +++ b/docs/verification/sysml2-workbench/app-shell-subsystem.md @@ -34,3 +34,12 @@ state across the catalog and diagnostics regions. Verified by **CustomViewWorkflow_PreviewsAndExportsFromShell**: A custom view can be previewed and exported as SysML text from the shell. Verified by `AppShellSubsystemTests.CustomViewWorkflow_PreviewsAndExportsFromShell`. + +**Startup_ShowsWorkspacePanelAsDefaultSidebarTab**: The sidebar composed at startup presents the workspace panel as its +leading tab and shows it by default, with the predefined-views panel second in the same column. Verified by +`AppShellSubsystemTests.Startup_ShowsWorkspacePanelAsDefaultSidebarTab`. + +**ActiveTabChanges_ProduceStatusSummary**: The subsystem summarizes the currently active document for the main window's +status area, reporting a neutral idle message with no tab open, the view kind and exposed entities once a predefined +view is opened, and the file name plus full path once a source-text tab takes over. Verified by +`AppShellSubsystemTests.ActiveTabChanges_ProduceStatusSummary`. 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 111d867..e37e53d 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,8 +63,8 @@ empty-workspace guard. Verified by a neighbor; closing the final tab leaves no open tabs, a null active tab, and an idle (empty) canvas. Verified by `MainWindowShellTests.CloseDiagramTab_RemovesTab_AndReassignsActiveTab`. -**NotifyActiveDiagramTab_UnknownId_IsIgnored**: Notifying the shell of an unknown/stale tab id is ignored rather than -clearing a still-valid active tab id. Verified by `MainWindowShellTests.NotifyActiveDiagramTab_UnknownId_IsIgnored`. +**NotifyActiveDocumentTab_UnknownId_IsIgnored**: Notifying the shell of an unknown/stale tab id is ignored rather than +clearing a still-valid active tab id. Verified by `MainWindowShellTests.NotifyActiveDocumentTab_UnknownId_IsIgnored`. **SelectPredefinedView_TabsHaveIndependentCanvases**: Each diagram tab owns a fully independent canvas: opening two predefined views produces two distinct canvas host instances, and zooming one does not affect the other's zoom level. @@ -135,3 +135,33 @@ Verified by `MainWindowShellTests.OpenSourceTextTab_SamePathTwice_RefocusesExist an open source-text tab, and `null` for a tab id that does not refer to any currently open tab, or that refers to a tab of a different kind. Verified by `MainWindowShellTests.GetTabFilePath_ReturnsPathForSourceTextTab_AndNullOtherwise`. + +**GetActiveTabStatusSummary_NoTabOpen_ReturnsIdleSummary**: With no tab open at all, the active-tab status summary +is the neutral idle message `Ready` rather than an empty string. Verified by +`MainWindowShellTests.GetActiveTabStatusSummary_NoTabOpen_ReturnsIdleSummary`. + +**GetActiveTabStatusSummary_SourceTextTab_ReturnsFileNameAndPath**: An active source-text tab is summarized as its +file's own name followed by the file's full path. Verified by +`MainWindowShellTests.GetActiveTabStatusSummary_SourceTextTab_ReturnsFileNameAndPath`. + +**GetActiveTabStatusSummary_PredefinedViewTab_ReturnsViewKindAndExposedEntities**: An active predefined-view tab is +summarized as its view kind followed by the short names of the entities its derived definition exposes. Verified by +`MainWindowShellTests.GetActiveTabStatusSummary_PredefinedViewTab_ReturnsViewKindAndExposedEntities`. + +**GetActiveTabStatusSummary_CustomPreviewTab_ReturnsViewKindAndExposedEntities**: An active custom-view-preview tab +is summarized the same way as a predefined-view tab, with the view kind spelled as its friendly, space-separated +label. Verified by `MainWindowShellTests.GetActiveTabStatusSummary_CustomPreviewTab_ReturnsViewKindAndExposedEntities`. + +**GetActiveTabStatusSummary_ManyExposedEntities_ElidesWithMoreCount**: A definition exposing more entities than the +summary lists reports only the first few, followed by a count of those left out, so the summary stays single-line. +Verified by `MainWindowShellTests.GetActiveTabStatusSummary_ManyExposedEntities_ElidesWithMoreCount`. + +**GetActiveTabStatusSummary_DiagramTabWithoutDefinition_FallsBackToTabTitle**: A diagram tab whose source definition +could not be derived (an unscoped predefined view with zero expose members) is summarized by its own tab title +rather than by an empty or invented summary. Verified by +`MainWindowShellTests.GetActiveTabStatusSummary_DiagramTabWithoutDefinition_FallsBackToTabTitle`. + +**GetActiveTabStatusSummary_UnresolvedExposeTarget_ReportsNoExposedEntities**: A diagram tab whose view definition +declares an expose member that resolves to no workspace element is summarized as its view kind followed by +"(no exposed entities)", rather than by an empty entity list. Verified by +`MainWindowShellTests.GetActiveTabStatusSummary_UnresolvedExposeTarget_ReportsNoExposedEntities`. diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowShell.cs b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowShell.cs index 81a2c41..55744b1 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowShell.cs +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowShell.cs @@ -66,6 +66,28 @@ public sealed record WorkbenchTab(string Id, string Title, WorkbenchTabKind Kind /// public sealed class MainWindowShell : IDisposable { + /// + /// Summary reported by when no tab is open at all. + /// + private const string IdleStatusSummary = "Ready"; + + /// + /// Summary fragment reported for a diagram tab whose definition exposes no entities (an unscoped + /// "expose everything" view). + /// + private const string NoExposedEntitiesSummary = "(no exposed entities)"; + + /// + /// Separator placed between the two halves of a status summary. + /// + private const string SummarySeparator = "\u2014"; + + /// + /// Maximum number of exposed entity names listed in a diagram tab's status summary before the remainder + /// is elided with a trailing "(+N more)" count. + /// + private const int MaxSummarizedEntities = 3; + private readonly WorkspaceModel _workspaceModel; private readonly FileWatcher _fileWatcher; private readonly DiagnosticsAggregator _diagnosticsAggregator; @@ -135,10 +157,10 @@ public sealed class MainWindowShell : IDisposable public IReadOnlyList OpenTabs => _openTabs; /// - /// Identifier of the diagram tab currently active/focused, or when no diagram + /// Identifier of the document tab currently active/focused, or when no document /// tab is open. Updated either by shell operations that open or focus a tab (, /// , , ) - /// or by the UI layer forwarding Dock's own focus-change signal via . + /// or by the UI layer forwarding Dock's own focus-change signal via . /// public string? ActiveTabId { get; private set; } @@ -645,12 +667,14 @@ public void CloseDiagramTab(string tabId) } /// - /// Notifies the shell that a diagram tab has gained UI focus, so a subsequent - /// call knows which tab is "active" for its in-place-update-or-new-tab decision. Called by the - /// Avalonia-aware UI layer when Dock reports a focus change onto a diagram document. + /// Notifies the shell that a document tab - a rendered diagram or a read-only source-text document - has + /// gained UI focus, so that identifies whichever document tab the user is + /// actually looking at. This drives both the active-tab status summary and a subsequent + /// call's in-place-update-or-new-tab decision. Called by the + /// Avalonia-aware UI layer when Dock reports a focus change onto a document. /// - /// Identifier of the newly focused diagram tab. - public void NotifyActiveDiagramTab(string? tabId) + /// Identifier of the newly focused document tab. + public void NotifyActiveDocumentTab(string? tabId) { if (tabId is not null && _openTabs.All(tab => tab.Id != tabId)) { @@ -686,6 +710,114 @@ public void NotifyActiveDiagramTab(string? tabId) return _openTabs.FirstOrDefault(tab => tab.Id == tabId)?.FilePath; } + /// + /// Builds a short, single-line summary of whatever the currently active tab presents, suitable for a + /// status area along the bottom of the main window. + /// + /// + /// when no tab is active; the file name and full path for a + /// tab; or the view kind plus the entities being viewed for a + /// diagram tab. + /// + /// + /// Returns a plain computed only from already-resident shell state, so the + /// Avalonia-aware UI layer only has to render it and this class stays free of any UI dependency. No file + /// is read and nothing is re-rendered, so this is cheap enough to call on every tab or focus change. + /// + public string GetActiveTabStatusSummary() + { + // No tab open is a first-class, non-error state - report a neutral idle message rather than an + // empty status area. + if (ActiveTab is not { } tab) + { + return IdleStatusSummary; + } + + return tab.Kind == WorkbenchTabKind.SourceText + ? FormatSourceTextSummary(tab) + : FormatDiagramSummary(tab); + } + + /// + /// Formats the status summary of a tab. + /// + /// Source-text tab to summarize. + /// + /// The file's own name followed by its full path, or the tab's title when the tab carries no file path. + /// + private static string FormatSourceTextSummary(WorkbenchTab tab) + { + if (string.IsNullOrEmpty(tab.FilePath)) + { + return tab.Title; + } + + return $"{Path.GetFileName(tab.FilePath)} {SummarySeparator} {tab.FilePath}"; + } + + /// + /// Formats the status summary of a predefined-view or custom-view-preview tab. + /// + /// Diagram tab to summarize. + /// + /// The view kind followed by the exposed entities' short names (at most + /// , with the remainder elided), or the tab's title when no source + /// definition could be derived for the tab. + /// + private static string FormatDiagramSummary(WorkbenchTab tab) + { + // A diagram tab legitimately has no derivable definition (an unscoped predefined view, or a + // brand-new custom-preview tab) - fall back to the tab's own label rather than inventing one. + if (tab.SourceDefinition is not { } definition) + { + return tab.Title; + } + + var names = definition.ExposeTargets.Select(target => ShortName(target.QualifiedName)).ToList(); + var entities = names.Count switch + { + 0 => NoExposedEntitiesSummary, + <= MaxSummarizedEntities => string.Join(", ", names), + + // Long expose lists would overflow the status area - list the first few and report the count of + // whatever was left out, so the summary stays single-line and readable. + _ => $"{string.Join(", ", names.Take(MaxSummarizedEntities))} (+{names.Count - MaxSummarizedEntities} more)", + }; + + return $"{FormatViewKindLabel(definition.ViewKind)} view {SummarySeparator} {entities}"; + } + + /// + /// Converts a view kind to its user-facing, space-separated label. + /// + /// View kind to label, or when the definition has none. + /// The friendly label, or a generic "Diagram" label when no view kind has been chosen. + private static string FormatViewKindLabel(ViewKind? viewKind) + { + return viewKind switch + { + ViewKind.General => "General", + ViewKind.Interconnection => "Interconnection", + ViewKind.StateTransition => "State Transition", + ViewKind.ActionFlow => "Action Flow", + ViewKind.Sequence => "Sequence", + ViewKind.Grid => "Grid", + _ => "Diagram", + }; + } + + /// + /// Reduces a SysML qualified name to its final segment, so the status area names entities the way the + /// user refers to them rather than repeating their full package path. + /// + /// Qualified name of an exposed target, for example Sample::Engine. + /// The text after the last :: separator, or the whole name when it has no separator. + private static string ShortName(string qualifiedName) + { + var separatorIndex = qualifiedName.LastIndexOf("::", StringComparison.Ordinal); + return separatorIndex < 0 ? qualifiedName : qualifiedName[(separatorIndex + 2)..]; + } + /// /// Opens a read-only source-text tab for the given workspace file, reusing an already-open tab for the /// same path instead of duplicating it. diff --git a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml index 528d6e7..97c7653 100644 --- a/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml +++ b/src/DemaConsulting.SysML2Workbench/AppShellSubsystem/MainWindowView.axaml @@ -73,6 +73,14 @@ + + + + +