Skip to content

Alt+N/Alt+Shift+N indexing and tab badges must be computed from Avalonia.Dock VisibleDockables, not the internal flat Tabs list #1067

Description

@JoshuaRowePhantom

Summary

Alt+N / Alt+Shift+N keyboard tab indexing and the numbered tab badges are computed from our internal flat ObservableCollection projections (WorkspacePaneViewModel.Tabs for content tabs, MainWindowViewModel.WorkspacePanes for workspace tabs) instead of from the Avalonia.Dock structure (the IDock.VisibleDockables of the real visual tab strips). The internal Tabs list is a flat, single-strip projection that cannot represent split/floated strips and only stays in visual order because a fragile back-sync (SyncPaneTabsOrderFromDock) copies the Dock order into it after certain reorder events. Consulting that projection is the wrong source of truth: it diverges from the visual order under splits and under the #1065 insert-to-the-right move whenever the back-sync does not fire (e.g. the drag path emits Remove+insert, not Move).

The fix is to compute both numberings and both sets of badges directly from the Dock VisibleDockables, per visual strip and per active workspace, keeping #1043's active-workspace scoping:

  • Alt+N (content tabs) → the active workspace's content DocumentDock.VisibleDockables (WorkspaceContentDock, reached via pane.ContentLayout), in visual order.
  • Alt+Shift+N (workspace tabs) → the workspace-tab host dock's VisibleDockables (the root WorkspacesPaneDock, Id="WorkspacesDock"), in visual order.

A dockable is mapped back to its WorkspaceTabViewModel via WorkspaceDocument.TabViewModel (and pane dockables via GetPaneDocument) for activation and label assignment.

Maintainer directive: "Consulting the internal tabs list for Alt+N and badge labels is incorrect, and should be addressed in #1067. Rewrite #1067 correctly using Avalonia.Dock primitives."

Root Cause / Current State

All indexing/badge logic is in Phantom.Workspaces/ViewModels/MainWindowViewModel.cs.

Alt+N content-tab order is read from the internal Tabs list

OnGoToTabAtIndex resolves the target through ComputeActiveWorkspaceTabOrder():

