mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 17:44:33 +00:00
- Replace direct script invocation with `$PROLE_HOME/env.sh` wrapper for consistent environment setup across all components. - Update runtime processes to dynamically resolve `$PROLE_HOME` or fallback to `$HOME/.prole`. - Deprecate `init-port-forwards.sh` script bundling; remove from LaunchAgentManager and build system. - Overhaul `env.sh` generation to include execution capability, customizable paths, and improved error handling. - Standardize app name and paths to "Prole Tools" across all files and UI elements. - Adjust build output to align with new structure, including executable naming and resource packaging.
588 lines
26 KiB
Swift
588 lines
26 KiB
Swift
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)
|
||
}
|
||
}
|