Follow-up to #186, which landed on Windows and the CLI in #193. The macOS Azure tab still lists flat.
The logic is already ported and pushed to azure-scope-hierarchy-macos (branched off azure-scope-hierarchy, the #193 branch). What is left is the SwiftUI layer.
⚠️ The Swift on that branch has never been compiled. It was written on a Windows machine with no Swift toolchain. swift test is the first thing to run — the tests mirror the C# suite case for case, so they should find anything the transliteration got wrong. Treat the logic as a head start, not as working code.
What is on the branch
macos/Sources/ElevateCore/Support/ArmScope.swift — reads an ARM path as its steps; isAtOrUnder, glob matches (delegates to the existing ArmActions.covers), displayName(detail:), label, tail.
macos/Sources/ElevateCore/Support/ScopeTree.swift — ScopeNode, ScopeTreeEntry, build(_:) and flatten(_:isCollapsed:).
PanelFilter.matches(query:role:tenantName:upn:) now also searches the ARM path.
- Tests for all three.
What is left
1. AppModel state and accessors — mirror windows/src/Elevate.App.Model/ViewModels/AppModel.Panel.cs:
collapsedScopes: Set<String> on AppModel, alongside collapsedTenants. In-memory, not persisted, and scopes start open — same as tenants, so nothing is hidden on first sight.
azureTree(for:) → ScopeTree.build(roles(for: tenantKey, tab: .azure)). Building from the already-filtered rows is what makes search narrow the tree instead of sitting beside it.
scopeNodeKey(_:_:) — "\(identityId)|\(tenantId)|\(node.scope)". Per tenant, because two accounts can reach the same scope.
isScopeCollapsed(_:_:) — returns false while isFiltering, so a match is never hidden behind a node closed earlier.
toggleScope(_:_:).
subtreeKeys(_:), subtreeState(_:) → .none / .some / .all, toggleSubtree(_:). A partly chosen subtree fills up rather than emptying — the checkbox is offering the rest.
canSelect(_:) — worth porting deliberately. canActivate only rules out view-only Entra roles; the "is this row's checkbox enabled" rule lived in the view. Without it the subtree checkbox selects rows whose own checkbox is disabled. It is: canActivate(key) && (assignment == nil || assignment is .failed).
2. The view — TenantRoles in macos/Sources/ElevateApp/Views/TenantSection.swift currently does ForEach(roles) { RoleRow(role: $0) }. On .azure it should walk ScopeTree.flatten instead, emitting a scope header or a RoleRow per entry, with a leading inset from entry.depth.
A new ScopeRow view needs: a disclosure chevron, the dimmed ancestors prefix ahead of title, ArmScope.label(kind), a roleCount on the right, and — in select mode — a three-state checkbox bound to subtreeState. Tooltip/.help should carry the full scope.
windows/src/Elevate.App/Views/PanelItems.cs (ScopeRow, ScopeRowFor, AddScopeTreeRows) and the ScopeRowTemplate in PanelView.xaml are the reference. Windows caps the indent at 3 steps of 12 pt; the panel is narrow and a management group path is long, so the same restraint applies.
3. Docs — docs/activating-roles.md is the macOS guide and says nothing about this yet; the Azure section and probably the Searching section want a paragraph. Windows got its version in windows/README.md. The CHANGELOG entry for #186 currently ends "The macOS Azure tab still lists flat; its tree follows" — drop that sentence and widen the entry to macOS.
Two things worth knowing before starting
Management groups sit beside subscriptions, not above them. #186 assumed the whole tree falls out of the scope string. It does for subscription → resource group → resource, but ARM writes a management group scope as its own flat path and never repeats it in a subscription's, so the eligibilities alone cannot say which subscriptions belong to which management group. Nesting them needs extra ARM calls. Pinned by buildPutsManagementGroupsBesideSubscriptionsRatherThanAboveThem.
Two foldings keep the tree from costing more rows than it saves. Without them the motivating case gets worse — sixty subscriptions with one eligibility each would become a hundred and twenty rows. flatten already does both; the view just has to respect them:
- a scope that only passes through (no eligibility of its own, one way down) is folded into the node below and named via
ancestors, so it reads "Alpha / prod";
- a scope leading to a single role gets no header at all, and its role row stands where the header would have.
Follow-up to #186, which landed on Windows and the CLI in #193. The macOS Azure tab still lists flat.
The logic is already ported and pushed to
azure-scope-hierarchy-macos(branched offazure-scope-hierarchy, the #193 branch). What is left is the SwiftUI layer.What is on the branch
macos/Sources/ElevateCore/Support/ArmScope.swift— reads an ARM path as its steps;isAtOrUnder, globmatches(delegates to the existingArmActions.covers),displayName(detail:),label,tail.macos/Sources/ElevateCore/Support/ScopeTree.swift—ScopeNode,ScopeTreeEntry,build(_:)andflatten(_:isCollapsed:).PanelFilter.matches(query:role:tenantName:upn:)now also searches the ARM path.What is left
1.
AppModelstate and accessors — mirrorwindows/src/Elevate.App.Model/ViewModels/AppModel.Panel.cs:collapsedScopes: Set<String>onAppModel, alongsidecollapsedTenants. In-memory, not persisted, and scopes start open — same as tenants, so nothing is hidden on first sight.azureTree(for:)→ScopeTree.build(roles(for: tenantKey, tab: .azure)). Building from the already-filtered rows is what makes search narrow the tree instead of sitting beside it.scopeNodeKey(_:_:)—"\(identityId)|\(tenantId)|\(node.scope)". Per tenant, because two accounts can reach the same scope.isScopeCollapsed(_:_:)— returnsfalsewhileisFiltering, so a match is never hidden behind a node closed earlier.toggleScope(_:_:).subtreeKeys(_:),subtreeState(_:)→.none/.some/.all,toggleSubtree(_:). A partly chosen subtree fills up rather than emptying — the checkbox is offering the rest.canSelect(_:)— worth porting deliberately.canActivateonly rules out view-only Entra roles; the "is this row's checkbox enabled" rule lived in the view. Without it the subtree checkbox selects rows whose own checkbox is disabled. It is:canActivate(key) && (assignment == nil || assignment is .failed).2. The view —
TenantRolesinmacos/Sources/ElevateApp/Views/TenantSection.swiftcurrently doesForEach(roles) { RoleRow(role: $0) }. On.azureit should walkScopeTree.flatteninstead, emitting a scope header or aRoleRowper entry, with a leading inset fromentry.depth.A new
ScopeRowview needs: a disclosure chevron, the dimmedancestorsprefix ahead oftitle,ArmScope.label(kind), aroleCounton the right, and — in select mode — a three-state checkbox bound tosubtreeState.Tooltip/.helpshould carry the fullscope.windows/src/Elevate.App/Views/PanelItems.cs(ScopeRow,ScopeRowFor,AddScopeTreeRows) and theScopeRowTemplateinPanelView.xamlare the reference. Windows caps the indent at 3 steps of 12 pt; the panel is narrow and a management group path is long, so the same restraint applies.3. Docs —
docs/activating-roles.mdis the macOS guide and says nothing about this yet; the Azure section and probably the Searching section want a paragraph. Windows got its version inwindows/README.md. The CHANGELOG entry for #186 currently ends "The macOS Azure tab still lists flat; its tree follows" — drop that sentence and widen the entry to macOS.Two things worth knowing before starting
Management groups sit beside subscriptions, not above them. #186 assumed the whole tree falls out of the scope string. It does for subscription → resource group → resource, but ARM writes a management group scope as its own flat path and never repeats it in a subscription's, so the eligibilities alone cannot say which subscriptions belong to which management group. Nesting them needs extra ARM calls. Pinned by
buildPutsManagementGroupsBesideSubscriptionsRatherThanAboveThem.Two foldings keep the tree from costing more rows than it saves. Without them the motivating case gets worse — sixty subscriptions with one eligibility each would become a hundred and twenty rows.
flattenalready does both; the view just has to respect them:ancestors, so it reads "Alpha / prod";