TreeKeyboardController (and its ITreeKeyboardTarget interface) drove keyboard gestures on the old ProDataGrid trees, including a reflection workaround for the grid's shift-range selection that only added and never shrank a range. Both trees are SharpTreeView (ListBox) now -- which handles those gestures natively in SharpTreeView.OnKeyDown and gets anchor extend/shrink for free -- so nothing constructs the controller. Remove it and the now-vestigial interface implementations on the two tree view-models.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Two implemented tree gestures had no coverage (flagged by a gesture audit): middle mouse button on a row opens it in a new document tab, and Ctrl+R analyzes the selected member into the analyzer pane.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Analyze dropped the entity's node into the tree collapsed, so the user had to expand it every time to see its analyzers (Used By, Uses, ...). Expand the freshly-added node on creation; re-analyzing an existing entity returns the same node without re-expanding it.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The startup welcome page renders the same About content in the reusable main tab. Help > About opened a second, static About tab beside it, so the user saw the page twice. Track the welcome content and, while it is still the live main-tab content, have Help > About activate it rather than open the singleton. Falls through to the singleton once a tree-node selection has replaced the welcome page.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Center-on-select reveals a navigated-to node centred in the viewport, but it was also yanking rows the user can already see: opening a visible row in a new tab (or any model-driven selection) pulled it to the middle. The skip cannot be decided from post-selection geometry because AutoScrollToSelectedItem first drags an off-screen row to an edge, making it look visible. Snapshot visibility in TreeSelectionBinder.SyncModelToTree before the selection changes: an already-visible primary is focused without scrolling, while a genuinely off-screen one (Back navigation, go-to-definition) is still centred.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The Simple theme drew keyboard focus as a dotted, fill-less adorner that vanished outright once a context menu took focus, and right-click versus keyboard invocation marked the target inconsistently. Render focus as a selection-coloured fill with a dotted darker-blue border via a FocusAdornerTemplate, and reuse the same visual as a ContextTargetVisual template part for the right-click target. A keyboard-invoked menu (Shift+F10 / Apps) now marks the focused row and restores its focus and adorner on close, since Avalonia's ContextMenu never restores focus itself.
Assisted-by: Claude:claude-opus-4-8:Claude Code
ScheduleBackgroundLoadSweep waited for TreeReady and then an extra unconditional
5s before populating sibling-assembly icons -- an artificial wall-clock delay on
top of the TreeReady gate. Fire the sweep as soon as the tree is on screen; the
TreeReady gate already keeps it off slow startups and the SemaphoreSlim(4)
throttle still bounds the load storm.
Reopening_About_Reuses_The_Same_Tab matched any tab whose Title is "About", but
two tabs can carry that title: the singleton menu-About (IsStaticContent: true,
opened via OpenSingletonTab) and the transient boot welcome page (ShowWelcome,
IsStaticContent: false, a non-static main-tab page shown when nothing is
restored). The welcome page's presence races with boot, so when it lingered the
test's .Single(IsAbout) saw two tabs and threw "Sequence contains more than one
matching element" -- a ~50% order-dependent flake.
The singleton is what the test means to verify, so match it precisely
(IsStaticContent: true). OpenSingletonTab/CloseDockable reuse was always sound;
this was purely an imprecise test predicate. Deterministic now (5/5 full-suite
runs green; was ~50%).
NodesInserted/NodesRemoved reported a multi-node change (a node plus its visible
descendants -- a contiguous run) as a sequence of single-item Add/Remove events,
but the underlying tree updates Count by the WHOLE run before they fire. A
consumer that reconciles the change one item at a time and re-indexes the source
mid-sequence then reads against a Count that has already dropped by the full run:
Avalonia's SelectionModel does exactly this (it re-reads SelectedItems while
handling each Remove), indexing past the end and throwing ArgumentOutOfRangeException
out of TreeFlattener's indexer whenever a selection was live during a bulk reshape
(e.g. toggling UseNestedNamespaceNodes). It only surfaced under full-suite timing,
which is why it read as flaky.
Raise one ranged event for the whole run instead, so the notification matches the
tree's state in a single step and Count/indices stay in agreement. The indexer
keeps throwing on genuine out-of-range access. Avalonia 12.4's virtualization and
selection handle ranged Add/Remove (every expand/collapse exercises it); full
headless suite is green.
A test that triggers a decompile spawns a Task.Run plus dispatcher continuations
and rarely awaits them; the per-test ResetAppState closed windows and drained the
dispatcher but never cancelled the background work, so a continuation could land
during the next test and read the swapped-in AppComposition.Current -- the
dominant source of order-dependent flakiness (a different test tipped each run).
Give DecompilerTabPageModel an internal CancelPendingAsync (store the in-flight
decompile task, cancel its CTS, return it) and DockWorkspace a
CancelPendingOperationsAsync that fans that across every document tab -- both
legitimate shutdown hygiene too. ResetAppState.AfterTest now cancels and pumps
the dispatcher until the work unwinds before the next test boots.
This stops the random rotation of the failing test (the victims are now stable
and diagnosable); two order-dependent failures remain (About-tab duplication and
a nested-namespace tree race) that need their own fixes -- they still pass in
isolation.
DecompilerTextView subscribed to the process-lived ThemeManager.Current.
ThemeChanged in its constructor and never unsubscribed, so every view (one per
decompiler tab) stayed rooted by the singleton for the lifetime of the process
-- a slow leak that, in the headless suite, accumulates views across every test
and adds GC pressure that tips timing-marginal tests. Bind the subscription to
the visual-tree lifetime instead (subscribe on attach, drop on detach); since
Dock hides and re-shows tab content, this also re-subscribes on re-attach, which
the sibling DecompilerTextEditor's subscribe-in-ctor variant does not.
Also quiet MenuIconWiringProbe: it dumped every menu leaf via TestContext.Out on
every run. Collect the leaves instead and name the icon-bearing ones only in the
assertion's failure message, so a passing run is silent.
The assembly-tree SharpTreeView migration (0995d32df, "WIP: tests pending")
left two bits of debt that surfaced as seven failing tests:
- It dropped the old AssemblyListPane.CenterRowInView, so a model-driven
selection only scrolled the row to the nearest viewport edge. Reinstate
centring in SharpTreeView.ScrollIntoNodeView via CenterNodeInView: bring the
row on screen, then offset so it sits at the vertical centre. It skips the
move only when the row is already roughly centred (not merely visible at an
edge), because the ListBox's AutoScrollToSelectedItem drags the selected row
to an edge first and a reveal should still pull it to the centre -- while a
click on an already-centred row doesn't twitch.
- DecompileInNewViewTests still queried the removed ProDataGrid surface
(DataGrid / DataGridRow / HierarchicalNode). Retarget to SharpTreeView /
SharpTreeViewItem and the node-valued SelectedItem, and clamp the right-click
X to the visible grid width (tree rows stretch to content width, so a row
centre can sit off-screen).
Full suite: the seven deterministic failures are gone; the only remaining two
are the pre-existing group-ordering-flaky pair (both pass in isolation).
On Linux desktops where the com.canonical.AppMenu.Registrar name is absent or
blinks in and out (e.g. KDE Plasma, where kappmenu owns it only transiently),
Avalonia's DBusMenuExporter fired an unguarded UnregisterWindowAsync on dispose.
The faulted fire-and-forget task went unobserved and was rethrown by the
finalizer as org.freedesktop.DBus.Error.ServiceUnknown ("The name is not
activatable"), surfacing through the global exception dialog. 12.0.4 observes
that task (AvaloniaUI/Avalonia#21344, fixing #17616); verified the ContinueWith
guard is present in the resolved Avalonia.FreeDesktop binary.
No lock-file churn: only the cross-platform decompiler libraries pin lock files
and none reference Avalonia. Full headless suite shows no new failures.
Sorting rebuilds every top-level assembly node (AssemblyList.Sort clears the
collection and re-adds it, so the tree tears down and recreates all nodes). That
dropped the selection entirely and snapped the list back to the top, so the user
watched the tree reshuffle out from under them. Capture the selected assemblies
before the sort and re-select them after, so the view settles on the same items
(the selection binder keeps the primary in view) instead of jumping to the top.
The Linux DBus failure (org.freedesktop.DBus.Error.ServiceUnknown) arrives as an
unobserved AggregateException rethrown by the finalizer thread, so it is
temporally divorced from the mouse/keyboard gesture that made Avalonia issue the
DBus call (AT-SPI, portals, clipboard, IME). To pin the trigger, add an
ILSPY_LOG=DBUSDEBUG category: InputDiagnostics logs every pointer/key/focus event
into a rolling ring buffer, and the unobserved-task handler dumps that trail next
to the fully unwrapped exception. The unwrapper flattens the aggregate, walks
each inner chain, and reflects DBusException.ErrorName/ErrorMessage (Tmds.DBus is
a transitive dependency we can't reference directly) -- detail the bare
ToString() buries. The exception dialog and clipboard report carry the unwrapped
chain too, so the DBus error name shows even without the category enabled.
A model selection set before the SharpTreeView is attached to the visual tree
(e.g. AnalyzerTreeViewModel.Analyze selects its node, then the pane is shown)
was lost: the ListBox isn't an initialised ItemsControl yet, so the binder's
SelectedItems add silently dropped. Nothing re-applied it, so the analyzed row
showed unselected and keyboard gestures had no current node.
Making SharpTreeView focusable (for first-press Ctrl+A) unmasked this: with the
tree itself taking focus instead of delegating to the selected row's container,
keyboard handlers fell back to SelectedItem, which was empty. Re-sync on the
tree's Loaded event so the selection sticks once the control is ready.
ILSpyTreeNode cached the SettingsService (and LanguageService) in a static
field that was never invalidated. In the headless suite the MEF container is
rebuilt per test, so from the second test onward the filter cascade
(ApplyFilterToChild / OnIsVisibleChanged) read a previous test's disposed
SettingsService and its stale ShowApiLevel. A test that set ShowApiLevel=All
on its own fresh settings then expanded a large type still had ~a third of the
members marked IsHidden against the stale level, so the flattener dropped them
and the rows never realised -- surfacing as an order-dependent timeout
(NonPublic_Member_Rows_Render and the in-place refilter test).
GetSuffixString already resolves the service fresh for exactly this reason;
apply the same to CurrentLanguageSettings and LanguageService and drop the dead
caches. A warm GetExport is a dictionary lookup, and in production (one
container for the app lifetime) behaviour is unchanged.
The assembly/analyzer panes now host SharpTreeView, but ~20 headless tests
still queried the old ProDataGrid surface (DataGrid/DataGridRow/Hierarchical
Model/HierarchicalNode), so they could not exercise the live tree. Retarget
them to SharpTreeView/SharpTreeViewItem and the flattener (ItemsSource) the
control actually exposes, and centralise the row/selection lookups in
TreeNodeAssertions so the scroll assertions ride the new container type.
Driving the retargeted tests against the real control surfaced four genuine
gaps, fixed here in SharpTreeView:
- Ctrl+A selected nothing on the first press because the base ListBox only
selects all once an item inside it is focused; handle it explicitly and make
the control itself focusable so the gesture works the moment the pane gains
focus.
- Left/Right navigation focused the parent/first-child container but never
moved the selection (selection must follow the caret); add SelectAndFocus.
- The expander toggle had no stable name; name it PART_Expander so hit-target
assertions can find it, and align the test to the shipped 13px column (kept
at 13 so the +/- box centres on the connector lines).
- Tree rows stretch to content width with horizontal scroll, so a row centre
can sit past the viewport's right edge; the pointer tests now click within
the visible grid width instead of the off-screen row centre.
The UseNestedNamespaceNodes re-bind test asserted a ProDataGrid Hierarchical
Model swap that no longer exists; rewrite it to assert the live flattener
reshapes the visible rows in place when the setting toggles.
WPF-parity reusable drag-drop: SharpTreeView now owns the drag gesture, the Before/Inside/After hit-testing, and the insert-marker (an AdornerLayer line), and delegates the actual drop to the target SharpTreeNode.CanDrop/Drop via thin Avalonia IPlatform adapters (AvaloniaDataObject / AvaloniaPlatformDragEventArgs). The drag start uses node.CanDrag + node.Copy for the payload but is orchestrated by the control (Avalonia's DnD is async/pointer-based, unlike WPF's synchronous IPlatformDragDrop).
AssemblyTreeNode gains CanDrag + Copy (drag the assemblies' file paths); AssemblyListTreeNode gains CanDrop/Drop that opens (dedupes) + Moves to the drop index -- so reorder and external file-drop unify. Post-drop selection is a view concern, delegated back to the pane via SelectAssembliesAfterDrop. AssemblyListPane sheds all its drag/file-drop code. Reorder + file-drop tests now drive the node contract directly.
Both tree panes had near-identical model<->tree selection sync; that's now one reusable TreeSelectionBinder(tree, model.SelectedItems) wiring both directions (tree->model mirror + model->tree reveal/focus, with the off-list guard) -- the analyzer and assembly panes just construct and dispose one. Adds the drag-reorder drop indicator: an accent line at the target row's Before/After edge, positioned during a reorder DragOver.
Ports assembly reorder onto the ListBox-based tree using Avalonia's DragDrop pipeline: a left-drag off a top-level assembly row starts DragDrop.DoDragDropAsync carrying a marker DataFormat (the dragged set is held in a field -- it's an internal move), DragOver/Drop dispatch reorder vs. file-drop on that marker, and the reorder itself goes through AssemblyList.Move via the new CanReorder/ReorderAssemblies methods (same validation as the old AssemblyRowDropHandler: top-level non-package assemblies only, never Inside/onto-self). The reorder tests now drive CanReorder/ReorderAssemblies directly instead of ProDataGrid's RowDropHandler, and are no longer [Ignore]d (4 passing).
Not yet ported: the drag insert-marker line (drop feedback is the Move cursor for now).
Replaces the ProDataGrid hierarchical tree in AssemblyListPane with the new ListBox-based SharpTreeView: Tree.Root binds to the model root, selection mirrors at the SharpTreeNode level (deleting the ~186 LOC HierarchicalModel sync + the TreeKeyboardController reflection workaround), the Thunderbird context-target / MMB-new-tab / Delete / Ctrl+R paths port over, file-drop is preserved, and the API-level filter re-applies in place via ILSpyTreeNode.RefreshRealizedFilter (the model self-filters into IsHidden; the flattener drops hidden nodes).
Styling to match the classic tree: flush list (no ListBox padding/border), exact WPF +/- expander box, 20px rows, gray dotted connector lines wired through the existing TreeLines control with an 18.5px indent step so a child's +/- box sits under the parent's icon and the line passes through it.
KNOWN GAP: assembly drag-reorder is not yet ported (AssemblyTreeDragReorderTests [Ignore]d, task #19) and ~20+ assembly headless tests still query the old DataGrid and need retargeting to SharpTreeView (task #20). Production builds green and the app runs; the test suite is red on those un-retargeted tests.
Groundwork for migrating the assembly tree onto SharpTreeView: the shared item theme now binds the autoloaded / non-public foreground classes (dimmed unless the row is selected), and ILSpyTreeNode gains RefreshRealizedFilter to re-apply the API-level filter to realised nodes in place when ShowApiLevel toggles -- the TreeFlattener then drops anything newly hidden, replacing ProDataGrid's ChildrenSelector filter.
Replaces the ProDataGrid + HierarchicalModel bridge and the ~57 LOC HierarchicalNode selection-sync in AnalyzerTreeView with the new control: the view-model's Root binds straight to SharpTreeView.Root and selection mirrors at the SharpTreeNode level (no FindNode/unwrap). Double-tap navigation now flows through SharpTreeView.OnDoubleTapped (node.ActivateItem first, expand only if unhandled), so the analyzer entity rows still navigate. TextViewContext.TreeGrid is widened from DataGrid? to Control? so both the (not-yet-migrated) assembly DataGrid and the SharpTreeView satisfy it. All 150 analyzer tests pass.
OnTextInput drives incremental prefix search over the visible (flattened) nodes with a 1s idle reset -- a fresh keystroke advances past the current row, a growing prefix refines forward. A regression test confirms the payoff of the ListBox base: SelectionMode.Multiple extends AND shrinks a shift-range from the anchor (Shift+Down x2 then Shift+Up -> A,B), so the migrated control needs none of the ProDataGrid reflection workaround.
First increment of the from-scratch Avalonia tree control that will replace the ProDataGrid hierarchical usage. SharpTreeView : ListBox binds the cross-platform TreeFlattener (an IList + INotifyCollectionChanged of visible SharpTreeNodes) straight as ItemsSource, so the ListBox virtualizes it and provides anchor-based extend/shrink selection natively -- no HierarchicalNode wrapper, no model<->grid sync layer. Includes Root/ShowRoot/ShowLines props, Reload/flattener wiring, deselect-on-hide, selection->node.IsSelected, FocusNode/ScrollIntoNodeView/HandleExpanding, container overrides, and the tree keyboard gestures (Left/Right, numpad +/-/*, Enter/Space activate). SharpTreeViewItem : ListBoxItem (double-click expand), an IPlatformRoutedEventArgs adapter, an indent converter, and a ControlTheme (chevron expander + indent + icon + text) wired into App.axaml. The control is additive and not yet wired to any pane. Smoke tests prove the core: flatten, virtualize, expand/collapse row-count updates, and selection sync.
Extending a tree selection with Shift+Down then pressing Shift+Up didn't shrink the range back toward the anchor -- the dropped rows stayed selected, and in the middle of a list the anchor drifted so the range crept the wrong way. Root cause is upstream in ProDataGrid: a shift+nav key moves the grid's current row and anchor correctly, but SelectFromAnchorToCurrent calls SetRowsSelection(start,end), which only ADDS the range and never deselects rows outside it, so a shrink is a no-op. After the grid processes a shift+nav key, TreeKeyboardController prunes the selection to exactly the [anchor..current] range. It deselects out-of-range rows via the internal SetRowSelection(slot, isSelected:false, setAnchorSlot:false) rather than SelectedItems.Remove: removing an item makes the grid re-derive its current row and corrupt the anchor for the next key, whereas SetRowSelection deselects a slot in place and leaves current/anchor intact. The slot members are read reflectively and defensively (falls back to the pre-fix behaviour if a future ProDataGrid renames them). Both the assembly and analyzer trees get it via the shared controller.
Proves the TreeKeyboardController extraction: pressing Right expands a node in the analyzer tree the same way it does in the assembly tree, via the shared controller wired onto both.
Extract the standard tree keyboard gestures -- Left/Right expand-collapse + parent/child nav, numpad +/-/*, and new type-ahead incremental search -- out of AssemblyListPane into a reusable TreeKeyboardController that drives any hierarchical DataGrid. The model implements ITreeKeyboardTarget (PrimarySelectedNode + SelectNode) so each tree keeps its own select-and-reveal behaviour; expansion goes through the grid's IHierarchicalModel. Both the assembly tree and the analyzer tree now attach one, so they share consistent keyboard behaviour. Delete (unload) and Ctrl+R (analyze) stay assembly-specific in the pane.
Numpad + expands the focused node, - collapses it, and * expands it recursively. The recursive walk only descends into children whose SharpTreeNode.CanExpandRecursively is true, which is false for lazy-loading nodes -- so * stays bounded and won't try to materialise an entire assembly. Mirrors WPF SharpTreeView.
Standard tree keyboard navigation in the assembly list: Left collapses an expanded node or moves to the parent when already collapsed; Right expands a closed node or steps into its first child. Operates on the primary selection via the hierarchical model (FindNode/Expand/Collapse) and SelectNode; the parent step is gated on the parent being a visible row so it never selects the hidden root.
Pressing Delete unloaded the assembly but left the grid showing a row selected while model.SelectedItems went empty -- so a second Delete read an empty selection and no-op'd ("spamming Delete breaks"). Record the deleted node's flattened index and, once the visible tree rebuilds, re-select the node that now occupies that slot (clamped to the new end) via SelectNode, which re-syncs grid and model. Selects nothing when the list empties.
Auto-loaded and non-public assembly-tree nodes set an explicit TextBlock foreground, which overrode the selected-row foreground and left the dim/gray colour bleeding through the accent selection fill -- often barely legible. Scope those foreground setters to DataGridRow:not(:selected) so the override drops on selection and the text inherits the normal selected-row foreground, like every other node.
Dark mode rendered the decompiled C# dark-on-dark: the port only had the algorithmic colour inversion, and it was applied solely to the .xshd fallback colorizer -- never the semantic RichTextColorizer that actually paints the decompiler output.
Port the WPF theming model instead: a SyntaxColor palette (SyntaxColorPalettes.CSharpDark, a hand-authored first cut) that ThemeManager.ApplyHighlightingColors writes onto the definition's named HighlightingColor instances in place, restoring the .xshd defaults for Light and falling back to the algorithmic conversion for any unlisted token. Definitions register with the theme manager on load and are re-themed on every switch.
The semantic path needed one extra step: RichTextModel.SetHighlighting clones the colours, so the model freezes them at decompile time. AvaloniaEditTextOutput now also keeps the spans referencing the live named colours, and DecompilerTextView rebuilds the model from them on ThemeChanged so already-decompiled output repaints with the new palette. Colours are a first cut, to be tuned.
Ports icsharpcode/ILSpy#2938. Selected text kept the run's syntax colours by leaving the AvaloniaEdit TextArea SelectionForeground unset, and the selection is drawn as a flat, translucent highlight (square corners) rather than an opaque recolour. The brush is theme-aware -- #007ACC at 30% on the light editor background, a brighter #3794FF at 35% so it still reads on the dark one.
Unload / Clear / ReloadAssembly / HotReplaceAssembly all called LoadedAssembly.Dispose() on the assembly they removed, which disposes its MetadataFile and unmaps the underlying file. But open document tabs and tree nodes can still hold that MetadataFile, and the list has no safe point at which to know those references are gone -- so disposing risked unmapping a file out from under a live reader (use-after-dispose). Drop the removed assembly and let the GC reclaim it once nothing references it instead. The DockWorkspace cancel-on-remove is now a courtesy, not a guard against an unmap race.
Search, Analyzer and Debug Steps cluttered the default layout. They now opt out via ExportToolPane.IsVisibleByDefault = false (which BuildToolDock finally honours), so a fresh launch shows just the assembly tree. Each pane keeps its home alignment and is materialised there on demand by ShowToolPane, so opening Search / Analyze surfaces it in the same place as before.
ShowToolPane only activated panes still in the layout, so once the user closed the Analyzer panel (Dock removes the pane and collapses its now-empty dock) Analyze became a silent no-op -- the entity was added but the panel never reappeared. ShowToolPane now finds-or-rebuilds the pane's home ToolDock at its alignment, splicing it back into the layout where CreateLayout would have placed it, then adds and activates the pane. This same materialize-on-demand path is what lets panes be hidden by default.
The Display-options "Expand using declarations / member definitions after decompilation" checkboxes had no effect: the live decompile cloned DecompilerSettings and baked in the language version but never copied these two flags from DisplaySettings. TextTokenWriter reads settings.ExpandUsingDeclarations / ExpandMemberDefinitions to decide each fold's DefaultClosed, so without the bridge both defaulted false and every using / member fold came back collapsed regardless of the setting.
The document tabs had two parallel hierarchies: the ContentTabPage dockable wrapper, and four unrelated content viewmodels in its object? Content slot -- three TabPageModel subclasses plus OptionsPageModel, which derived from a bare ObservableObject. The wrapper bridged the gap by reflection (reading Title off the runtime type) and the router duck-typed IsStaticContent across two unrelated classes.
Collapse the content side under one abstract ContentPageModel base (named to avoid a clash with Avalonia.Controls.ContentPage). OptionsPageModel joins it, dropping its duplicate Title/IsStaticContent. ContentTabPage.Content becomes ContentPageModel?, so title/language-switching read directly and IsWritablePreview collapses from two runtime type-tests to one IsStaticContent check. CreateTab and the dock-router helpers are typed through. Morph-in-place is unchanged -- the One still swaps its Content in place; this only strengthens the types behind it.
DecompilerSettingsGroupViewModel carries the full tri-state AreAllItemsChecked plumbing to bulk-toggle every setting in a category (the C# language-version groups), but the panel's Expander header had been reduced to plain category text, so each group rendered without its checkbox. Restore the header CheckBox bound to AreAllItemsChecked, matching the original.
The neutral gray focused-tool header read as flat and lifeless. A muted accent-blue (calmer than the #007ACC document-active fill) ties tool focus into the app's blue/purple accent language while staying subordinate to documents. Tuned per-theme: pale #C4DEF5 on light, deep #21527D on dark, both with readable title text.
MoveDockable freezes the One on in-strip and cross-dock drags, but the tear-off-to-floating-window gesture never reaches it: Dock funnels that through CreateWindowFrom (FloatDockable -> SplitToWindow -> CreateWindowFrom), where the torn-off document is still the One before it gets wrapped in a fresh dock. Overriding it makes a float-out one more way to keep the tab, consistent with every other drag.
Pinning the One immovably at index 0 made a drag on it a dead no-op, which reads as a broken tab. Dragging now freezes it instead: pulling the tab out is itself the gesture to keep it, mirroring the snowflake and right-click Freeze, and the next tree selection forges a fresh preview at index 0. Index-0 protection and the self-healing re-assert now apply only while the One is still the preview.
Ctrl+W closes the active document; closing the One drops the cached decompiler viewmodel so the next selection rebuilds it. Long type signatures no longer stretch a tab without bound -- the on-tab title is capped and ellipsised with the full title in a tooltip, and the close button names its shortcut.
The One preview tab's visuals had grown as ad-hoc IsPreview converter bindings
layered on shared selectors, each forced to also answer "what about a non-preview
tab?". The trick that handled the negative case -- a converter returning
UnsetValue so a DynamicResource FallbackValue could win -- misfired and stripped
the blue hover from regular tabs entirely.
Drive the distinction with a previewTab style class instead, toggled on each
DocumentTabStripItem from IsPreview by PreviewTabClassBehavior. The One's purple
fill, hover, accent line and italic title now hang off `.previewTab` selectors
that match ONLY the One, so frozen tabs are never touched and keep their theme
states intact -- the regression becomes structurally impossible. Every color is a
named token declared in both the light and dark dictionaries, and the focused
tool-pane chrome is neutralised (a subtle header tint plus a readable title in
place of the saturated system accent and its white-on-white text).
Assisted-by: Claude:claude-opus-4-8:Claude Code
Freezing the preview tab is already offered by the on-tab snowflake button and
the tab's right-click "Freeze tab" entry, so the Window-menu command duplicated
an action that is more naturally invoked on the tab itself. DockWorkspace.
FreezeCurrentTab stays -- both remaining affordances call it.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Avalonia 12 dropped the in-process Avalonia.Diagnostics package (its last
release is 11.3.x), so AttachDevTools no longer shows a window. Move to the
AvaloniaUI.DiagnosticsSupport bridge driven by WithDeveloperTools() in
BuildAvaloniaApp -- it attaches internally, so a separate App-level
AttachDeveloperTools call would double-attach and throw. The inspector is the
AvaloniaUI.DeveloperTools global tool (avdt) connecting over the bridge.
DEBUG-only: the assets are dropped from Release builds.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The One must stay leftmost: a frozen tab dragged before it, or the One dragged
out of slot 0, would break the 'preview is always first' rule. Dock's in-strip
reorder commits through FactoryBase.MoveDockable (the tab strip ignores
IDockable.CanDrag), so override it: refuse moving the One, and clamp any tab
targeting the One's slot to position 1 instead. Override the cross-dock variant
too, and add an idempotent OnDockableMoved guard that re-asserts the One at
index 0 against any drag path.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The preview tab only got reused when it was the active document; selecting a
node while a frozen tab was active spawned a fresh preview beside it, so
previews piled up. Make 'the One' an invariant: tree selections always
find-or-create the single preview tracked by factory.MainTab and reuse it
wherever it sits (activating it), only forging a new one when it was frozen
away or closed. The fresh One is inserted at documents-dock index 0 (leftmost)
rather than appended, so the preview always sits at the front.
Assisted-by: Claude:claude-opus-4-8:Claude Code