Skip to content

feat(ui): multi-line VM card + network-mode cycle restored#15

Merged
DaxxSec merged 2 commits into
mainfrom
feat/multiline-vm-card
May 13, 2026
Merged

feat(ui): multi-line VM card + network-mode cycle restored#15
DaxxSec merged 2 commits into
mainfrom
feat/multiline-vm-card

Conversation

@DaxxSec
Copy link
Copy Markdown
Owner

@DaxxSec DaxxSec commented May 13, 2026

Why

User feedback after the merged redesign called out two things this PR addresses together:

The VM card should be 2 lines or maybe even 3 …
one critical piece we no longer have since removing the right side panel is a way to cycle the networking type for VMs now. We used to be able to click the networking to cycle it between NAT / Router modes
lets see if we can add that capability somewhere on the larger multi-line VM card

Bundled because the cycle action needs a place to live — the new row-3 of the card.

The new card

62pt-tall row, three lines:

```
┌──────────────────────────────────────────────────────────┐
│ kali-router ● RUNNING │
│ Kali Linux 2025.1 · 4 cores · 4.0 GB · 32 GB disk │
│ [⬢ ROUTER] ↓ 12 kB/s · ↑ 0.3 kB/s 1,284 pkts ▁▄▇ │
└──────────────────────────────────────────────────────────┘
```

The row-3 chip is the network-mode target — clicking cycles:

```
NAT → Virtual (isolated) → Router → NAT
```

A guest VM (virtual-mode with a routerVMId pointing somewhere) cleanly drops to NAT in one step rather than dragging its stale guest relationship through the cycle.

Cycling a running VM is blocked — Apple's Virtualization framework attaches the network device once at VM-start and can't hot-swap it. The click still fires; the user gets an alert with that explanation.

AI Sandbox bundles use the same card with row 3 collapsed to a static badge: ◆ TEMPLATE (orange) for the base, ● SESSION (green) for children.

Implementation

  • VMCardCellView (new) — NSTableCellView subclass with the three-row layout. Right-edge subviews (status pill, sparkline, packets) anchor via layout() so they track on table resize; left-edge labels reflow into available width via autoresize.
  • switchTableToCardMode(_:) — drops the XIB-defined per-field columns at runtime and replaces them with a single full-width `VMCardColumn`. Idempotent.
  • VMManager.cycleNetworkMode(_:) — updates the in-memory VM list and persists to metadata.json. Pure step function `nextNetworkConfig(after:)` is testable without touching the singleton.
  • VMError.networkModeChangeWhileRunning — carries the user-facing alert text.

Tests

New `VMCardCellAndCycleTests` — 14 passing:

Area Coverage
Cycle NAT → Virtual → Router → NAT loops, guest VM short-circuits to NAT, cycle is circular
Chip rendering NAT / ROUTER / GUEST / ISOLATED labels match config state
Status pill RUNNING / STARTING / STOPPING / STOPPED text matches each `VMStatus`
Formatters Packet count uses comma grouping; bps crosses B → kB → MB → GB
Cell survival configure() doesn't crash for any status

Full suite: 264/264 green (one pre-existing flaky network test skipped as before).

Test plan

  • `xcodebuild build` succeeds
  • `xcodebuild test` — 264/264 passing
  • Launch app — VM table shows multi-line cards; selection still works; sparkline still animates
  • Click a network chip on a stopped VM — mode cycles, label updates immediately, persists across app relaunch
  • Click a network chip on a running VM — alert appears explaining why; mode unchanged
  • Switch to AI Sandbox tab — same card, row 3 shows ◆ BASE BUNDLE or ↳ SESSION (non-clickable)
  • Cycle a VM into Router mode, start it, start a second VM with that one as its router — connection bracket overlay between the two rows still works (rows are taller but the overlay tracks rect(ofRow:) so it should follow)

Coordinating with other open PRs

#13 (detail-card layout fixes) and #14 (sidebar filter nav) both live off main. This branch is independent of both — its only mention of refreshSidebarCounts is a code comment noting that when this and #14 merge in either order, the cycle handler should also call that to refresh the Network section's count badges.

🤖 Generated with Claude Code

DaxxSec and others added 2 commits May 13, 2026 10:10
Two user-reported asks land together here because they're tightly
coupled (the cycle action needs space to live on the card):

1. VM rows are now multi-line cards
   The previous column-based table (Name | Status | OS | CPU | RAM |
   Disk | LastUsed | Traffic) felt cramped and broke the mockup's
   intended density when window widths shrank. New 62pt-tall card:

     Row 1: <name>                                  <status pill>
     Row 2: <os> · <cores · GB ram> · <disk size>
     Row 3: [⇄ network chip ▾]  ↓/↑ rate    pkts  ▁▄▇

2. Network-mode cycle (lost when the right panel went away)
   The mockup's right-side panel had a click-to-cycle network mode
   target. That capability disappeared when the panel was removed
   in the redesign. Restored here as the row-3 network chip — click
   it to cycle:

     NAT  →  Virtual (isolated)  →  Router  →  NAT

   Cycling a running VM is blocked (Apple's Virtualization
   framework attaches the network device once at VM-start; it
   can't hot-swap). Stop-then-cycle is the correct workflow and
   the click handler surfaces the standard alert with that
   explanation when the user tries it on a live VM.

Implementation
==============

- `VMCardCellView`: NSTableCellView subclass with the three-row
  layout. Right-edge subviews (status pill, sparkline, packets
  label) anchor to bounds.maxX via layout() so they track on
  table resize; left-edge labels reflow into the available width.
- AI Sandbox tab uses the same card with row 3 collapsed to a
  static badge — TEMPLATE (orange) or SESSION (green) — instead
  of a clickable network chip.
- `switchTableToCardMode(_:)` removes the XIB-defined columns at
  runtime and replaces them with a single full-width VMCardColumn.
  Idempotent — safe to call on every awakeFromNib.
- `VMManager.cycleNetworkMode(_:)` updates the in-memory list
  + persists to metadata.json. Pure step function
  `nextNetworkConfig(after:)` is testable without singleton state.
- `VMError.networkModeChangeWhileRunning` carries the user-facing
  explanation for why a running VM can't cycle (Apple framework
  limitation, not our choice).

