From fbb81c678f851b8db677a5f67a8f5c9b1f671d04 Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 14 Aug 2026 22:03:24 -0300 Subject: [PATCH 01/14] test: cover no-op workspace telemetry clears --- ...erminalControllerSocketSecurityTests.swift | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/programaTests/TerminalControllerSocketSecurityTests.swift b/programaTests/TerminalControllerSocketSecurityTests.swift index 93f9130c..76327a83 100644 --- a/programaTests/TerminalControllerSocketSecurityTests.swift +++ b/programaTests/TerminalControllerSocketSecurityTests.swift @@ -1,5 +1,6 @@ import XCTest import AppKit +import Combine import Darwin #if canImport(Programa_DEV) @@ -27,6 +28,50 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { super.tearDown() } + private func drainMainQueue() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + continuation.resume() + } + } + } + + func testClearingEmptyWorkspaceTelemetryDoesNotRepublishWorkspace() async { + let tabManager = TabManager() + let workspace = tabManager.addWorkspace(select: true, eagerLoadTerminal: false) + let socketPath = makeSocketPath("empty-telemetry") + + TerminalController.shared.start( + tabManager: tabManager, + socketPath: socketPath, + accessMode: .allowAll + ) + + var publishCount = 0 + let cancellable = workspace.objectWillChange.sink { _ in + publishCount += 1 + } + defer { cancellable.cancel() } + + _ = TerminalController.shared.v2WorkspaceClearStatus(params: [ + "workspace_id": workspace.id.uuidString, + "key": "missing", + ]) + _ = TerminalController.shared.v2WorkspaceClearLog(params: [ + "workspace_id": workspace.id.uuidString, + ]) + _ = TerminalController.shared.v2WorkspaceClearProgress(params: [ + "workspace_id": workspace.id.uuidString, + ]) + await drainMainQueue() + + XCTAssertEqual( + publishCount, + 0, + "Clearing telemetry that is already absent should not invalidate workspace observers" + ) + } + /// Regression for #6618: `shouldPublishShellActivity` used to record the state /// it was queried with (write-on-read). When a report arrived before the panel /// existed, that premature write suppressed every later identical report, so the From 50aa8dcfc7a5a44f18295b519d3a4b07b8cd3bb1 Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 14 Aug 2026 22:27:05 -0300 Subject: [PATCH 02/14] test: cover unused UI publication paths --- programaTests/BrowserConfigTests.swift | 18 ++++++++++++++++++ ...TerminalControllerSocketSecurityTests.swift | 1 + 2 files changed, 19 insertions(+) diff --git a/programaTests/BrowserConfigTests.swift b/programaTests/BrowserConfigTests.swift index c2a57097..cfeefcd8 100644 --- a/programaTests/BrowserConfigTests.swift +++ b/programaTests/BrowserConfigTests.swift @@ -1275,6 +1275,24 @@ final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { @MainActor final class BrowserDeveloperToolsConfigurationTests: XCTestCase { + func testLifecycleOnlyProgressDoesNotRepublishBrowserChrome() { + let panel = BrowserPanel(workspaceId: UUID()) + var publishCount = 0 + let cancellable = panel.objectWillChange.sink { + publishCount += 1 + } + defer { cancellable.cancel() } + + panel.estimatedProgress = 0.5 + + XCTAssertEqual( + publishCount, + 0, + "WebKit progress that the browser chrome does not render should not invalidate its SwiftUI observers" + ) + XCTAssertEqual(panel.estimatedProgress, 0.5) + } + func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() { let panel = BrowserPanel(workspaceId: UUID()) let developerExtras = panel.webView.configuration.preferences.value(forKey: "developerExtrasEnabled") as? Bool diff --git a/programaTests/TerminalControllerSocketSecurityTests.swift b/programaTests/TerminalControllerSocketSecurityTests.swift index 76327a83..1a253655 100644 --- a/programaTests/TerminalControllerSocketSecurityTests.swift +++ b/programaTests/TerminalControllerSocketSecurityTests.swift @@ -46,6 +46,7 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { socketPath: socketPath, accessMode: .allowAll ) + await drainMainQueue() var publishCount = 0 let cancellable = workspace.objectWillChange.sink { _ in From 5849e67b2c64c17c4d0f7fcc595dc7bf7b0d5a1e Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 14 Aug 2026 22:32:46 -0300 Subject: [PATCH 03/14] test: prove telemetry clears reach workspace --- ...erminalControllerSocketSecurityTests.swift | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/programaTests/TerminalControllerSocketSecurityTests.swift b/programaTests/TerminalControllerSocketSecurityTests.swift index 1a253655..49782787 100644 --- a/programaTests/TerminalControllerSocketSecurityTests.swift +++ b/programaTests/TerminalControllerSocketSecurityTests.swift @@ -48,12 +48,41 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { ) await drainMainQueue() + workspace.statusEntries["present"] = SidebarStatusEntry(key: "present", value: "running") + workspace.logEntries = [ + SidebarLogEntry(message: "running", level: .progress, source: nil, timestamp: Date()), + ] + workspace.progress = SidebarProgressState(value: 0.5, label: "running") + var publishCount = 0 let cancellable = workspace.objectWillChange.sink { _ in publishCount += 1 } defer { cancellable.cancel() } + _ = TerminalController.shared.v2WorkspaceClearStatus(params: [ + "workspace_id": workspace.id.uuidString, + "key": "present", + ]) + _ = TerminalController.shared.v2WorkspaceClearLog(params: [ + "workspace_id": workspace.id.uuidString, + ]) + _ = TerminalController.shared.v2WorkspaceClearProgress(params: [ + "workspace_id": workspace.id.uuidString, + ]) + await drainMainQueue() + + XCTAssertNil(workspace.statusEntries["present"]) + XCTAssertTrue(workspace.logEntries.isEmpty) + XCTAssertNil(workspace.progress) + XCTAssertGreaterThanOrEqual( + publishCount, + 3, + "Populated clear commands should reach the workspace and publish their removals" + ) + + publishCount = 0 + _ = TerminalController.shared.v2WorkspaceClearStatus(params: [ "workspace_id": workspace.id.uuidString, "key": "missing", From 382398a208a27563555342a6e7aaa88c84ac78a2 Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 14 Aug 2026 22:38:02 -0300 Subject: [PATCH 04/14] perf: avoid redundant UI publications --- Sources/GhosttyApp.swift | 8 ++++++-- Sources/Panels/BrowserPanel.swift | 6 ++++-- Sources/TerminalController+Telemetry.swift | 6 +++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Sources/GhosttyApp.swift b/Sources/GhosttyApp.swift index 20422203..4d44db78 100644 --- a/Sources/GhosttyApp.swift +++ b/Sources/GhosttyApp.swift @@ -1963,8 +1963,12 @@ class GhosttyApp { .first(where: { $0.id == tabId }) else { return } switch state { case GHOSTTY_PROGRESS_STATE_REMOVE: - workspace.progress = nil - workspace.progressSourcePanelId = nil + if workspace.progress != nil { + workspace.progress = nil + } + if workspace.progressSourcePanelId != nil { + workspace.progressSourcePanelId = nil + } case GHOSTTY_PROGRESS_STATE_SET where rawProgress >= 0: let clamped = max(0, min(100, Int(rawProgress))) let value = Double(clamped) / 100.0 diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 9748ed93..d500534d 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -549,8 +549,10 @@ final class BrowserPanel: Panel, ObservableObject { var restoredForwardHistoryStack: [URL] = [] var restoredHistoryCurrentURL: URL? - /// Published estimated progress (0.0 - 1.0) - @Published var estimatedProgress: Double = 0.0 + /// Lifecycle-only estimated progress (0.0 - 1.0). + /// The browser chrome does not render this value, so WebKit progress ticks must not + /// invalidate every SwiftUI observer of the panel. + var estimatedProgress: Double = 0.0 /// Increment to request a UI-only flash highlight (e.g. from a keyboard shortcut). @Published private(set) var focusFlashToken: Int = 0 diff --git a/Sources/TerminalController+Telemetry.swift b/Sources/TerminalController+Telemetry.swift index 24c142f3..6c841178 100644 --- a/Sources/TerminalController+Telemetry.swift +++ b/Sources/TerminalController+Telemetry.swift @@ -640,7 +640,9 @@ extension TerminalController { v2ScheduleTelemetryMutation(workspaceId: workspaceId) { [weak self] _, tab in guard let self else { return } - _ = tab.statusEntries.removeValue(forKey: key) + if tab.statusEntries[key] != nil { + tab.statusEntries.removeValue(forKey: key) + } if tab.agentPIDs.removeValue(forKey: key) != nil { self.refreshTrackedAgentPorts(for: tab) } @@ -722,6 +724,7 @@ extension TerminalController { } v2ScheduleTelemetryMutation(workspaceId: workspaceId) { _, tab in + guard !tab.logEntries.isEmpty else { return } tab.logEntries.removeAll() } @@ -796,6 +799,7 @@ extension TerminalController { } v2ScheduleTelemetryMutation(workspaceId: workspaceId) { _, tab in + guard tab.progress != nil else { return } tab.progress = nil } From d822e3904732af514d33c4fa4e3e642328615f71 Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 14 Aug 2026 22:38:16 -0300 Subject: [PATCH 05/14] refactor: tighten native interaction ownership --- Sources/CommandPaletteController.swift | 22 +- Sources/ContentView+SidebarResizer.swift | 397 ++++++++---------- Sources/ContentView.swift | 39 +- Sources/VerticalTabsSidebar.swift | 13 - Sources/WindowOverlayControllers.swift | 85 +++- .../AppDelegateShortcutRoutingTests.swift | 67 +++ 6 files changed, 348 insertions(+), 275 deletions(-) diff --git a/Sources/CommandPaletteController.swift b/Sources/CommandPaletteController.swift index f03aaf7a..4a34042e 100644 --- a/Sources/CommandPaletteController.swift +++ b/Sources/CommandPaletteController.swift @@ -4,11 +4,12 @@ // ContentView and is exclusively used by the command palette (query, mode, // search corpus/results, rename/workspace-description drafts, focus-restore // targets, usage history, etc.). ContentView holds a single -// `@StateObject private var commandPaletteController` and exposes each +// `@State private var commandPaletteController` and exposes each // property back to its existing (unqualified) call sites in its body via // thin computed proxies — this keeps the ~4000 lines of palette orchestration // code that reads/writes these properties unchanged while genuinely moving -// storage ownership onto the controller (no more @State duplicated per-view). +// storage ownership onto the controller without subscribing the entire window +// shell to palette-only updates. // // The two @FocusState properties (isCommandPaletteSearchFocused, // isCommandPaletteRenameFocused) stay on ContentView: @FocusState is a @@ -69,3 +70,20 @@ final class CommandPaletteController: ObservableObject { var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces @Published var commandPaletteShouldFocusWorkspaceDescriptionEditor = false } + +/// The existing AppKit window overlay hosts this root directly. It is the only +/// SwiftUI owner that observes palette-only query, result, and selection state. +struct CommandPaletteRootView: View { + @ObservedObject var controller: CommandPaletteController + let content: () -> AnyView + + var body: some View { + Group { + if controller.isCommandPalettePresented { + content() + } else { + EmptyView() + } + } + } +} diff --git a/Sources/ContentView+SidebarResizer.swift b/Sources/ContentView+SidebarResizer.swift index f46d89c6..4868be69 100644 --- a/Sources/ContentView+SidebarResizer.swift +++ b/Sources/ContentView+SidebarResizer.swift @@ -1,31 +1,157 @@ -// Sidebar resizer member group extracted from ContentView.swift (nuclear-review CV1 / issue #94). -// The backing @State stays on `ContentView` (SwiftUI requires stored properties on the primary -// declaration); those properties, plus `SidebarResizerHandle`, `updateSidebarResizerBandState`, -// `installSidebarResizerPointerMonitorIfNeeded`, `removeSidebarResizerPointerMonitor`, and -// `sidebarResizerOverlay`, were widened from `private` to internal so this extension and -// ContentView.swift's view body can both see them. See the PR description for the exact list. - import AppKit import SwiftUI -extension ContentView { - private static let fixedSidebarResizeCursor = NSCursor( +/// Owns the complete pointer lifecycle for the sidebar divider. AppKit keeps the +/// drag capture after the pointer crosses a portal-hosted terminal or browser, +/// so the SwiftUI root no longer needs a window-wide event monitor or cursor timer. +private final class NativeSidebarDividerView: NSView { + private static let resizeCursor = NSCursor( image: NSCursor.resizeLeftRight.image, hotSpot: NSCursor.resizeLeftRight.hotSpot ) - private static let minimumSidebarWidth: CGFloat = CGFloat(SessionPersistencePolicy.minimumSidebarWidth) - private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0 - enum SidebarResizerHandle: Hashable { - case divider + var currentWidth: CGFloat = 0 + var onResizeBegan: () -> Void = {} + var onWidthChanged: (CGFloat) -> Void = { _ in } + var onResizeEnded: () -> Void = {} + + private var trackingArea: NSTrackingArea? + private var windowResignObserver: NSObjectProtocol? + private var dragStartWidth: CGFloat = 0 + private var dragStartWindowX: CGFloat = 0 + private var isDragging = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setAccessibilityElement(true) + setAccessibilityRole(.splitter) + setAccessibilityIdentifier("SidebarResizer") } - private var sidebarResizerSidebarHitWidth: CGFloat { - SidebarResizeInteraction.sidebarSideHitWidth + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + if let windowResignObserver { + NotificationCenter.default.removeObserver(windowResignObserver) + } + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func resetCursorRects() { + super.resetCursorRects() + addCursorRect(bounds, cursor: Self.resizeCursor) + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea { + removeTrackingArea(trackingArea) + } + let nextTrackingArea = NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .cursorUpdate, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(nextTrackingArea) + trackingArea = nextTrackingArea + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let windowResignObserver { + NotificationCenter.default.removeObserver(windowResignObserver) + self.windowResignObserver = nil + } + guard let window else { + cancelActiveResize() + return + } + windowResignObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + self?.cancelActiveResize() + } + } + + override func cursorUpdate(with event: NSEvent) { + Self.resizeCursor.set() } - private var sidebarResizerContentHitWidth: CGFloat { - SidebarResizeInteraction.contentSideHitWidth + override func mouseEntered(with event: NSEvent) { + Self.resizeCursor.set() + } + + override func mouseDown(with event: NSEvent) { + guard !isDragging else { return } + isDragging = true + dragStartWidth = currentWidth + dragStartWindowX = event.locationInWindow.x + Self.resizeCursor.set() + onResizeBegan() + } + + override func mouseDragged(with event: NSEvent) { + guard isDragging else { return } + Self.resizeCursor.set() + onWidthChanged(dragStartWidth + event.locationInWindow.x - dragStartWindowX) + } + + override func mouseUp(with event: NSEvent) { + finishResize() + } + + func cancelActiveResize() { + finishResize() + } + + private func finishResize() { + guard isDragging else { return } + isDragging = false + onResizeEnded() + } +} + +private struct NativeSidebarDividerRepresentable: NSViewRepresentable { + let currentWidth: CGFloat + let onResizeBegan: () -> Void + let onWidthChanged: (CGFloat) -> Void + let onResizeEnded: () -> Void + + func makeNSView(context: Context) -> NativeSidebarDividerView { + NativeSidebarDividerView(frame: .zero) + } + + func updateNSView(_ nsView: NativeSidebarDividerView, context: Context) { + nsView.currentWidth = currentWidth + nsView.onResizeBegan = onResizeBegan + nsView.onWidthChanged = onWidthChanged + nsView.onResizeEnded = onResizeEnded + nsView.window?.invalidateCursorRects(for: nsView) + } + + static func dismantleNSView(_ nsView: NativeSidebarDividerView, coordinator: Void) { + nsView.cancelActiveResize() + nsView.onResizeBegan = {} + nsView.onWidthChanged = { _ in } + nsView.onResizeEnded = {} + } +} + +extension ContentView { + private static let minimumSidebarWidth: CGFloat = CGFloat(SessionPersistencePolicy.minimumSidebarWidth) + private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0 + + private var sidebarResizerSidebarHitWidth: CGFloat { + SidebarResizeInteraction.sidebarSideHitWidth } private func maxSidebarWidth(availableWidth: CGFloat? = nil) -> CGFloat { @@ -68,216 +194,6 @@ extension ContentView { Self.clampedSidebarWidth(candidate, maximumWidth: maxSidebarWidth()) } - private func activateSidebarResizerCursor() { - sidebarResizerCursorReleaseWorkItem?.cancel() - sidebarResizerCursorReleaseWorkItem = nil - isSidebarResizerCursorActive = true - Self.fixedSidebarResizeCursor.set() - } - - private func releaseSidebarResizerCursorIfNeeded(force: Bool = false) { - let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left) - let shouldKeepCursor = !force - && (isResizerDragging || isResizerBandActive || !hoveredResizerHandles.isEmpty || isLeftMouseButtonDown) - guard !shouldKeepCursor else { return } - guard isSidebarResizerCursorActive else { return } - isSidebarResizerCursorActive = false - NSCursor.arrow.set() - } - - private func scheduleSidebarResizerCursorRelease(force: Bool = false, delay: TimeInterval = 0) { - sidebarResizerCursorReleaseWorkItem?.cancel() - let workItem = DispatchWorkItem { - sidebarResizerCursorReleaseWorkItem = nil - releaseSidebarResizerCursorIfNeeded(force: force) - } - sidebarResizerCursorReleaseWorkItem = workItem - if delay > 0 { - DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) - } else { - DispatchQueue.main.async(execute: workItem) - } - } - - private func dividerBandContains(pointInContent point: NSPoint, contentBounds: NSRect) -> Bool { - guard point.y >= contentBounds.minY, point.y <= contentBounds.maxY else { return false } - let minX = sidebarWidth - sidebarResizerSidebarHitWidth - let maxX = sidebarWidth + sidebarResizerContentHitWidth - return point.x >= minX && point.x <= maxX - } - - func updateSidebarResizerBandState(using event: NSEvent? = nil) { - guard sidebarState.isVisible, - let window = observedWindow, - let contentView = window.contentView else { - if isResizerBandActive { isResizerBandActive = false } - scheduleSidebarResizerCursorRelease(force: true) - return - } - - // Use live global pointer location instead of per-event coordinates. - // Overlapping tracking areas (notably WKWebView) can deliver stale/jittery - // event locations during cursor updates, which causes visible cursor flicker. - let pointInWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation) - let pointInContent = contentView.convert(pointInWindow, from: nil) - let isInDividerBand = dividerBandContains(pointInContent: pointInContent, contentBounds: contentView.bounds) - if isResizerBandActive != isInDividerBand { isResizerBandActive = isInDividerBand } - - if isInDividerBand || isResizerDragging { - activateSidebarResizerCursor() - startSidebarResizerCursorStabilizer() - // AppKit cursorUpdate handlers from overlapped portal/web views can run - // after our local monitor callback and temporarily reset the cursor. - // Re-assert on the next runloop turn to keep the resize cursor stable. - DispatchQueue.main.async { - Self.fixedSidebarResizeCursor.set() - } - } else { - stopSidebarResizerCursorStabilizer() - scheduleSidebarResizerCursorRelease() - } - } - - private func startSidebarResizerCursorStabilizer() { - guard sidebarResizerCursorStabilizer == nil else { return } - let timer = DispatchSource.makeTimerSource(queue: .main) - timer.schedule(deadline: .now(), repeating: .milliseconds(16), leeway: .milliseconds(2)) - timer.setEventHandler { - updateSidebarResizerBandState() - if isResizerBandActive || isResizerDragging { - Self.fixedSidebarResizeCursor.set() - } else { - stopSidebarResizerCursorStabilizer() - } - } - sidebarResizerCursorStabilizer = timer - timer.resume() - } - - private func stopSidebarResizerCursorStabilizer() { - sidebarResizerCursorStabilizer?.cancel() - sidebarResizerCursorStabilizer = nil - } - - func installSidebarResizerPointerMonitorIfNeeded() { - guard sidebarResizerPointerMonitor == nil else { return } - observedWindow?.acceptsMouseMovedEvents = true - sidebarResizerPointerMonitor = NSEvent.addLocalMonitorForEvents( - matching: [ - .mouseMoved, - .mouseEntered, - .mouseExited, - .cursorUpdate, - .appKitDefined, - .systemDefined, - .leftMouseDown, - .leftMouseUp, - .leftMouseDragged, - ] - ) { event in - updateSidebarResizerBandState(using: event) - let shouldOverrideCursorEvent: Bool = { - switch event.type { - case .cursorUpdate, .mouseMoved, .mouseEntered, .mouseExited, .appKitDefined, .systemDefined: - return true - default: - return false - } - }() - if shouldOverrideCursorEvent, (isResizerBandActive || isResizerDragging) { - // Consume hover motion in divider band so overlapped views cannot - // continuously reassert their own cursor while we are resizing. - activateSidebarResizerCursor() - Self.fixedSidebarResizeCursor.set() - return nil - } - return event - } - updateSidebarResizerBandState() - } - - func removeSidebarResizerPointerMonitor() { - if let monitor = sidebarResizerPointerMonitor { - NSEvent.removeMonitor(monitor) - sidebarResizerPointerMonitor = nil - } - if isResizerBandActive { isResizerBandActive = false } - isSidebarResizerCursorActive = false - stopSidebarResizerCursorStabilizer() - scheduleSidebarResizerCursorRelease(force: true) - } - - private func sidebarResizerHandleOverlay( - _ handle: SidebarResizerHandle, - width: CGFloat, - availableWidth: CGFloat, - accessibilityIdentifier: String? = nil - ) -> some View { - Color.clear - .frame(width: width) - .frame(maxHeight: .infinity) - .contentShape(Rectangle()) - .onHover { hovering in - if hovering { - hoveredResizerHandles.insert(handle) - activateSidebarResizerCursor() - } else { - hoveredResizerHandles.remove(handle) - let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left) - if isLeftMouseButtonDown { - // Keep resize cursor pinned through mouse-down so AppKit - // cursorUpdate events from overlapping views do not flash arrow. - activateSidebarResizerCursor() - } else { - // Give mouse-down + drag-start callbacks time to establish state - // before any cursor pop is attempted. - scheduleSidebarResizerCursorRelease(delay: 0.05) - } - } - updateSidebarResizerBandState() - } - .onDisappear { - hoveredResizerHandles.remove(handle) - if isResizerDragging { - TerminalWindowPortalRegistry.endInteractiveGeometryResize() - isResizerDragging = false - } - sidebarDragStartWidth = nil - if isResizerBandActive { isResizerBandActive = false } - scheduleSidebarResizerCursorRelease(force: true) - } - .gesture( - DragGesture(minimumDistance: 0, coordinateSpace: .global) - .onChanged { value in - if !isResizerDragging { - TerminalWindowPortalRegistry.beginInteractiveGeometryResize() - isResizerDragging = true - sidebarDragStartWidth = sidebarWidth - } - - activateSidebarResizerCursor() - let startWidth = sidebarDragStartWidth ?? sidebarWidth - let nextWidth = Self.clampedSidebarWidth( - startWidth + value.translation.width, - maximumWidth: maxSidebarWidth(availableWidth: availableWidth) - ) - withTransaction(Transaction(animation: nil)) { - sidebarWidth = nextWidth - } - } - .onEnded { _ in - if isResizerDragging { - TerminalWindowPortalRegistry.endInteractiveGeometryResize() - isResizerDragging = false - sidebarDragStartWidth = nil - } - activateSidebarResizerCursor() - scheduleSidebarResizerCursorRelease() - } - ) - .modifier(SidebarResizerAccessibilityModifier(accessibilityIdentifier: accessibilityIdentifier)) - } - var sidebarResizerOverlay: some View { GeometryReader { proxy in let totalWidth = max(0, proxy.size.width) @@ -289,12 +205,29 @@ extension ContentView { .frame(width: leadingWidth) .allowsHitTesting(false) - sidebarResizerHandleOverlay( - .divider, - width: SidebarResizeInteraction.totalHitWidth, - availableWidth: totalWidth, - accessibilityIdentifier: "SidebarResizer" + NativeSidebarDividerRepresentable( + currentWidth: sidebarWidth, + onResizeBegan: { + isSidebarResizerDragging = true + TerminalWindowPortalRegistry.beginInteractiveGeometryResize() + }, + onWidthChanged: { candidate in + let nextWidth = Self.clampedSidebarWidth( + candidate, + maximumWidth: maxSidebarWidth(availableWidth: totalWidth) + ) + guard abs(nextWidth - sidebarWidth) > 0.5 else { return } + withTransaction(Transaction(animation: nil)) { + sidebarWidth = nextWidth + } + }, + onResizeEnded: { + isSidebarResizerDragging = false + TerminalWindowPortalRegistry.endInteractiveGeometryResize() + } ) + .frame(width: SidebarResizeInteraction.totalHitWidth) + .frame(maxHeight: .infinity) Color.clear .frame(maxWidth: .infinity) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9eb55111..a2862803 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -17,9 +17,7 @@ struct ContentView: View { @EnvironmentObject var programaConfigStore: ProgramaConfigStore @ObservedObject private var programaLayoutStore = ProgramaLayoutStore.shared @State var sidebarWidth: CGFloat = 200 - @State var hoveredResizerHandles: Set = [] - @State var isResizerDragging = false - @State var sidebarDragStartWidth: CGFloat? + @State var isSidebarResizerDragging = false @State private var selectedTabIds: Set = [] @State private var mountedWorkspaceIds: [UUID] = [] @State private var lastSidebarSelectionIndex: Int? = nil @@ -35,12 +33,10 @@ struct ContentView: View { @State private var titlebarThemeGeneration: UInt64 = 0 @State private var sidebarDraggedTabId: UUID? @State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) - @State var sidebarResizerCursorReleaseWorkItem: DispatchWorkItem? - @State var sidebarResizerPointerMonitor: Any? - @State var isResizerBandActive = false - @State var isSidebarResizerCursorActive = false - @State var sidebarResizerCursorStabilizer: DispatchSourceTimer? - @StateObject private var commandPaletteController = CommandPaletteController() + // The dedicated CommandPaletteRootView observes this reference inside the + // AppKit overlay. Keeping only its identity in State prevents palette query + // and selection publishes from invalidating the whole window shell. + @State private var commandPaletteController = CommandPaletteController() private var isCommandPalettePresented: Bool { get { commandPaletteController.isCommandPalettePresented } nonmutating set { commandPaletteController.isCommandPalettePresented = newValue } @@ -1011,7 +1007,6 @@ struct ContentView: View { tabManager.applyWindowBackgroundForSelectedTab() reconcileMountedWorkspaceIds() previousSelectedWorkspaceId = tabManager.selectedTabId - installSidebarResizerPointerMonitorIfNeeded() let restoredWidth = normalizedSidebarWidth(sidebarState.persistedWidth) if abs(sidebarWidth - restoredWidth) > 0.5 { sidebarWidth = restoredWidth @@ -1396,7 +1391,14 @@ struct ContentView: View { let tmuxOverlayController = tmuxWorkspacePaneWindowOverlayController(for: window) tmuxOverlayController.update(state: tmuxWorkspacePaneWindowOverlayState(for: window)) let overlayController = commandPaletteWindowOverlayController(for: window) - overlayController.update(rootView: AnyView(commandPaletteOverlay), isVisible: isCommandPalettePresented) + let paletteRoot = CommandPaletteRootView( + controller: commandPaletteController, + content: { AnyView(commandPaletteOverlay) } + ) + overlayController.update( + rootView: AnyView(paletteRoot), + controller: commandPaletteController + ) } }) } @@ -1434,7 +1436,6 @@ struct ContentView: View { guard let window = notification.object as? NSWindow, window === observedWindow else { return } clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width) - updateSidebarResizerBandState() } } @@ -1457,7 +1458,6 @@ struct ContentView: View { } else { TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() } - updateSidebarResizerBandState() } .onChange(of: sidebarState.isVisible) { if let observedWindow { @@ -1465,7 +1465,6 @@ struct ContentView: View { } else { TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() } - updateSidebarResizerBandState() syncTrafficLightInset() } .onChange(of: sidebarMatchTerminalBackground) { @@ -1486,7 +1485,7 @@ struct ContentView: View { sidebarState.persistedWidth = sanitized return } - guard !isResizerDragging else { return } + guard !isSidebarResizerDragging else { return } if abs(sidebarWidth - sanitized) > 0.5 { sidebarWidth = sanitized } @@ -1497,14 +1496,6 @@ struct ContentView: View { private func attachFinalLifecycleHandlers(to view: some View) -> some View { view .ignoresSafeArea() - .onDisappear { - if isResizerDragging { - TerminalWindowPortalRegistry.endInteractiveGeometryResize() - isResizerDragging = false - sidebarDragStartWidth = nil - } - removeSidebarResizerPointerMonitor() - } } @ViewBuilder @@ -1534,8 +1525,6 @@ struct ContentView: View { isFullScreen = window.styleMask.contains(.fullScreen) clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width) syncCommandPaletteDebugStateForObservedWindow() - installSidebarResizerPointerMonitorIfNeeded() - updateSidebarResizerBandState() } } diff --git a/Sources/VerticalTabsSidebar.swift b/Sources/VerticalTabsSidebar.swift index fad40e40..992d143f 100644 --- a/Sources/VerticalTabsSidebar.swift +++ b/Sources/VerticalTabsSidebar.swift @@ -42,19 +42,6 @@ enum SidebarResizeInteraction { } } -struct SidebarResizerAccessibilityModifier: ViewModifier { - let accessibilityIdentifier: String? - - @ViewBuilder - func body(content: Content) -> some View { - if let accessibilityIdentifier { - content.accessibilityIdentifier(accessibilityIdentifier) - } else { - content - } - } -} - struct SidebarTabItemSettingsSnapshot: Equatable { let sidebarShortcutHintXOffset: Double let sidebarShortcutHintYOffset: Double diff --git a/Sources/WindowOverlayControllers.swift b/Sources/WindowOverlayControllers.swift index f5ce2766..38f6c5b5 100644 --- a/Sources/WindowOverlayControllers.swift +++ b/Sources/WindowOverlayControllers.swift @@ -1,5 +1,6 @@ import AppKit import Bonsplit +import Combine import ObjectiveC import SwiftUI import WebKit @@ -115,6 +116,10 @@ final class WindowCommandPaletteOverlayController: NSObject { private var isPaletteVisible = false private var windowDidBecomeKeyObserver: NSObjectProtocol? private var windowDidResignKeyObserver: NSObjectProtocol? + private var windowWillCloseObserver: NSObjectProtocol? + private var paletteVisibilityCancellable: AnyCancellable? + private var observedPaletteControllerID: ObjectIdentifier? + private var isTornDown = false init(window: NSWindow) { self.window = window @@ -138,10 +143,12 @@ final class WindowCommandPaletteOverlayController: NSObject { ]) _ = ensureInstalled() installWindowKeyObservers() + installWindowCloseObserver() } @discardableResult private func ensureInstalled() -> Bool { + guard !isTornDown else { return false } guard let window, let contentView = window.contentView, let themeFrame = contentView.superview else { return false } @@ -335,6 +342,7 @@ final class WindowCommandPaletteOverlayController: NSObject { } private func focusIntoPalette(retries: Int) { + guard !isTornDown else { return } guard let window else { return } #if DEBUG dlog( @@ -426,7 +434,56 @@ final class WindowCommandPaletteOverlayController: NSObject { } } + private func installWindowCloseObserver() { + guard let window else { return } + windowWillCloseObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.tearDown() + } + } + } + + private func tearDown() { + guard !isTornDown else { return } + isTornDown = true + + paletteVisibilityCancellable?.cancel() + paletteVisibilityCancellable = nil + observedPaletteControllerID = nil + stopFocusLockTimer() + isPaletteVisible = false + + if let window, isPaletteResponder(window.firstResponder) { + _ = window.makeFirstResponder(nil) + } + hostingView.rootView = AnyView(EmptyView()) + containerView.capturesMouseEvents = false + containerView.alphaValue = 0 + containerView.isHidden = true + NSLayoutConstraint.deactivate(installConstraints) + installConstraints.removeAll() + containerView.removeFromSuperview() + installedThemeFrame = nil + + for observer in [windowDidBecomeKeyObserver, windowDidResignKeyObserver, windowWillCloseObserver] { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } + windowDidBecomeKeyObserver = nil + windowDidResignKeyObserver = nil + windowWillCloseObserver = nil + } + private func updateFocusLockForWindowState() { + guard !isTornDown else { + stopFocusLockTimer() + return + } guard let window else { stopFocusLockTimer() return @@ -468,6 +525,7 @@ final class WindowCommandPaletteOverlayController: NSObject { } private func startFocusLockTimer() { + guard !isTornDown else { return } guard focusLockTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: .main) timer.schedule(deadline: .now(), repeating: .milliseconds(80), leeway: .milliseconds(12)) @@ -511,7 +569,30 @@ final class WindowCommandPaletteOverlayController: NSObject { editor.setSelectedRange(NSRange(location: length, length: 0)) } - func update(rootView: AnyView, isVisible: Bool) { + func update(rootView: AnyView, controller: CommandPaletteController) { + guard !isTornDown else { return } + guard ensureInstalled() else { return } + hostingView.rootView = rootView + + let controllerID = ObjectIdentifier(controller) + if observedPaletteControllerID != controllerID { + paletteVisibilityCancellable?.cancel() + observedPaletteControllerID = controllerID + paletteVisibilityCancellable = controller.$isCommandPalettePresented + .removeDuplicates() + .sink { [weak self] isVisible in + Task { @MainActor [weak self] in + self?.setVisible(isVisible) + } + } + } + setVisible(controller.isCommandPalettePresented) + } + + /// The AppKit owner observes only presentation state. Query, result, and + /// selection publishes remain scoped to `CommandPaletteRootView`. + private func setVisible(_ isVisible: Bool) { + guard !isTornDown else { return } guard ensureInstalled() else { return } let shouldPromote = CommandPaletteOverlayPromotionPolicy.shouldPromote( previouslyVisible: isPaletteVisible, @@ -530,7 +611,6 @@ final class WindowCommandPaletteOverlayController: NSObject { #endif isPaletteVisible = isVisible if isVisible { - hostingView.rootView = rootView containerView.capturesMouseEvents = true containerView.isHidden = false containerView.alphaValue = 1 @@ -543,7 +623,6 @@ final class WindowCommandPaletteOverlayController: NSObject { if let window, isPaletteResponder(window.firstResponder) { _ = window.makeFirstResponder(nil) } - hostingView.rootView = AnyView(EmptyView()) containerView.capturesMouseEvents = false containerView.alphaValue = 0 containerView.isHidden = true diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index 1f9436b5..fedb02f8 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -1,4 +1,5 @@ import XCTest +import SwiftUI #if canImport(Programa_DEV) @testable import Programa_DEV @@ -11,6 +12,14 @@ private final class FakeWKInspectorContainerView: NSView {} private final class FocusableTestView: NSView { override var acceptsFirstResponder: Bool { true } } +private final class CommandPaletteOverlayLifetimeProbe {} +private struct CommandPaletteOverlayProbeView: View { + let probe: CommandPaletteOverlayLifetimeProbe + + var body: some View { + EmptyView() + } +} @MainActor final class AppDelegateShortcutRoutingTests: XCTestCase { @@ -3208,6 +3217,64 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(observedDelta, 1) } + func testCommandPaletteOverlayReleasesHostedRootAndStopsAfterWindowClose() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.contentView = NSView(frame: window.contentLayoutRect) + + let overlayController = commandPaletteWindowOverlayController(for: window) + let paletteController = CommandPaletteController() + paletteController.isCommandPalettePresented = true + + weak var weakProbe: CommandPaletteOverlayLifetimeProbe? + do { + let probe = CommandPaletteOverlayLifetimeProbe() + weakProbe = probe + overlayController.update( + rootView: AnyView(CommandPaletteOverlayProbeView(probe: probe)), + controller: paletteController + ) + } + + guard let overlayContainer = findRealCommandPaletteOverlayContainer(in: window) else { + XCTFail("Expected the command palette overlay to be installed") + return + } + XCTAssertNotNil(weakProbe) + XCTAssertNotNil(overlayContainer.superview) + + let paletteTextField = NSTextField(frame: NSRect(x: 12, y: 12, width: 180, height: 24)) + overlayContainer.addSubview(paletteTextField) + XCTAssertTrue(window.makeFirstResponder(paletteTextField)) + XCTAssertTrue( + window.firstResponder === paletteTextField || + ((window.firstResponder as? NSTextView)?.delegate as? NSTextField) === paletteTextField + ) + + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + XCTAssertTrue( + waitUntil(description: "command palette hosted root release") { + weakProbe == nil + } + ) + XCTAssertNil(overlayContainer.superview) + XCTAssertFalse( + window.firstResponder === paletteTextField || + ((window.firstResponder as? NSTextView)?.delegate as? NSTextField) === paletteTextField + ) + + // A late controller publish and a duplicate close must both be harmless. + paletteController.isCommandPalettePresented = false + paletteController.isCommandPalettePresented = true + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertNil(overlayContainer.superview) + } + func testControlKDoesNotRoutePaletteMoveSelectionWhenSearchFieldIsFocused() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") From b5aed430ec967e65ed741a2c4ea22a5b6b687e69 Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 14 Aug 2026 22:38:40 -0300 Subject: [PATCH 06/14] refactor: streamline native search and review rendering --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + Sources/Find/BrowserSearchOverlay.swift | 201 ++---------------- Sources/Find/SearchTextFieldHost.swift | 235 ++++++++++++++++++++ Sources/Find/SurfaceSearchOverlay.swift | 218 +------------------ Sources/GhosttySurfaceScrollView.swift | 2 +- Sources/Panels/ReviewPanel.swift | 2 + Sources/Panels/ReviewPanelView.swift | 260 ++++++++++++++++++----- programaTests/ReviewPanelViewTests.swift | 246 +++++++++++++++++++++ 8 files changed, 721 insertions(+), 451 deletions(-) create mode 100644 Sources/Find/SearchTextFieldHost.swift create mode 100644 programaTests/ReviewPanelViewTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index e8360afd..d160ee3f 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -211,6 +211,7 @@ NRTM00000006 /* NotificationSoundStaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRTM00000005 /* NotificationSoundStaging.swift */; }; A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; }; A5008371 /* BrowserSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008370 /* BrowserSearchOverlay.swift */; }; + AK110002 /* SearchTextFieldHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = AK110001 /* SearchTextFieldHost.swift */; }; A5008373 /* BrowserFindJavaScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008372 /* BrowserFindJavaScript.swift */; }; A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; }; A50012F3 /* KeyboardShortcutSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F2 /* KeyboardShortcutSettings.swift */; }; @@ -321,6 +322,7 @@ FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */; }; A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; }; A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; }; + RPVW0002 /* ReviewPanelViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RPVW0001 /* ReviewPanelViewTests.swift */; }; DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; A5001623 /* programa.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* programa.sdef */; }; @@ -634,6 +636,7 @@ NRTM00000005 /* NotificationSoundStaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSoundStaging.swift; sourceTree = ""; }; A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = ""; }; A5008370 /* BrowserSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserSearchOverlay.swift; sourceTree = ""; }; + AK110001 /* SearchTextFieldHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SearchTextFieldHost.swift; sourceTree = ""; }; A5008372 /* BrowserFindJavaScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserFindJavaScript.swift; sourceTree = ""; }; A50012F0 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; A50012F2 /* KeyboardShortcutSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettings.swift; sourceTree = ""; }; @@ -738,6 +741,7 @@ FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = ""; }; A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; + RPVW0001 /* ReviewPanelViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPanelViewTests.swift; sourceTree = ""; }; DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; A5001622 /* programa.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = programa.sdef; sourceTree = ""; }; @@ -1054,6 +1058,7 @@ NRTM00000005 /* NotificationSoundStaging.swift */, A5001301 /* SurfaceSearchOverlay.swift */, A5008370 /* BrowserSearchOverlay.swift */, + AK110001 /* SearchTextFieldHost.swift */, A5008372 /* BrowserFindJavaScript.swift */, A5001410 /* Panel.swift */, A5001411 /* TerminalPanel.swift */, @@ -1265,6 +1270,7 @@ FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */, A5008380 /* BrowserFindJavaScriptTests.swift */, A5008382 /* CommandPaletteSearchEngineTests.swift */, + RPVW0001 /* ReviewPanelViewTests.swift */, 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */, 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */, @@ -1614,6 +1620,7 @@ NRTM00000006 /* NotificationSoundStaging.swift in Sources */, A5001303 /* SurfaceSearchOverlay.swift in Sources */, A5008371 /* BrowserSearchOverlay.swift in Sources */, + AK110002 /* SearchTextFieldHost.swift in Sources */, A5008373 /* BrowserFindJavaScript.swift in Sources */, A5001400 /* Panel.swift in Sources */, A5001401 /* TerminalPanel.swift in Sources */, @@ -1743,6 +1750,7 @@ FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, + RPVW0002 /* ReviewPanelViewTests.swift in Sources */, E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */, 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */, 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */, diff --git a/Sources/Find/BrowserSearchOverlay.swift b/Sources/Find/BrowserSearchOverlay.swift index 59aaa715..5b31e7f6 100644 --- a/Sources/Find/BrowserSearchOverlay.swift +++ b/Sources/Find/BrowserSearchOverlay.swift @@ -30,14 +30,22 @@ struct BrowserSearchOverlay: View { private var searchControls: some View { HStack(spacing: 4) { - BrowserSearchTextFieldRepresentable( + SearchTextFieldHost( text: $searchState.needle, isFocused: $isSearchFieldFocused, - panelId: panelId, - focusRequestGeneration: focusRequestGeneration, - canApplyFocusRequest: canApplyFocusRequest, + accessibilityIdentifier: "BrowserFindSearchTextField", + focusNotificationName: .browserSearchFocus, + shouldApplyFocusNotification: { notification in + guard let notifiedPanelId = notification.object as? UUID else { return false } + return notifiedPanelId == panelId + }, + canApplyFocusRequest: { + canApplyFocusRequest(focusRequestGeneration) + }, + focusSelection: .caretAtEnd, + debugContext: nil, onFieldDidFocus: onFieldDidFocus, - onEscape: onClose, + onEscape: { _ in onClose() }, onReturn: { isShift in if isShift { onPrevious() @@ -209,186 +217,3 @@ struct BrowserSearchOverlay: View { return point.y < midY ? .topRight : .bottomRight } } - -private final class BrowserSearchNativeTextField: NSTextField { - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - isBordered = false - isBezeled = false - drawsBackground = false - focusRingType = .none - usesSingleLineMode = true - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable { - @Binding var text: String - @Binding var isFocused: Bool - let panelId: UUID - let focusRequestGeneration: UInt64 - let canApplyFocusRequest: (UInt64) -> Bool - let onFieldDidFocus: () -> Void - let onEscape: () -> Void - let onReturn: (_ isShift: Bool) -> Void - - final class Coordinator: NSObject, NSTextFieldDelegate { - var parent: BrowserSearchTextFieldRepresentable - var isProgrammaticMutation = false - weak var parentField: BrowserSearchNativeTextField? - var pendingFocusRequest: Bool? - var searchFocusObserver: NSObjectProtocol? - - init(parent: BrowserSearchTextFieldRepresentable) { - self.parent = parent - } - - deinit { - if let searchFocusObserver { - NotificationCenter.default.removeObserver(searchFocusObserver) - } - } - - func focusField(_ field: BrowserSearchNativeTextField, in window: NSWindow) { - guard window.makeFirstResponder(field) else { return } - DispatchQueue.main.async { [weak field] in - guard let field, - let editor = field.currentEditor() as? NSTextView else { return } - let end = field.stringValue.utf16.count - editor.setSelectedRange(NSRange(location: end, length: 0)) - } - } - - func controlTextDidChange(_ obj: Notification) { - guard !isProgrammaticMutation else { return } - guard let field = obj.object as? NSTextField else { return } - parent.text = field.stringValue - } - - func controlTextDidBeginEditing(_ obj: Notification) { - parent.onFieldDidFocus() - if !parent.isFocused { - DispatchQueue.main.async { - self.parent.isFocused = true - } - } - } - - func controlTextDidEndEditing(_ obj: Notification) { - if parent.isFocused { - DispatchQueue.main.async { - self.parent.isFocused = false - } - } - } - - func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - switch commandSelector { - case #selector(NSResponder.cancelOperation(_:)): - if textView.hasMarkedText() { return false } - parent.onEscape() - return true - case #selector(NSResponder.insertNewline(_:)): - if textView.hasMarkedText() { return false } - let isShift = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false - parent.onReturn(isShift) - return true - default: - return false - } - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - func makeNSView(context: Context) -> BrowserSearchNativeTextField { - let field = BrowserSearchNativeTextField(frame: .zero) - field.font = .systemFont(ofSize: NSFont.systemFontSize) - field.placeholderString = String(localized: "search.placeholder", defaultValue: "Search") - field.setAccessibilityIdentifier("BrowserFindSearchTextField") - field.delegate = context.coordinator - field.target = nil - field.action = nil - field.isEditable = true - field.isSelectable = true - field.isEnabled = true - field.stringValue = text - context.coordinator.parentField = field - context.coordinator.searchFocusObserver = NotificationCenter.default.addObserver( - forName: .browserSearchFocus, - object: nil, - queue: .main - ) { [weak field, weak coordinator = context.coordinator] notification in - guard let field, let coordinator else { return } - guard let notifiedPanelId = notification.object as? UUID, - notifiedPanelId == coordinator.parent.panelId else { return } - guard coordinator.parent.canApplyFocusRequest(coordinator.parent.focusRequestGeneration) else { return } - guard let window = field.window else { return } - let fr = window.firstResponder - let alreadyFocused = fr === field || - field.currentEditor() != nil || - ((fr as? NSTextView)?.delegate as? NSTextField) === field - guard !alreadyFocused else { return } - coordinator.focusField(field, in: window) - } - return field - } - - func updateNSView(_ nsView: BrowserSearchNativeTextField, context: Context) { - context.coordinator.parent = self - context.coordinator.parentField = nsView - - if let editor = nsView.currentEditor() as? NSTextView { - if editor.string != text, !editor.hasMarkedText() { - context.coordinator.isProgrammaticMutation = true - editor.string = text - nsView.stringValue = text - context.coordinator.isProgrammaticMutation = false - } - } else if nsView.stringValue != text { - nsView.stringValue = text - } - - if let window = nsView.window { - let fr = window.firstResponder - let isFirstResponder = - fr === nsView || - nsView.currentEditor() != nil || - ((fr as? NSTextView)?.delegate as? NSTextField) === nsView - - if isFocused, - canApplyFocusRequest(focusRequestGeneration), - !isFirstResponder, - context.coordinator.pendingFocusRequest != true { - context.coordinator.pendingFocusRequest = true - DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in - coordinator?.pendingFocusRequest = nil - guard let coordinator, - coordinator.parent.isFocused, - coordinator.parent.canApplyFocusRequest(coordinator.parent.focusRequestGeneration) else { return } - guard let nsView, let window = nsView.window else { return } - let fr = window.firstResponder - let alreadyFocused = fr === nsView || - nsView.currentEditor() != nil || - ((fr as? NSTextView)?.delegate as? NSTextField) === nsView - guard !alreadyFocused else { return } - coordinator.focusField(nsView, in: window) - } - } - } - } - - static func dismantleNSView(_ nsView: BrowserSearchNativeTextField, coordinator: Coordinator) { - if let observer = coordinator.searchFocusObserver { - NotificationCenter.default.removeObserver(observer) - coordinator.searchFocusObserver = nil - } - nsView.delegate = nil - coordinator.parentField = nil - } -} diff --git a/Sources/Find/SearchTextFieldHost.swift b/Sources/Find/SearchTextFieldHost.swift new file mode 100644 index 00000000..762106fc --- /dev/null +++ b/Sources/Find/SearchTextFieldHost.swift @@ -0,0 +1,235 @@ +import AppKit +import Bonsplit +import SwiftUI + +enum SearchTextFieldFocusSelection: Equatable { + case preserve + case caretAtEnd +} + +/// Shared AppKit owner for terminal and browser find fields. +/// SwiftUI owns the surrounding controls while this host owns responder and IME behavior. +struct SearchTextFieldHost: NSViewRepresentable { + @Binding var text: String + @Binding var isFocused: Bool + let accessibilityIdentifier: String + let focusNotificationName: Notification.Name + let shouldApplyFocusNotification: (Notification) -> Bool + let canApplyFocusRequest: () -> Bool + let focusSelection: SearchTextFieldFocusSelection + let debugContext: String? + let onFieldDidFocus: () -> Void + let onEscape: (NSTextField) -> Void + let onReturn: (_ isShift: Bool) -> Void + + final class NativeTextField: NSTextField { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + isBordered = false + isBezeled = false + drawsBackground = false + focusRingType = .none + usesSingleLineMode = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } + + final class Coordinator: NSObject, NSTextFieldDelegate { + var parent: SearchTextFieldHost + var isProgrammaticMutation = false + var isFocusRequestPending = false + weak var parentField: NativeTextField? + private var focusObserver: NSObjectProtocol? + private var observedFocusNotificationName: Notification.Name? + + init(parent: SearchTextFieldHost) { + self.parent = parent + } + + deinit { + removeFocusObserver() + } + + func installFocusObserver(for field: NativeTextField) { + guard observedFocusNotificationName != parent.focusNotificationName else { return } + removeFocusObserver() + observedFocusNotificationName = parent.focusNotificationName + focusObserver = NotificationCenter.default.addObserver( + forName: parent.focusNotificationName, + object: nil, + queue: .main + ) { [weak field, weak self] notification in + guard let self, let field else { return } + guard self.parent.shouldApplyFocusNotification(notification) else { return } + guard self.parent.canApplyFocusRequest() else { return } + guard let window = field.window else { return } + let firstResponder = window.firstResponder + let alreadyFocused = self.isFirstResponder(field, in: window) +#if DEBUG + if let debugContext = self.parent.debugContext { + dlog( + "find.nativeField.searchFocusNotification \(debugContext) " + + "alreadyFocused=\(alreadyFocused) firstResponder=\(String(describing: firstResponder))" + ) + } +#endif + guard !alreadyFocused else { return } + let result = self.focus(field, in: window) +#if DEBUG + if let debugContext = self.parent.debugContext { + dlog( + "find.nativeField.searchFocusApply \(debugContext) " + + "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" + ) + } +#endif + } + } + + func removeFocusObserver() { + if let focusObserver { + NotificationCenter.default.removeObserver(focusObserver) + self.focusObserver = nil + } + observedFocusNotificationName = nil + } + + func synchronizeText(in field: NativeTextField) { + if let editor = field.currentEditor() as? NSTextView { + guard editor.string != parent.text, !editor.hasMarkedText() else { return } + isProgrammaticMutation = true + defer { isProgrammaticMutation = false } + editor.string = parent.text + field.stringValue = parent.text + } else if field.stringValue != parent.text { + isProgrammaticMutation = true + defer { isProgrammaticMutation = false } + field.stringValue = parent.text + } + } + + func requestFocusIfNeeded(for field: NativeTextField) { + guard let window = field.window else { return } + guard parent.isFocused, + parent.canApplyFocusRequest(), + !isFirstResponder(field, in: window), + !isFocusRequestPending else { return } + + isFocusRequestPending = true + DispatchQueue.main.async { [weak field, weak self] in + guard let self else { return } + self.isFocusRequestPending = false + guard self.parent.isFocused, self.parent.canApplyFocusRequest() else { return } + guard let field, let window = field.window else { return } + guard !self.isFirstResponder(field, in: window) else { return } + _ = self.focus(field, in: window) + } + } + + @discardableResult + private func focus(_ field: NativeTextField, in window: NSWindow) -> Bool { + guard window.makeFirstResponder(field) else { return false } + guard parent.focusSelection == .caretAtEnd else { return true } + DispatchQueue.main.async { [weak field] in + guard let field, + let editor = field.currentEditor() as? NSTextView else { return } + let end = field.stringValue.utf16.count + editor.setSelectedRange(NSRange(location: end, length: 0)) + } + return true + } + + private func isFirstResponder(_ field: NativeTextField, in window: NSWindow) -> Bool { + let firstResponder = window.firstResponder + return firstResponder === field || + field.currentEditor() != nil || + ((firstResponder as? NSTextView)?.delegate as? NSTextField) === field + } + + func controlTextDidChange(_ obj: Notification) { + guard !isProgrammaticMutation else { return } + guard let field = obj.object as? NSTextField else { return } + parent.text = field.stringValue + } + + func controlTextDidBeginEditing(_ obj: Notification) { +#if DEBUG + if let debugContext = parent.debugContext { + dlog("find.nativeField.beginEditing \(debugContext)") + } +#endif + parent.onFieldDidFocus() + guard !parent.isFocused else { return } + DispatchQueue.main.async { [weak self] in + self?.parent.isFocused = true + } + } + + func controlTextDidEndEditing(_ obj: Notification) { +#if DEBUG + if let debugContext = parent.debugContext { + dlog("find.nativeField.endEditing \(debugContext)") + } +#endif + guard parent.isFocused else { return } + DispatchQueue.main.async { [weak self] in + self?.parent.isFocused = false + } + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + switch commandSelector { + case #selector(NSResponder.cancelOperation(_:)): + guard !textView.hasMarkedText() else { return false } + guard let field = control as? NSTextField else { return false } + parent.onEscape(field) + return true + case #selector(NSResponder.insertNewline(_:)): + guard !textView.hasMarkedText() else { return false } + let isShift = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false + parent.onReturn(isShift) + return true + default: + return false + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> NativeTextField { + let field = NativeTextField(frame: .zero) + field.font = .systemFont(ofSize: NSFont.systemFontSize) + field.placeholderString = String(localized: "search.placeholder", defaultValue: "Search") + field.setAccessibilityIdentifier(accessibilityIdentifier) + field.delegate = context.coordinator + field.target = nil + field.action = nil + field.isEditable = true + field.isSelectable = true + field.isEnabled = true + field.stringValue = text + context.coordinator.parentField = field + context.coordinator.installFocusObserver(for: field) + return field + } + + func updateNSView(_ nsView: NativeTextField, context: Context) { + context.coordinator.parent = self + context.coordinator.parentField = nsView + context.coordinator.installFocusObserver(for: nsView) + context.coordinator.synchronizeText(in: nsView) + context.coordinator.requestFocusIfNeeded(for: nsView) + } + + static func dismantleNSView(_ nsView: NativeTextField, coordinator: Coordinator) { + coordinator.removeFocusObserver() + nsView.delegate = nil + coordinator.parentField = nil + } +} diff --git a/Sources/Find/SurfaceSearchOverlay.swift b/Sources/Find/SurfaceSearchOverlay.swift index 49b65eaf..3604fced 100644 --- a/Sources/Find/SurfaceSearchOverlay.swift +++ b/Sources/Find/SurfaceSearchOverlay.swift @@ -42,13 +42,21 @@ struct SurfaceSearchOverlay: View { private var searchControls: some View { HStack(spacing: 4) { - SearchTextFieldRepresentable( + SearchTextFieldHost( text: $searchState.needle, isFocused: $isSearchFieldFocused, - surfaceId: surfaceId, + accessibilityIdentifier: "TerminalFindSearchTextField", + focusNotificationName: .ghosttySearchFocus, + shouldApplyFocusNotification: { notification in + guard let surface = notification.object as? TerminalSurface else { return false } + return surface.id == surfaceId + }, canApplyFocusRequest: canApplyFocusRequest, + focusSelection: .preserve, + debugContext: "surface=\(surfaceId.uuidString.prefix(5))", onFieldDidFocus: onFieldDidFocus, - onEscape: { + onEscape: { field in + field.programaAncestor(of: GhosttySurfaceScrollView.self)?.beginFindEscapeSuppression() #if DEBUG dlog("find.nativeField.escape surface=\(surfaceId.uuidString.prefix(5)) needleEmpty=\(searchState.needle.isEmpty)") #endif @@ -230,210 +238,6 @@ struct SurfaceSearchOverlay: View { } } -// MARK: - Native Search Text Field (AppKit) - -/// NSTextField subclass for the terminal find bar. -/// Strips visual chrome so SwiftUI handles the background/border appearance. -private final class SearchNativeTextField: NSTextField { - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - isBordered = false - isBezeled = false - drawsBackground = false - focusRingType = .none - usesSingleLineMode = true - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -/// NSViewRepresentable wrapping SearchNativeTextField. -/// Handles Escape and Return at the AppKit delegate level, eliminating the -/// SwiftUI @FocusState / AppKit first-responder mismatch that broke focus -/// after window switching. -private struct SearchTextFieldRepresentable: NSViewRepresentable { - @Binding var text: String - @Binding var isFocused: Bool - let surfaceId: UUID - let canApplyFocusRequest: () -> Bool - let onFieldDidFocus: () -> Void - let onEscape: () -> Void - let onReturn: (_ isShift: Bool) -> Void - - final class Coordinator: NSObject, NSTextFieldDelegate { - var parent: SearchTextFieldRepresentable - var isProgrammaticMutation = false - weak var parentField: SearchNativeTextField? - var pendingFocusRequest: Bool? - var searchFocusObserver: NSObjectProtocol? - - init(parent: SearchTextFieldRepresentable) { - self.parent = parent - } - - deinit { - if let searchFocusObserver { - NotificationCenter.default.removeObserver(searchFocusObserver) - } - } - - func controlTextDidChange(_ obj: Notification) { - guard !isProgrammaticMutation else { return } - guard let field = obj.object as? NSTextField else { return } - parent.text = field.stringValue - } - - func controlTextDidBeginEditing(_ obj: Notification) { - #if DEBUG - dlog("find.nativeField.beginEditing surface=\(parent.surfaceId.uuidString.prefix(5))") - #endif - parent.onFieldDidFocus() - if !parent.isFocused { - DispatchQueue.main.async { - self.parent.isFocused = true - } - } - } - - func controlTextDidEndEditing(_ obj: Notification) { - #if DEBUG - dlog("find.nativeField.endEditing surface=\(parent.surfaceId.uuidString.prefix(5))") - #endif - if parent.isFocused { - DispatchQueue.main.async { - self.parent.isFocused = false - } - } - } - - func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - switch commandSelector { - case #selector(NSResponder.cancelOperation(_:)): - // Don't intercept Escape during CJK IME composition (issue #118) - if textView.hasMarkedText() { return false } - control.programaAncestor(of: GhosttySurfaceScrollView.self)?.beginFindEscapeSuppression() - parent.onEscape() - return true - case #selector(NSResponder.insertNewline(_:)): - if textView.hasMarkedText() { return false } - let isShift = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false - parent.onReturn(isShift) - return true - default: - return false - } - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - func makeNSView(context: Context) -> SearchNativeTextField { - let field = SearchNativeTextField(frame: .zero) - field.font = .systemFont(ofSize: NSFont.systemFontSize) - field.placeholderString = String(localized: "search.placeholder", defaultValue: "Search") - field.setAccessibilityIdentifier("TerminalFindSearchTextField") - field.delegate = context.coordinator - field.stringValue = text - context.coordinator.parentField = field - - // Observe .ghosttySearchFocus to immediately focus from AppKit level. - // This is the primary mechanism for restoring focus after window switches. - context.coordinator.searchFocusObserver = NotificationCenter.default.addObserver( - forName: .ghosttySearchFocus, - object: nil, - queue: .main - ) { [weak field, weak coordinator = context.coordinator] notification in - guard let field, let coordinator else { return } - guard let surface = notification.object as? TerminalSurface, - surface.id == coordinator.parent.surfaceId else { return } - guard coordinator.parent.canApplyFocusRequest() else { return } - guard let window = field.window else { return } - // Don't re-focus if already first responder. makeFirstResponder on an - // already-editing NSTextField ends the editing session and restarts it - // with all text selected, causing typed characters to replace each other. - let fr = window.firstResponder - let alreadyFocused = fr === field || - field.currentEditor() != nil || - ((fr as? NSTextView)?.delegate as? NSTextField) === field - #if DEBUG - dlog( - "find.nativeField.searchFocusNotification surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " + - "alreadyFocused=\(alreadyFocused) firstResponder=\(String(describing: fr))" - ) - #endif - guard !alreadyFocused else { return } - let result = window.makeFirstResponder(field) -#if DEBUG - dlog( - "find.nativeField.searchFocusApply surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " + - "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" - ) -#endif - } - - return field - } - - func updateNSView(_ nsView: SearchNativeTextField, context: Context) { - context.coordinator.parent = self - context.coordinator.parentField = nsView - - // Sync text from binding to field (skip during active IME composition) - if let editor = nsView.currentEditor() as? NSTextView { - if editor.string != text, !editor.hasMarkedText() { - context.coordinator.isProgrammaticMutation = true - editor.string = text - nsView.stringValue = text - context.coordinator.isProgrammaticMutation = false - } - } else if nsView.stringValue != text { - nsView.stringValue = text - } - - // Sync focus from binding to AppKit - if let window = nsView.window { - let fr = window.firstResponder - let isFirstResponder = - fr === nsView || - nsView.currentEditor() != nil || - ((fr as? NSTextView)?.delegate as? NSTextField) === nsView - - if isFocused, - canApplyFocusRequest(), - !isFirstResponder, - context.coordinator.pendingFocusRequest != true { - context.coordinator.pendingFocusRequest = true - DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in - coordinator?.pendingFocusRequest = nil - guard let coordinator, - coordinator.parent.isFocused, - coordinator.parent.canApplyFocusRequest() else { return } - guard let nsView, let window = nsView.window else { return } - let fr = window.firstResponder - let alreadyFocused = fr === nsView || - nsView.currentEditor() != nil || - ((fr as? NSTextView)?.delegate as? NSTextField) === nsView - guard !alreadyFocused else { return } - window.makeFirstResponder(nsView) - } - } - } - } - - static func dismantleNSView(_ nsView: SearchNativeTextField, coordinator: Coordinator) { - if let observer = coordinator.searchFocusObserver { - NotificationCenter.default.removeObserver(observer) - coordinator.searchFocusObserver = nil - } - nsView.delegate = nil - coordinator.parentField = nil - } -} - struct SearchButtonStyle: ButtonStyle { @State private var isHovered = false diff --git a/Sources/GhosttySurfaceScrollView.swift b/Sources/GhosttySurfaceScrollView.swift index 1e97ac0a..9efeb256 100644 --- a/Sources/GhosttySurfaceScrollView.swift +++ b/Sources/GhosttySurfaceScrollView.swift @@ -2378,7 +2378,7 @@ final class GhosttySurfaceScrollView: NSView { // Explicitly unfocus the terminal so cursor stops blinking immediately. // The notification observer also does this, but it runs async when posted from main. surfaceView.terminalSurface?.setFocus(false) - // Post notification — SearchTextFieldRepresentable's Coordinator + // Post notification — SearchTextFieldHost's Coordinator // observes it and calls makeFirstResponder on the native NSTextField. if let terminalSurface = surfaceView.terminalSurface { NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface) diff --git a/Sources/Panels/ReviewPanel.swift b/Sources/Panels/ReviewPanel.swift index 305def73..2903558b 100644 --- a/Sources/Panels/ReviewPanel.swift +++ b/Sources/Panels/ReviewPanel.swift @@ -26,6 +26,7 @@ final class ReviewPanel: Panel, ObservableObject { @Published private(set) var mode: ReviewDiffMode @Published private(set) var baseBranch: String @Published private(set) var files: [ReviewFileDiff] = [] + private(set) var filesRevision: UInt64 = 0 @Published private(set) var comments: [ReviewComment] = [] @Published private(set) var isRefreshing: Bool = false @Published private(set) var lastError: ReviewDiffError? @@ -118,6 +119,7 @@ final class ReviewPanel: Panel, ObservableObject { /// `TerminalController+Review.swift`). func apply(snapshot: ReviewDiffSnapshot) { files = snapshot.files + filesRevision &+= 1 lastError = snapshot.error lastRefreshedAt = snapshot.generatedAt isRefreshing = false diff --git a/Sources/Panels/ReviewPanelView.swift b/Sources/Panels/ReviewPanelView.swift index cf17237e..80ba6575 100644 --- a/Sources/Panels/ReviewPanelView.swift +++ b/Sources/Panels/ReviewPanelView.swift @@ -1,6 +1,156 @@ import AppKit import SwiftUI +struct ReviewPanelComposerTopology: Equatable { + let filePath: String + let anchorLine: Int + let startLine: Int + let endLine: Int + let text: String +} + +struct ReviewPanelFileKey: Hashable { + let path: String + let occurrence: Int +} + +struct ReviewPanelAnnotatedLine: Equatable { + let line: ReviewDiffLine + /// The new-file line number a comment attached at this row would address: the line's + /// own `newLineNumber` when present, otherwise the nearest preceding new-file line. + let anchorLine: Int +} + +enum ReviewPanelRowID: Hashable { + case fileHeader(ReviewPanelFileKey) + case notDiffable(ReviewPanelFileKey) + case hunkHeader(ReviewPanelFileKey, Int) + case line(ReviewPanelFileKey, Int, Int) + case composer(ReviewPanelFileKey, Int, Int) + case fileSpacing(ReviewPanelFileKey) +} + +enum ReviewPanelRow: Identifiable, Equatable { + case fileHeader(key: ReviewPanelFileKey, file: ReviewFileDiff) + case notDiffable(key: ReviewPanelFileKey, reason: ReviewNotDiffableReason) + case hunkHeader(key: ReviewPanelFileKey, hunkIndex: Int, header: String) + case line( + key: ReviewPanelFileKey, + hunkIndex: Int, + lineIndex: Int, + row: ReviewPanelAnnotatedLine, + filePath: String + ) + case composer(key: ReviewPanelFileKey, hunkIndex: Int, lineIndex: Int) + case fileSpacing(key: ReviewPanelFileKey) + + var id: ReviewPanelRowID { + switch self { + case .fileHeader(let key, _): + return .fileHeader(key) + case .notDiffable(let key, _): + return .notDiffable(key) + case .hunkHeader(let key, let hunkIndex, _): + return .hunkHeader(key, hunkIndex) + case .line(let key, let hunkIndex, let lineIndex, _, _): + return .line(key, hunkIndex, lineIndex) + case .composer(let key, let hunkIndex, let lineIndex): + return .composer(key, hunkIndex, lineIndex) + case .fileSpacing(let key): + return .fileSpacing(key) + } + } +} + +/// Computes the stable row topology consumed by the lazy review list. The revision is supplied +/// by `ReviewPanel.apply(snapshot:)`; composer fields that do not move the composer are excluded +/// from the cache key so editing text or extending a range upward does not rebuild every row. +final class ReviewPanelRowPlanner { + private struct ComposerPlacement: Equatable { + let filePath: String + let endLine: Int + } + + private struct CacheKey: Equatable { + let panelID: UUID + let filesRevision: UInt64 + let collapsedFilePaths: Set + let composerPlacement: ComposerPlacement? + } + + private var key: CacheKey? + private var cachedRows: [ReviewPanelRow] = [] + private(set) var rebuildCount = 0 + + func rows( + panelID: UUID, + filesRevision: UInt64, + collapsedFilePaths: Set, + composer: ReviewPanelComposerTopology?, + files: [ReviewFileDiff] + ) -> [ReviewPanelRow] { + let nextKey = CacheKey( + panelID: panelID, + filesRevision: filesRevision, + collapsedFilePaths: collapsedFilePaths, + composerPlacement: composer.map { + ComposerPlacement(filePath: $0.filePath, endLine: $0.endLine) + } + ) + guard key != nextKey else { return cachedRows } + + var rows: [ReviewPanelRow] = [] + var fileOccurrences: [String: Int] = [:] + + for file in files { + let occurrence = fileOccurrences[file.id, default: 0] + fileOccurrences[file.id] = occurrence + 1 + let fileKey = ReviewPanelFileKey(path: file.id, occurrence: occurrence) + rows.append(.fileHeader(key: fileKey, file: file)) + + if let reason = file.notDiffableReason { + rows.append(.notDiffable(key: fileKey, reason: reason)) + } else if !collapsedFilePaths.contains(file.id) { + for (hunkIndex, hunk) in file.hunks.enumerated() { + rows.append(.hunkHeader(key: fileKey, hunkIndex: hunkIndex, header: hunk.header)) + for (lineIndex, row) in annotatedRows(for: hunk).enumerated() { + rows.append( + .line( + key: fileKey, + hunkIndex: hunkIndex, + lineIndex: lineIndex, + row: row, + filePath: file.id + ) + ) + if nextKey.composerPlacement?.filePath == file.id, + nextKey.composerPlacement?.endLine == row.anchorLine { + rows.append(.composer(key: fileKey, hunkIndex: hunkIndex, lineIndex: lineIndex)) + } + } + } + } + + rows.append(.fileSpacing(key: fileKey)) + } + + key = nextKey + cachedRows = rows + rebuildCount += 1 + return rows + } + + private func annotatedRows(for hunk: ReviewHunk) -> [ReviewPanelAnnotatedLine] { + var anchor = hunk.lines.first(where: { $0.newLineNumber != nil })?.newLineNumber ?? 0 + return hunk.lines.map { line in + if let newLineNumber = line.newLineNumber { + anchor = newLineNumber + } + return ReviewPanelAnnotatedLine(line: line, anchorLine: anchor) + } + } +} + /// SwiftUI view for a `ReviewPanel`: per-file collapsible diff sections, click-a-line (or /// shift-click a range) to attach a comment, and a "Send to agent" action. Modeled on /// `MarkdownPanelView.swift`'s structure (focus-flash overlay, read-only content). No syntax @@ -15,6 +165,7 @@ struct ReviewPanelView: View { @State private var collapsedFilePaths: Set = [] @State private var composer: InlineComposerState? + @State private var rowPlanner = ReviewPanelRowPlanner() private struct InlineComposerState { let filePath: String @@ -31,7 +182,7 @@ struct ReviewPanelView: View { .padding(.vertical, 10) Divider() ScrollView { - VStack(alignment: .leading, spacing: 0) { + LazyVStack(alignment: .leading, spacing: 0) { if let lastError = panel.lastError { errorBanner(for: lastError) .padding(16) @@ -39,8 +190,8 @@ struct ReviewPanelView: View { emptyStateView .padding(24) } else { - ForEach(panel.files) { file in - fileSection(file) + ForEach(reviewRows) { row in + reviewRow(row) } } @@ -151,23 +302,49 @@ struct ReviewPanelView: View { } } - // MARK: - Per-file sections + // MARK: - Flattened review rows + + private var reviewRows: [ReviewPanelRow] { + let composerTopology = composer.map { + ReviewPanelComposerTopology( + filePath: $0.filePath, + anchorLine: $0.anchorLine, + startLine: $0.startLine, + endLine: $0.endLine, + text: $0.text + ) + } + return rowPlanner.rows( + panelID: panel.id, + filesRevision: panel.filesRevision, + collapsedFilePaths: collapsedFilePaths, + composer: composerTopology, + files: panel.files + ) + } @ViewBuilder - private func fileSection(_ file: ReviewFileDiff) -> some View { - VStack(alignment: .leading, spacing: 0) { + private func reviewRow(_ row: ReviewPanelRow) -> some View { + switch row { + case .fileHeader(_, let file): fileHeader(file) - if let reason = file.notDiffableReason { - notDiffableRow(reason) - .padding(.horizontal, 16) - .padding(.vertical, 8) - } else if !collapsedFilePaths.contains(file.id) { - ForEach(Array(file.hunks.enumerated()), id: \.offset) { _, hunk in - hunkView(hunk, file: file) - } + case .notDiffable(_, let reason): + notDiffableRow(reason) + .padding(.horizontal, 16) + .padding(.vertical, 8) + case .hunkHeader(_, _, let header): + hunkHeader(header) + case .line(_, _, _, let row, let filePath): + lineRow(row, filePath: filePath) + case .composer: + if let composer { + inlineComposer(composer) } + case .fileSpacing(_): + Color.clear + .frame(height: 4) + .accessibilityHidden(true) } - .padding(.bottom, 4) } private func fileHeader(_ file: ReviewFileDiff) -> some View { @@ -240,44 +417,17 @@ struct ReviewPanelView: View { // MARK: - Hunks / lines - private func hunkView(_ hunk: ReviewHunk, file: ReviewFileDiff) -> some View { - VStack(alignment: .leading, spacing: 0) { - Text(hunk.header) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(.secondary) - .padding(.horizontal, 16) - .padding(.vertical, 2) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.primary.opacity(0.02)) - - ForEach(Array(annotatedRows(for: hunk).enumerated()), id: \.offset) { _, row in - lineRow(row, file: file) - if let composer, composer.filePath == file.id, composer.endLine == row.anchorLine { - inlineComposer(composer) - } - } - } - } - - private struct AnnotatedLine { - let line: ReviewDiffLine - /// The new-file line number a comment attached at this row would address: the line's - /// own `newLineNumber` when present, otherwise the nearest preceding new-file line (for - /// pure-deletion rows). See docs/plans/diff-review-panel.md §4. - let anchorLine: Int - } - - private func annotatedRows(for hunk: ReviewHunk) -> [AnnotatedLine] { - var anchor = hunk.lines.first(where: { $0.newLineNumber != nil })?.newLineNumber ?? 0 - return hunk.lines.map { line in - if let newLineNumber = line.newLineNumber { - anchor = newLineNumber - } - return AnnotatedLine(line: line, anchorLine: anchor) - } + private func hunkHeader(_ header: String) -> some View { + Text(header) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.secondary) + .padding(.horizontal, 16) + .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(0.02)) } - private func lineRow(_ row: AnnotatedLine, file: ReviewFileDiff) -> some View { + private func lineRow(_ row: ReviewPanelAnnotatedLine, filePath: String) -> some View { HStack(spacing: 0) { Text(row.line.oldLineNumber.map(String.init) ?? "") .frame(width: 36, alignment: .trailing) @@ -297,7 +447,7 @@ struct ReviewPanelView: View { .background(backgroundColor(for: row.line.kind)) .contentShape(Rectangle()) .onTapGesture { - handleLineTap(file: file, anchorLine: row.anchorLine) + handleLineTap(filePath: filePath, anchorLine: row.anchorLine) } } @@ -312,16 +462,16 @@ struct ReviewPanelView: View { } } - private func handleLineTap(file: ReviewFileDiff, anchorLine: Int) { + private func handleLineTap(filePath: String, anchorLine: Int) { let isShiftHeld = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false if isShiftHeld, var existing = composer, - existing.filePath == file.id { + existing.filePath == filePath { existing.startLine = min(existing.anchorLine, anchorLine) existing.endLine = max(existing.anchorLine, anchorLine) composer = existing } else { - composer = InlineComposerState(filePath: file.id, anchorLine: anchorLine, startLine: anchorLine, endLine: anchorLine) + composer = InlineComposerState(filePath: filePath, anchorLine: anchorLine, startLine: anchorLine, endLine: anchorLine) } } diff --git a/programaTests/ReviewPanelViewTests.swift b/programaTests/ReviewPanelViewTests.swift new file mode 100644 index 00000000..011d1a60 --- /dev/null +++ b/programaTests/ReviewPanelViewTests.swift @@ -0,0 +1,246 @@ +import XCTest + +#if canImport(Programa_DEV) +@testable import Programa_DEV +#elseif canImport(Programa) +@testable import Programa +#endif + +@MainActor +final class ReviewPanelRowPlannerTests: XCTestCase { + func testRepeatedIdenticalInputReusesRowsWithoutRebuilding() { + let planner = ReviewPanelRowPlanner() + let panelID = UUID() + let files = [makeFile(path: "Sources/App.swift", lineText: "first")] + + let first = planner.rows( + panelID: panelID, + filesRevision: 1, + collapsedFilePaths: [], + composer: nil, + files: files + ) + let second = planner.rows( + panelID: panelID, + filesRevision: 1, + collapsedFilePaths: [], + composer: nil, + files: files + ) + + XCTAssertEqual(first, second) + XCTAssertEqual(planner.rebuildCount, 1, "An unchanged review should reuse its existing row topology") + } + + func testSnapshotRevisionInvalidatesCacheAndReflectsChangedRows() { + let planner = ReviewPanelRowPlanner() + let panelID = UUID() + let initialFiles = [makeFile(path: "Sources/App.swift", lineText: "before")] + let changedFiles = [makeFile(path: "Sources/App.swift", lineText: "after")] + + let initialRows = planner.rows( + panelID: panelID, + filesRevision: 1, + collapsedFilePaths: [], + composer: nil, + files: initialFiles + ) + let changedRows = planner.rows( + panelID: panelID, + filesRevision: 2, + collapsedFilePaths: [], + composer: nil, + files: changedFiles + ) + + XCTAssertEqual(lineTexts(in: initialRows), ["before"]) + XCTAssertEqual(lineTexts(in: changedRows), ["after"]) + XCTAssertEqual(planner.rebuildCount, 2, "A new snapshot revision must invalidate cached review rows") + } + + func testCollapseHidesDiffAndComposerRowsAndExpandRestoresThem() { + let planner = ReviewPanelRowPlanner() + let panelID = UUID() + let path = "Sources/App.swift" + let files = [makeFile(path: path, lineText: "changed", newLineNumber: 12)] + let composer = makeComposer(path: path, endLine: 12) + + let expanded = planner.rows( + panelID: panelID, + filesRevision: 1, + collapsedFilePaths: [], + composer: composer, + files: files + ) + let collapsed = planner.rows( + panelID: panelID, + filesRevision: 1, + collapsedFilePaths: [path], + composer: composer, + files: files + ) + let restored = planner.rows( + panelID: panelID, + filesRevision: 1, + collapsedFilePaths: [], + composer: composer, + files: files + ) + + XCTAssertEqual(rowKinds(in: expanded), ["file", "hunk", "line", "composer", "spacing"]) + XCTAssertEqual(rowKinds(in: collapsed), ["file", "spacing"]) + XCTAssertEqual(restored.map(\.id), expanded.map(\.id), "Expanding should restore the same stable row identities") + } + + func testComposerPlacementFollowsAnchorWhileTextAndRangeStartReuseTopology() { + let planner = ReviewPanelRowPlanner() + let panelID = UUID() + let path = "Sources/App.swift" + let file = ReviewFileDiff( + oldPath: path, + newPath: path, + status: .modified, + hunks: [ + ReviewHunk( + header: "@@ -9,2 +9,2 @@", + lines: [ + ReviewDiffLine(kind: .context, oldLineNumber: 9, newLineNumber: 9, text: "before"), + ReviewDiffLine(kind: .addition, oldLineNumber: nil, newLineNumber: 10, text: "target"), + ] + ) + ], + notDiffableReason: nil + ) + + let initial = makeComposer(path: path, anchorLine: 10, startLine: 10, endLine: 10, text: "draft") + let initialRows = planner.rows( + panelID: panelID, + filesRevision: 1, + collapsedFilePaths: [], + composer: initial, + files: [file] + ) + let edited = makeComposer(path: path, anchorLine: 10, startLine: 9, endLine: 10, text: "edited draft") + let editedRows = planner.rows( + panelID: panelID, + filesRevision: 1, + collapsedFilePaths: [], + composer: edited, + files: [file] + ) + + XCTAssertEqual(rowKinds(in: initialRows), ["file", "hunk", "line", "line", "composer", "spacing"]) + XCTAssertEqual(initialRows.map(\.id), editedRows.map(\.id)) + XCTAssertEqual(planner.rebuildCount, 1, "Composer text and range-start edits should not rebuild row topology") + } + + func testDuplicateFilePathsProduceDistinctStableRowIDs() { + let planner = ReviewPanelRowPlanner() + let panelID = UUID() + let duplicateFiles = [ + makeFile(path: "Sources/App.swift", lineText: "first"), + makeFile(path: "Sources/App.swift", lineText: "second"), + ] + + let firstRevision = planner.rows( + panelID: panelID, + filesRevision: 1, + collapsedFilePaths: [], + composer: nil, + files: duplicateFiles + ) + let secondRevision = planner.rows( + panelID: panelID, + filesRevision: 2, + collapsedFilePaths: [], + composer: nil, + files: duplicateFiles + ) + let firstIDs = firstRevision.map(\.id) + + XCTAssertEqual(Set(firstIDs).count, firstIDs.count, "Duplicate paths must not collide in SwiftUI row identity") + XCTAssertEqual(secondRevision.map(\.id), firstIDs, "Snapshot refreshes should preserve row identity for unchanged files") + } + + func testApplyingSnapshotsAdvancesFilesRevision() { + let panel = ReviewPanel( + workspaceId: UUID(), + sourceSurfaceId: UUID(), + directory: "/tmp", + mode: .uncommitted, + baseBranch: "origin/main" + ) + let firstFile = makeFile(path: "Sources/App.swift", lineText: "first") + let secondFile = makeFile(path: "Sources/App.swift", lineText: "second") + + panel.apply(snapshot: ReviewDiffSnapshot(files: [firstFile], generatedAt: Date(timeIntervalSince1970: 1))) + XCTAssertEqual(panel.filesRevision, 1) + XCTAssertEqual(panel.files, [firstFile]) + + panel.apply(snapshot: ReviewDiffSnapshot(files: [secondFile], generatedAt: Date(timeIntervalSince1970: 2))) + XCTAssertEqual(panel.filesRevision, 2) + XCTAssertEqual(panel.files, [secondFile]) + } + + private func makeFile( + path: String, + lineText: String, + newLineNumber: Int = 1 + ) -> ReviewFileDiff { + ReviewFileDiff( + oldPath: path, + newPath: path, + status: .modified, + hunks: [ + ReviewHunk( + header: "@@ -1 +1 @@", + lines: [ + ReviewDiffLine( + kind: .addition, + oldLineNumber: nil, + newLineNumber: newLineNumber, + text: lineText + ) + ] + ) + ], + notDiffableReason: nil + ) + } + + private func makeComposer( + path: String, + anchorLine: Int = 1, + startLine: Int = 1, + endLine: Int, + text: String = "comment" + ) -> ReviewPanelComposerTopology { + ReviewPanelComposerTopology( + filePath: path, + anchorLine: anchorLine, + startLine: startLine, + endLine: endLine, + text: text + ) + } + + private func lineTexts(in rows: [ReviewPanelRow]) -> [String] { + rows.compactMap { row in + guard case .line(_, _, _, let annotated, _) = row else { return nil } + return annotated.line.text + } + } + + private func rowKinds(in rows: [ReviewPanelRow]) -> [String] { + rows.map { row in + switch row { + case .fileHeader: return "file" + case .notDiffable: return "notDiffable" + case .hunkHeader: return "hunk" + case .line: return "line" + case .composer: return "composer" + case .fileSpacing: return "spacing" + } + } + } +} From 750f7baa7fdc492d5791f62bc39436ac29b69ffb Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 14 Aug 2026 22:38:55 -0300 Subject: [PATCH 07/14] docs: record native ownership gates --- CHANGELOG.md | 1 + docs/plans/main-window-appkit-ownership.md | 132 ++++++++++++++++++ docs/plans/native-sidebar-owner-experiment.md | 111 +++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 docs/plans/main-window-appkit-ownership.md create mode 100644 docs/plans/native-sidebar-owner-experiment.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1828ff..cfa7d9f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - A restart after an update no longer kills every terminal when the new app comes up faster than the background session-holder notices the old one is gone. The app now waits out that window instead of giving up, escrow sockets no longer leak into shell processes (which silently delayed that detection), and a session that falls back anyway keeps its reattach records on disk while its process is still alive instead of deleting them. ### Changed +- Sidebar resizing now uses native macOS pointer capture, so the resize cursor and drag stay stable when crossing terminal or browser content. Command-palette, review-panel, find-field, and workspace telemetry updates also avoid unnecessary whole-window redraws. - Hidden terminal panes now release their Metal renderer and IOSurface pool after a short idle period while keeping the shell, scrollback, and terminal state alive. Returning to the pane rebuilds its renderer before it becomes visible, so graphics memory scales with the terminals on screen instead of every workspace opened during the session. - Terminal output subscriptions now take one bounded snapshot per surface and publish only the changed suffix, reducing main-thread work and memory churn for automation clients watching busy terminals. - Less background churn under agent load: repeated identical progress and port reports no longer redraw workspaces, moving the mouse across a window no longer re-renders its chrome, and scrolling no longer builds debug strings that get thrown away. diff --git a/docs/plans/main-window-appkit-ownership.md b/docs/plans/main-window-appkit-ownership.md new file mode 100644 index 00000000..5a21aad8 --- /dev/null +++ b/docs/plans/main-window-appkit-ownership.md @@ -0,0 +1,132 @@ +# Main-window AppKit ownership plan + +Status: planned, gated behind lifecycle proof + +## Outcome + +Every Programa main window should eventually have one `NSWindowController` owner and one +Programa-specific `NSWindow` subclass. SwiftUI should remain the root-content system through +`MainWindowHostingView`. This change is about deterministic window lifecycle and narrower event +ownership, not a claim that AppKit draws ordinary content faster. + +The current release must not switch the primary window away from `WindowGroup`. The first window +still depends on SwiftUI scene restoration and command installation, while secondary windows are +created by `AppDelegate.createMainWindow`. Moving the first window before the contracts below are +executable would trade known duplication for unbounded restoration and activation risk. + +## Non-goals + +- Do not move or hide AppKit's standard traffic-light buttons. +- Do not replace the terminal or browser portal system. +- Do not rewrite command-palette rows, settings, forms, or other state-driven content in AppKit. +- Do not remove the application-level event hook until its app-wide responsibilities are separated + from window-specific routing. +- Do not report a performance gain without repeated before-and-after measurements. + +## Functional DAG + +```text +MW1 lifecycle contract inventory + -> MW2 MainWindowCoordinator extraction + -> MW3 ProgramaMainWindow for secondary windows + -> MW4 replace window-specific global exchanges + -> MW5 primary-window restoration harness + -> MW6 AppKit-owned primary window + -> MW7 delete WindowAccessor ownership path + -> MW8 remove registry compensation made unreachable +``` + +Each node must merge independently. A later node cannot begin while its predecessor has an open +correctness failure. + +## MW1: executable lifecycle contracts + +Capture the current behavior before moving ownership: + +- first launch with and without restorable state; +- close and reopen the last main window; +- create, close, and restore multiple main windows; +- key/main transitions without focus theft; +- enter and leave fullscreen with titlebar accessories intact; +- route SwiftUI commands to the correct window context; +- retain the key-window fallback and `didBecomeKey` self-heal when `occlusionState` is temporarily + wrong during creation; +- preserve session identifiers and window-to-`TabManager` routing across restoration. + +Use runtime window objects, responder state, and restored sessions as assertions. Do not test source +shape, project files, or the presence of a class name. + +## MW2: extract `MainWindowCoordinator` + +Move the existing main-window registration, overlay installation, accessory attachment, context +reindexing, and close-observer lifecycle out of `AppDelegate` into one coordinator. Keep both current +window creation paths calling the coordinator. This stage changes ownership boundaries without +changing which object creates the first window. + +The coordinator must remain idempotent because the current scene-window accessor and secondary +creation path can both reach configuration. Its public surface should accept an existing `NSWindow`, +the window identifier, `TabManager`, and `SidebarState`; feature-specific views should not leak into +it. + +## MW3: introduce `ProgramaMainWindow` + +Use the subclass for manually created secondary main windows first. Keep standard AppKit style-mask, +close, minimize, zoom, titlebar, fullscreen, and restoration behavior. The subclass should own only +Programa-specific responder and event overrides that currently apply to all `NSWindow` instances. + +Settings, import, update, debug, and auxiliary panels must remain ordinary `NSWindow` instances and +must stop receiving main-window-only behavior after this stage. + +## MW4: retire window-specific global exchanges + +Move `makeFirstResponder`, `sendEvent`, and `performKeyEquivalent` behavior into +`ProgramaMainWindow` one method at a time. Keep the existing keyboard fast path: do not restore a +hit test for key-down, key-up, or flags-changed events. Leave the `NSApplication.sendEvent` hook in +place until its app-wide and main-window responsibilities have separate owners. + +For each override, prove parity on secondary windows before expanding its use. The old exchanged +implementation remains the fallback for the primary window until MW6. + +## MW5: primary-window restoration harness + +Add a debug or test-only creation seam that can construct the first main-window context through the +coordinator without ordering it on screen. The harness must round-trip the same restoration payload, +window identifier, `TabManager`, and command-routing context as `WindowGroup`. + +This is the go/no-go gate for replacing scene ownership. Stop if SwiftUI commands, restoration, +activation policy, or close/reopen behavior cannot be exercised through the seam. + +## MW6: switch the primary window + +Create the first main window through the same `MainWindowController` path as later windows and host +`ContentView` in `MainWindowHostingView`. Preserve SwiftUI command installation and Settings scene +behavior explicitly; do not keep a hidden or duplicate scene window as a compatibility shim. + +The change ships only when the MW1 suite is green and a tagged app passes launch, restore, +multiwindow focus, fullscreen, and close/reopen checks on the supported macOS versions. + +## MW7: delete `WindowAccessor` ownership work + +After every main window is controller-owned, remove the `WindowAccessor` path that discovers and +configures main windows after SwiftUI attachment. Keep `WindowAccessor` only where it still provides +an unrelated view-local service. Delete duplicate registration and asynchronous window-derived state +writes that become unreachable. + +## MW8: simplify registry recovery + +Remove orphan sweeps, identifier reindexing, and fallback lookup branches only when runtime coverage +proves the unified controller makes them unreachable. Keep recovery that protects external AppKit +lifecycle behavior rather than compensating for the former dual ownership paths. + +## Verification and measurements + +Every stage requires: + +1. the focused runtime lifecycle tests for that stage; +2. a tagged Debug build and real-window interaction pass; +3. existing command-palette, titlebar, sidebar-resize, terminal, browser, and multiwindow suites; +4. no new main-thread work in keyboard event paths; +5. repeated A/B latency samples before any performance statement. + +Roll back the current stage if it needs a second window owner, delayed frame writes, forced standard +button state, or a new global `NSWindow` exchange to work. diff --git a/docs/plans/native-sidebar-owner-experiment.md b/docs/plans/native-sidebar-owner-experiment.md new file mode 100644 index 00000000..420f9e94 --- /dev/null +++ b/docs/plans/native-sidebar-owner-experiment.md @@ -0,0 +1,111 @@ +# Native Sidebar Owner Experiment + +Status: **NO-GO for implementation or adoption on 2026-08-14.** + +This plan records the 2026-08-14 AppKit audit gate. The production sidebar +stays on the existing SwiftUI scroll and reorder path. No native-owner +performance claim exists because no controlled A/B run has been completed. + +## Current blocker + +`TabItemView` is both the row renderer and the interaction owner. Its body +installs the internal drag source, sidebar and Bonsplit drop destinations, +selection tap, context menu, and accessibility reorder actions. Its drop +delegate also drives the existing 60 Hz autoscroll controller. The sidebar's +empty-area view owns the remaining internal and external drop destinations. + +Putting the unchanged row in `NSTableView` or `NSCollectionView` would leave +SwiftUI as the drag/drop owner. Adding native delegates at the same time would +create two competing lifecycle owners. A wrapper that keeps the five-event +failsafe monitor and the timer active is not the native-owner experiment that +AK3 requires. + +The same boundary blocks an exact row-body counter. A counter outside the +hosted row measures wrapper creation, not whether `TabItemView.equatable()` +skipped the child body. + +## Required interaction seam + +The prototype may begin only after a narrow `TabItemView` interaction seam is +approved. The seam must: + +1. Preserve the row's visual subtree, precomputed inputs, `Equatable` + implementation, and `.equatable()` call site unchanged. +2. Preserve the SwiftUI context menu and accessibility content/actions. +3. Allow DEBUG native mode to omit only `.onDrag`, both `.onDrop` handlers, + and `.onTapGesture` from the row. +4. Extract the current command-click and shift-range selection algorithm into + one shared policy used by both owners. +5. Add a DEBUG-only, disabled-by-default body counter inside the row. Disabled + profiling must add no release work and no allocation to the typing path. + +Production and DEBUG builds must continue to select the SwiftUI owner by +default. + +## Native owner responsibilities + +The DEBUG-only native variant should use `NSTableView` or `NSCollectionView` +and host the existing SwiftUI row in `NSHostingView`. It must exclusively own: + +- selected indexes and synchronization with `selectedTabIds` and the active + workspace; +- internal sidebar and external Bonsplit pasteboard types; +- drag-session start, cancellation, app-resign cleanup, validation, and drop + completion; +- native edge autoscroll; +- row and empty-area drop targeting, including end-of-list insertion; +- keyboard focus and navigation; +- variable row-height invalidation; and +- accessibility selection and reorder behavior. + +Native mode must not create or start `SidebarDragFailsafeMonitor`, +`SidebarDragAutoScrollController`, or the SwiftUI empty-area drop handlers. + +## Parity gate + +Before measurement, both variants must pass the same manual script for: + +- click, command-click, and shift-range selection; +- active-workspace synchronization; +- reorder before and after a row, at the end, and over empty space; +- pinned-boundary rules and multi-row reorder; +- Bonsplit transfers into a row and empty space; +- context menus for single and multiple selected workspaces; +- keyboard navigation, first-responder restoration, and window switching; +- VoiceOver labels, selection, and reorder actions; +- mouse-up, Escape, app-resign, and window-close drag cancellation; +- live sidebar-width changes; and +- backdrop and non-backdrop sidebar layouts. + +Any mismatch keeps the native path NO-GO. + +## Measurement protocol + +Run the same scripted session for each variant at 4, 20, and 48 workspaces. +Use multiple repetitions with a unique session and repetition identifier. Write +raw JSONL records to the tagged app's debug-log directory; each record must +include: + +- variant, workspace count, session ID, repetition, monotonic timestamp, and + operation; +- raw typing-delay/duration samples used for p95 and p99; +- main-run-loop stall duration samples; +- `getrusage` user/system CPU deltas for the measured interval; +- row construction and `TabItemView.body` update counts; +- mouse-down-to-selection latency; and +- drag-start-to-drop-or-cancel latency and outcome. + +Use the existing `TypingProfiler`, `CACurrentMediaTime`, debug log, run-loop, +and stress-workspace facilities. The Sidebar Debug window should expose the +selected variant, workspace count, repetition/session state, and the raw log +path. Percentiles must be computed from saved samples, not emitted without the +underlying data. + +## Adoption gate + +Native ownership remains experimental unless repeated 4/20/48 runs show a +material improvement in the targeted lifecycle or measured latency without a +regression in any required metric or parity case. Reviewers must evaluate the +raw samples and scripts before changing the production default. If results are +neutral, noisy, or behaviorally worse, delete the prototype and keep the +existing SwiftUI owner. From c3502b3ff92e61332125fe55da271e7123bd113e Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 14 Aug 2026 23:14:11 -0300 Subject: [PATCH 08/14] fix: gate native pane glass by compiler --- Sources/WindowPaneChromePortal.swift | 2 ++ Sources/WorkspaceContentView.swift | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Sources/WindowPaneChromePortal.swift b/Sources/WindowPaneChromePortal.swift index cb7f2c17..25406b49 100644 --- a/Sources/WindowPaneChromePortal.swift +++ b/Sources/WindowPaneChromePortal.swift @@ -10,6 +10,7 @@ extension Notification.Name { Notification.Name("programaTerminalPortalDidMoveHostedContent") } +#if compiler(>=6.2) @MainActor @available(macOS 26.0, *) final class WindowPaneChromePortalRegistry: NSObject, BonsplitPaneChromePortalBridge { @@ -1018,3 +1019,4 @@ private final class ActionBox: NSObject { let action: TabContextAction init(_ action: TabContextAction) { self.action = action } } +#endif diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 7357cf91..8343616d 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -413,11 +413,13 @@ struct WorkspaceContentView: View { )) .background( WindowAccessor(dedupeByWindow: false) { window in + #if compiler(>=6.2) if #available(macOS 26.0, *) { workspace.bonsplitController.setPaneChromePortalBridge( WindowPaneChromePortalRegistry.bridge(for: window) ) } + #endif } .frame(width: 0, height: 0) ) From cae7a56e2dad152012e94509453ac116307d32e4 Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 14 Aug 2026 23:37:36 -0300 Subject: [PATCH 09/14] ci: preserve smoke crash diagnostics --- scripts/smoke-test-ci.sh | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/smoke-test-ci.sh b/scripts/smoke-test-ci.sh index 7cbdf370..3ef438bd 100755 --- a/scripts/smoke-test-ci.sh +++ b/scripts/smoke-test-ci.sh @@ -36,7 +36,7 @@ sleep 2 if ! kill -0 "$APP_PID" 2>/dev/null; then echo "ERROR: App exited immediately after launch" echo "--- stdout/stderr ---" - cat /tmp/programa-smoke-stdout.log 2>/dev/null | tail -50 || true + tail -200 /tmp/programa-smoke-stdout.log 2>/dev/null || true echo "--- debug log ---" tail -50 /tmp/programa-debug.log 2>/dev/null || true echo "--- crash reports ---" @@ -57,7 +57,7 @@ for i in $(seq 1 60); do if ! kill -0 "$APP_PID" 2>/dev/null; then echo "ERROR: App crashed while waiting for socket" echo "--- stdout/stderr ---" - cat /tmp/programa-smoke-stdout.log 2>/dev/null | tail -50 || true + tail -200 /tmp/programa-smoke-stdout.log 2>/dev/null || true echo "--- debug log ---" tail -50 /tmp/programa-debug.log 2>/dev/null || true exit 1 @@ -67,7 +67,7 @@ done if [ "$SOCKET_READY" != "true" ]; then echo "ERROR: Socket not ready after 30s" echo "--- stdout/stderr ---" - cat /tmp/programa-smoke-stdout.log 2>/dev/null | tail -30 || true + tail -200 /tmp/programa-smoke-stdout.log 2>/dev/null || true echo "--- debug log ---" tail -30 /tmp/programa-debug.log 2>/dev/null || true ls -la /tmp/programa-debug* 2>/dev/null || true @@ -115,9 +115,11 @@ sleep "$STABILITY_WAIT" if ! kill -0 "$APP_PID" 2>/dev/null; then echo "ERROR: App crashed during ${STABILITY_WAIT}s stability check" echo "--- stdout/stderr ---" - cat /tmp/programa-smoke-stdout.log 2>/dev/null | tail -30 || true + tail -200 /tmp/programa-smoke-stdout.log 2>/dev/null || true echo "--- debug log ---" - tail -30 /tmp/programa-debug.log 2>/dev/null || true + tail -100 /tmp/programa-debug.log 2>/dev/null || true + echo "--- crash reports ---" + ls -lt ~/Library/Logs/DiagnosticReports/*Programa* 2>/dev/null | head -5 || echo "(none)" exit 1 fi From 6c7e791d9ceca9f6c748fdda503096bb62d0482a Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 14 Aug 2026 23:48:59 -0300 Subject: [PATCH 10/14] fix: stop native divider layout feedback --- Sources/ContentView+SidebarResizer.swift | 1 - scripts/smoke-test-ci.sh | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/ContentView+SidebarResizer.swift b/Sources/ContentView+SidebarResizer.swift index 4868be69..2457b697 100644 --- a/Sources/ContentView+SidebarResizer.swift +++ b/Sources/ContentView+SidebarResizer.swift @@ -135,7 +135,6 @@ private struct NativeSidebarDividerRepresentable: NSViewRepresentable { nsView.onResizeBegan = onResizeBegan nsView.onWidthChanged = onWidthChanged nsView.onResizeEnded = onResizeEnded - nsView.window?.invalidateCursorRects(for: nsView) } static func dismantleNSView(_ nsView: NativeSidebarDividerView, coordinator: Void) { diff --git a/scripts/smoke-test-ci.sh b/scripts/smoke-test-ci.sh index 3ef438bd..e8f1fad8 100755 --- a/scripts/smoke-test-ci.sh +++ b/scripts/smoke-test-ci.sh @@ -114,6 +114,11 @@ sleep "$STABILITY_WAIT" if ! kill -0 "$APP_PID" 2>/dev/null; then echo "ERROR: App crashed during ${STABILITY_WAIT}s stability check" + set +e + wait "$APP_PID" + APP_EXIT_STATUS=$? + set -e + echo "App exit status: $APP_EXIT_STATUS" echo "--- stdout/stderr ---" tail -200 /tmp/programa-smoke-stdout.log 2>/dev/null || true echo "--- debug log ---" From 0b5c97f90d6b3146ad93694b0f0b3bc2125950d4 Mon Sep 17 00:00:00 2001 From: arzafran Date: Sat, 15 Aug 2026 00:01:16 -0300 Subject: [PATCH 11/14] ci: capture socket app launch failures --- scripts/run-tests-v2-ci.sh | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/scripts/run-tests-v2-ci.sh b/scripts/run-tests-v2-ci.sh index 4923bdf4..47d949e0 100755 --- a/scripts/run-tests-v2-ci.sh +++ b/scripts/run-tests-v2-ci.sh @@ -14,6 +14,31 @@ cd "$(dirname "$0")/.." RUN_TAG="ci-v2" SUBSET_FILE="tests_v2/ci_subset.txt" +APP_PID="" +APP_LOG="/tmp/programa-v2-ci-stdout.log" + +report_failure() { + local status="$?" + echo "--- Programa process ---" >&2 + if [ -n "$APP_PID" ] && kill -0 "$APP_PID" 2>/dev/null; then + echo "App PID $APP_PID is still alive" >&2 + elif [ -n "$APP_PID" ]; then + set +e + wait "$APP_PID" + local app_status="$?" + set -e + echo "App PID $APP_PID exited with status $app_status" >&2 + else + echo "App PID was not captured" >&2 + fi + echo "--- Programa stdout/stderr ---" >&2 + tail -200 "$APP_LOG" 2>/dev/null >&2 || true + echo "--- Programa debug log ---" >&2 + tail -100 "/tmp/programa-debug-$RUN_TAG.log" 2>/dev/null >&2 || true + return "$status" +} + +trap report_failure ERR APP="$(find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Debug/Programa DEV.app" -print -quit 2>/dev/null || true)" if [ -z "$APP" ] || [ ! -d "$APP" ]; then @@ -55,7 +80,9 @@ launch_and_wait() { # Launch the app binary directly (not `open`, which can silently flake on CI runners) with # UI test mode enabled so startup follows deterministic test codepaths. - PROGRAMA_TAG="$RUN_TAG" PROGRAMA_UI_TEST_MODE=1 "$APP/Contents/MacOS/Programa DEV" >/dev/null 2>&1 & + : > "$APP_LOG" + PROGRAMA_TAG="$RUN_TAG" PROGRAMA_UI_TEST_MODE=1 "$APP/Contents/MacOS/Programa DEV" >"$APP_LOG" 2>&1 & + APP_PID=$! SOCK="" for _ in {1..120}; do From 6a32b64ff8bd4d4b8068ad32dbd93d9984af68f8 Mon Sep 17 00:00:00 2001 From: arzafran Date: Sat, 15 Aug 2026 00:01:41 -0300 Subject: [PATCH 12/14] ci: keep smoke stability probe authoritative --- scripts/smoke-test-ci.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/smoke-test-ci.sh b/scripts/smoke-test-ci.sh index e8f1fad8..f1485ca9 100755 --- a/scripts/smoke-test-ci.sh +++ b/scripts/smoke-test-ci.sh @@ -102,7 +102,10 @@ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.connect('$SOCKET_PATH') s.settimeout(5.0) s.sendall(json.dumps({'id': 2, 'method': 'surface.send_text', 'params': {'text': 'time\n'}}).encode() + b'\n') -data = s.recv(4096).decode().strip() +try: + data = s.recv(4096).decode().strip() +except TimeoutError: + data = 'TIMEOUT (terminal surface not ready)' s.close() print(data) ") From c2c745872178d2f83a9cf5cf306e3883e4230423 Mon Sep 17 00:00:00 2001 From: arzafran Date: Sat, 15 Aug 2026 00:07:17 -0300 Subject: [PATCH 13/14] fix: keep sidebar resize on proven interaction path --- Sources/ContentView+SidebarResizer.swift | 398 +++++++++++++---------- Sources/ContentView.swift | 25 +- Sources/VerticalTabsSidebar.swift | 13 + 3 files changed, 270 insertions(+), 166 deletions(-) diff --git a/Sources/ContentView+SidebarResizer.swift b/Sources/ContentView+SidebarResizer.swift index 2457b697..6f446c67 100644 --- a/Sources/ContentView+SidebarResizer.swift +++ b/Sources/ContentView+SidebarResizer.swift @@ -1,158 +1,33 @@ +// Sidebar resizer member group extracted from ContentView.swift (nuclear-review CV1 / issue #94). +// The backing @State stays on `ContentView` (SwiftUI requires stored properties on the primary +// declaration); those properties, plus `SidebarResizerHandle`, `updateSidebarResizerBandState`, +// `installSidebarResizerPointerMonitorIfNeeded`, `removeSidebarResizerPointerMonitor`, and +// `sidebarResizerOverlay`, were widened from `private` to internal so this extension and +// ContentView.swift's view body can both see them. See the PR description for the exact list. + import AppKit import SwiftUI -/// Owns the complete pointer lifecycle for the sidebar divider. AppKit keeps the -/// drag capture after the pointer crosses a portal-hosted terminal or browser, -/// so the SwiftUI root no longer needs a window-wide event monitor or cursor timer. -private final class NativeSidebarDividerView: NSView { - private static let resizeCursor = NSCursor( +extension ContentView { + private static let fixedSidebarResizeCursor = NSCursor( image: NSCursor.resizeLeftRight.image, hotSpot: NSCursor.resizeLeftRight.hotSpot ) - - var currentWidth: CGFloat = 0 - var onResizeBegan: () -> Void = {} - var onWidthChanged: (CGFloat) -> Void = { _ in } - var onResizeEnded: () -> Void = {} - - private var trackingArea: NSTrackingArea? - private var windowResignObserver: NSObjectProtocol? - private var dragStartWidth: CGFloat = 0 - private var dragStartWindowX: CGFloat = 0 - private var isDragging = false - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - setAccessibilityElement(true) - setAccessibilityRole(.splitter) - setAccessibilityIdentifier("SidebarResizer") - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - if let windowResignObserver { - NotificationCenter.default.removeObserver(windowResignObserver) - } - } - - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - true - } - - override func resetCursorRects() { - super.resetCursorRects() - addCursorRect(bounds, cursor: Self.resizeCursor) - } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - if let trackingArea { - removeTrackingArea(trackingArea) - } - let nextTrackingArea = NSTrackingArea( - rect: .zero, - options: [.mouseEnteredAndExited, .cursorUpdate, .activeInKeyWindow, .inVisibleRect], - owner: self, - userInfo: nil - ) - addTrackingArea(nextTrackingArea) - trackingArea = nextTrackingArea - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - if let windowResignObserver { - NotificationCenter.default.removeObserver(windowResignObserver) - self.windowResignObserver = nil - } - guard let window else { - cancelActiveResize() - return - } - windowResignObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didResignKeyNotification, - object: window, - queue: .main - ) { [weak self] _ in - self?.cancelActiveResize() - } - } - - override func cursorUpdate(with event: NSEvent) { - Self.resizeCursor.set() - } - - override func mouseEntered(with event: NSEvent) { - Self.resizeCursor.set() - } - - override func mouseDown(with event: NSEvent) { - guard !isDragging else { return } - isDragging = true - dragStartWidth = currentWidth - dragStartWindowX = event.locationInWindow.x - Self.resizeCursor.set() - onResizeBegan() - } - - override func mouseDragged(with event: NSEvent) { - guard isDragging else { return } - Self.resizeCursor.set() - onWidthChanged(dragStartWidth + event.locationInWindow.x - dragStartWindowX) - } - - override func mouseUp(with event: NSEvent) { - finishResize() - } - - func cancelActiveResize() { - finishResize() - } - - private func finishResize() { - guard isDragging else { return } - isDragging = false - onResizeEnded() - } -} - -private struct NativeSidebarDividerRepresentable: NSViewRepresentable { - let currentWidth: CGFloat - let onResizeBegan: () -> Void - let onWidthChanged: (CGFloat) -> Void - let onResizeEnded: () -> Void - - func makeNSView(context: Context) -> NativeSidebarDividerView { - NativeSidebarDividerView(frame: .zero) - } - - func updateNSView(_ nsView: NativeSidebarDividerView, context: Context) { - nsView.currentWidth = currentWidth - nsView.onResizeBegan = onResizeBegan - nsView.onWidthChanged = onWidthChanged - nsView.onResizeEnded = onResizeEnded - } - - static func dismantleNSView(_ nsView: NativeSidebarDividerView, coordinator: Void) { - nsView.cancelActiveResize() - nsView.onResizeBegan = {} - nsView.onWidthChanged = { _ in } - nsView.onResizeEnded = {} - } -} - -extension ContentView { private static let minimumSidebarWidth: CGFloat = CGFloat(SessionPersistencePolicy.minimumSidebarWidth) private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0 + enum SidebarResizerHandle: Hashable { + case divider + } + private var sidebarResizerSidebarHitWidth: CGFloat { SidebarResizeInteraction.sidebarSideHitWidth } + private var sidebarResizerContentHitWidth: CGFloat { + SidebarResizeInteraction.contentSideHitWidth + } + private func maxSidebarWidth(availableWidth: CGFloat? = nil) -> CGFloat { let resolvedAvailableWidth = availableWidth ?? observedWindow?.contentView?.bounds.width @@ -193,6 +68,218 @@ extension ContentView { Self.clampedSidebarWidth(candidate, maximumWidth: maxSidebarWidth()) } + private func activateSidebarResizerCursor() { + sidebarResizerCursorReleaseWorkItem?.cancel() + sidebarResizerCursorReleaseWorkItem = nil + if !isSidebarResizerCursorActive { + isSidebarResizerCursorActive = true + } + Self.fixedSidebarResizeCursor.set() + } + + private func releaseSidebarResizerCursorIfNeeded(force: Bool = false) { + let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left) + let shouldKeepCursor = !force + && (isResizerDragging || isResizerBandActive || !hoveredResizerHandles.isEmpty || isLeftMouseButtonDown) + guard !shouldKeepCursor else { return } + guard isSidebarResizerCursorActive else { return } + isSidebarResizerCursorActive = false + NSCursor.arrow.set() + } + + private func scheduleSidebarResizerCursorRelease(force: Bool = false, delay: TimeInterval = 0) { + sidebarResizerCursorReleaseWorkItem?.cancel() + let workItem = DispatchWorkItem { + sidebarResizerCursorReleaseWorkItem = nil + releaseSidebarResizerCursorIfNeeded(force: force) + } + sidebarResizerCursorReleaseWorkItem = workItem + if delay > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } else { + DispatchQueue.main.async(execute: workItem) + } + } + + private func dividerBandContains(pointInContent point: NSPoint, contentBounds: NSRect) -> Bool { + guard point.y >= contentBounds.minY, point.y <= contentBounds.maxY else { return false } + let minX = sidebarWidth - sidebarResizerSidebarHitWidth + let maxX = sidebarWidth + sidebarResizerContentHitWidth + return point.x >= minX && point.x <= maxX + } + + func updateSidebarResizerBandState(using event: NSEvent? = nil) { + guard sidebarState.isVisible, + let window = observedWindow, + let contentView = window.contentView else { + if isResizerBandActive { isResizerBandActive = false } + scheduleSidebarResizerCursorRelease(force: true) + return + } + + // Use live global pointer location instead of per-event coordinates. + // Overlapping tracking areas (notably WKWebView) can deliver stale/jittery + // event locations during cursor updates, which causes visible cursor flicker. + let pointInWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation) + let pointInContent = contentView.convert(pointInWindow, from: nil) + let isInDividerBand = dividerBandContains(pointInContent: pointInContent, contentBounds: contentView.bounds) + if isResizerBandActive != isInDividerBand { isResizerBandActive = isInDividerBand } + + if isInDividerBand || isResizerDragging { + activateSidebarResizerCursor() + startSidebarResizerCursorStabilizer() + // AppKit cursorUpdate handlers from overlapped portal/web views can run + // after our local monitor callback and temporarily reset the cursor. + // Re-assert on the next runloop turn to keep the resize cursor stable. + DispatchQueue.main.async { + Self.fixedSidebarResizeCursor.set() + } + } else { + stopSidebarResizerCursorStabilizer() + scheduleSidebarResizerCursorRelease() + } + } + + private func startSidebarResizerCursorStabilizer() { + guard sidebarResizerCursorStabilizer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now(), repeating: .milliseconds(16), leeway: .milliseconds(2)) + timer.setEventHandler { + updateSidebarResizerBandState() + if isResizerBandActive || isResizerDragging { + Self.fixedSidebarResizeCursor.set() + } else { + stopSidebarResizerCursorStabilizer() + } + } + sidebarResizerCursorStabilizer = timer + timer.resume() + } + + private func stopSidebarResizerCursorStabilizer() { + sidebarResizerCursorStabilizer?.cancel() + sidebarResizerCursorStabilizer = nil + } + + func installSidebarResizerPointerMonitorIfNeeded() { + guard sidebarResizerPointerMonitor == nil else { return } + observedWindow?.acceptsMouseMovedEvents = true + sidebarResizerPointerMonitor = NSEvent.addLocalMonitorForEvents( + matching: [ + .mouseMoved, + .mouseEntered, + .mouseExited, + .cursorUpdate, + .appKitDefined, + .systemDefined, + .leftMouseDown, + .leftMouseUp, + .leftMouseDragged, + ] + ) { event in + updateSidebarResizerBandState(using: event) + let shouldOverrideCursorEvent: Bool = { + switch event.type { + case .cursorUpdate, .mouseMoved, .mouseEntered, .mouseExited, .appKitDefined, .systemDefined: + return true + default: + return false + } + }() + if shouldOverrideCursorEvent, (isResizerBandActive || isResizerDragging) { + // Consume hover motion in divider band so overlapped views cannot + // continuously reassert their own cursor while we are resizing. + activateSidebarResizerCursor() + Self.fixedSidebarResizeCursor.set() + return nil + } + return event + } + updateSidebarResizerBandState() + } + + func removeSidebarResizerPointerMonitor() { + if let monitor = sidebarResizerPointerMonitor { + NSEvent.removeMonitor(monitor) + sidebarResizerPointerMonitor = nil + } + if isResizerBandActive { isResizerBandActive = false } + isSidebarResizerCursorActive = false + stopSidebarResizerCursorStabilizer() + scheduleSidebarResizerCursorRelease(force: true) + } + + private func sidebarResizerHandleOverlay( + _ handle: SidebarResizerHandle, + width: CGFloat, + availableWidth: CGFloat, + accessibilityIdentifier: String? = nil + ) -> some View { + Color.clear + .frame(width: width) + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .onHover { hovering in + if hovering { + hoveredResizerHandles.insert(handle) + activateSidebarResizerCursor() + } else { + hoveredResizerHandles.remove(handle) + let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left) + if isLeftMouseButtonDown { + // Keep resize cursor pinned through mouse-down so AppKit + // cursorUpdate events from overlapping views do not flash arrow. + activateSidebarResizerCursor() + } else { + // Give mouse-down + drag-start callbacks time to establish state + // before any cursor pop is attempted. + scheduleSidebarResizerCursorRelease(delay: 0.05) + } + } + updateSidebarResizerBandState() + } + .onDisappear { + hoveredResizerHandles.remove(handle) + if isResizerDragging { + TerminalWindowPortalRegistry.endInteractiveGeometryResize() + isResizerDragging = false + } + sidebarDragStartWidth = nil + if isResizerBandActive { isResizerBandActive = false } + scheduleSidebarResizerCursorRelease(force: true) + } + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .global) + .onChanged { value in + if !isResizerDragging { + TerminalWindowPortalRegistry.beginInteractiveGeometryResize() + isResizerDragging = true + sidebarDragStartWidth = sidebarWidth + } + + activateSidebarResizerCursor() + let startWidth = sidebarDragStartWidth ?? sidebarWidth + let nextWidth = Self.clampedSidebarWidth( + startWidth + value.translation.width, + maximumWidth: maxSidebarWidth(availableWidth: availableWidth) + ) + withTransaction(Transaction(animation: nil)) { + sidebarWidth = nextWidth + } + } + .onEnded { _ in + if isResizerDragging { + TerminalWindowPortalRegistry.endInteractiveGeometryResize() + isResizerDragging = false + sidebarDragStartWidth = nil + } + activateSidebarResizerCursor() + scheduleSidebarResizerCursorRelease() + } + ) + .modifier(SidebarResizerAccessibilityModifier(accessibilityIdentifier: accessibilityIdentifier)) + } + var sidebarResizerOverlay: some View { GeometryReader { proxy in let totalWidth = max(0, proxy.size.width) @@ -204,29 +291,12 @@ extension ContentView { .frame(width: leadingWidth) .allowsHitTesting(false) - NativeSidebarDividerRepresentable( - currentWidth: sidebarWidth, - onResizeBegan: { - isSidebarResizerDragging = true - TerminalWindowPortalRegistry.beginInteractiveGeometryResize() - }, - onWidthChanged: { candidate in - let nextWidth = Self.clampedSidebarWidth( - candidate, - maximumWidth: maxSidebarWidth(availableWidth: totalWidth) - ) - guard abs(nextWidth - sidebarWidth) > 0.5 else { return } - withTransaction(Transaction(animation: nil)) { - sidebarWidth = nextWidth - } - }, - onResizeEnded: { - isSidebarResizerDragging = false - TerminalWindowPortalRegistry.endInteractiveGeometryResize() - } + sidebarResizerHandleOverlay( + .divider, + width: SidebarResizeInteraction.totalHitWidth, + availableWidth: totalWidth, + accessibilityIdentifier: "SidebarResizer" ) - .frame(width: SidebarResizeInteraction.totalHitWidth) - .frame(maxHeight: .infinity) Color.clear .frame(maxWidth: .infinity) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a2862803..d748e6c8 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -17,7 +17,9 @@ struct ContentView: View { @EnvironmentObject var programaConfigStore: ProgramaConfigStore @ObservedObject private var programaLayoutStore = ProgramaLayoutStore.shared @State var sidebarWidth: CGFloat = 200 - @State var isSidebarResizerDragging = false + @State var hoveredResizerHandles: Set = [] + @State var isResizerDragging = false + @State var sidebarDragStartWidth: CGFloat? @State private var selectedTabIds: Set = [] @State private var mountedWorkspaceIds: [UUID] = [] @State private var lastSidebarSelectionIndex: Int? = nil @@ -33,6 +35,11 @@ struct ContentView: View { @State private var titlebarThemeGeneration: UInt64 = 0 @State private var sidebarDraggedTabId: UUID? @State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) + @State var sidebarResizerCursorReleaseWorkItem: DispatchWorkItem? + @State var sidebarResizerPointerMonitor: Any? + @State var isResizerBandActive = false + @State var isSidebarResizerCursorActive = false + @State var sidebarResizerCursorStabilizer: DispatchSourceTimer? // The dedicated CommandPaletteRootView observes this reference inside the // AppKit overlay. Keeping only its identity in State prevents palette query // and selection publishes from invalidating the whole window shell. @@ -1007,6 +1014,7 @@ struct ContentView: View { tabManager.applyWindowBackgroundForSelectedTab() reconcileMountedWorkspaceIds() previousSelectedWorkspaceId = tabManager.selectedTabId + installSidebarResizerPointerMonitorIfNeeded() let restoredWidth = normalizedSidebarWidth(sidebarState.persistedWidth) if abs(sidebarWidth - restoredWidth) > 0.5 { sidebarWidth = restoredWidth @@ -1436,6 +1444,7 @@ struct ContentView: View { guard let window = notification.object as? NSWindow, window === observedWindow else { return } clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width) + updateSidebarResizerBandState() } } @@ -1458,6 +1467,7 @@ struct ContentView: View { } else { TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() } + updateSidebarResizerBandState() } .onChange(of: sidebarState.isVisible) { if let observedWindow { @@ -1465,6 +1475,7 @@ struct ContentView: View { } else { TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() } + updateSidebarResizerBandState() syncTrafficLightInset() } .onChange(of: sidebarMatchTerminalBackground) { @@ -1485,7 +1496,7 @@ struct ContentView: View { sidebarState.persistedWidth = sanitized return } - guard !isSidebarResizerDragging else { return } + guard !isResizerDragging else { return } if abs(sidebarWidth - sanitized) > 0.5 { sidebarWidth = sanitized } @@ -1496,6 +1507,14 @@ struct ContentView: View { private func attachFinalLifecycleHandlers(to view: some View) -> some View { view .ignoresSafeArea() + .onDisappear { + if isResizerDragging { + TerminalWindowPortalRegistry.endInteractiveGeometryResize() + isResizerDragging = false + sidebarDragStartWidth = nil + } + removeSidebarResizerPointerMonitor() + } } @ViewBuilder @@ -1525,6 +1544,8 @@ struct ContentView: View { isFullScreen = window.styleMask.contains(.fullScreen) clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width) syncCommandPaletteDebugStateForObservedWindow() + installSidebarResizerPointerMonitorIfNeeded() + updateSidebarResizerBandState() } } diff --git a/Sources/VerticalTabsSidebar.swift b/Sources/VerticalTabsSidebar.swift index 992d143f..fad40e40 100644 --- a/Sources/VerticalTabsSidebar.swift +++ b/Sources/VerticalTabsSidebar.swift @@ -42,6 +42,19 @@ enum SidebarResizeInteraction { } } +struct SidebarResizerAccessibilityModifier: ViewModifier { + let accessibilityIdentifier: String? + + @ViewBuilder + func body(content: Content) -> some View { + if let accessibilityIdentifier { + content.accessibilityIdentifier(accessibilityIdentifier) + } else { + content + } + } +} + struct SidebarTabItemSettingsSnapshot: Equatable { let sidebarShortcutHintXOffset: Double let sidebarShortcutHintYOffset: Double From bf2f21968b79c438e1db318ad48553e31cce8208 Mon Sep 17 00:00:00 2001 From: arzafran Date: Sat, 15 Aug 2026 00:24:36 -0300 Subject: [PATCH 14/14] fix: restore command palette startup lifecycle --- Sources/CommandPaletteController.swift | 22 +---- Sources/ContentView.swift | 14 +-- Sources/WindowOverlayControllers.swift | 85 +------------------ .../AppDelegateShortcutRoutingTests.swift | 67 --------------- 4 files changed, 7 insertions(+), 181 deletions(-) diff --git a/Sources/CommandPaletteController.swift b/Sources/CommandPaletteController.swift index 4a34042e..f03aaf7a 100644 --- a/Sources/CommandPaletteController.swift +++ b/Sources/CommandPaletteController.swift @@ -4,12 +4,11 @@ // ContentView and is exclusively used by the command palette (query, mode, // search corpus/results, rename/workspace-description drafts, focus-restore // targets, usage history, etc.). ContentView holds a single -// `@State private var commandPaletteController` and exposes each +// `@StateObject private var commandPaletteController` and exposes each // property back to its existing (unqualified) call sites in its body via // thin computed proxies — this keeps the ~4000 lines of palette orchestration // code that reads/writes these properties unchanged while genuinely moving -// storage ownership onto the controller without subscribing the entire window -// shell to palette-only updates. +// storage ownership onto the controller (no more @State duplicated per-view). // // The two @FocusState properties (isCommandPaletteSearchFocused, // isCommandPaletteRenameFocused) stay on ContentView: @FocusState is a @@ -70,20 +69,3 @@ final class CommandPaletteController: ObservableObject { var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces @Published var commandPaletteShouldFocusWorkspaceDescriptionEditor = false } - -/// The existing AppKit window overlay hosts this root directly. It is the only -/// SwiftUI owner that observes palette-only query, result, and selection state. -struct CommandPaletteRootView: View { - @ObservedObject var controller: CommandPaletteController - let content: () -> AnyView - - var body: some View { - Group { - if controller.isCommandPalettePresented { - content() - } else { - EmptyView() - } - } - } -} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d748e6c8..9eb55111 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -40,10 +40,7 @@ struct ContentView: View { @State var isResizerBandActive = false @State var isSidebarResizerCursorActive = false @State var sidebarResizerCursorStabilizer: DispatchSourceTimer? - // The dedicated CommandPaletteRootView observes this reference inside the - // AppKit overlay. Keeping only its identity in State prevents palette query - // and selection publishes from invalidating the whole window shell. - @State private var commandPaletteController = CommandPaletteController() + @StateObject private var commandPaletteController = CommandPaletteController() private var isCommandPalettePresented: Bool { get { commandPaletteController.isCommandPalettePresented } nonmutating set { commandPaletteController.isCommandPalettePresented = newValue } @@ -1399,14 +1396,7 @@ struct ContentView: View { let tmuxOverlayController = tmuxWorkspacePaneWindowOverlayController(for: window) tmuxOverlayController.update(state: tmuxWorkspacePaneWindowOverlayState(for: window)) let overlayController = commandPaletteWindowOverlayController(for: window) - let paletteRoot = CommandPaletteRootView( - controller: commandPaletteController, - content: { AnyView(commandPaletteOverlay) } - ) - overlayController.update( - rootView: AnyView(paletteRoot), - controller: commandPaletteController - ) + overlayController.update(rootView: AnyView(commandPaletteOverlay), isVisible: isCommandPalettePresented) } }) } diff --git a/Sources/WindowOverlayControllers.swift b/Sources/WindowOverlayControllers.swift index 38f6c5b5..f5ce2766 100644 --- a/Sources/WindowOverlayControllers.swift +++ b/Sources/WindowOverlayControllers.swift @@ -1,6 +1,5 @@ import AppKit import Bonsplit -import Combine import ObjectiveC import SwiftUI import WebKit @@ -116,10 +115,6 @@ final class WindowCommandPaletteOverlayController: NSObject { private var isPaletteVisible = false private var windowDidBecomeKeyObserver: NSObjectProtocol? private var windowDidResignKeyObserver: NSObjectProtocol? - private var windowWillCloseObserver: NSObjectProtocol? - private var paletteVisibilityCancellable: AnyCancellable? - private var observedPaletteControllerID: ObjectIdentifier? - private var isTornDown = false init(window: NSWindow) { self.window = window @@ -143,12 +138,10 @@ final class WindowCommandPaletteOverlayController: NSObject { ]) _ = ensureInstalled() installWindowKeyObservers() - installWindowCloseObserver() } @discardableResult private func ensureInstalled() -> Bool { - guard !isTornDown else { return false } guard let window, let contentView = window.contentView, let themeFrame = contentView.superview else { return false } @@ -342,7 +335,6 @@ final class WindowCommandPaletteOverlayController: NSObject { } private func focusIntoPalette(retries: Int) { - guard !isTornDown else { return } guard let window else { return } #if DEBUG dlog( @@ -434,56 +426,7 @@ final class WindowCommandPaletteOverlayController: NSObject { } } - private func installWindowCloseObserver() { - guard let window else { return } - windowWillCloseObserver = NotificationCenter.default.addObserver( - forName: NSWindow.willCloseNotification, - object: window, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.tearDown() - } - } - } - - private func tearDown() { - guard !isTornDown else { return } - isTornDown = true - - paletteVisibilityCancellable?.cancel() - paletteVisibilityCancellable = nil - observedPaletteControllerID = nil - stopFocusLockTimer() - isPaletteVisible = false - - if let window, isPaletteResponder(window.firstResponder) { - _ = window.makeFirstResponder(nil) - } - hostingView.rootView = AnyView(EmptyView()) - containerView.capturesMouseEvents = false - containerView.alphaValue = 0 - containerView.isHidden = true - NSLayoutConstraint.deactivate(installConstraints) - installConstraints.removeAll() - containerView.removeFromSuperview() - installedThemeFrame = nil - - for observer in [windowDidBecomeKeyObserver, windowDidResignKeyObserver, windowWillCloseObserver] { - if let observer { - NotificationCenter.default.removeObserver(observer) - } - } - windowDidBecomeKeyObserver = nil - windowDidResignKeyObserver = nil - windowWillCloseObserver = nil - } - private func updateFocusLockForWindowState() { - guard !isTornDown else { - stopFocusLockTimer() - return - } guard let window else { stopFocusLockTimer() return @@ -525,7 +468,6 @@ final class WindowCommandPaletteOverlayController: NSObject { } private func startFocusLockTimer() { - guard !isTornDown else { return } guard focusLockTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: .main) timer.schedule(deadline: .now(), repeating: .milliseconds(80), leeway: .milliseconds(12)) @@ -569,30 +511,7 @@ final class WindowCommandPaletteOverlayController: NSObject { editor.setSelectedRange(NSRange(location: length, length: 0)) } - func update(rootView: AnyView, controller: CommandPaletteController) { - guard !isTornDown else { return } - guard ensureInstalled() else { return } - hostingView.rootView = rootView - - let controllerID = ObjectIdentifier(controller) - if observedPaletteControllerID != controllerID { - paletteVisibilityCancellable?.cancel() - observedPaletteControllerID = controllerID - paletteVisibilityCancellable = controller.$isCommandPalettePresented - .removeDuplicates() - .sink { [weak self] isVisible in - Task { @MainActor [weak self] in - self?.setVisible(isVisible) - } - } - } - setVisible(controller.isCommandPalettePresented) - } - - /// The AppKit owner observes only presentation state. Query, result, and - /// selection publishes remain scoped to `CommandPaletteRootView`. - private func setVisible(_ isVisible: Bool) { - guard !isTornDown else { return } + func update(rootView: AnyView, isVisible: Bool) { guard ensureInstalled() else { return } let shouldPromote = CommandPaletteOverlayPromotionPolicy.shouldPromote( previouslyVisible: isPaletteVisible, @@ -611,6 +530,7 @@ final class WindowCommandPaletteOverlayController: NSObject { #endif isPaletteVisible = isVisible if isVisible { + hostingView.rootView = rootView containerView.capturesMouseEvents = true containerView.isHidden = false containerView.alphaValue = 1 @@ -623,6 +543,7 @@ final class WindowCommandPaletteOverlayController: NSObject { if let window, isPaletteResponder(window.firstResponder) { _ = window.makeFirstResponder(nil) } + hostingView.rootView = AnyView(EmptyView()) containerView.capturesMouseEvents = false containerView.alphaValue = 0 containerView.isHidden = true diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index fedb02f8..1f9436b5 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -1,5 +1,4 @@ import XCTest -import SwiftUI #if canImport(Programa_DEV) @testable import Programa_DEV @@ -12,14 +11,6 @@ private final class FakeWKInspectorContainerView: NSView {} private final class FocusableTestView: NSView { override var acceptsFirstResponder: Bool { true } } -private final class CommandPaletteOverlayLifetimeProbe {} -private struct CommandPaletteOverlayProbeView: View { - let probe: CommandPaletteOverlayLifetimeProbe - - var body: some View { - EmptyView() - } -} @MainActor final class AppDelegateShortcutRoutingTests: XCTestCase { @@ -3217,64 +3208,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(observedDelta, 1) } - func testCommandPaletteOverlayReleasesHostedRootAndStopsAfterWindowClose() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - window.contentView = NSView(frame: window.contentLayoutRect) - - let overlayController = commandPaletteWindowOverlayController(for: window) - let paletteController = CommandPaletteController() - paletteController.isCommandPalettePresented = true - - weak var weakProbe: CommandPaletteOverlayLifetimeProbe? - do { - let probe = CommandPaletteOverlayLifetimeProbe() - weakProbe = probe - overlayController.update( - rootView: AnyView(CommandPaletteOverlayProbeView(probe: probe)), - controller: paletteController - ) - } - - guard let overlayContainer = findRealCommandPaletteOverlayContainer(in: window) else { - XCTFail("Expected the command palette overlay to be installed") - return - } - XCTAssertNotNil(weakProbe) - XCTAssertNotNil(overlayContainer.superview) - - let paletteTextField = NSTextField(frame: NSRect(x: 12, y: 12, width: 180, height: 24)) - overlayContainer.addSubview(paletteTextField) - XCTAssertTrue(window.makeFirstResponder(paletteTextField)) - XCTAssertTrue( - window.firstResponder === paletteTextField || - ((window.firstResponder as? NSTextView)?.delegate as? NSTextField) === paletteTextField - ) - - NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) - XCTAssertTrue( - waitUntil(description: "command palette hosted root release") { - weakProbe == nil - } - ) - XCTAssertNil(overlayContainer.superview) - XCTAssertFalse( - window.firstResponder === paletteTextField || - ((window.firstResponder as? NSTextView)?.delegate as? NSTextField) === paletteTextField - ) - - // A late controller publish and a duplicate close must both be harmless. - paletteController.isCommandPalettePresented = false - paletteController.isCommandPalettePresented = true - NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) - XCTAssertNil(overlayContainer.superview) - } - func testControlKDoesNotRoutePaletteMoveSelectionWhenSearchFieldIsFocused() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared")