diff --git a/SecVF.xcodeproj/project.pbxproj b/SecVF.xcodeproj/project.pbxproj index ccd20e8..d3e2fb2 100644 --- a/SecVF.xcodeproj/project.pbxproj +++ b/SecVF.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ CC1111CC11111111CC111CC1 /* VMConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1111DD11111111DD111DD1 /* VMConfigurationTests.swift */; }; SPKT002SPKT002SPKT002002 /* SparklineViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SPKT001SPKT001SPKT001001 /* SparklineViewTests.swift */; }; TUIT002TUIT002TUIT002002 /* TacticalUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TUIT001TUIT001TUIT001001 /* TacticalUITests.swift */; }; + SBFT002SBFT002SBFT002002 /* SidebarFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SBFT001SBFT001SBFT001001 /* SidebarFilterTests.swift */; }; NPT002NPT002NPT002002 /* NetworkPeersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = NPT001NPT001NPT001001 /* NetworkPeersTests.swift */; }; PFPT002PFPT002PFPT002002 /* PacketFilterPresetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PFPT001PFPT001PFPT001001 /* PacketFilterPresetsTests.swift */; }; OVLT002OVLT002OVLT002002 /* OverlayViewsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = OVLT001OVLT001OVLT001001 /* OverlayViewsTests.swift */; }; @@ -70,6 +71,7 @@ THBT002THBT002THBT002002 /* TacticalHoverButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = THBT001THBT001THBT001001 /* TacticalHoverButton.swift */; }; TTRV002TTRV002TTRV002002 /* TacticalTableRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = TTRV001TTRV001TTRV001001 /* TacticalTableRowView.swift */; }; TESV002TESV002TESV002002 /* TacticalEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = TESV001TESV001TESV001001 /* TacticalEmptyStateView.swift */; }; + TSSC002TSSC002TSSC002002 /* TacticalSidebarSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = TSSC001TSSC001TSSC001001 /* TacticalSidebarSection.swift */; }; TTHC002TTHC002TTHC002002 /* TacticalTableHeaderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = TTHC001TTHC001TTHC001001 /* TacticalTableHeaderCell.swift */; }; PCPR002PCPR002PCPR002002 /* PacketCaptureProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = PCPR001PCPR001PCPR001001 /* PacketCaptureProtocol.swift */; }; PROCX002PROCX002PROCX002 /* ProcessExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = PROCX001PROCX001PROCX001 /* ProcessExecutor.swift */; }; @@ -123,6 +125,7 @@ DD1111DD11111111DD111DD1 /* VMConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigurationTests.swift; sourceTree = ""; }; SPKT001SPKT001SPKT001001 /* SparklineViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparklineViewTests.swift; sourceTree = ""; }; TUIT001TUIT001TUIT001001 /* TacticalUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalUITests.swift; sourceTree = ""; }; + SBFT001SBFT001SBFT001001 /* SidebarFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFilterTests.swift; sourceTree = ""; }; NPT001NPT001NPT001001 /* NetworkPeersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkPeersTests.swift; sourceTree = ""; }; PFPT001PFPT001PFPT001001 /* PacketFilterPresetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketFilterPresetsTests.swift; sourceTree = ""; }; OVLT001OVLT001OVLT001001 /* OverlayViewsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayViewsTests.swift; sourceTree = ""; }; @@ -160,6 +163,7 @@ THBT001THBT001THBT001001 /* TacticalHoverButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalHoverButton.swift; sourceTree = ""; }; TTRV001TTRV001TTRV001001 /* TacticalTableRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalTableRowView.swift; sourceTree = ""; }; TESV001TESV001TESV001001 /* TacticalEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalEmptyStateView.swift; sourceTree = ""; }; + TSSC001TSSC001TSSC001001 /* TacticalSidebarSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalSidebarSection.swift; sourceTree = ""; }; TTHC001TTHC001TTHC001001 /* TacticalTableHeaderCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalTableHeaderCell.swift; sourceTree = ""; }; PCPR001PCPR001PCPR001001 /* PacketCaptureProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketCaptureProtocol.swift; sourceTree = ""; }; PROCX001PROCX001PROCX001 /* ProcessExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessExecutor.swift; sourceTree = ""; }; @@ -237,6 +241,7 @@ THBT001THBT001THBT001001 /* TacticalHoverButton.swift */, TTRV001TTRV001TTRV001001 /* TacticalTableRowView.swift */, TESV001TESV001TESV001001 /* TacticalEmptyStateView.swift */, + TSSC001TSSC001TSSC001001 /* TacticalSidebarSection.swift */, TTHC001TTHC001TTHC001001 /* TacticalTableHeaderCell.swift */, NTNM001NTNM001NTNM001001 /* NotificationNames.swift */, PROCX001PROCX001PROCX001 /* ProcessExecutor.swift */, @@ -291,6 +296,7 @@ DD1111DD11111111DD111DD1 /* VMConfigurationTests.swift */, SPKT001SPKT001SPKT001001 /* SparklineViewTests.swift */, TUIT001TUIT001TUIT001001 /* TacticalUITests.swift */, + SBFT001SBFT001SBFT001001 /* SidebarFilterTests.swift */, NPT001NPT001NPT001001 /* NetworkPeersTests.swift */, PFPT001PFPT001PFPT001001 /* PacketFilterPresetsTests.swift */, OVLT001OVLT001OVLT001001 /* OverlayViewsTests.swift */, @@ -456,6 +462,7 @@ THBT002THBT002THBT002002 /* TacticalHoverButton.swift in Sources */, TTRV002TTRV002TTRV002002 /* TacticalTableRowView.swift in Sources */, TESV002TESV002TESV002002 /* TacticalEmptyStateView.swift in Sources */, + TSSC002TSSC002TSSC002002 /* TacticalSidebarSection.swift in Sources */, TTHC002TTHC002TTHC002002 /* TacticalTableHeaderCell.swift in Sources */, NTNM002NTNM002NTNM002002 /* NotificationNames.swift in Sources */, UTEX002UTEX002UTEX002002 /* UTTypeExtensions.swift in Sources */, @@ -489,6 +496,7 @@ CC1111CC11111111CC111CC1 /* VMConfigurationTests.swift in Sources */, SPKT002SPKT002SPKT002002 /* SparklineViewTests.swift in Sources */, TUIT002TUIT002TUIT002002 /* TacticalUITests.swift in Sources */, + SBFT002SBFT002SBFT002002 /* SidebarFilterTests.swift in Sources */, NPT002NPT002NPT002002 /* NetworkPeersTests.swift in Sources */, PFPT002PFPT002PFPT002002 /* PacketFilterPresetsTests.swift in Sources */, OVLT002OVLT002OVLT002002 /* OverlayViewsTests.swift in Sources */, diff --git a/SecVF/TacticalSidebarSection.swift b/SecVF/TacticalSidebarSection.swift new file mode 100644 index 0000000..3c594a1 --- /dev/null +++ b/SecVF/TacticalSidebarSection.swift @@ -0,0 +1,266 @@ +// +// TacticalSidebarSection.swift +// SecVF +// +// Sidebar section composed of a label header and a list of selectable +// rows, each showing a leading glyph + title + trailing count. Used +// for the mockup-spec navigation panes (Filters / Operating System / +// Network) and for the Logs shortcut group, so the four blocks share +// the same tactical look + interaction surface. +// +// Each row is a single-selection radio within its section: clicking a +// row sets the section's `selectedID` and invokes the supplied +// `onSelect` closure with the row's identifier. Sections are +// independent — the controller composes their selections into a +// combined filter over the VM list. +// + +import Cocoa + +final class TacticalSidebarSection: NSView { + + /// Row definition. `id` is the value handed back via `onSelect`; + /// `title` is the visible label; `glyph` is the leading icon + /// character; `count` is the trailing count badge (nil = no badge). + struct Row { + let id: String + let glyph: String + let title: String + var count: Int? + } + + /// Externally-mutated selection. Setting this updates the visual + /// state and does NOT fire `onSelect` (so callers can drive the + /// selection programmatically without recursion). + var selectedID: String? { + didSet { refreshSelectionStyling() } + } + + /// Called when the user clicks a row. The row's `id` is supplied. + /// Empty `onSelect` (the default) means the section is display-only. + var onSelect: (String) -> Void = { _ in } + + private let titleLabel: NSTextField + private var rowViews: [String: SidebarRowView] = [:] + + /// Convenience: total height the section currently lays out to. Use + /// when stacking sections vertically — caller positions origin.y + /// and reads `intrinsicHeight` to advance. + var intrinsicHeight: CGFloat { _intrinsicHeight } + private var _intrinsicHeight: CGFloat = 0 + + init(title: String, rows: [Row]) { + titleLabel = NSTextField(labelWithString: title.uppercased()) + titleLabel.font = NSFont.monospacedSystemFont(ofSize: 9, weight: .bold) + titleLabel.textColor = AppColors.textSubtle + + super.init(frame: .zero) + + addSubview(titleLabel) + rebuild(rows: rows) + } + + required init?(coder: NSCoder) { + fatalError("Use init(title:rows:)") + } + + /// Replace the row set in place. Called when counts update (which + /// happens on every VM list reload — see refreshCounts(rows:)). + /// Frame is recomputed; caller is responsible for re-laying out + /// any sections stacked below this one if `intrinsicHeight` changes. + func rebuild(rows: [Row]) { + // Tear down existing rows + rowViews.values.forEach { $0.removeFromSuperview() } + rowViews.removeAll() + + let labelH: CGFloat = 14 + let rowH: CGFloat = 26 + let rowGap: CGFloat = 2 + let belowLabelGap: CGFloat = 6 + let inset: CGFloat = 12 + + // Lay out from top (y=0 is bottom in standard AppKit coords; + // but this view's frame's origin will be positioned by the + // caller, so we build assuming top-down placement and then + // size ourselves accordingly). + let totalRowsHeight = CGFloat(rows.count) * rowH + CGFloat(max(0, rows.count - 1)) * rowGap + let total = labelH + belowLabelGap + totalRowsHeight + _intrinsicHeight = total + + // Width follows the parent — we'll resize via autoresizing. + autoresizingMask = [.width] + + // Title sits at the top of our bounds + titleLabel.frame = NSRect(x: inset, y: total - labelH, + width: bounds.width - inset * 2, + height: labelH) + titleLabel.autoresizingMask = [.width, .minYMargin] + + // Rows below, top-down + var y = total - labelH - belowLabelGap - rowH + for row in rows { + let view = SidebarRowView(row: row, height: rowH) + view.frame = NSRect(x: inset, y: y, + width: bounds.width - inset * 2, + height: rowH) + view.autoresizingMask = [.width, .minYMargin] + view.onTap = { [weak self] id in + guard let self = self else { return } + if self.selectedID != id { + self.selectedID = id + } + self.onSelect(id) + } + addSubview(view) + rowViews[row.id] = view + y -= (rowH + rowGap) + } + + refreshSelectionStyling() + } + + /// Update just the count badges + (optionally) the selection state + /// without rebuilding the row layout. Cheaper than `rebuild` for + /// the common "VM list changed, counts shifted" path. + func refreshCounts(_ counts: [String: Int]) { + for (id, view) in rowViews { + view.setCount(counts[id]) + } + } + + private func refreshSelectionStyling() { + for (id, view) in rowViews { + view.isSelected = (id == selectedID) + } + } +} + +// MARK: - SidebarRowView + +/// Visual row inside a TacticalSidebarSection. Custom NSView (rather +/// than NSButton) because the layered selection band + leading glyph +/// + trailing count layout doesn't fit NSButton's title-only API. +private final class SidebarRowView: NSView { + + private let glyphLabel: NSTextField + private let titleLabel: NSTextField + private let countLabel: NSTextField + private let rowID: String + + var onTap: (String) -> Void = { _ in } + + var isSelected: Bool = false { + didSet { refreshStyling() } + } + + private var isHovered: Bool = false { + didSet { refreshStyling() } + } + + init(row: TacticalSidebarSection.Row, height: CGFloat) { + self.rowID = row.id + + glyphLabel = NSTextField(labelWithString: row.glyph) + glyphLabel.font = NSFont.systemFont(ofSize: 11, weight: .medium) + glyphLabel.alignment = .center + + titleLabel = NSTextField(labelWithString: row.title) + titleLabel.font = NSFont.systemFont(ofSize: 11, weight: .medium) + + countLabel = NSTextField(labelWithString: row.count.map { String($0) } ?? "") + countLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular) + countLabel.alignment = .right + countLabel.textColor = AppColors.textSubtle + + super.init(frame: NSRect(x: 0, y: 0, width: 100, height: height)) + + wantsLayer = true + layer?.cornerRadius = LayoutConstants.cornerRadiusSM + + let glyphW: CGFloat = 18 + let countW: CGFloat = 32 + let textPadL: CGFloat = 6 + let textPadR: CGFloat = 4 + + glyphLabel.frame = NSRect(x: 6, y: 0, width: glyphW, height: height) + glyphLabel.autoresizingMask = [.maxXMargin] + addSubview(glyphLabel) + + countLabel.frame = NSRect(x: bounds.width - countW - 8, y: 0, + width: countW, height: height) + countLabel.autoresizingMask = [.minXMargin] + addSubview(countLabel) + + let titleX = 6 + glyphW + textPadL + titleLabel.frame = NSRect(x: titleX, y: 0, + width: bounds.width - titleX - countW - textPadR - 8, + height: height) + titleLabel.autoresizingMask = [.width] + titleLabel.lineBreakMode = .byTruncatingTail + addSubview(titleLabel) + + refreshStyling() + + // Click handling — install a tracking area for hover; mouseDown + // fires the tap callback. + let area = NSTrackingArea(rect: bounds, + options: [.mouseEnteredAndExited, + .activeInActiveApp, + .inVisibleRect], + owner: self, + userInfo: nil) + addTrackingArea(area) + + setAccessibilityRole(.button) + setAccessibilityLabel("\(row.title)\(row.count.map { " (\($0))" } ?? "")") + } + + required init?(coder: NSCoder) { + fatalError("Use init(row:height:)") + } + + func setCount(_ count: Int?) { + countLabel.stringValue = count.map { String($0) } ?? "" + } + + private func refreshStyling() { + let bgColor: NSColor + let glyphColor: NSColor + let titleColor: NSColor + let countColor: NSColor + + if isSelected { + bgColor = AppColors.backgroundRowSelected.withAlphaComponent(0.55) + glyphColor = AppColors.accentODGlow + titleColor = AppColors.textPrimary + countColor = AppColors.textPrimary + } else if isHovered { + bgColor = AppColors.backgroundRowHover.withAlphaComponent(0.4) + glyphColor = AppColors.textPrimary + titleColor = AppColors.textPrimary + countColor = AppColors.textSubtle + } else { + bgColor = .clear + glyphColor = AppColors.textMuted + titleColor = AppColors.textMuted + countColor = AppColors.textSubtle + } + + layer?.backgroundColor = bgColor.cgColor + glyphLabel.textColor = glyphColor + titleLabel.textColor = titleColor + countLabel.textColor = countColor + } + + override func mouseEntered(with event: NSEvent) { + isHovered = true + } + + override func mouseExited(with event: NSEvent) { + isHovered = false + } + + override func mouseDown(with event: NSEvent) { + onTap(rowID) + } +} diff --git a/SecVF/Tests/SidebarFilterTests.swift b/SecVF/Tests/SidebarFilterTests.swift new file mode 100644 index 0000000..245ccb7 --- /dev/null +++ b/SecVF/Tests/SidebarFilterTests.swift @@ -0,0 +1,108 @@ +// +// SidebarFilterTests.swift +// SecVFTests +// +// Tests for the three pure filter predicates that drive the +// TacticalSidebarSection navigation: status / OS / network. These run +// against `VMLibraryWindowController.matches…Filter` statics so the +// test surface doesn't need the live window. +// + +import XCTest +@testable import SecVF + +@MainActor +final class SidebarFilterTests: XCTestCase { + + // MARK: - Status + + func testStatusFilterRunning() { + XCTAssertTrue(VMLibraryWindowController.matchesStatusFilter(.running, id: "running")) + XCTAssertFalse(VMLibraryWindowController.matchesStatusFilter(.stopped, id: "running")) + XCTAssertFalse(VMLibraryWindowController.matchesStatusFilter(.starting, id: "running")) + } + + func testStatusFilterStopped() { + XCTAssertTrue(VMLibraryWindowController.matchesStatusFilter(.stopped, id: "stopped")) + XCTAssertFalse(VMLibraryWindowController.matchesStatusFilter(.running, id: "stopped")) + } + + func testStatusFilterPausedCoversTransitionStates() { + // "Paused" in the sidebar covers both .starting and .stopping — + // the framework's two transient states. Pure .stopped or + // .running do NOT count. + XCTAssertTrue(VMLibraryWindowController.matchesStatusFilter(.starting, id: "paused")) + XCTAssertTrue(VMLibraryWindowController.matchesStatusFilter(.stopping, id: "paused")) + XCTAssertFalse(VMLibraryWindowController.matchesStatusFilter(.running, id: "paused")) + XCTAssertFalse(VMLibraryWindowController.matchesStatusFilter(.stopped, id: "paused")) + } + + func testStatusFilterUnknownIDLetEverythingPass() { + // Unknown id ("all" or anything else) means "no filter applied" + // — the controller skips the predicate entirely, but defensive + // check the static returns true so a callsite that DID call it + // doesn't accidentally drop everything. + XCTAssertTrue(VMLibraryWindowController.matchesStatusFilter(.running, id: "all")) + XCTAssertTrue(VMLibraryWindowController.matchesStatusFilter(.stopped, id: "garbage")) + } + + // MARK: - OS + + func testOSFilterLinux() { + XCTAssertTrue(VMLibraryWindowController.matchesOSFilter("Linux", id: "linux")) + XCTAssertTrue(VMLibraryWindowController.matchesOSFilter("linux", id: "linux")) + XCTAssertTrue(VMLibraryWindowController.matchesOSFilter("Kali Linux", id: "linux")) + XCTAssertFalse(VMLibraryWindowController.matchesOSFilter("macOS", id: "linux")) + XCTAssertFalse(VMLibraryWindowController.matchesOSFilter("Windows", id: "linux")) + } + + func testOSFilterMacOS() { + XCTAssertTrue(VMLibraryWindowController.matchesOSFilter("macOS", id: "macos")) + XCTAssertTrue(VMLibraryWindowController.matchesOSFilter("Apple Mac OS", id: "macos")) + XCTAssertFalse(VMLibraryWindowController.matchesOSFilter("Linux", id: "macos")) + } + + func testOSFilterWindows() { + XCTAssertTrue(VMLibraryWindowController.matchesOSFilter("Windows", id: "windows")) + XCTAssertTrue(VMLibraryWindowController.matchesOSFilter("Windows 10", id: "windows")) + XCTAssertFalse(VMLibraryWindowController.matchesOSFilter("Linux", id: "windows")) + } + + // MARK: - Network + + func testNetworkFilterNAT() { + let nat = VirtualNetworkConfig(mode: .nat, routerVMId: nil, isRouter: false) + let virt = VirtualNetworkConfig(mode: .virtual, routerVMId: nil, isRouter: false) + XCTAssertTrue(VMLibraryWindowController.matchesNetworkFilter(nat, id: "nat")) + XCTAssertFalse(VMLibraryWindowController.matchesNetworkFilter(virt, id: "nat")) + } + + func testNetworkFilterVirtual() { + let virt = VirtualNetworkConfig(mode: .virtual, routerVMId: nil, isRouter: false) + let nat = VirtualNetworkConfig(mode: .nat, routerVMId: nil, isRouter: false) + XCTAssertTrue(VMLibraryWindowController.matchesNetworkFilter(virt, id: "virtual")) + XCTAssertFalse(VMLibraryWindowController.matchesNetworkFilter(nat, id: "virtual")) + } + + func testNetworkFilterIsolatedExcludesRoutersAndGuests() { + // "Isolated" means virtual-mode WITHOUT a router relationship — + // a guest pinned to a router doesn't count; a router doesn't + // count; only a virtual-mode VM with no router connection at + // all matches. + let isolated = VirtualNetworkConfig(mode: .virtual, routerVMId: nil, isRouter: false) + let router = VirtualNetworkConfig(mode: .virtual, routerVMId: nil, isRouter: true) + let guest = VirtualNetworkConfig(mode: .virtual, routerVMId: UUID(), isRouter: false) + let nat = VirtualNetworkConfig(mode: .nat, routerVMId: nil, isRouter: false) + + XCTAssertTrue(VMLibraryWindowController.matchesNetworkFilter(isolated, id: "isolated")) + XCTAssertFalse(VMLibraryWindowController.matchesNetworkFilter(router, id: "isolated")) + XCTAssertFalse(VMLibraryWindowController.matchesNetworkFilter(guest, id: "isolated")) + XCTAssertFalse(VMLibraryWindowController.matchesNetworkFilter(nat, id: "isolated")) + } + + func testNetworkFilterUnknownIDLetEverythingPass() { + let cfg = VirtualNetworkConfig(mode: .nat, routerVMId: nil, isRouter: false) + XCTAssertTrue(VMLibraryWindowController.matchesNetworkFilter(cfg, id: "all")) + XCTAssertTrue(VMLibraryWindowController.matchesNetworkFilter(cfg, id: "garbage")) + } +} diff --git a/SecVF/VMLibraryWindowController.swift b/SecVF/VMLibraryWindowController.swift index 90c912a..2b4515c 100644 --- a/SecVF/VMLibraryWindowController.swift +++ b/SecVF/VMLibraryWindowController.swift @@ -149,6 +149,23 @@ class VMLibraryWindowController: NSWindowController, /// "Focus Running ▾" button in the tabs row. private var runningFilterIDs: Set? + // Sidebar categorical filters — populated by the new mockup-spec + // sidebar navigation. All three combine AND-style with the + // runningFilterIDs set (so the user can ticked-list + status + + // OS + network all at once and the table shows the intersection). + // + // `nil` means "all" for the section. Strings are the row IDs that + // each TacticalSidebarSection emits via `onSelect`. + private var sidebarStatusFilter: String? // "running" / "paused" / "stopped" + private var sidebarOSFilter: String? // "linux" / "macos" / "windows" + private var sidebarNetworkFilter: String? // "isolated" / "virtual" / "nat" + + /// Sidebar section views, retained so refreshSidebarCounts can + /// update their badges in place without a full layout pass. + private var sidebarStatusSection: TacticalSidebarSection? + private var sidebarOSSection: TacticalSidebarSection? + private var sidebarNetworkSection: TacticalSidebarSection? + /// Button that pops the running-VM filter menu. Hidden when no VMs /// are running (nothing to filter to). Title gets a count badge /// while a filter is active so the user can see at a glance. @@ -322,6 +339,7 @@ class VMLibraryWindowController: NSWindowController, self.refreshStatusBar() self.refreshEmptyStateOverlays() self.refreshConnectionOverlay() + self.refreshSidebarCounts() } // Force the table to use view-based mode @@ -672,12 +690,11 @@ class VMLibraryWindowController: NSWindowController, let sidebarWidth: CGFloat = 220 - // Create sidebar view - full height let sidebar = NSView(frame: NSRect(x: 0, y: 0, width: sidebarWidth, height: contentView.bounds.height)) sidebar.autoresizingMask = [.height] sidebar.wantsLayer = true - // Cybersecurity gradient - dark grey to black with blue tint + // Tactical gradient — dark gradient anchors the left rail visually let gradientLayer = CAGradientLayer() gradientLayer.frame = sidebar.bounds gradientLayer.colors = [ @@ -689,154 +706,212 @@ class VMLibraryWindowController: NSWindowController, gradientLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] sidebar.layer?.addSublayer(gradientLayer) - // Logo - CENTERED in sidebar - let logoWidth: CGFloat = 170 - let logoHeight: CGFloat = 120 - let logoX = (sidebarWidth - logoWidth) / 2 // Center horizontally - let logoView = NSImageView(frame: NSRect(x: logoX, y: sidebar.bounds.height - 130, width: logoWidth, height: logoHeight)) - logoView.imageScaling = .scaleProportionallyUpOrDown - logoView.image = createStylizedLogo() - logoView.autoresizingMask = [.minYMargin, .minXMargin, .maxXMargin] - sidebar.addSubview(logoView) - - // Title - Two-tone: "Sec" in light gray, "VF" in medium gray - CENTERED - let titleLabel = NSTextField() - titleLabel.frame = NSRect(x: 0, y: sidebar.bounds.height - 170, width: sidebarWidth, height: 35) + // ── Compact brand block (top) ──────────────────────────────────── + // + // Mockup shows a small hex mark + "SecVF" wordmark + "Security + // Virtualization" tag — total ~85pt tall. The previous build had + // a 170×120 logo plus a 26pt-heavy title plus a subtitle (~225pt + // of brand real estate), which left no room for navigation and + // turned the rail into marketing space. + let brandH: CGFloat = 88 + let brandY = sidebar.bounds.height - brandH + let brand = makeCompactBrandBlock(width: sidebarWidth) + brand.frame = NSRect(x: 0, y: brandY, width: sidebarWidth, height: brandH) + brand.autoresizingMask = [.minYMargin, .width] + sidebar.addSubview(brand) + + // ── Filter sections (mockup parity) ────────────────────────────── + // + // Three stacked TacticalSidebarSection blocks: Filters (status) / + // Operating System / Network. Counts get refreshed on every VM + // list change via refreshSidebarCounts(). The "All …" row in + // each section clears that section's filter. + let sectionGap: CGFloat = 16 + var nextY = brandY - sectionGap + + let statusSection = TacticalSidebarSection( + title: "Filters", + rows: sidebarStatusRows(counts: [:])) + statusSection.frame = NSRect(x: 0, y: nextY - statusSection.intrinsicHeight, + width: sidebarWidth, height: statusSection.intrinsicHeight) + statusSection.autoresizingMask = [.minYMargin, .width] + statusSection.selectedID = "all" + statusSection.onSelect = { [weak self] id in + self?.sidebarStatusFilter = (id == "all") ? nil : id + self?.handleSidebarFilterChange() + } + sidebar.addSubview(statusSection) + sidebarStatusSection = statusSection + nextY = statusSection.frame.minY - sectionGap + + let osSection = TacticalSidebarSection( + title: "Operating System", + rows: sidebarOSRows(counts: [:])) + osSection.frame = NSRect(x: 0, y: nextY - osSection.intrinsicHeight, + width: sidebarWidth, height: osSection.intrinsicHeight) + osSection.autoresizingMask = [.minYMargin, .width] + osSection.selectedID = "all" + osSection.onSelect = { [weak self] id in + self?.sidebarOSFilter = (id == "all") ? nil : id + self?.handleSidebarFilterChange() + } + sidebar.addSubview(osSection) + sidebarOSSection = osSection + nextY = osSection.frame.minY - sectionGap + + let netSection = TacticalSidebarSection( + title: "Network", + rows: sidebarNetworkRows(counts: [:])) + netSection.frame = NSRect(x: 0, y: nextY - netSection.intrinsicHeight, + width: sidebarWidth, height: netSection.intrinsicHeight) + netSection.autoresizingMask = [.minYMargin, .width] + netSection.selectedID = "all" + netSection.onSelect = { [weak self] id in + self?.sidebarNetworkFilter = (id == "all") ? nil : id + self?.handleSidebarFilterChange() + } + sidebar.addSubview(netSection) + sidebarNetworkSection = netSection + nextY = netSection.frame.minY - sectionGap + + // ── LOGS section ───────────────────────────────────────────────── + // + // Re-implemented in the same TacticalSidebarSection style as the + // filter blocks (matching width, glyphs, hover). Tapping a row + // dispatches through the responder chain to the AppDelegate's + // existing @objc handlers — no new notifications needed. + let logsSection = TacticalSidebarSection( + title: "Logs", + rows: [ + .init(id: "security", glyph: "◆", title: "Security", count: nil), + .init(id: "network", glyph: "◆", title: "Network", count: nil), + .init(id: "iso-cache", glyph: "◆", title: "ISO Cache", count: nil), + ]) + logsSection.frame = NSRect(x: 0, y: nextY - logsSection.intrinsicHeight, + width: sidebarWidth, height: logsSection.intrinsicHeight) + logsSection.autoresizingMask = [.minYMargin, .width] + // Logs is *not* a selectable filter — clear the visual selection + // after the action fires so the row doesn't read as "active". + logsSection.onSelect = { [weak logsSection] id in + let selector: Selector + switch id { + case "security": selector = NSSelectorFromString("showSecurityLogs") + case "network": selector = NSSelectorFromString("showNetworkLogs") + case "iso-cache": selector = NSSelectorFromString("showISOCacheLogs") + default: return + } + NSApp.sendAction(selector, to: nil, from: nil) + // Pop the selection — Logs rows are actions, not filters. + DispatchQueue.main.async { logsSection?.selectedID = nil } + } + sidebar.addSubview(logsSection) + + contentView.addSubview(sidebar, positioned: .above, relativeTo: nil) + adjustContentForSidebar(sidebarWidth: sidebarWidth) + + // Initial count populate — Vm list may already be loaded. + refreshSidebarCounts() + } + + /// Compact brand block. Small hex glyph + "SecVF" wordmark + small + /// "Security Virtualization" tag underneath. ~88pt tall vs the + /// previous ~225pt marketing block, freeing real estate for actual + /// navigation. + private func makeCompactBrandBlock(width: CGFloat) -> NSView { + let block = NSView() + + let markSize: CGFloat = 28 + let mark = NSImageView(frame: NSRect(x: (width - markSize) / 2, y: 50, + width: markSize, height: markSize)) + mark.image = createStylizedLogo() + mark.imageScaling = .scaleProportionallyUpOrDown + block.addSubview(mark) + + let titleLabel = NSTextField(labelWithAttributedString: NSAttributedString( + string: "SecVF", + attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: 16, weight: .heavy), + .foregroundColor: AppColors.textLight, + ])) titleLabel.alignment = .center - titleLabel.isBordered = false - titleLabel.isEditable = false - titleLabel.drawsBackground = false - titleLabel.autoresizingMask = [.minYMargin, .width] - - // Create attributed string with two colors and center alignment - let attributedTitle = NSMutableAttributedString() - let font = NSFont.monospacedSystemFont(ofSize: 26, weight: .heavy) - - // Create paragraph style for centering - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.alignment = .center - - // "Sec" in light gray - let secPart = NSAttributedString(string: "Sec", attributes: [ - .font: font, - .foregroundColor: AppColors.textLight, - .paragraphStyle: paragraphStyle - ]) - attributedTitle.append(secPart) + titleLabel.frame = NSRect(x: 0, y: 24, width: width, height: 20) + block.addSubview(titleLabel) - // "VF" in medium gray - let vfPart = NSAttributedString(string: "VF", attributes: [ - .font: font, - .foregroundColor: AppColors.textMuted, - .paragraphStyle: paragraphStyle - ]) - attributedTitle.append(vfPart) - - titleLabel.attributedStringValue = attributedTitle - sidebar.addSubview(titleLabel) - - // Subtitle - Light gray - CENTERED - let subtitleLabel = NSTextField(labelWithString: "Security Virtualization Framework") - subtitleLabel.frame = NSRect(x: 0, y: sidebar.bounds.height - 195, width: sidebarWidth, height: 20) - subtitleLabel.alignment = .center - subtitleLabel.font = NSFont.monospacedSystemFont(ofSize: 9, weight: .medium) - subtitleLabel.textColor = AppColors.textMuted - subtitleLabel.isBordered = false - subtitleLabel.isEditable = false - subtitleLabel.drawsBackground = false - subtitleLabel.autoresizingMask = [.minYMargin, .width] - sidebar.addSubview(subtitleLabel) - - // Separator line - let separator1 = createSeparator(y: sidebar.bounds.height - 220, width: sidebarWidth) - separator1.autoresizingMask = [.minYMargin, .width] - sidebar.addSubview(separator1) - - // Stats/Info below title - Medium gray accents - CENTERED - let statsLabel = NSTextField(labelWithString: "▸ Malware Analysis\n▸ Isolated Sandbox\n▸ Virtual Networking") - statsLabel.frame = NSRect(x: 0, y: sidebar.bounds.height - 310, width: sidebarWidth, height: 80) - statsLabel.alignment = .center - statsLabel.font = NSFont.monospacedSystemFont(ofSize: 10, weight: .medium) - statsLabel.textColor = AppColors.textSubtle - statsLabel.isBordered = false - statsLabel.isEditable = false - statsLabel.drawsBackground = false - statsLabel.autoresizingMask = [.minYMargin, .width] - sidebar.addSubview(statsLabel) - - // Logs section — surfaces the three log viewers from the Monitoring - // menu in the sidebar so the user can hop to them without diving - // into the menu bar. Buttons dispatch via responder chain so the - // AppDelegate's existing @objc handlers are called directly (no - // duplicate state, no new notifications needed). - let logsHeaderY: CGFloat = sidebar.bounds.height - 340 - let logsHeader = NSTextField(labelWithString: "LOGS") - logsHeader.frame = NSRect(x: 0, y: logsHeaderY, width: sidebarWidth, height: 14) - logsHeader.alignment = .center - logsHeader.font = NSFont.monospacedSystemFont(ofSize: 9, weight: .bold) - logsHeader.textColor = AppColors.textSubtle - logsHeader.isBordered = false - logsHeader.isEditable = false - logsHeader.drawsBackground = false - logsHeader.autoresizingMask = [.minYMargin, .width] - sidebar.addSubview(logsHeader) - - let logButtonsTopY: CGFloat = logsHeaderY - 8 - let logEntries: [(title: String, selectorName: String, tooltip: String)] = [ - ("◆ Security", "showSecurityLogs", - "Security events captured by VMSecurityMonitor (suspicious activity, resource pressure, isolation breaches). ⇧⌘1"), - ("◆ Network", "showNetworkLogs", - "Per-VM network logs (traffic summaries, switch events, NAT activity). ⇧⌘2"), - ("◆ ISO Cache", "showISOCacheLogs", - "Distro ISO download/verify activity from ISOCacheManager."), + let tag = NSTextField(labelWithString: "Security Virtualization") + tag.font = NSFont.monospacedSystemFont(ofSize: 8, weight: .medium) + tag.textColor = AppColors.textMuted + tag.alignment = .center + tag.frame = NSRect(x: 0, y: 8, width: width, height: 12) + block.addSubview(tag) + + return block + } + + /// Build the "Filters" section rows (status filter). Each row's + /// `count` is the number of VMs in the master list with that status. + /// Passed an empty dict on initial build before `refreshSidebarCounts` + /// has populated the live numbers. + private func sidebarStatusRows(counts: [String: Int]) -> [TacticalSidebarSection.Row] { + return [ + .init(id: "all", glyph: "▣", title: "All VMs", count: counts["all"]), + .init(id: "running", glyph: "●", title: "Running", count: counts["running"]), + .init(id: "paused", glyph: "◐", title: "Paused", count: counts["paused"]), + .init(id: "stopped", glyph: "○", title: "Stopped", count: counts["stopped"]), ] - let buttonHeight: CGFloat = 26 - let buttonGap: CGFloat = 4 - let buttonInset: CGFloat = 16 - for (i, entry) in logEntries.enumerated() { - let btnY = logButtonsTopY - CGFloat(i + 1) * (buttonHeight + buttonGap) - let btn = TacticalHoverButton(title: entry.title, - target: nil, - action: NSSelectorFromString(entry.selectorName)) - btn.frame = NSRect(x: buttonInset, y: btnY, - width: sidebarWidth - buttonInset * 2, - height: buttonHeight) - btn.isBordered = false - btn.bezelStyle = .regularSquare - btn.font = NSFont.systemFont(ofSize: 11, weight: .medium) - btn.contentTintColor = AppColors.textPrimary - btn.alignment = .left - btn.layer?.backgroundColor = AppColors.backgroundButton.cgColor - btn.layer?.borderColor = AppColors.borderOD.cgColor - btn.layer?.borderWidth = 1.0 - btn.layer?.cornerRadius = LayoutConstants.cornerRadiusSM - btn.attributedTitle = NSAttributedString(string: " " + entry.title, attributes: [ - .foregroundColor: AppColors.textPrimary, - .font: NSFont.systemFont(ofSize: 11, weight: .medium) - ]) - btn.toolTip = entry.tooltip - btn.autoresizingMask = [.minYMargin, .width] - btn.setAccessibilityLabel(entry.title.replacingOccurrences(of: "◆ ", with: "") + " logs") - btn.setHoverTreatment(hoverBorder: AppColors.accentODGlow) - sidebar.addSubview(btn) - } + } - // Separator line above developer info - let separator2 = createSeparator(y: 155, width: sidebarWidth) - separator2.autoresizingMask = [.maxYMargin, .width] - sidebar.addSubview(separator2) + private func sidebarOSRows(counts: [String: Int]) -> [TacticalSidebarSection.Row] { + return [ + .init(id: "all", glyph: "▾", title: "All", count: counts["all"]), + .init(id: "linux", glyph: "🐧", title: "Linux", count: counts["linux"]), + .init(id: "macos", glyph: "⌘", title: "macOS", count: counts["macos"]), + .init(id: "windows", glyph: "▩", title: "Windows", count: counts["windows"]), + ] + } - // Framework info section at bottom - CENTERED - let infoY: CGFloat = 115 - addInfoLabel(to: sidebar, text: "Built on", y: infoY, bold: false, width: sidebarWidth) - addInfoLabel(to: sidebar, text: "Apple Virtualization Framework", y: infoY - 28, bold: true, width: sidebarWidth) - addInfoLabel(to: sidebar, text: "github.com/DaxxSec/SecVF", y: infoY - 58, bold: false, color: AppColors.textSubtle, width: sidebarWidth) + private func sidebarNetworkRows(counts: [String: Int]) -> [TacticalSidebarSection.Row] { + return [ + .init(id: "all", glyph: "▾", title: "All", count: counts["all"]), + .init(id: "isolated", glyph: "⊘", title: "Isolated", count: counts["isolated"]), + .init(id: "virtual", glyph: "⇄", title: "Virtual", count: counts["virtual"]), + .init(id: "nat", glyph: "🌐", title: "NAT", count: counts["nat"]), + ] + } - // Add sidebar to window - contentView.addSubview(sidebar, positioned: .above, relativeTo: nil) + /// Recompute the per-section count badges from the current VM list. + /// Called on every VM status / list change so the badges stay + /// honest. Cheap — three full-list walks. + func refreshSidebarCounts() { + let vms = vmManager.virtualMachines + + var statusCounts: [String: Int] = ["all": vms.count] + statusCounts["running"] = vms.filter { Self.matchesStatusFilter($0.status, id: "running") }.count + statusCounts["paused"] = vms.filter { Self.matchesStatusFilter($0.status, id: "paused") }.count + statusCounts["stopped"] = vms.filter { Self.matchesStatusFilter($0.status, id: "stopped") }.count + sidebarStatusSection?.refreshCounts(statusCounts) + + var osCounts: [String: Int] = ["all": vms.count] + osCounts["linux"] = vms.filter { Self.matchesOSFilter($0.osType, id: "linux") }.count + osCounts["macos"] = vms.filter { Self.matchesOSFilter($0.osType, id: "macos") }.count + osCounts["windows"] = vms.filter { Self.matchesOSFilter($0.osType, id: "windows") }.count + sidebarOSSection?.refreshCounts(osCounts) + + var netCounts: [String: Int] = ["all": vms.count] + netCounts["isolated"] = vms.filter { Self.matchesNetworkFilter($0.networkConfig, id: "isolated") }.count + netCounts["virtual"] = vms.filter { Self.matchesNetworkFilter($0.networkConfig, id: "virtual") }.count + netCounts["nat"] = vms.filter { Self.matchesNetworkFilter($0.networkConfig, id: "nat") }.count + sidebarNetworkSection?.refreshCounts(netCounts) + } - // Adjust existing content to make room for sidebar - adjustContentForSidebar(sidebarWidth: sidebarWidth) + /// Called when any sidebar filter section changes. Re-renders the + /// table to honor the new combined filter, refreshes the connection + /// overlay (rows may have changed), and updates the detail card + + /// quick-action button enablement. + private func handleSidebarFilterChange() { + tableView?.reloadData() + refreshConnectionOverlay() + updateSelectedVMDetailCard() + refreshEmptyStateOverlays() } private func createProtocolLegend(width: CGFloat, height: CGFloat) -> NSView { @@ -1986,9 +2061,54 @@ class VMLibraryWindowController: NSWindowController, /// action handlers) must read through this — never `vmManager.virtualMachines` /// directly — so row indices stay consistent with what's on screen. private var displayedStandardVMs: [VMConfiguration] { - let all = vmManager.virtualMachines - guard let ids = runningFilterIDs, !ids.isEmpty else { return all } - return all.filter { ids.contains($0.id) } + var list = vmManager.virtualMachines + if let ids = runningFilterIDs, !ids.isEmpty { + list = list.filter { ids.contains($0.id) } + } + if let id = sidebarStatusFilter { + list = list.filter { Self.matchesStatusFilter($0.status, id: id) } + } + if let id = sidebarOSFilter { + list = list.filter { Self.matchesOSFilter($0.osType, id: id) } + } + if let id = sidebarNetworkFilter { + list = list.filter { Self.matchesNetworkFilter($0.networkConfig, id: id) } + } + return list + } + + // MARK: - Sidebar filter predicates (pure, testable) + + static func matchesStatusFilter(_ status: VMStatus, id: String) -> Bool { + switch id { + case "running": return status == .running + case "paused": return status == .starting || status == .stopping + case "stopped": return status == .stopped + default: return true + } + } + + static func matchesOSFilter(_ osType: String, id: String) -> Bool { + let lower = osType.lowercased() + switch id { + case "linux": return lower.contains("linux") + case "macos": return lower.contains("mac") + case "windows": return lower.contains("windows") + default: return true + } + } + + static func matchesNetworkFilter(_ config: VirtualNetworkConfig, id: String) -> Bool { + switch id { + case "nat": return config.mode == .nat + case "virtual": return config.mode == .virtual + case "isolated": + // "Isolated" in the mockup means "virtual-mode with no router + // assigned and not acting as a router itself" — purely + // isolated, no internet access path. + return config.mode == .virtual && config.routerVMId == nil && !config.isRouter + default: return true + } } /// Safe row→VM lookup against the currently-displayed standard list. @@ -3552,6 +3672,10 @@ class VMLibraryWindowController: NSWindowController, // Re-evaluate VM-VM connection brackets on running pills self.refreshConnectionOverlay() + + // Refresh sidebar count badges — a VM moving Running→Stopped + // changes which section bucket it falls into. + self.refreshSidebarCounts() } } @@ -4647,6 +4771,7 @@ class VMLibraryWindowController: NSWindowController, self.updateButtonStates() self.refreshEmptyStateOverlays() self.refreshConnectionOverlay() + self.refreshSidebarCounts() } }