A guest VM (virtual-mode, routerVMId set) short-circuits straight
to NAT on the next cycle rather than getting dragged through the
canonical path with a stale routerVMId — keeps the cycle clean.

Tests
=====

New `VMCardCellAndCycleTests` — **14 passing**:
- Cycle: NAT → Virtual → Router → NAT loops correctly
- Guest VM cleanly drops to NAT, clearing the routerVMId
- Network chip text reflects each mode (NAT / ROUTER / GUEST /
  ISOLATED)
- Status pill text matches each VMStatus
- Packet count formatter uses comma grouping (0 / 42 / 1,234 /
  1,234,567)
- Bps formatter crosses B → kB → MB → GB thresholds
- Cell survives configure() with every status without crashing

Full suite: 264/264 green (one pre-existing flaky network test
skipped as before).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves 8 conflicts in SecVF.xcodeproj/project.pbxproj — all of them
"both branches added a file ref at the same insertion point" cases.
Kept both sides' additions so VMCardCellView.swift, VMCardCellAndCycleTests.swift
stay registered alongside the new files brought in by main:
ISOCacheManagerWindow.swift, SwitchStatisticsWindowController.swift,
TacticalSidebarSection.swift, SidebarFilterTests.swift.

Also wired the deferred TODO: handleCycleNetworkMode now calls
refreshSidebarCounts() so the Network section's badges (Isolated /
Virtual / NAT) update immediately after a cycle, instead of staying
stale until the next status-change notification.

Full suite: 292/292 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@DaxxSec DaxxSec merged commit 87ee7ac into main May 13, 2026
1 check passed
DaxxSec added a commit that referenced this pull request May 14, 2026
* fix(ui): three layout regressions from the typography pass merge

Three visible regressions that the user reported after #15+#18 landed:

1. Sidebar section titles truncating ("ERATING SYSTEM", "TWORK", "GS")
==================================================================
`TacticalSidebarSection.rebuild()` runs inside `init` before the
caller has set the section's frame. It read `bounds.width` (=0 at
that moment) and gave the title label / row views
`width: bounds.width - inset * 2` = **-24pt**. The autoresizing mask
(`.width`) compensates when the parent's bounds change, but it adds
delta only — a section that grows from `bounds.width = 0` to `220`
produces a title width of `-24 + 220 = 196`. That should be visible
end-to-end, but NSTextField that's first sized with a negative-width
frame caches its rendering metrics and the title gets visually
left-truncated by ~inset pixels on every paint after.

Fix: defer layout. `rebuild` now adds the subviews without sizing
them; a new `relayoutContents()` runs from both `rebuild` (so the
initial paint has a valid frame once the caller assigns one) AND
from `layout()` (Cocoa hook fired whenever the section's bounds
resolve). Layout constants moved to type-level statics so the two
call sites share them.

2. VM table rows squished to ~20pt — multi-line card not showing
================================================================
The XIB hardcodes `tableView rowHeight="20"`. `switchTableToCardMode`
sets `tableView.rowHeight = 62`, which SHOULD be authoritative — but
when the XIB latches the lower value before the awakeFromNib call
that runs the swap, NSTableView's first layout pass uses the latched
20pt and the card cell renders as a single squished line with
row-3 chips spilling into row-1's name area.

Fix: implement `tableView(_:heightOfRow:)` returning
`VMCardCellView.rowHeight`. The delegate is queried on every row
draw, so the value can't be stale or pre-empted by the XIB.

3. Detail-card status pill drifts off-center inside its 80pt bounds
====================================================================
The pill is an NSTextField with `alignment = .center`,
`isBordered = false`, layer-backed background. On some macOS
revisions the plain `alignment` property doesn't fully center
text inside a borderless layer-backed label — the text drifts
leftward, producing the dead-space halo on the right that
appears as "the pill is spaced wrong".

Fix: use `attributedStringValue` with an explicit centered
NSMutableParagraphStyle. The paragraph-style centering bypasses
NSTextField's plain alignment quirk and works deterministically.

No behavior changes — these are all visual/positioning fixes.
Full test suite still 292/292 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ui): the VM-card row pill (the one user actually meant)

User clarified the "pill is spaced wrong" report — they meant the
status pill inside the VM-card row (e.g. "● RUNNING" / "○ STOPPED"
on the right edge of row 1), not the detail card pill at the bottom
of the window. My previous commit on this PR fixed the wrong pill.

Same root cause + fix: NSTextField's plain `alignment = .center`
drifts leftward on layer-backed borderless labels. Apply explicit
centered NSMutableParagraphStyle via attributedStringValue on the
VMCardCellView statusPill (running-state path) and the AI Sandbox
TEMPLATE/SESSION pill (sandbox-bundle path).

The previous fix to the detail card's makeStatusPill stays — same
class of bug, two separate widgets, both needed the treatment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: DaxxSec <dax@example.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant