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/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/ContentView+SidebarResizer.swift b/Sources/ContentView+SidebarResizer.swift index f46d89c6..6f446c67 100644 --- a/Sources/ContentView+SidebarResizer.swift +++ b/Sources/ContentView+SidebarResizer.swift @@ -71,7 +71,9 @@ extension ContentView { private func activateSidebarResizerCursor() { sidebarResizerCursorReleaseWorkItem?.cancel() sidebarResizerCursorReleaseWorkItem = nil - isSidebarResizerCursorActive = true + if !isSidebarResizerCursorActive { + isSidebarResizerCursorActive = true + } Self.fixedSidebarResizeCursor.set() } 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/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/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/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/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/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 } 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) ) 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. 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/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" + } + } + } +} diff --git a/programaTests/TerminalControllerSocketSecurityTests.swift b/programaTests/TerminalControllerSocketSecurityTests.swift index 93f9130c..49782787 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,80 @@ 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 + ) + 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", + ]) + _ = 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 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 diff --git a/scripts/smoke-test-ci.sh b/scripts/smoke-test-ci.sh index 7cbdf370..f1485ca9 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 @@ -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) ") @@ -114,10 +117,17 @@ 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 ---" - 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