// MainWindowViewModel.cs:1759-1761
private void OnGoToTabAtIndex(int index)
{
    var match = FindTabByAltShortcutIndex(this.ComputeActiveWorkspaceTabOrder(), index);

ComputeActiveWorkspaceTabOrder() delegates to ComputeGlobalTabOrder([pane], …), which walks pane.Tabs — the internal ObservableCollection<WorkspaceTabViewModel> — not the Dock:

// MainWindowViewModel.cs:2779-2789
private List<(WorkspacePaneViewModel Pane, WorkspaceDocument Document)> ComputeActiveWorkspaceTabOrder()
{
    var pane = this.selectedWorkspacePane;
    if (pane?.ContentLayout is null || pane.Id.StartsWith("loading-workspace:",)) return new();
    return ComputeGlobalTabOrder([pane], this.dockFactory.GetDocumentForTab);
}

// MainWindowViewModel.cs:2754-2771
internal static List<> ComputeGlobalTabOrder(IEnumerable<WorkspacePaneViewModel> panes, Func<string, WorkspaceDocument?> getDocumentForTab)
{
    var order = new List<>();
    foreach (var pane in panes)
        foreach (var tab in pane.Tabs)                       // <-- internal flat list, NOT VisibleDockables
            if (getDocumentForTab(tab.Id) is WorkspaceDocument doc)
                order.Add((pane, doc));
    return order;
}

Alt+N badge labels are assigned by iterating the internal Tabs list

// MainWindowViewModel.cs:2691-2708
private void RefreshActiveWorkspaceAltShortcutLabels()
{
    foreach (var pane in this.WorkspacePanes)
        foreach (var tab in pane.Tabs)                       // <-- internal flat list
            if (this.dockFactory.GetDocumentForTab(tab.Id) is WorkspaceDocument doc)
                doc.EffectiveTabHeader.AltShortcutLabel = null;

    AssignGlobalAltShortcutLabels(this.ComputeActiveWorkspaceTabOrder());  // also Tabs-derived
}

Alt+N badge visibility iterates the internal Tabs list

// MainWindowViewModel.cs:2791-2804
private void PropagateBadgeVisibility(bool isAltHeld, bool isShiftHeld)
{
    var contentBadge = isAltHeld && !isShiftHeld;
    foreach (var pane in this.WorkspacePanes)
    {
        var isActive = ReferenceEquals(pane, this.selectedWorkspacePane);
        foreach (var tab in pane.Tabs)                       // <-- internal flat list
            if (this.dockFactory.GetDocumentForTab(tab.Id) is WorkspaceDocument doc)
                doc.EffectiveTabHeader.IsShortcutBadgeVisible = isActive && contentBadge;
    }
    // workspace-pane badges below (Alt+Shift): iterate this.WorkspacePanes …
}

Alt+Shift+N workspace-tab order is read from the internal WorkspacePanes list

// MainWindowViewModel.cs:2674-2689
internal static void RefreshWorkspacePaneAltShortcutLabels(
    IReadOnlyList<WorkspacePaneViewModel> workspacePanes,
    Func<string, WorkspacePaneDocument?> getPaneDocument)
{
    for (var i = 0; i < workspacePanes.Count; i++)           // <-- internal WorkspacePanes, NOT WorkspacesDock.VisibleDockables
        if (getPaneDocument(workspacePanes[i].Id) is { } paneDoc)
            paneDoc.EffectiveTabHeader.AltShortcutLabel = AltShortcutLabelForIndex(i);
}

// MainWindowViewModel.cs:1791-1798
private void OnGoToWorkspacePaneAtIndex(int index)
{
    if (index < 0 || index >= this.WorkspacePanes.Count) return;
    this.SelectedWorkspacePane = this.WorkspacePanes[index];  // <-- indexed off internal list}

Why the internal list is the wrong source of truth

WorkspacePaneViewModel.Tabs (WorkspacePaneViewModel.cs:88) is our own ObservableCollection<WorkspaceTabViewModel> bound as the ItemsSource of a single WorkspaceContentDock (WorkspaceDockFactory.cs:115-128). It is a flat, single-strip projection:

  • It maps 1:1 to one strip only. Once a user splits/floats documents, pane.ContentLayout holds multiple DocumentDock strips, and the flat Tabs order no longer corresponds to any single visual strip's order.
  • Its order only tracks the visual order because SyncPaneTabsOrderFromDock (MainWindowViewModel.cs:2626-2647) copies VisibleDockables order back into Tabs — but only when SyncPaneTabsFromDockChange (:2603-2619) decides to, i.e. only on Move/Reset:
// MainWindowViewModel.cs:2603-2619
if (e.Action == NotifyCollectionChangedAction.Remove)      { /* close/float only */ }
else if (e.Action is NotifyCollectionChangedAction.Move or NotifyCollectionChangedAction.Reset)
    SyncPaneTabsOrderFromDock(workspacePane, documentDock);
// Add + the Remove+insert that Dock.Avalonia's drag path emits are NOT re-synced.

So after a live drag-reorder (Remove+insert, not Move) or an #1065 insert-to-the-right move, RefreshActiveWorkspaceAltShortcutLabels re-reads a stale pane.Tabs and re-labels tabs in the old order — the originally reported "badge 2 retained after reordering" symptom. The Dock's VisibleDockables is always in correct visual order; the projection is not. The correct fix is to read the Dock directly and stop depending on the projection for indexing/badges.

Affected Files

File Contribution
Phantom.Workspaces/ViewModels/MainWindowViewModel.cs OnGoToTabAtIndex (:1759), ComputeActiveWorkspaceTabOrder/ComputeGlobalTabOrder (:2779, :2754) read pane.Tabs; RefreshActiveWorkspaceAltShortcutLabels (:2691), PropagateBadgeVisibility (:2791) iterate pane.Tabs; RefreshWorkspacePaneAltShortcutLabels (:2674) / OnGoToWorkspacePaneAtIndex (:1791) index this.WorkspacePanes; FindDocumentDock (:2816) and EnumerateAllDocuments (:2843) are the Dock-traversal building blocks; SyncPaneTabsFromDockChange/SyncPaneTabsOrderFromDock (:2603, :2626) is the back-sync that indexing/badges will no longer depend on
Phantom.Workspaces/ViewModels/WorkspaceDockFactory.cs Root WorkspacesPaneDock (Id="WorkspacesDock", ItemsSource=WorkspacePanes, :79-107) = Alt+Shift+N host; per-pane WorkspaceContentDock (Id="WorkspaceContent_{paneId}", ItemsSource=workspacePane.Tabs, :115-141) = Alt+N scope; GetDocumentForTab (:44), GetPaneDocument (:71)
Phantom.Workspaces/ViewModels/WorkspaceContentDock.cs The DocumentDock strip type (VisibleDockables, Owner) hosting content documents
Phantom.Workspaces/ViewModels/WorkspaceDocument.cs WorkspaceDocument is the content IDockable; TabViewModel (:165, base.Context as WorkspaceTabViewModel) maps a dockable back to its tab VM; EffectiveTabHeader (:109) carries AltShortcutLabel/IsShortcutBadgeVisible
Phantom.Workspaces/ViewModels/WorkspacePaneDocument.cs The workspace-tab IDockable hosted by WorkspacesPaneDock; its EffectiveTabHeader carries the Alt+Shift+N badge
Phantom.Workspaces/ViewModels/WorkspacePaneViewModel.cs Tabs (:88) remains the content ItemsSource + membership source, but is no longer the indexing/badge source of truth

Design / Fix (Dock primitives)

Compute all four things — Alt+N order, Alt+N badges, Alt+Shift+N order, Alt+Shift+N badges — from IDock.VisibleDockables, mapping each dockable back to its VM.

1. Enumerate the active workspace's content dockables in visual order (Alt+N)

pane.ContentLayout is an IRootDock; its content strip(s) are WorkspaceContentDock : DocumentDock. Walk the tree and take WorkspaceDocuments in visual order. EnumerateAllDocuments (:2843) already recurses VisibleDockables depth-first and handles split content docks:

private List<(WorkspacePaneViewModel Pane, WorkspaceDocument Document)> ComputeActiveWorkspaceTabOrder()
{
    var pane = this.selectedWorkspacePane;
    if (pane?.ContentLayout is null || pane.Id.StartsWith("loading-workspace:", StringComparison.Ordinal))
        return new();

    // Source of truth = the Dock tree's VisibleDockables order, NOT pane.Tabs.
    return EnumerateAllDocuments(pane.ContentLayout)
        .Select(doc => (pane, doc))
        .ToList();
}

EnumerateAllDocuments yields documents in VisibleDockables order across every DocumentDock strip in the workspace, so a split layout numbers strip-by-strip in visual order. (If per-strip restart is desired later, iterate each WorkspaceContentDock.VisibleDockables separately — but for Alt+N the active workspace's documents form one numbering 1..N, consistent with #1043.)

2. Assign Alt+N badges from the same Dock-derived order

RefreshActiveWorkspaceAltShortcutLabels clears every document's label, then assigns from the Dock-derived order. Clearing should also iterate the Dock, not pane.Tabs:

private void RefreshActiveWorkspaceAltShortcutLabels()
{
    foreach (var pane in this.WorkspacePanes)
        if (pane.ContentLayout is { } layout)
            foreach (var doc in EnumerateAllDocuments(layout))
                doc.EffectiveTabHeader.AltShortcutLabel = null;

    AssignGlobalAltShortcutLabels(this.ComputeActiveWorkspaceTabOrder());  // now Dock-derived
}

3. Alt+N badge visibility from the Dock

private void PropagateBadgeVisibility(bool isAltHeld, bool isShiftHeld)
{
    var contentBadge = isAltHeld && !isShiftHeld;
    foreach (var pane in this.WorkspacePanes)
    {
        var isActive = ReferenceEquals(pane, this.selectedWorkspacePane);
        if (pane.ContentLayout is not { } layout) continue;
        foreach (var doc in EnumerateAllDocuments(layout))
            doc.EffectiveTabHeader.IsShortcutBadgeVisible = isActive && contentBadge;
    }

    // Alt+Shift+N workspace-pane badges from the workspace-tab host's VisibleDockables (see 4).
    var paneBadge = isAltHeld && isShiftHeld;
    var host = this.FindDocumentDock(this.Layout);           // the WorkspacesPaneDock
    if (host?.VisibleDockables is { } dockables)
        foreach (var paneDoc in dockables.OfType<WorkspacePaneDocument>())
            paneDoc.EffectiveTabHeader.IsShortcutBadgeVisible = paneBadge;
}

4. Alt+Shift+N order and activation from the workspace-tab host dock

The workspace-tab host is the root WorkspacesPaneDock (Id="WorkspacesDock"), reachable via FindDocumentDock(this.Layout). Its VisibleDockables are WorkspacePaneDocuments in visual order. Number and resolve off that:

private IReadOnlyList<WorkspacePaneDocument> WorkspaceTabHostOrder()
    => this.FindDocumentDock(this.Layout)?.VisibleDockables?.OfType<WorkspacePaneDocument>().ToList()
       ?? [];

private void RefreshWorkspacePaneAltShortcutLabels()
{
    var order = this.WorkspaceTabHostOrder();
    for (var i = 0; i < order.Count; i++)
        order[i].EffectiveTabHeader.AltShortcutLabel = AltShortcutLabelForIndex(i);
}

private void OnGoToWorkspacePaneAtIndex(int index)
{
    var order = this.WorkspaceTabHostOrder();
    if (index < 0 || index >= order.Count) return;
    var paneDoc = order[index];
    // map dockable -> pane VM (paneDoc.Context / registry) and activate:
    this.dockFactory.SetActiveDockable(paneDoc);}

5. Dockable → VM mapping for activation

  • Content: WorkspaceDocument.TabViewModel (WorkspaceDocument.cs:165) gives the WorkspaceTabViewModel; the document itself is the dockable, so SetActiveDockable(doc) / SetFocusedDockable(dock, doc) activate it. The owning strip for focus is doc.Owner as IDock (or FindDocumentDock(pane.ContentLayout)).
  • Workspace: WorkspacePaneDocument maps back to its WorkspacePaneViewModel; GetPaneDocument(paneId) remains the id→dockable registry.

Why this stays correct after the #1065 insert-to-the-right move

#1065 makes the Dock the ordering authority: a new tab is moved to sourceIndex + 1 inside the source document's DocumentDock.VisibleDockables. Because indexing/badges now read VisibleDockables directly, the numbering reflects the new visual order immediately, without waiting for SyncPaneTabsOrderFromDock to copy the order back into pane.Tabs. This also fixes the original drag-reorder symptom: the drag path's Remove+insert changes VisibleDockables, and reading it directly always yields the current visual order.

Relationship to #1043 and #1065

Considered / Background (rejected: wrong layer)

The current implementation treats WorkspacePaneViewModel.Tabs (and MainWindowViewModel.WorkspacePanes) as the ordering source of truth and keeps it aligned to the Dock with a back-sync (SyncPaneTabsOrderFromDock). Rejected per the maintainer: the internal flat list is a single-strip projection that cannot represent split/floated strips and only appears correct when the back-sync fires. Depending on it for Alt+N and badge labels is incorrect; the Dock VisibleDockables tree is the real visual structure and must be the source.

Remaining legitimate consumers of the internal Tabs list (keep — do NOT remove in this bug)

Tabs is not removed by #1067; it stays for uses unrelated to visual ordering of indexing/badges:

  • ItemsSource generationWorkspaceContentDock.ItemsSource = workspacePane.Tabs (WorkspaceDockFactory.cs:124) is what creates the WorkspaceDocument dockables via WorkspaceDocumentGenerator. Removing Tabs would remove the documents themselves.
  • Membership / lookup / open / close / replace — many sites resolve or mutate tabs by id via pane.Tabs (e.g. MainWindowViewModel.cs:264, 1725, 1930, 1949, 2178, 2223-2236, 2255-2267, 2327-2340). These use Tabs for membership, which is legitimate.
  • AnyTabIsRunning status aggregationWorkspacePaneViewModel.RecomputeAnyTabIsRunning (WorkspacePaneViewModel.cs:113, 191-195) and notification aggregation iterate Tabs.
  • Persistence write-backAppendWorkspaceTabRelationshipChanges / WriteBackWorkspaceTabs (MainWindowViewModel.cs:2500) derive saved workspace-tab relationships from Tabs. (This consumes membership; if saved order must match the visual order, the existing SyncPaneTabsOrderFromDock back-sync can remain for persistence only — indexing/badges no longer depend on it.)

Scope: #1067 switches only Alt+N/Alt+Shift+N indexing and badge computation to Dock primitives. Fully retiring the Tabs projection everywhere is a larger, higher-risk refactor and is explicitly out of scope here.

Expected Tests

Match the existing style in Phantom.Workspaces.Tests/MainWindowIntegrationTests.cs (e.g. GoToTabAtIndexCommand_WithThreeTabs_ActivatesCorrectTab at :1275, badge assertions reading documentDock.VisibleDockables … EffectiveTabHeader.AltShortcutLabel around :4117). Existing tests that assert badges/indexing off pane.Tabs should be updated to assert off VisibleDockables.

Test Name Class What It Verifies
AltN_IndexesFromActiveWorkspaceDockVisibleDockables MainWindowIntegrationTests Alt+N order is computed from the active workspace's content DocumentDock.VisibleDockables (documents in visual order), not from pane.Tabs
AltShiftN_IndexesFromWorkspaceTabHostVisibleDockables MainWindowIntegrationTests Alt+Shift+N order is computed from the WorkspacesPaneDock.VisibleDockables (WorkspacePaneDocuments in visual order), not from this.WorkspacePanes
BadgeLabels_DeriveFromDockVisibleDockablesOrder MainWindowIntegrationTests Content-tab AltShortcutLabels are assigned 1..N following VisibleDockables order per strip
AltN_AfterInsertToRight_ReflectsVisualOrder MainWindowIntegrationTests After the #1065 insert-to-the-right move (Dock VisibleDockables reordered), Alt+N numbering and badges reflect the new visual order without depending on SyncPaneTabsOrderFromDock
AltN_AfterDragReorderViaRemoveAndInsert_ReflectsVisualOrder MainWindowIntegrationTests Reordering by removing a dockable from VisibleDockables and re-inserting it (the live drag path, which fires Remove+insert not Move) updates badges/indexing to the new visual order
Indexing_DoesNotConsultInternalTabsList MainWindowIntegrationTests When pane.Tabs order is deliberately out of sync with VisibleDockables, Alt+N resolves the tab at the visual position (proving the Dock, not Tabs, is the source of truth)
AltShortcut_AfterReorder_ActivatesDockableAtNewPosition MainWindowIntegrationTests Pressing Alt+N after a reorder activates the dockable now displaying badge N (badge remains the single source of truth, mapped via WorkspaceDocument.TabViewModel)
SplitWorkspace_ContentBadges_FollowVisibleDockablesAcrossStrips MainWindowIntegrationTests With the active workspace's content split into two DocumentDock strips, content badges number 1..N across strips in VisibleDockables order

Related

Metadata

Metadata

Labels

bugSomething isn't workingdiagnosedRoot cause identifiedverified-locallyImplementation has been verified locally

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions