From 6fa949cabceda5838a73dc0c77e051790157ff3f Mon Sep 17 00:00:00 2001 From: chrisfu Date: Mon, 22 Dec 2025 21:27:00 -0800 Subject: [PATCH] refactor(app, scripts): remove deprecated `init-port-forwards.sh`; enhance UI and db management - Deleted `init-port-forwards.sh`, removing deprecated placeholder script. - Introduced `dbStatus` command in `PFScriptBridge.swift` for querying database status with `kubecolor`. - Improved `StatusView` UI: refined button interactivity and font standardization. - Added `DBStatusWindowController` to `AppDelegate` for better database status visibility and control. - Updated `OverlayWindow`: adjusted window levels and interaction behavior for smoother UX. --- etc/init-port-forwards.sh | 17 ----- prole-tools-app/Sources/AppDelegate.swift | 21 +++++- prole-tools-app/Sources/Debug.swift | 7 +- prole-tools-app/Sources/OverlayWindow.swift | 74 ++++++++++++-------- prole-tools-app/Sources/PFScriptBridge.swift | 33 +++++++++ prole-tools-app/Sources/StatusView.swift | 32 ++++++--- 6 files changed, 119 insertions(+), 65 deletions(-) delete mode 100755 etc/init-port-forwards.sh diff --git a/etc/init-port-forwards.sh b/etc/init-port-forwards.sh deleted file mode 100755 index b285a4a..0000000 --- a/etc/init-port-forwards.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# DEPRECATED: this script has been renamed to etc/init_port_forwards.sh -# Keep as a thin wrapper for backward compatibility. - -SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -NEW_SCRIPT="$SCRIPT_DIR/init_port_forwards.sh" - -echo "[init-port-forwards.sh] WARNING: This script is deprecated. Please use etc/init_port_forwards.sh instead." >&2 - -if [ -x "$NEW_SCRIPT" ]; then - exec "$NEW_SCRIPT" "$@" -else - echo "[init-port-forwards.sh] ERROR: $NEW_SCRIPT not found or not executable." >&2 - exit 127 -fi diff --git a/prole-tools-app/Sources/AppDelegate.swift b/prole-tools-app/Sources/AppDelegate.swift index eb5cbfd..7a6cf1e 100644 --- a/prole-tools-app/Sources/AppDelegate.swift +++ b/prole-tools-app/Sources/AppDelegate.swift @@ -14,6 +14,7 @@ import AppKit final class AppDelegate: NSObject, NSApplicationDelegate { private var overlayWindowController: OverlayWindowController! private var mainWindowController: MainWindowController! + private var dbStatusWindowController: DBStatusWindowController! private var workstationWindowController: WorkstationWindowController! private var ircWindowController: IRCWindowController! private var hotKeyManager: HotKeyManager! @@ -64,6 +65,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ), pfManager: nil) dlog("MainWindowController created") + // Database status window — positioned slightly down and left of main window + dbStatusWindowController = DBStatusWindowController(actions: .init( + onMinimizeToStatusBar: { [weak self] in self?.switchToStatusBarMode() } + )) + dlog("DBStatusWindowController created") + // Workstation window (VNC viewer) — shown to the right of Prole Status when not in status bar mode workstationWindowController = WorkstationWindowController() dlog("WorkstationWindowController created") @@ -152,6 +159,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func switchToStatusBarMode() { mode = .statusBar mainWindowController.hide() + dbStatusWindowController.hide() workstationWindowController.hide() ircWindowController.hide() overlayWindowController.showOverlayNow() @@ -167,6 +175,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { mode = .appWindow overlayWindowController.hideOverlay() mainWindowController.show() + // Position database status window slightly down and to the left of the main window + if let ref = mainWindowController.window { dbStatusWindowController.position(relativeTo: ref) } + dbStatusWindowController.show() // Position workstation window to the right of the main window and show it if let ref = mainWindowController.window { workstationWindowController.position(rightOf: ref) } workstationWindowController.show() @@ -194,14 +205,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func toggleAppWindowVisibility() { if mainWindowController.isVisible { mainWindowController.hide() + dbStatusWindowController.hide() workstationWindowController.hide() ircWindowController.hide() if mode == .appWindow { mode = .statusBar } } else { mainWindowController.show() - if let ref = mainWindowController.window { workstationWindowController.position(rightOf: ref) } + if let ref = mainWindowController.window { + dbStatusWindowController.position(relativeTo: ref) + workstationWindowController.position(rightOf: ref) + ircWindowController.position(below: ref) + } + dbStatusWindowController.show() workstationWindowController.show() - if let ref = mainWindowController.window { ircWindowController.position(below: ref) } ircWindowController.show() mode = .appWindow overlayWindowController.hideOverlay() @@ -297,6 +313,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Invoked by overlay maximize button @objc private func showMainWindowRequested() { + dlog("AppDelegate: showMainWindowRequested notification received") switchToAppWindowMode() // Maximize (zoom) the window to ensure it's fully visible/readable mainWindowController.window?.zoom(nil) diff --git a/prole-tools-app/Sources/Debug.swift b/prole-tools-app/Sources/Debug.swift index 23e670a..dae9b6c 100644 --- a/prole-tools-app/Sources/Debug.swift +++ b/prole-tools-app/Sources/Debug.swift @@ -1,12 +1,7 @@ import Foundation enum Debug { - static let isEnabled: Bool = { - if let v = ProcessInfo.processInfo.environment["PROLESTATUS_DEBUG"] { - return v == "1" || v.lowercased() == "true" || v.lowercased() == "yes" - } - return false - }() + static let isEnabled: Bool = true static func log(_ message: @autoclosure () -> String) { guard isEnabled else { return } diff --git a/prole-tools-app/Sources/OverlayWindow.swift b/prole-tools-app/Sources/OverlayWindow.swift index 0a05836..4160be7 100644 --- a/prole-tools-app/Sources/OverlayWindow.swift +++ b/prole-tools-app/Sources/OverlayWindow.swift @@ -9,8 +9,8 @@ final class OverlayWindowController: NSWindowController { let window = OverlayWindow(contentRect: .zero, styleMask: style, backing: .buffered, defer: false) super.init(window: window) window.isReleasedWhenClosed = false - window.level = .floating // below status bar level, above normal windows - window.collectionBehavior = [.canJoinAllSpaces, .stationary] + window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()) + 1) + window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] window.hasShadow = false window.backgroundColor = .clear window.isOpaque = false @@ -53,8 +53,7 @@ final class OverlayWindowController: NSWindowController { let rect = NSRect(x: frame.minX, y: y, width: frame.width, height: overlayHeight) window?.setFrame(rect, display: true) window?.orderFrontRegardless() - // Allow mouse for our inline controls (refresh/maximize). The view will pass-through elsewhere. - window?.ignoresMouseEvents = false + window?.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()) + 1) dlog("Overlay window forced visible at top of visible frame") } @@ -86,19 +85,12 @@ final class OverlayWindowController: NSWindowController { dlog("Computed overlay frame: x=\(rect.origin.x), y=\(rect.origin.y), w=\(rect.size.width), h=\(rect.size.height) | menuBarHeight=\(menuBarHeight), statusBarThickness=\(statusThickness), overlayHeight=\(overlayHeight)") window?.setFrame(rect, display: true) window?.orderFrontRegardless() - // Enable mouse so the inline buttons work; StatusView forwards other clicks through. - window?.ignoresMouseEvents = false + window?.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()) + 1) dlog("Overlay window ordered front") } func showContextMenu() { guard let window = window, window.isVisible else { return } - // Temporarily enable mouse interactions so the context menu can be used - let wasIgnoring = window.ignoresMouseEvents - if wasIgnoring { - dlog("Enabling mouse events for context menu interaction") - window.ignoresMouseEvents = false - } let menu = NSMenu(title: "Prole Tools") menu.addItem(withTitle: statusView.aggregateStatusSummary(), action: nil, keyEquivalent: "") menu.addItem(.separator()) @@ -109,11 +101,6 @@ final class OverlayWindowController: NSWindowController { let point = NSPoint(x: window.frame.midX, y: window.frame.minY) menu.popUp(positioning: nil, at: point, in: nil) - // Restore click-through after the menu is dismissed - if wasIgnoring { - dlog("Restoring click-through after context menu") - window.ignoresMouseEvents = true - } } @objc private func refreshNow() { @@ -131,17 +118,49 @@ final class OverlayWindowController: NSWindowController { } final class OverlayWindow: NSWindow { - override var canBecomeKey: Bool { false } - override var canBecomeMain: Bool { false } + override var canBecomeKey: Bool { return false } + override var canBecomeMain: Bool { return false } + + // Allow interaction with the window even if it's not key + override var acceptsMouseMovedEvents: Bool { + get { return true } + set { } + } + + override func mouseDown(with event: NSEvent) { + dlog("OverlayWindow: mouseDown at \(event.locationInWindow)") + super.mouseDown(with: event) + } + + override func sendEvent(_ event: NSEvent) { + if event.type == .leftMouseDown { + dlog("OverlayWindow: sendEvent .leftMouseDown at \(event.locationInWindow)") + // Fallback: Manually check if the click is in the maximize button area + if let contentView = self.contentView, + let statusView = contentView.subviews.first(where: { $0 is StatusView }) as? StatusView { + let pointInStatusView = statusView.convert(event.locationInWindow, from: nil) + if let hitView = statusView.hitTest(pointInStatusView), hitView.toolTip == "Show Main Window" { + dlog("OverlayWindow: Manual hit detected for maximize button via sendEvent") + NotificationCenter.default.post(name: NSNotification.Name("ProleStatus.showMainWindow"), object: nil) + return // Intercepted + } + } + } + super.sendEvent(event) + } override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) { super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag) isOpaque = false backgroundColor = .clear - // Default to fully click-through. We temporarily disable this only while showing the context menu. - ignoresMouseEvents = true + // Default to NOT click-through so we can intercept clicks on the maximize button. + // TransparentContainerView.hitTest handles passing through clicks for non-button areas. + ignoresMouseEvents = false acceptsMouseMovedEvents = true - dlog("OverlayWindow initialized (borderless, transparent, accepts mouse moved events)") + isMovableByWindowBackground = false + // Ensure the window level is high and it doesn't ignore mouse events + level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()) + 1) + dlog("OverlayWindow initialized (borderless, transparent, level: \(level.rawValue))") } } @@ -149,13 +168,8 @@ final class OverlayWindow: NSWindow { // while still allowing mouse-moved events for subviews that add tracking areas. final class TransparentContainerView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { - // Allow interaction only with interactive subviews (e.g., buttons inside StatusView). - // Otherwise, pass clicks through. - if let hit = super.hitTest(point) { - if hit is NSButton { return hit } - // If a subview wants events but isn't a button, also allow it - if hit.acceptsFirstResponder { return hit } - } - return nil + let view = super.hitTest(point) + dlog("TransparentContainerView: hitTest at \(point) -> \(view?.description ?? "nil")") + return view } } diff --git a/prole-tools-app/Sources/PFScriptBridge.swift b/prole-tools-app/Sources/PFScriptBridge.swift index b3cc80f..c8523e0 100644 --- a/prole-tools-app/Sources/PFScriptBridge.swift +++ b/prole-tools-app/Sources/PFScriptBridge.swift @@ -15,6 +15,39 @@ enum PFScriptBridge { static func status() -> (code: Int32, out: String, err: String) { runScript(arg: "status") } + static func dbStatus() -> (code: Int32, out: String, err: String) { + runRawCommand(command: "kubecolor cnpg status prole-db", description: "kubecolor cnpg status prole-db") + } + + private static func runRawCommand(command: String, description: String) -> (code: Int32, out: String, err: String) { + lastInvocation = description + let p = Process() + p.executableURL = URL(fileURLWithPath: "/bin/bash") + p.arguments = ["-lc", command] + // Use home as default CWD + let env = ProcessInfo.processInfo.environment + let home = env["HOME"] ?? NSHomeDirectory() + let proleHomeEnv = env["PROLE_HOME"] + p.currentDirectoryURL = URL(fileURLWithPath: proleHomeEnv ?? home) + + Logger.shared.info("invoking raw: \(description)") + + let outPipe = Pipe(); let errPipe = Pipe() + p.standardOutput = outPipe + p.standardError = errPipe + do { try p.run() } catch { + let errStr = String(describing: error) + Logger.shared.error("spawn error: \(errStr)") + return (-1, "", errStr) + } + p.waitUntilExit() + let out = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let err = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let code = p.terminationStatus + + return (code, out, err) + } + private static func runScript(arg: String) -> (code: Int32, out: String, err: String) { let fm = FileManager.default let env = ProcessInfo.processInfo.environment diff --git a/prole-tools-app/Sources/StatusView.swift b/prole-tools-app/Sources/StatusView.swift index af08154..c177d6b 100644 --- a/prole-tools-app/Sources/StatusView.swift +++ b/prole-tools-app/Sources/StatusView.swift @@ -8,7 +8,7 @@ final class StatusView: NSView { let tf = NSTextField(labelWithString: "0000-00-00 00:00:00 +00:00") tf.textColor = .secondaryLabelColor tf.alignment = .left - tf.font = NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular) + tf.font = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular) tf.setContentHuggingPriority(.required, for: .horizontal) tf.setContentCompressionResistancePriority(.required, for: .horizontal) return tf @@ -17,7 +17,10 @@ final class StatusView: NSView { private let maximizeButton: NSButton = { let b = NSButton(title: "□", target: nil, action: nil) b.bezelStyle = .texturedRounded + b.setButtonType(.momentaryPushIn) b.toolTip = "Show Main Window" + b.isEnabled = true + b.refusesFirstResponder = true // Don't take focus away from other apps b.setContentHuggingPriority(.required, for: .horizontal) b.setContentCompressionResistancePriority(.required, for: .horizontal) return b @@ -68,18 +71,26 @@ final class StatusView: NSView { // Configure marquee width to be ~64 monospace characters configureMarqueeWidth() + + // Ensure button responds to mouse down immediately for better responsiveness in overlays + maximizeButton.sendAction(on: [.leftMouseDown, .leftMouseUp]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func hitTest(_ point: NSPoint) -> NSView? { - // Allow interaction with our inline controls and status lights (for tooltips), - // pass other clicks through to avoid blocking the menu bar. - if let v = super.hitTest(point) { - if v is NSButton { return v } - if v is TrafficLight { return v } - if let tl = v.toolTip, !tl.isEmpty { return v } + // Allow interaction ONLY with the maximize button. + // Other subviews (like traffic lights) are no longer returned here to ensure they are click-through. + // Only the small area of the box click should be active. + + // Convert point to maximizeButton's coordinate system + let pointInButton = convert(point, to: maximizeButton) + let hit = maximizeButton.bounds.contains(pointInButton) + dlog("StatusView: hitTest at \(point) (inButton: \(pointInButton)) -> \(hit ? "maximizeButton" : "nil") | buttonFrame=\(maximizeButton.frame) window=\(String(describing: window))") + if hit { + return maximizeButton } + return nil } @@ -144,6 +155,7 @@ final class StatusView: NSView { // MARK: - Button actions @objc private func didTapMaximize() { + dlog("StatusView: didTapMaximize button clicked") // Explicitly request the main window to show (don’t toggle modes implicitly) NotificationCenter.default.post(name: .showMainWindow, object: nil) } @@ -172,7 +184,7 @@ final class MarqueeView: NSView { // Configure labels for lbl in [label1, label2] { - lbl.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular) + lbl.font = NSFont.monospacedSystemFont(ofSize: 10, weight: .regular) lbl.textColor = .secondaryLabelColor lbl.alignment = .left lbl.backgroundColor = .clear @@ -244,7 +256,7 @@ final class MarqueeView: NSView { } private func intrinsicLineHeight() -> CGFloat { - let f = label1.font ?? NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular) + let f = label1.font ?? NSFont.monospacedSystemFont(ofSize: 10, weight: .regular) return ceil(f.ascender - f.descender) } @@ -295,7 +307,7 @@ private func widthForMonospaceCharacters(_ count: Int, font: NSFont) -> CGFloat private extension StatusView { func configureMarqueeWidth() { // Match font with timestamp for visual cohesion - let font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular) + let font = NSFont.monospacedSystemFont(ofSize: 10, weight: .regular) marquee.heightAnchor.constraint(equalToConstant: ceil(font.capHeight * 1.8)).isActive = true let width = widthForMonospaceCharacters(64, font: font) let wConstraint = marquee.widthAnchor.constraint(equalToConstant: width)