AvaloniaEdit's copy puts only plain text on the clipboard, so pasting into
an HTML-aware target (a document, mail, chat) lost the colours the previous
version preserved. Build a coloured HTML fragment from the selection that
merges BOTH the xshd syntax highlighter and ILSpy's semantic RichTextModel
(the decompiler's reference / theme colours the xshd alone doesn't carry),
and place it alongside the text under the native HTML format -- text/html on
Linux/macOS, CF_HTML on Windows. Wired to Ctrl+C (tunnel, so it pre-empts
the plain-text copy) and the right-click Copy entry.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Port the mixed IL/C# disassembler language that the previous version
shipped: it disassembles IL with the decompiled C# source interleaved
above each instruction (mapped via sequence points) as gray comments. The
language was missing entirely from the Avalonia tree, though it is a
Release-visible entry in the language dropdown.
Assisted-by: Claude:claude-opus-4-8:Claude Code
A member that decompiled to a very large amount of text could hang or
exhaust memory on the UI thread with no escape. Cap display output at five
million characters and, on overflow, show a message offering to display
anyway (at an extended limit) or save to disk.
Also bridge three Display options that were persisted but never reached the
decompiler -- brace folding, debug-symbol info, and the indentation string
-- so toggling them once again affects the generated source.
Assisted-by: Claude:claude-opus-4-8:Claude Code
An on-demand resolved dependency had no way to be promoted into the
assembly list, so it was discarded on the next list reload. Restore the
context entry that clears IsAutoLoaded and re-saves the list.
Assisted-by: Claude:claude-opus-4-8:Claude Code
FindTreeNode only handled entity references, so activating an assembly,
resource, or namespace search result -- and clicking a resource or
namespace link -- silently did nothing. Restore the non-entity cases
(LoadedAssembly / MetadataFile / Resource / namespace) the previous
version handled.
Assisted-by: Claude:claude-opus-4-8:Claude Code
TestPlugin was still WPF (net10.0-windows, UseWpf, System.Windows,
TomsToolbox, the old ICSharpCode.ILSpy namespace), so it could not load
into the cross-platform app. Re-target it to net10.0 and map each
extension point onto the port's contracts: the custom Language, About-page
addition, context-menu entry, main-menu/toolbar command, and an Avalonia
options page whose view the ViewLocator resolves by naming convention. The
command needs [ImportingConstructor] for System.Composition to satisfy its
service dependency. Reference it build-only from the tests (no DLL copied
into the test output) so a composition test can load it without the
headless app auto-loading the plugin into every other test.
Assisted-by: Claude:claude-opus-4-8:Claude Code
A plugin whose assembly won't load, or an exported command whose
constructor can't be satisfied, previously either blanked the app into the
startup error window or threw mid-build and took the whole menu/toolbar
down with it. Collect such failures in a CompositionErrors sink instead,
skip the offending part, keep running, and show the errors in a document
tab via an ITextOutput once the window is up. Add a Composition log
category for opt-in file logging.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The document tab strip can now flow tabs onto multiple rows or stay a
single scrolling row; the mode is toggled by the mouse wheel over the
strip and persisted in SessionSettings. In single-line mode an overflow
dropdown at the strip's trailing edge lists every open document for quick
switching. Popups render in the window overlay layer (OverlayPopups) so
the dropdown's menu doesn't open as a native X11 child window that loses
focus and self-dismisses before it becomes visible.
Assisted-by: Claude:claude-opus-4-8:Claude Code
#3753: when many documents are open, the tab strip scrolls a single row,
hiding most tabs behind the overflow scroller.
Dock's DocumentTabStrip hardcodes a horizontal StackPanel inside a
PART_ScrollViewer in its theme template and ignores an ItemsPanel set via
a Style, so a MultiRowTabStripBehavior sets the WrapPanel ItemsPanel
directly and disables the scroll-viewer's horizontal scrolling, giving
the WrapPanel a bounded width so tabs wrap onto new rows. Enabled on the
document strip (not tool tabs) from App.axaml.
Drag-reorder across rows is not yet validated (Dock's drop-position logic
assumes a single horizontal strip), so this stays an opt-in prototype for
now rather than a closing fix for the issue.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Project/solution export, PDB generation, Create Diagram and Extract
Package all ran via RunWithCancellation on the active preview tab --
whose cancellation token tree-node navigation cancels. So selecting any
node while one of these ran killed it: you couldn't browse while an
export was in progress.
Add DockWorkspace.RunInNewTabAsync, which opens a new frozen document
tab (its own decompile/cancellation scope, never the navigation target),
runs the work there, and shows the report in it. Route the long-running
command sites through it. A frozen tab is immune to the selection-driven
re-decompile, so navigation can no longer cancel the work -- and the
report opens in its own tab instead of replacing the current view.
The single-node Save Code path intentionally stays on the active tab
(matches the prior version and is quick).
Assisted-by: Claude:claude-opus-4-8:Claude Code
Right-click Save Code on a node without a Save() override (type,
namespace, member) did nothing: the context-menu entry only called
node.Save() and stopped when it returned false, unlike Ctrl+S which
falls through to a generic decompile-to-file save. The prior version
always had this fallback in both paths; the port dropped it from the
context entry.
Extract the shared "Save() else decompile the node to a single file"
flow into SaveCodeHelper and route both Ctrl+S and the context-menu
entry through it, targeting the clicked node. As before, the fallback
runs in the active decompiler tab with its progress/cancel overlay and
then shows a "decompilation complete" breadcrumb with an Open-folder
button; the default file name derives from the node's text.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Window > Debug Steps did nothing. Two bugs in ToolPaneMenuItem:
the getter treated a pane with a null Owner (one hidden by default and
never placed in the layout) as visible, so the menu showed it checked
and toggling tried to hide it; and the show branch used
factory.RestoreDockable, which only un-hides a previously-shown pane and
is a no-op for one that was never in the layout.
Report visibility from real layout membership, and show via
ShowToolPane, which materialises the pane and (re)creates its home dock.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The freeze work pointed Dock's DocumentContextMenu (which it shows on a
document-tab right-click, overriding the standard ContextMenu) at a menu
holding only "Freeze tab". That hid the Close / Close all but this /
Close all entries defined in App.axaml, and on a frozen tab — where the
Freeze item is hidden — the whole menu came up empty.
Build the full menu (Close / Close all but this / Close all, plus the
preview-only Freeze entry) in PreviewTabContextMenuBehavior and drop the
now-superseded App.axaml ContextMenu setter, so there is a single source
for the document-tab menu.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Selecting a list in the dialog only set SessionSettings.ActiveAssemblyList
directly, which merely persists the choice for the next launch — it never
reloaded the tree, so the active list appeared unchanged. Route selection
through AssemblyTreeModel.ActiveListName instead, whose setter reloads the
list (OnActiveListNameChanged -> ShowAssemblyList) and persists the choice.
Also make double-clicking an entry a shortcut for the Select button (both
go through the same activation path).
Assisted-by: Claude:claude-opus-4-8:Claude Code
The command always built a decompiler tab and force-decompiled the
node, so a metadata-table (or resource) node opened an empty code tab
instead of its grid. Route a single node through OpenNodeInNewTab, which
honours the node's CreateTab() custom content and falls back to a
decompiler tab for ordinary nodes; multi-node selections keep the
decompile-the-union behaviour.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The tree node's text leads with the table's token-kind byte (e.g.
"02 TypeDef (1234)"), but the opened tab's title dropped it and showed
just "TypeDef (1234)". Lead the tab title with the same byte so the two
match.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Jumping to a token (the "Go to token" entry and token-cell clicks) set
suppressHistoryRecording while selecting the target table, so the jump
left no back-stack entry and Back skipped over it. Stop suppressing so
the destination is recorded, and carry the target row on the entry
(TreeNodeEntry.MetadataRow) so Back/Forward restores the exact token
rather than the top of the table.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The quick Save Code path exports a project (one assembly) or a solution
(several) straight through a file picker, with no way to tune the
output. Add a dedicated, discoverable "Export Project/Solution..." entry
(File menu + assembly context menu, alongside Save Code) that opens a
configuration dialog: pick the output folder, preview the projects that
will be written (with invalid / duplicate-name / PDB-eligible badges),
toggle project-format and decompiler options, optionally sign with a
strong-name key, and optionally emit a portable PDB per assembly --
defaulting source-embedding off since the project's .cs are on disk.
The export runs on a clone of the live settings (never the persisted
instance) behind the tab's cancellable progress UI and reports into the
active decompiler tab. StrongNameKeyFile now flows through
DecompilationOptions into the project writer, which had no reachable
setter before. The engine (ProjectExporter) and the preview computation
are split out from the window so they are headless-testable.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The single-assembly project save was already wired, but selecting
multiple assemblies and choosing Save Code only ever saved one of
them: SolutionWriter had never been ported, so there was no path that
emitted a .sln with one decompiled project per assembly.
Port SolutionWriter UI-agnostically and route both Save Code surfaces
(the context-menu entry and the Ctrl+S command) through a shared
SolutionExport helper that recognises a multi-assembly selection,
prompts for the .sln path, runs the export behind the tab's cancellable
progress UI, and reports the result into the active decompiler tab
(matching how the other long-running commands surface their output).
Unlike the prior app, the project-export path writes the .csproj to
disk itself and only returns a breadcrumb through the text output, so
each project's on-disk file is located after decompiling rather than
captured from the output stream.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Finishes the decompiler text-view context menu: the remaining tree-only entries now also act on a right-clicked symbol (IEntity). Decompile to new tab opens the entity's definition in a fresh tab -- which required honouring the previously-dead InNewTabPage flag on NavigateToReferenceEventArgs (OnNavigateToReference now opens the resolved node via OpenNodeInNewTab). Scope search to assembly/namespace read the entity's ParentModule.AssemblyName / Namespace, matching the existing inassembly:/innamespace: filters.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The decompiler text-view context menu was missing the navigation and folding entries the previous version had. Add Decompile (go to definition) on a right-clicked symbol -- it navigates the tree to the entity's definition via the existing NavigateToReference bus message -- and Toggle folding / Toggle all folding, which reuse the editor's existing Ctrl+M logic (extracted into public DecompilerTextView methods). The folding entries only show when the document has foldings.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Avalonia has no global ICommand re-query signal like WPF's CommandManager.RequerySuggested, so state-dependent main-menu commands were frozen at their startup CanExecute: a NativeMenuItem only re-reads CanExecute when the command raises CanExecuteChanged, and nothing did. The assembly list is empty when the menu is built, so Clear assembly list was disabled forever; Save and Remove-assemblies-with-load-errors had the same latent bug. Add a weak global CommandManager (held weakly so menu/toolbar items aren't pinned) that SimpleCommand routes CanExecuteChanged through -- exactly as WPF's SimpleCommand routed it -- and invalidate it at the central state-change points: tree selection, the active assembly list, and background-load-sweep completion (so load errors surface). One signal, every platform: NativeMenuItem maps CanExecute onto IsEnabled, which the macOS Cocoa, Linux DBus, and in-window menu exporters all track.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The decompiler text-view context menu only read SelectedTreeNodes, so Analyze never appeared when right-clicking a reference in the code. Read the resolved entity from context.Reference too (the same seam Show-in-Metadata uses), so right-clicking a type/member in the decompiled output offers Analyze and pushes it into the analyzer pane.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Right-clicking a document tab opened an empty menu. Add Close, Close all but this, and Close all, bound to commands on ContentTabPage that delegate to DockWorkspace. Close all now keeps the persistent preview/home tab (the One) rather than leaving whichever tab Dock's last-dockable veto happened to spare, so the app always retains a main tab to project the next selection onto.
Assisted-by: Claude:claude-opus-4-8:Claude Code
SharpTreeView.SelectedItems is IList?, so reading .Count on the realised grid raised CS8602. Null-forgive the dereferences in the Ctrl+A and plain-click selection tests, where the grid is always realised.
Assisted-by: Claude:claude-opus-4-8:Claude Code
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
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%).
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).
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.
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.
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.
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.