import AppKit final class PreferencesWindowController: NSWindowController, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate, NSTabViewDelegate { private enum Tab: Int { case kubernetes = 0, services = 1, ports = 2, agentPF = 3 } private var kubeItems: [Config.KubernetesEndpoint] = [] private var serviceItems: [Config.ServiceEndpoint] = [] private var portItems: [PortMappingXML] = [] private let tabView = NSTabView() private let launchAgentManager = LaunchAgentManager() private let agentCommandsTextView = NSTextView() private var didCenterOnce = false // Tables private let kubeTable = NSTableView() private let svcTable = NSTableView() private let portTable = NSTableView() convenience init() { let rect = NSRect(x: 0, y: 0, width: 640, height: 400) // Make Preferences window resizable to avoid cramped layouts let window = NSWindow(contentRect: rect, styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) self.init(window: window) window.isReleasedWhenClosed = false window.title = "Preferences" // Ensure the window has a sensible minimum size so controls remain visible window.contentMinSize = NSSize(width: 520, height: 320) // Let macOS remember user-adjusted placement on subsequent opens window.setFrameAutosaveName("PreferencesWindow") kubeItems = Config.shared.kubernetes serviceItems = Config.shared.services portItems = PortMappingsXMLStore.load() setupUI() } override func showWindow(_ sender: Any?) { super.showWindow(sender) // Center only on first show so subsequent shows restore autosaved frame if !didCenterOnce { window?.center() didCenterOnce = true } NSApp.activate(ignoringOtherApps: true) } private func setupUI() { guard let content = window?.contentView else { return } tabView.translatesAutoresizingMaskIntoConstraints = false // Make sure tabs are at the top and styled like standard preferences tabView.tabPosition = .top if #available(macOS 11.0, *) { tabView.tabViewType = .topTabsBezelBorder } else { tabView.tabViewType = .topTabsBezelBorder } tabView.delegate = self content.addSubview(tabView) // Prefer the window's contentLayoutGuide to avoid title-bar overlap and ensure proper insets if let guide = window?.contentLayoutGuide as? NSLayoutGuide { NSLayoutConstraint.activate([ tabView.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: 12), tabView.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -12), tabView.topAnchor.constraint(equalTo: guide.topAnchor, constant: 12), tabView.bottomAnchor.constraint(equalTo: guide.bottomAnchor, constant: -12) ]) } else { NSLayoutConstraint.activate([ tabView.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 12), tabView.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -12), tabView.topAnchor.constraint(equalTo: content.topAnchor, constant: 12), tabView.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -12) ]) } // Tabs addKubernetesTab() addServicesTab() addPortsTab() addAgentPortForwardsTab() // Ensure initial data is visible on all tabs kubeTable.reloadData() svcTable.reloadData() portTable.reloadData() // Load agent commands loadAgentCommandsIntoEditor() // Column sizing and layout stabilization sizeColumnsAndLayout(table: kubeTable) sizeColumnsAndLayout(table: svcTable) sizeColumnsAndLayout(table: portTable) // Ensure tab content views adopt the tab's content rect and resize with it syncAllTabContentFrames() tabView.selectTabViewItem(at: 0) // Observe window resizes to keep current tab content sized correctly if let win = window { NotificationCenter.default.addObserver(self, selector: #selector(windowDidResize), name: NSWindow.didResizeNotification, object: win) } } // MARK: - Tabs private func addKubernetesTab() { let item = NSTabViewItem(identifier: Tab.kubernetes.rawValue) item.label = "Kubernetes" let view = makeTableContainer(table: kubeTable, columns: [ (identifier: NSUserInterfaceItemIdentifier("host"), title: "Hostname", width: 360), (identifier: NSUserInterfaceItemIdentifier("port"), title: "Port", width: 100) ], addAction: #selector(addKube), removeAction: #selector(removeKube), saveAction: #selector(saveKube)) adoptTabContentSizing(view) item.view = view tabView.addTabViewItem(item) } private func addServicesTab() { let item = NSTabViewItem(identifier: Tab.services.rawValue) item.label = "Services" let view = makeTableContainer(table: svcTable, columns: [ (identifier: NSUserInterfaceItemIdentifier("name"), title: "Service Name", width: 180), (identifier: NSUserInterfaceItemIdentifier("host"), title: "Hostname", width: 260), (identifier: NSUserInterfaceItemIdentifier("port"), title: "Port", width: 100) ], addAction: #selector(addService), removeAction: #selector(removeService), saveAction: #selector(saveService)) adoptTabContentSizing(view) item.view = view tabView.addTabViewItem(item) } private func addPortsTab() { let item = NSTabViewItem(identifier: Tab.ports.rawValue) item.label = "Ports" let view = makeTableContainer(table: portTable, columns: [ (identifier: NSUserInterfaceItemIdentifier("service"), title: "Target (svc/deploy/pod)", width: 260), (identifier: NSUserInterfaceItemIdentifier("namespace"), title: "Namespace", width: 160), (identifier: NSUserInterfaceItemIdentifier("expose"), title: "Host Port", width: 100), (identifier: NSUserInterfaceItemIdentifier("internal"), title: "Service Port", width: 100) ], addAction: #selector(addPort), removeAction: #selector(removePort), saveAction: #selector(savePort)) adoptTabContentSizing(view) item.view = view tabView.addTabViewItem(item) } private func addAgentPortForwardsTab() { let item = NSTabViewItem(identifier: Tab.agentPF.rawValue) item.label = "Port Forwards (Agent)" let container = NSView() container.translatesAutoresizingMaskIntoConstraints = true container.autoresizingMask = [.width, .height] let scroll = NSScrollView() scroll.translatesAutoresizingMaskIntoConstraints = false scroll.hasVerticalScroller = true scroll.documentView = agentCommandsTextView agentCommandsTextView.isVerticallyResizable = true agentCommandsTextView.isHorizontallyResizable = true agentCommandsTextView.font = .monospacedSystemFont(ofSize: 12, weight: .regular) agentCommandsTextView.autoresizingMask = [.width, .height] let helpLabel = NSTextField(labelWithString: "One kubectl command per line. These are stored in your LaunchAgent plist and run by a helper script.") helpLabel.lineBreakMode = .byWordWrapping helpLabel.translatesAutoresizingMaskIntoConstraints = false let saveButton = NSButton(title: "Save", target: self, action: #selector(saveAgentCommands)) saveButton.bezelStyle = .rounded let reloadButton = NSButton(title: "Reload Agent", target: self, action: #selector(reloadAgent)) reloadButton.bezelStyle = .rounded let hstack = NSStackView(views: [helpLabel, NSView(), saveButton, reloadButton]) hstack.translatesAutoresizingMaskIntoConstraints = false hstack.orientation = .horizontal hstack.alignment = .centerY hstack.spacing = 8 container.addSubview(scroll) container.addSubview(hstack) NSLayoutConstraint.activate([ scroll.leadingAnchor.constraint(equalTo: container.leadingAnchor), scroll.trailingAnchor.constraint(equalTo: container.trailingAnchor), scroll.topAnchor.constraint(equalTo: container.topAnchor), scroll.bottomAnchor.constraint(equalTo: hstack.topAnchor, constant: -8), hstack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4), hstack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4), hstack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4) ]) adoptTabContentSizing(container) item.view = container tabView.addTabViewItem(item) } private func loadAgentCommandsIntoEditor() { let cmds = launchAgentManager.readCommands() agentCommandsTextView.string = cmds.joined(separator: "\n") } @objc private func saveAgentCommands() { let raw = agentCommandsTextView.string let cmds = raw .components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } launchAgentManager.writeCommands(cmds) } @objc private func reloadAgent() { saveAgentCommands() launchAgentManager.resetAgent() } private func makeTableContainer(table: NSTableView, columns: [(identifier: NSUserInterfaceItemIdentifier, title: String, width: CGFloat)], addAction: Selector, removeAction: Selector, saveAction: Selector) -> NSView { let scroll = NSScrollView() scroll.translatesAutoresizingMaskIntoConstraints = false scroll.hasVerticalScroller = true scroll.hasHorizontalScroller = true scroll.autohidesScrollers = true scroll.autoresizesSubviews = true scroll.contentView.copiesOnScroll = false // Important: use frame-based layout inside NSScrollView's documentView to ensure visibility on all tabs table.translatesAutoresizingMaskIntoConstraints = true table.autoresizingMask = [.width, .height] table.headerView = NSTableHeaderView() table.usesAlternatingRowBackgroundColors = true table.allowsColumnReordering = false table.allowsColumnResizing = true table.usesAutomaticRowHeights = false table.rowHeight = 28 table.intercellSpacing = NSSize(width: 4, height: 4) // Using sequential autoresizing prevents later columns from collapsing when the view first appears table.columnAutoresizingStyle = .sequentialColumnAutoresizingStyle table.delegate = self table.dataSource = self // Columns for c in columns { let col = NSTableColumn(identifier: c.identifier) col.title = c.title // Allow user resizing while also participating in automatic resizing col.resizingMask = [.autoresizingMask, .userResizingMask] col.minWidth = max(80, c.width * 0.4) col.maxWidth = max(c.width * 2.0, col.minWidth + 40) col.width = c.width // Fit to header initially to ensure visibility even before rows render col.sizeToFit() // Enforce requested minimum character widths per tab/column if table === svcTable { if c.identifier.rawValue == "name" || c.identifier.rawValue == "host" { let minChars = widthForChars(32) + 12 // padding col.minWidth = max(col.minWidth, minChars) // equal starting widths for name and host; keep port smaller } } else if table === portTable { if c.identifier.rawValue == "service" { let minChars = widthForChars(32) + 12 col.minWidth = max(col.minWidth, minChars) } } table.addTableColumn(col) } // Set an initial frame and attach as documentView table.frame = NSRect(origin: .zero, size: NSSize(width: 800, height: 600)) scroll.documentView = table // Buttons let addButton = NSButton(title: "+", target: self, action: addAction) addButton.bezelStyle = .texturedRounded let removeButton = NSButton(title: "−", target: self, action: removeAction) removeButton.bezelStyle = .texturedRounded let saveButton = NSButton(title: "Save", target: self, action: saveAction) saveButton.bezelStyle = .rounded let spacer = NSView() spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let buttons = NSStackView(views: [addButton, removeButton, spacer, saveButton]) buttons.orientation = .horizontal buttons.alignment = .centerY buttons.spacing = 8 buttons.distribution = .fill buttons.translatesAutoresizingMaskIntoConstraints = false let container = NSView() // Very important: NSTabView expects its item.view to be frame-based. If we // disable translatesAutoresizingMaskIntoConstraints on this container, the // tab content can collapse to a tiny area. Keep it frame-based and let the // tab view drive its size via frame/autoresizing. container.translatesAutoresizingMaskIntoConstraints = true container.autoresizingMask = [.width, .height] container.addSubview(scroll) container.addSubview(buttons) NSLayoutConstraint.activate([ scroll.leadingAnchor.constraint(equalTo: container.leadingAnchor), scroll.trailingAnchor.constraint(equalTo: container.trailingAnchor), scroll.topAnchor.constraint(equalTo: container.topAnchor), scroll.bottomAnchor.constraint(equalTo: buttons.topAnchor, constant: -8), buttons.leadingAnchor.constraint(equalTo: container.leadingAnchor), buttons.trailingAnchor.constraint(equalTo: container.trailingAnchor), buttons.bottomAnchor.constraint(equalTo: container.bottomAnchor), // Ensure a minimal height for the buttons bar so it doesn't collapse buttons.heightAnchor.constraint(greaterThanOrEqualToConstant: 32) ]) return container } // MARK: - NSTabViewDelegate func tabView(_ tabView: NSTabView, didSelect tabViewItem: NSTabViewItem?) { // Refresh data when switching tabs to avoid stale/blank views kubeTable.reloadData() svcTable.reloadData() portTable.reloadData() // Force layout for the newly visible table to avoid zero-sized text fields if let id = tabViewItem?.identifier as? Int, let tab = Tab(rawValue: id) { // Ensure the selected tab's content view fills the tab content area if let v = tabViewItem?.view { adoptTabContentSizing(v) } switch tab { case .kubernetes: forceLayout(for: kubeTable) case .services: forceLayout(for: svcTable) case .ports: forceLayout(for: portTable) case .agentPF: break } } } // MARK: - Actions @objc private func addKube() { kubeItems.append(.init(host: "", port: 6443)) kubeTable.reloadData() } @objc private func removeKube() { let row = kubeTable.selectedRow if row >= 0 && row < kubeItems.count { kubeItems.remove(at: row) kubeTable.reloadData() } } @objc private func saveKube() { // Commit any in-progress text edits to our backing arrays window?.endEditing(for: nil) Config.shared.kubernetes = kubeItems Config.shared.save() } @objc private func addService() { serviceItems.append(.init(name: "", host: "", port: 0)) svcTable.reloadData() } @objc private func removeService() { let row = svcTable.selectedRow if row >= 0 && row < serviceItems.count { serviceItems.remove(at: row) svcTable.reloadData() } } @objc private func saveService() { // Commit any in-progress text edits to our backing arrays window?.endEditing(for: nil) Config.shared.services = serviceItems Config.shared.save() } @objc private func addPort() { portItems.append(.init(id: "", namespace: "default", target: "svc/", address: "127.0.0.1", hostPort: "", servicePort: "", proto: "TCP", description: "")) portTable.reloadData() } @objc private func removePort() { let row = portTable.selectedRow if row >= 0 && row < portItems.count { portItems.remove(at: row) portTable.reloadData() } } @objc private func savePort() { // Commit any in-progress text edits to our backing arrays window?.endEditing(for: nil) do { try PortMappingsXMLStore.save(portItems) } catch { NSSound.beep() } } // MARK: - NSTableView func numberOfRows(in tableView: NSTableView) -> Int { if tableView === kubeTable { return kubeItems.count } if tableView === svcTable { return serviceItems.count } if tableView === portTable { return portItems.count } return 0 } func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { guard let tableColumn = tableColumn else { return nil } let colId = tableColumn.identifier // Reuse a cell per column identifier let cell: NSTableCellView if let reused = tableView.makeView(withIdentifier: colId, owner: self) as? NSTableCellView { cell = reused } else { let newCell = NSTableCellView() newCell.identifier = colId let tf = NSTextField() tf.isBordered = true tf.isEditable = true tf.lineBreakMode = .byTruncatingTail tf.controlSize = .regular tf.font = NSFont.systemFont(ofSize: NSFont.systemFontSize) tf.setContentHuggingPriority(.defaultLow, for: .horizontal) tf.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) tf.target = self tf.action = #selector(cellEdited(_:)) tf.translatesAutoresizingMaskIntoConstraints = false newCell.addSubview(tf) newCell.textField = tf NSLayoutConstraint.activate([ tf.leadingAnchor.constraint(equalTo: newCell.leadingAnchor, constant: 4), tf.trailingAnchor.constraint(equalTo: newCell.trailingAnchor, constant: -4), tf.topAnchor.constraint(equalTo: newCell.topAnchor, constant: 2), tf.bottomAnchor.constraint(equalTo: newCell.bottomAnchor, constant: -2) ]) cell = newCell } guard let tf = cell.textField else { return cell } tf.tag = row if tableView === kubeTable { let item = kubeItems[row] if colId.rawValue == "host" { tf.stringValue = item.host } else if colId.rawValue == "port" { tf.stringValue = String(item.port) } } else if tableView === svcTable { let item = serviceItems[row] if colId.rawValue == "name" { tf.stringValue = item.name } else if colId.rawValue == "host" { tf.stringValue = item.host } else if colId.rawValue == "port" { tf.stringValue = String(item.port) } } else if tableView === portTable { let item = portItems[row] if colId.rawValue == "service" { tf.stringValue = item.target } else if colId.rawValue == "namespace" { tf.stringValue = item.namespace } else if colId.rawValue == "expose" { tf.stringValue = item.hostPort } else if colId.rawValue == "internal" { tf.stringValue = item.servicePort } } return cell } @objc private func cellEdited(_ sender: NSTextField) { // Find table and column from hierarchy var v: NSView? = sender var table: NSTableView? var cell: NSTableCellView? while let cur = v { if let tv = cur as? NSTableView { table = tv; break } if let c = cur as? NSTableCellView { cell = c } v = cur.superview } guard let tv = table, let cid = cell?.identifier?.rawValue else { return } let row = sender.tag let val = sender.stringValue if tv === kubeTable { if cid == "host" { kubeItems[row].host = val } else if cid == "port" { kubeItems[row].port = Int(val) ?? 6443 } } else if tv === svcTable { if cid == "name" { serviceItems[row].name = val } else if cid == "host" { serviceItems[row].host = val } else if cid == "port" { serviceItems[row].port = Int(val) ?? 0 } } else if tv === portTable { if cid == "service" { portItems[row].target = val } else if cid == "namespace" { portItems[row].namespace = val } else if cid == "expose" { portItems[row].hostPort = val } else if cid == "internal" { portItems[row].servicePort = val } } } // MARK: - Helpers @objc private func windowDidResize() { // Keep current tab content sized to the tab's content rect during window resizes syncCurrentTabContentFrame() // Re-layout the currently visible table for updated width if let id = tabView.selectedTabViewItem?.identifier as? Int, let tab = Tab(rawValue: id) { switch tab { case .kubernetes: sizeColumnsAndLayout(table: kubeTable) case .services: sizeColumnsAndLayout(table: svcTable) case .ports: sizeColumnsAndLayout(table: portTable) case .agentPF: break } } } private func adoptTabContentSizing(_ view: NSView) { view.translatesAutoresizingMaskIntoConstraints = true view.autoresizingMask = [.width, .height] view.frame = tabView.contentRect } private func syncCurrentTabContentFrame() { if let v = tabView.selectedTabViewItem?.view { adoptTabContentSizing(v) } } private func syncAllTabContentFrames() { for item in tabView.tabViewItems { if let v = item.view { adoptTabContentSizing(v) } } } private func sizeColumnsAndLayout(table: NSTableView) { // Defer sizing to when the table is in a window so we have real bounds DispatchQueue.main.async { guard let sv = table.enclosingScrollView else { return } // Expand the table to at least the visible area so columns can lay out let visible = sv.contentView.bounds.size var f = table.frame f.size.width = max(f.size.width, visible.width) f.size.height = max(f.size.height, visible.height) table.frame = f // Distribute widths sensibly depending on which table this is self.distributeColumnWidths(for: table, availableWidth: visible.width) table.noteNumberOfRowsChanged() self.forceLayout(for: table) } } private func forceLayout(for table: NSTableView) { // Ensure the scroll view and its content lay out now; dispatch to next runloop DispatchQueue.main.async { table.enclosingScrollView?.tile() table.layoutSubtreeIfNeeded() table.enclosingScrollView?.layoutSubtreeIfNeeded() table.headerView?.layoutSubtreeIfNeeded() } } // Distribute columns per table type to avoid first-column-only syndrome when appearing private func distributeColumnWidths(for table: NSTableView, availableWidth: CGFloat) { guard availableWidth.isFinite, availableWidth > 0 else { return } let cols = table.tableColumns if cols.isEmpty { return } // Side paddings inside container are ~0 here; keep a small safety margin let padding: CGFloat = 8 let width = max(100, availableWidth - padding) func apply(_ fractions: [CGFloat]) { // Normalize fractions let sum = fractions.reduce(0, +) let norm = sum > 0 ? fractions.map { $0 / sum } : Array(repeating: 1.0 / CGFloat(fractions.count), count: fractions.count) for (i, col) in cols.enumerated() { let frac = i < norm.count ? norm[i] : (1.0 / CGFloat(cols.count)) let target = max(col.minWidth, min(col.maxWidth, width * frac)) col.width = target } } if table === kubeTable { // host, port apply([0.75, 0.25]) } else if table === svcTable { // name, host, port — make name and host same size by default apply([0.41, 0.41, 0.18]) } else if table === portTable { // service, namespace, expose, internal apply([0.42, 0.26, 0.16, 0.16]) } else { // Fallback equal distribution apply(Array(repeating: 1.0, count: cols.count)) } } } // MARK: - Character width utilities private extension PreferencesWindowController { /// Approximate pixel width for N characters using the table cell font func widthForChars(_ count: Int) -> CGFloat { let chars = max(0, count) if chars == 0 { return 0 } // Use a wide glyph to avoid underestimating; system font to match cells let sample = String(repeating: "W", count: chars) as NSString let font = NSFont.systemFont(ofSize: NSFont.systemFontSize) let size = sample.size(withAttributes: [.font: font]) return ceil(size.width) } }