diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index ec05061a0..86a41f25c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -13,7 +13,7 @@ body: id: before-reporting attributes: label: Before Reporting - description: Before reporting the bug, we suggestion that you first refer to the [FAQ](https://github.com/intitni/CopilotForXcode/wiki/Frequently-Asked-Questions) to check if it may address your issue. And search for existing issues to avoid duplication. + description: Before reporting the bug, we suggestion that you first refer to the [FAQ](https://github.com/intitni/CopilotForXcode/wiki/Frequently-Asked-Questions) to check if it may address your issue. And search for existing issues to avoid duplication. If you are reporting a bug from a beta build, please use the dedicated template for beta build. options: - label: I have checked FAQ, and there is no solution to my issue required: true @@ -32,9 +32,9 @@ body: id: reproduce attributes: label: How to reproduce the bug. - description: If possible, please provide the steps to reproduce the bug. + description: If possible, please provide the steps to reproduce the bug and relevant settings in screenshots. placeholder: "1. *****\n2.*****" - value: "It just happens!" + value: "It just happened!" - type: textarea id: logs attributes: @@ -53,8 +53,4 @@ body: id: copilot-for-xcode-version attributes: label: Copilot for Xcode version - - type: input - id: node-version - attributes: - label: Node version diff --git a/.github/ISSUE_TEMPLATE/z_bug_report_beta.yaml b/.github/ISSUE_TEMPLATE/z_bug_report_beta.yaml new file mode 100644 index 000000000..2f80eaaf3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/z_bug_report_beta.yaml @@ -0,0 +1,56 @@ +name: Bug Report (Beta) +description: File a bug report +title: "[Bug (Beta)]: " +labels: ["bug", "beta"] +assignees: + - intitni +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: checkboxes + id: before-reporting + attributes: + label: Before Reporting + description: Before reporting the bug, we suggestion that you first refer to the [FAQ](https://github.com/intitni/CopilotForXcode/wiki/Frequently-Asked-Questions) to check if it may address your issue. And search for existing issues to avoid duplication. + options: + - label: I have checked FAQ, and there is no solution to my issue + required: true + - label: I have searched the existing issues, and there is no existing issue for my issue + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what did you expect to happen? + placeholder: Tell us what you see! + value: "A bug happened!" + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: How to reproduce the bug. + description: If possible, please provide the steps to reproduce the bug and relevant settings in screenshots. + placeholder: "1. *****\n2.*****" + value: "It just happened!" + - type: textarea + id: logs + attributes: + label: Relevant log output + description: If it's a crash, please provide the crash report. You can find it in the Console.app. + render: shell + - type: input + id: mac-version + attributes: + label: macOS version + - type: input + id: xcode-version + attributes: + label: Xcode version + - type: input + id: copilot-for-xcode-version + attributes: + label: Copilot for Xcode version + diff --git a/Core/Package.swift b/Core/Package.swift index 32d85d132..0eab77eec 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -242,6 +242,7 @@ let package = Package( .target( name: "ChatPlugin", dependencies: [ + .product(name: "AppMonitoring", package: "Tool"), .product(name: "OpenAIService", package: "Tool"), .product(name: "Terminal", package: "Tool"), ] diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index c2fad5436..d2bcea30b 100644 --- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift +++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift @@ -31,7 +31,7 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType { return userPreferredLanguage.isEmpty ? "" : " in \(userPreferredLanguage)" }() - let editor: EditorInformation = XcodeInspector.shared.focusedEditorContent ?? .init( + let editor: EditorInformation = XcodeInspector.shared.getFocusedEditorContent() ?? .init( editorContent: .init( content: source.content, lines: source.lines, diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 52f05d076..c6fa353b8 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -31,12 +31,12 @@ public actor RealtimeSuggestionController { private func observeXcodeChange() { cancellable.forEach { $0.cancel() } - + XcodeInspector.shared.$focusedEditor .sink { [weak self] editor in guard let self else { return } Task { - guard let editor else { return } + guard let editor else { return } await self.handleFocusElementChange(editor) } }.store(in: &cancellable) @@ -51,7 +51,7 @@ public actor RealtimeSuggestionController { } self.sourceEditor = sourceEditor - + let notificationsFromEditor = sourceEditor.axNotifications editorObservationTask?.cancel() @@ -65,25 +65,54 @@ public actor RealtimeSuggestionController { ) } - for await notification in notificationsFromEditor { - guard let self else { return } - try Task.checkCancellation() - - switch notification.kind { - case .valueChanged: - await cancelInFlightTasks() - await self.triggerPrefetchDebounced() - await self.notifyEditingFileChange(editor: sourceEditor.element) - case .selectedTextChanged: - guard let fileURL = XcodeInspector.shared.activeDocumentURL - else { break } - await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( - fileURL: fileURL, - sourceEditor: sourceEditor - ) - default: - break + let valueChange = notificationsFromEditor.filter { $0.kind == .valueChanged } + let selectedTextChanged = notificationsFromEditor + .filter { $0.kind == .selectedTextChanged } + + await withTaskGroup(of: Void.self) { [weak self] group in + group.addTask { [weak self] in + let handler = { [weak self] in + guard let self else { return } + await cancelInFlightTasks() + await self.triggerPrefetchDebounced() + await self.notifyEditingFileChange(editor: sourceEditor.element) + } + + if #available(macOS 13.0, *) { + for await _ in valueChange.throttle(for: .milliseconds(200)) { + if Task.isCancelled { return } + await handler() + } + } else { + for await _ in valueChange { + if Task.isCancelled { return } + await handler() + } + } } + group.addTask { + let handler = { + guard let fileURL = XcodeInspector.shared.activeDocumentURL else { return } + await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( + fileURL: fileURL, + sourceEditor: sourceEditor + ) + } + + if #available(macOS 13.0, *) { + for await _ in selectedTextChanged.throttle(for: .milliseconds(200)) { + if Task.isCancelled { return } + await handler() + } + } else { + for await _ in selectedTextChanged { + if Task.isCancelled { return } + await handler() + } + } + } + + await group.waitForAll() } } @@ -94,7 +123,7 @@ public actor RealtimeSuggestionController { .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) if filespace.codeMetadata.uti == nil { - Logger.service.info("Generate cache for file.") + Logger.service.info("Generate cache for file.") // avoid the command get called twice filespace.codeMetadata.uti = "" do { @@ -111,10 +140,11 @@ public actor RealtimeSuggestionController { func triggerPrefetchDebounced(force: Bool = false) { inflightPrefetchTask = Task(priority: .utility) { @WorkspaceActor in - try? await Task.sleep(nanoseconds: UInt64(( + try? await Task.sleep(nanoseconds: UInt64( max(UserDefaults.shared.value(for: \.realtimeSuggestionDebounce), 0.15) - ) * 1_000_000_000)) - + * 1_000_000_000 + )) + if Task.isCancelled { return } guard UserDefaults.shared.value(for: \.realtimeSuggestionToggle) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index b1742cfab..fae36a704 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift @@ -85,7 +85,7 @@ public struct ChatPanelFeature: ReducerProtocol { @Dependency(\.chatTabBuilderCollection) var chatTabBuilderCollection @MainActor func toggleFullScreen() { - let window = suggestionWidgetControllerDependency.windows + let window = suggestionWidgetControllerDependency.windowsController?.windows .chatPanelWindow window?.toggleFullScreen(nil) } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift index abbf302ad..8c09a7691 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift @@ -16,7 +16,6 @@ public struct CircularWidgetFeature: ReducerProtocol { var isContentEmpty: Bool var isChatPanelDetached: Bool var isChatOpen: Bool - var animationProgress: Double = 0 } public enum Action: Equatable { @@ -27,7 +26,6 @@ public struct CircularWidgetFeature: ReducerProtocol { case markIsProcessing case endIsProcessing case _forceEndIsProcessing - case _refreshRing } struct CancelAutoEndIsProcessKey: Hashable {} @@ -75,14 +73,6 @@ public struct CircularWidgetFeature: ReducerProtocol { state.isProcessingCounters.removeAll() state.isProcessing = false return .none - - case ._refreshRing: - if state.isProcessing { - state.animationProgress = 1 - state.animationProgress - } else { - state.animationProgress = state.isContentEmpty ? 0 : 1 - } - return .none } } } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift index 5af9f2ba3..5208bf3bc 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift @@ -38,7 +38,7 @@ public struct PanelFeature: ReducerProtocol { @Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency @Dependency(\.xcodeInspector) var xcodeInspector @Dependency(\.activateThisApp) var activateThisApp - var windows: WidgetWindows { suggestionWidgetControllerDependency.windows } + var windows: WidgetWindows? { suggestionWidgetControllerDependency.windowsController?.windows } public var body: some ReducerProtocol { Scope(state: \.suggestionPanelState, action: /Action.suggestionPanel) { @@ -122,7 +122,9 @@ public struct PanelFeature: ReducerProtocol { if hasPromptToCode { activateThisApp() - await windows.sharedPanelWindow.makeKey() + await MainActor.run { + windows?.sharedPanelWindow.makeKey() + } } }.animation(.easeInOut(duration: 0.2)) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 503ee8dbf..cbb1979f2 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -39,7 +39,6 @@ public struct WidgetFeature: ReducerProtocol { public struct CircularWidgetState: Equatable { var isProcessingCounters = [CircularWidgetFeature.IsProcessingCounter]() var isProcessing: Bool = false - var animationProgress: Double = 0 } public var circularWidgetState = CircularWidgetState() @@ -67,21 +66,17 @@ public struct WidgetFeature: ReducerProtocol { isContentEmpty: chatPanelState.chatTabGroup.tabInfo.isEmpty && panelState.sharedPanelState.isEmpty, isChatPanelDetached: chatPanelState.chatPanelInASeparateWindow, - isChatOpen: chatPanelState.isPanelDisplayed, - animationProgress: circularWidgetState.animationProgress + isChatOpen: chatPanelState.isPanelDisplayed ) } set { circularWidgetState = .init( isProcessingCounters: newValue.isProcessingCounters, - isProcessing: newValue.isProcessing, - animationProgress: newValue.animationProgress + isProcessing: newValue.isProcessing ) } } - var lastUpdateWindowOpacityTime = Date(timeIntervalSince1970: 0) - public init() {} } @@ -97,21 +92,14 @@ public struct WidgetFeature: ReducerProtocol { public enum Action: Equatable { case startup case observeActiveApplicationChange - case observeCompletionPanelChange case observeFullscreenChange case observeColorSchemeChange - case observePresentationModeChange - - case observeWindowChange - case observeEditorChange case updateActiveApplication case updateColorScheme - case updateWindowLocation(animated: Bool) - case updateWindowOpacity(immediately: Bool) + case updatePanelStateToMatch(WidgetLocation) case updateFocusingDocumentURL - case updateWindowOpacityFinished case updateKeyWindow(WindowCanBecomeKey) case toastPanel(ToastPanel.Action) @@ -120,8 +108,8 @@ public struct WidgetFeature: ReducerProtocol { case circularWidget(CircularWidgetFeature.Action) } - var windows: WidgetWindows { - suggestionWidgetControllerDependency.windows + var windowsController: WidgetWindowsController? { + suggestionWidgetControllerDependency.windowsController } @Dependency(\.suggestionWidgetUserDefaultsObservers) var userDefaultsObservers @@ -203,20 +191,27 @@ public struct WidgetFeature: ReducerProtocol { switch action { case .chatPanel(.presentChatPanel): let isDetached = state.chatPanelState.chatPanelInASeparateWindow - return .run { send in - await send(.updateWindowLocation(animated: false)) - await send(.updateWindowOpacity(immediately: false)) + return .run { _ in + await windowsController?.updateWindowLocation( + animated: false, + immediately: false + ) + await windowsController?.updateWindowOpacity(immediately: false) if isDetached { Task { @MainActor in - windows.chatPanelWindow.isWindowHidden = false + windowsController?.windows.chatPanelWindow.isWindowHidden = false } } } + case .chatPanel(.toggleChatPanelDetachedButtonClicked): let isDetached = state.chatPanelState.chatPanelInASeparateWindow - return .run { send in - await send(.updateWindowLocation(animated: !isDetached)) - await send(.updateWindowOpacity(immediately: false)) + return .run { _ in + await windowsController?.updateWindowLocation( + animated: !isDetached, + immediately: false + ) + await windowsController?.updateWindowOpacity(immediately: false) } default: return .none } @@ -229,10 +224,8 @@ public struct WidgetFeature: ReducerProtocol { .run { send in await send(.toastPanel(.start)) await send(.observeActiveApplicationChange) - await send(.observeCompletionPanelChange) await send(.observeFullscreenChange) await send(.observeColorSchemeChange) - await send(.observePresentationModeChange) } ) @@ -258,32 +251,6 @@ public struct WidgetFeature: ReducerProtocol { } }.cancellable(id: CancelID.observeActiveApplicationChange, cancelInFlight: true) - case .observeCompletionPanelChange: - return .run { send in - let stream = AsyncStream { continuation in - let cancellable = XcodeInspector.shared.$completionPanel.sink { newValue in - Task { - if newValue == nil { - // so that the buttons on the suggestion panel could be - // clicked - // before the completion panel updates the location of the - // suggestion panel - try await Task.sleep(nanoseconds: 400_000_000) - } - continuation.yield() - } - } - continuation.onTermination = { _ in - cancellable.cancel() - } - } - for await _ in stream { - try Task.checkCancellation() - await send(.updateWindowLocation(animated: false)) - await send(.updateWindowOpacity(immediately: false)) - } - }.cancellable(id: CancelID.observeCompletionPanelChange, cancelInFlight: true) - case .observeFullscreenChange: return .run { _ in let sequence = NSWorkspace.shared.notificationCenter @@ -291,12 +258,14 @@ public struct WidgetFeature: ReducerProtocol { for await _ in sequence { try Task.checkCancellation() guard let activeXcode = xcodeInspector.activeXcode else { continue } - guard await windows.fullscreenDetector.isOnActiveSpace else { continue } + guard let windowsController, + await windowsController.windows.fullscreenDetector.isOnActiveSpace + else { continue } let app = AXUIElementCreateApplication( activeXcode.processIdentifier ) if let _ = app.focusedWindow { - await windows.orderFront() + await windowsController.windows.orderFront() } } }.cancellable(id: CancelID.observeFullscreenChange, cancelInFlight: true) @@ -325,117 +294,13 @@ public struct WidgetFeature: ReducerProtocol { } }.cancellable(id: CancelID.observeUserDefaults, cancelInFlight: true) - case .observePresentationModeChange: - return .run { send in - await send(.updateColorScheme) - let stream = AsyncStream { continuation in - userDefaultsObservers.presentationModeChangeObserver.onChange = { - continuation.yield() - } - - continuation.onTermination = { _ in - userDefaultsObservers.presentationModeChangeObserver.onChange = {} - } - } - - for await _ in stream { - try Task.checkCancellation() - await send(.updateWindowLocation(animated: false)) - } - }.cancellable(id: CancelID.observeUserDefaults, cancelInFlight: true) - - case .observeWindowChange: - guard let app = xcodeInspector.activeXcode else { return .none } - - let documentURL = state.focusingDocumentURL - - let notifications = app.axNotifications - - #warning("TODO: Handling events outside of TCA because the fire rate is too high.") - - return .run { send in - await send(.observeEditorChange) - await send(.panel(.switchToAnotherEditorAndUpdateContent)) - - for await notification in notifications { - try Task.checkCancellation() - - // Hide the widgets before switching to another window/editor - // so the transition looks better. - if [ - .focusedUIElementChanged, - .focusedWindowChanged, - ].contains(notification.kind) { - let newDocumentURL = xcodeInspector.realtimeActiveDocumentURL - if documentURL != newDocumentURL { - await send(.panel(.removeDisplayedContent)) - await hidePanelWindows() - } - await send(.updateFocusingDocumentURL) - } - - // update widgets. - if [ - .focusedUIElementChanged, - .applicationActivated, - .mainWindowChanged, - .focusedWindowChanged, - ].contains(notification.kind) { - await send(.updateWindowLocation(animated: false)) - await send(.updateWindowOpacity(immediately: false)) - await send(.observeEditorChange) - await send(.panel(.switchToAnotherEditorAndUpdateContent)) - } else { - await send(.updateWindowLocation(animated: false)) - await send(.updateWindowOpacity(immediately: false)) - } - } - }.cancellable(id: CancelID.observeWindowChange, cancelInFlight: true) - - case .observeEditorChange: - guard let editor = xcodeInspector.focusedEditor else { return .none } - - let selectionRangeChange = editor.axNotifications - .filter { $0.kind == .selectedTextChanged } - let scroll = editor.axNotifications - .filter { $0.kind == .scrollPositionChanged } - - return .run { send in - if #available(macOS 13.0, *) { - for await _ in merge( - selectionRangeChange.debounce(for: Duration.milliseconds(500)), - scroll - ) { - guard xcodeInspector.latestActiveXcode != nil else { return } - try Task.checkCancellation() - await send(.updateWindowLocation(animated: false)) - await send(.updateWindowOpacity(immediately: false)) - } - } else { - for await _ in merge(selectionRangeChange, scroll) { - guard xcodeInspector.latestActiveXcode != nil else { return } - try Task.checkCancellation() - await send(.updateWindowLocation(animated: false)) - await send(.updateWindowOpacity(immediately: false)) - } - } - - }.cancellable(id: CancelID.observeEditorChange, cancelInFlight: true) - case .updateActiveApplication: if let app = xcodeInspector.activeApplication, app.isXcode { return .run { send in await send(.panel(.switchToAnotherEditorAndUpdateContent)) - await send(.updateWindowLocation(animated: false)) - await send(.updateWindowOpacity(immediately: true)) - await windows.orderFront() - await send(.observeWindowChange) } } - return .run { send in - await send(.updateWindowLocation(animated: false)) - await send(.updateWindowOpacity(immediately: true)) - } + return .none case .updateColorScheme: let widgetColorScheme = UserDefaults.shared.value(for: \.widgetColorScheme) @@ -465,8 +330,7 @@ public struct WidgetFeature: ReducerProtocol { state.focusingDocumentURL = xcodeInspector.realtimeActiveDocumentURL return .none - case let .updateWindowLocation(animated): - guard let widgetLocation = generateWidgetLocation() else { return .none } + case let .updatePanelStateToMatch(widgetLocation): state.panelState.sharedPanelState.alignTopToAnchor = widgetLocation .defaultPanelLocation .alignPanelTop @@ -484,122 +348,19 @@ public struct WidgetFeature: ReducerProtocol { .defaultPanelLocation .alignPanelTop - let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow - - return .run { _ in - Task { @MainActor in - windows.widgetWindow.setFrame( - widgetLocation.widgetFrame, - display: false, - animate: animated - ) - windows.toastWindow.setFrame( - widgetLocation.defaultPanelLocation.frame, - display: false, - animate: animated - ) - windows.sharedPanelWindow.setFrame( - widgetLocation.defaultPanelLocation.frame, - display: false, - animate: animated - ) - - if let suggestionPanelLocation = widgetLocation.suggestionPanelLocation { - windows.suggestionPanelWindow.setFrame( - suggestionPanelLocation.frame, - display: false, - animate: animated - ) - } - - if isChatPanelDetached { - // don't update it! - } else { - windows.chatPanelWindow.setFrame( - widgetLocation.defaultPanelLocation.frame, - display: false, - animate: animated - ) - } - } - } - - case let .updateWindowOpacity(immediately): - let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow - let hasChat = !state.chatPanelState.chatTabGroup.tabInfo.isEmpty - let shouldDebounce = !immediately && - Date().timeIntervalSince(state.lastUpdateWindowOpacityTime) < 1 - return .run { send in - let activeApp = xcodeInspector.activeApplication - if shouldDebounce { - try await mainQueue.sleep(for: .seconds(0.2)) - } - try Task.checkCancellation() - let task = Task { @MainActor in - if let activeApp, activeApp.isXcode { - let application = AXUIElementCreateApplication( - activeApp.processIdentifier - ) - /// We need this to hide the windows when Xcode is minimized. - let noFocus = application.focusedWindow == nil - windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 - windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 - windows.widgetWindow.alphaValue = noFocus ? 0 : 1 - windows.toastWindow.alphaValue = noFocus ? 0 : 1 - - if isChatPanelDetached { - windows.chatPanelWindow.isWindowHidden = !hasChat - } else { - windows.chatPanelWindow.isWindowHidden = noFocus - } - } else if let activeApp, activeApp.isExtensionService { - let noFocus = { - guard let xcode = xcodeInspector.latestActiveXcode - else { return true } - if let window = xcode.appElement.focusedWindow, - window.role == "AXWindow" - { - return false - } - return true - }() - - windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 - windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 - windows.widgetWindow.alphaValue = noFocus ? 0 : 1 - windows.toastWindow.alphaValue = noFocus ? 0 : 1 - if isChatPanelDetached { - windows.chatPanelWindow.isWindowHidden = !hasChat - } else { - windows.chatPanelWindow.isWindowHidden = noFocus && !windows - .chatPanelWindow.isKeyWindow - } - } else { - windows.sharedPanelWindow.alphaValue = 0 - windows.suggestionPanelWindow.alphaValue = 0 - windows.widgetWindow.alphaValue = 0 - windows.toastWindow.alphaValue = 0 - if !isChatPanelDetached { - windows.chatPanelWindow.isWindowHidden = true - } - } - } - _ = await task.value - await send(.updateWindowOpacityFinished) - } - .cancellable(id: DebounceKey.updateWindowOpacity, cancelInFlight: true) - - case .updateWindowOpacityFinished: - state.lastUpdateWindowOpacityTime = Date() return .none case let .updateKeyWindow(window): return .run { _ in - switch window { - case .chatPanel: - await windows.chatPanelWindow.makeKeyAndOrderFront(nil) - case .sharedPanel: - await windows.sharedPanelWindow.makeKeyAndOrderFront(nil) + await MainActor.run { + switch window { + case .chatPanel: + windowsController?.windows.chatPanelWindow + .makeKeyAndOrderFront(nil) + case .sharedPanel: + windowsController?.windows.sharedPanelWindow + .makeKeyAndOrderFront(nil) + } } } @@ -619,113 +380,3 @@ public struct WidgetFeature: ReducerProtocol { } } -extension WidgetFeature { - @MainActor - func hidePanelWindows() { - windows.sharedPanelWindow.alphaValue = 0 - windows.suggestionPanelWindow.alphaValue = 0 - } - - func generateWidgetLocation() -> WidgetLocation? { - if let application = xcodeInspector.latestActiveXcode?.appElement { - if let focusElement = xcodeInspector.focusedEditor?.element, - let parent = focusElement.parent, - let frame = parent.rect, - let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), - let firstScreen = NSScreen.main - { - let positionMode = UserDefaults.shared - .value(for: \.suggestionWidgetPositionMode) - let suggestionMode = UserDefaults.shared - .value(for: \.suggestionPresentationMode) - - switch positionMode { - case .fixedToBottom: - var result = UpdateLocationStrategy.FixedToBottom().framesForWindows( - editorFrame: frame, - mainScreen: screen, - activeScreen: firstScreen - ) - switch suggestionMode { - case .nearbyTextCursor: - result.suggestionPanelLocation = UpdateLocationStrategy - .NearbyTextCursor() - .framesForSuggestionWindow( - editorFrame: frame, mainScreen: screen, - activeScreen: firstScreen, - editor: focusElement, - completionPanel: xcodeInspector.completionPanel - ) - default: - break - } - return result - case .alignToTextCursor: - var result = UpdateLocationStrategy.AlignToTextCursor().framesForWindows( - editorFrame: frame, - mainScreen: screen, - activeScreen: firstScreen, - editor: focusElement - ) - switch suggestionMode { - case .nearbyTextCursor: - result.suggestionPanelLocation = UpdateLocationStrategy - .NearbyTextCursor() - .framesForSuggestionWindow( - editorFrame: frame, mainScreen: screen, - activeScreen: firstScreen, - editor: focusElement, - completionPanel: xcodeInspector.completionPanel - ) - default: - break - } - return result - } - } else if var window = application.focusedWindow, - var frame = application.focusedWindow?.rect, - !["menu bar", "menu bar item"].contains(window.description), - frame.size.height > 300, - let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), - let firstScreen = NSScreen.main - { - if ["open_quickly"].contains(window.identifier) - || ["alert"].contains(window.label) - { - // fallback to use workspace window - guard let workspaceWindow = application.windows - .first(where: { $0.identifier == "Xcode.WorkspaceWindow" }), - let rect = workspaceWindow.rect - else { - return WidgetLocation( - widgetFrame: .zero, - tabFrame: .zero, - defaultPanelLocation: .init(frame: .zero, alignPanelTop: false) - ) - } - - window = workspaceWindow - frame = rect - } - - if ["Xcode.WorkspaceWindow"].contains(window.identifier) { - // extra padding to bottom so buttons won't be covered - frame.size.height -= 40 - } else { - // move a bit away from the window so buttons won't be covered - frame.origin.x -= Style.widgetPadding + Style.widgetWidth / 2 - frame.size.width += Style.widgetPadding * 2 + Style.widgetWidth - } - - return UpdateLocationStrategy.FixedToBottom().framesForWindows( - editorFrame: frame, - mainScreen: screen, - activeScreen: firstScreen, - preferredInsideEditorMinWidth: 9_999_999_999 // never - ) - } - } - return nil - } -} - diff --git a/Core/Sources/SuggestionWidget/ModuleDependency.swift b/Core/Sources/SuggestionWidget/ModuleDependency.swift index af322303f..0e83df6ae 100644 --- a/Core/Sources/SuggestionWidget/ModuleDependency.swift +++ b/Core/Sources/SuggestionWidget/ModuleDependency.swift @@ -13,32 +13,11 @@ public final class SuggestionWidgetControllerDependency { public var suggestionWidgetDataSource: SuggestionWidgetDataSource? public var onOpenChatClicked: () -> Void = {} public var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } - public var windows: WidgetWindows = .init() + var windowsController: WidgetWindowsController? public init() {} } -@MainActor -public final class WidgetWindows { - var fullscreenDetector: NSWindow! - var widgetWindow: NSWindow! - var sharedPanelWindow: NSWindow! - var suggestionPanelWindow: NSWindow! - var chatPanelWindow: ChatWindow! - var toastWindow: NSWindow! - - nonisolated - init() {} - - func orderFront() { - widgetWindow?.orderFrontRegardless() - toastWindow?.orderFrontRegardless() - sharedPanelWindow?.orderFrontRegardless() - suggestionPanelWindow?.orderFrontRegardless() - chatPanelWindow?.orderFrontRegardless() - } -} - public final class WidgetUserDefaultsObservers { let presentationModeChangeObserver = UserDefaultsObserver( object: UserDefaults.shared, diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index c72a605c9..0c1cc7099 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -11,194 +11,10 @@ import XcodeInspector @MainActor public final class SuggestionWidgetController: NSObject { - // you should make these window `.transient` so they never show up in the mission control. - - private lazy var fullscreenDetector = { - let it = CanBecomeKeyWindow( - contentRect: .zero, - styleMask: .borderless, - backing: .buffered, - defer: false - ) - it.isReleasedWhenClosed = false - it.isOpaque = false - it.backgroundColor = .clear - it.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] - it.hasShadow = false - it.setIsVisible(false) - it.canBecomeKeyChecker = { false } - return it - }() - - private lazy var widgetWindow = { - let it = CanBecomeKeyWindow( - contentRect: .zero, - styleMask: .borderless, - backing: .buffered, - defer: false - ) - it.isReleasedWhenClosed = false - it.isOpaque = false - it.backgroundColor = .clear - it.level = .floating - it.collectionBehavior = [.fullScreenAuxiliary, .transient] - it.hasShadow = true - it.contentView = NSHostingView( - rootView: WidgetView( - store: store.scope( - state: \._circularWidgetState, - action: WidgetFeature.Action.circularWidget - ) - ) - ) - it.setIsVisible(true) - it.canBecomeKeyChecker = { false } - return it - }() - - private lazy var sharedPanelWindow = { - let it = CanBecomeKeyWindow( - contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), - styleMask: .borderless, - backing: .buffered, - defer: false - ) - it.isReleasedWhenClosed = false - it.isOpaque = false - it.backgroundColor = .clear - it.level = .init(NSWindow.Level.floating.rawValue + 2) - it.collectionBehavior = [.fullScreenAuxiliary, .transient] - it.hasShadow = true - it.contentView = NSHostingView( - rootView: SharedPanelView( - store: store.scope( - state: \.panelState, - action: WidgetFeature.Action.panel - ).scope( - state: \.sharedPanelState, - action: PanelFeature.Action.sharedPanel - ) - ) - ) - it.setIsVisible(true) - it.canBecomeKeyChecker = { [store] in - store.withState { state in - state.panelState.sharedPanelState.content.promptToCode != nil - } - } - return it - }() - - private lazy var suggestionPanelWindow = { - let it = CanBecomeKeyWindow( - contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), - styleMask: .borderless, - backing: .buffered, - defer: false - ) - it.isReleasedWhenClosed = false - it.isOpaque = false - it.backgroundColor = .clear - it.level = .init(NSWindow.Level.floating.rawValue + 2) - it.collectionBehavior = [.fullScreenAuxiliary, .transient] - it.hasShadow = true - it.contentView = NSHostingView( - rootView: SuggestionPanelView( - store: store.scope( - state: \.panelState, - action: WidgetFeature.Action.panel - ).scope( - state: \.suggestionPanelState, - action: PanelFeature.Action.suggestionPanel - ) - ) - ) - it.canBecomeKeyChecker = { false } - it.setIsVisible(true) - return it - }() - - private lazy var chatPanelWindow = { - let it = ChatWindow( - contentRect: .zero, - styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView], - backing: .buffered, - defer: false - ) - it.minimizeWindow = { [weak self] in - self?.store.send(.chatPanel(.hideButtonClicked)) - } - it.titleVisibility = .hidden - it.addTitlebarAccessoryViewController({ - let controller = NSTitlebarAccessoryViewController() - let view = NSHostingView(rootView: ChatTitleBar(store: store.scope( - state: \.chatPanelState, - action: WidgetFeature.Action.chatPanel - ))) - controller.view = view - view.frame = .init(x: 0, y: 0, width: 100, height: 40) - controller.layoutAttribute = .right - return controller - }()) - it.titlebarAppearsTransparent = true - it.isReleasedWhenClosed = false - it.isOpaque = false - it.backgroundColor = .clear - it.level = .init(NSWindow.Level.floating.rawValue + 1) - it.collectionBehavior = [ - .fullScreenAuxiliary, - .transient, - .fullScreenPrimary, - .fullScreenAllowsTiling, - ] - it.hasShadow = true - it.contentView = NSHostingView( - rootView: ChatWindowView( - store: store.scope( - state: \.chatPanelState, - action: WidgetFeature.Action.chatPanel - ), - toggleVisibility: { [weak it] isDisplayed in - guard let window = it else { return } - window.isPanelDisplayed = isDisplayed - } - ) - .environment(\.chatTabPool, chatTabPool) - ) - it.setIsVisible(true) - it.isPanelDisplayed = false - it.delegate = self - return it - }() - - private lazy var toastWindow = { - let it = CanBecomeKeyWindow( - contentRect: .zero, - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - it.isReleasedWhenClosed = false - it.isOpaque = true - it.backgroundColor = .clear - it.level = .floating - it.collectionBehavior = [.fullScreenAuxiliary, .transient] - it.hasShadow = false - it.contentView = NSHostingView( - rootView: ToastPanelView(store: store.scope( - state: \.toastPanel, - action: WidgetFeature.Action.toastPanel - )) - ) - it.setIsVisible(true) - it.ignoresMouseEvents = true - it.canBecomeKeyChecker = { false } - return it - }() - let store: StoreOf let viewStore: ViewStoreOf let chatTabPool: ChatTabPool + let windowsController: WidgetWindowsController private var cancellable = Set() public let dependency: SuggestionWidgetControllerDependency @@ -212,19 +28,18 @@ public final class SuggestionWidgetController: NSObject { self.store = store self.chatTabPool = chatTabPool viewStore = .init(store, observe: { $0 }) + windowsController = .init(store: store, chatTabPool: chatTabPool) super.init() if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return } - dependency.windows.chatPanelWindow = chatPanelWindow - dependency.windows.toastWindow = toastWindow - dependency.windows.sharedPanelWindow = sharedPanelWindow - dependency.windows.suggestionPanelWindow = suggestionPanelWindow - dependency.windows.fullscreenDetector = fullscreenDetector - dependency.windows.widgetWindow = widgetWindow - + dependency.windowsController = windowsController + store.send(.startup) + Task { + await windowsController.start() + } } } @@ -267,69 +82,3 @@ public extension SuggestionWidgetController { } } -// MARK: - NSWindowDelegate - -extension SuggestionWidgetController: NSWindowDelegate { - public func windowWillMove(_ notification: Notification) { - guard (notification.object as? NSWindow) === chatPanelWindow else { return } - Task { @MainActor in - await Task.yield() - store.send(.chatPanel(.detachChatPanel)) - } - } - - public func windowWillEnterFullScreen(_ notification: Notification) { - guard (notification.object as? NSWindow) === chatPanelWindow else { return } - Task { @MainActor in - await Task.yield() - store.send(.chatPanel(.enterFullScreen)) - } - } - - public func windowWillExitFullScreen(_ notification: Notification) { - guard (notification.object as? NSWindow) === chatPanelWindow else { return } - Task { @MainActor in - await Task.yield() - store.send(.chatPanel(.exitFullScreen)) - } - } -} - -// MARK: - Window Subclasses - -class CanBecomeKeyWindow: NSWindow { - var canBecomeKeyChecker: () -> Bool = { true } - override var canBecomeKey: Bool { canBecomeKeyChecker() } - override var canBecomeMain: Bool { canBecomeKeyChecker() } -} - -class ChatWindow: NSWindow { - override var canBecomeKey: Bool { true } - override var canBecomeMain: Bool { true } - - - var minimizeWindow: () -> Void = {} - - var isWindowHidden: Bool = false { - didSet { - alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0 - } - } - - var isPanelDisplayed: Bool = false { - didSet { - alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0 - } - } - - override var alphaValue: CGFloat { - didSet { - ignoresMouseEvents = alphaValue <= 0 - } - } - - override func miniaturize(_: Any?) { - minimizeWindow() - } -} - diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index b46c705cc..a53d68aed 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -1,8 +1,8 @@ import AppKit import Foundation -struct WidgetLocation { - struct PanelLocation { +public struct WidgetLocation: Equatable { + struct PanelLocation: Equatable { var frame: CGRect var alignPanelTop: Bool } diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index d8f12097d..04c11c86a 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -21,7 +21,7 @@ struct WidgetView: View { store.send(.widgetClicked) } } - .overlay { overlayCircle } + .overlay { WidgetAnimatedCircle(store: store) } .onHover { yes in withAnimation(.easeInOut(duration: 0.2)) { isHovering = yes @@ -40,86 +40,101 @@ struct WidgetView: View { ) } } +} + +struct WidgetAnimatedCircle: View { + let store: StoreOf + @State var processingProgress: Double = 0 struct OverlayCircleState: Equatable { var isProcessing: Bool var isContentEmpty: Bool } - @ViewBuilder var overlayCircle: some View { - WithViewStore(store, observe: { $0.animationProgress }) { viewStore in - let processingProgress = viewStore.state - let minimumLineWidth: Double = 3 - let lineWidth = (1 - processingProgress) * - (Style.widgetWidth - minimumLineWidth / 2) + minimumLineWidth - let scale = max(processingProgress * 1, 0.0001) - ZStack { - Circle() - .stroke( - Color(nsColor: .darkGray), - style: .init(lineWidth: minimumLineWidth) - ) - .padding(minimumLineWidth / 2) + var body: some View { + let minimumLineWidth: Double = 3 + let lineWidth = (1 - processingProgress) * + (Style.widgetWidth - minimumLineWidth / 2) + minimumLineWidth + let scale = max(processingProgress * 1, 0.0001) + ZStack { + Circle() + .stroke( + Color(nsColor: .darkGray), + style: .init(lineWidth: minimumLineWidth) + ) + .padding(minimumLineWidth / 2) - // how do I stop the repeatForever animation without removing the view? - // I tried many solutions found on stackoverflow but non of them works. - WithViewStore( - store, - observe: { - OverlayCircleState( - isProcessing: $0.isProcessing, - isContentEmpty: $0.isContentEmpty - ) - } - ) { viewStore in - Group { - if viewStore.isProcessing { - Circle() - .stroke( - Color.accentColor, - style: .init(lineWidth: lineWidth) - ) - .padding(minimumLineWidth / 2) - .scaleEffect(x: scale, y: scale) - .opacity( - !viewStore.isContentEmpty || viewStore - .isProcessing ? 1 : 0 - ) - .animation( - featureFlag: \.animationCCrashSuggestion, - .easeInOut(duration: 1) - .repeatForever(autoreverses: true), - value: processingProgress - ) - } else { - Circle() - .stroke( - Color.accentColor, - style: .init(lineWidth: lineWidth) - ) - .padding(minimumLineWidth / 2) - .scaleEffect(x: scale, y: scale) - .opacity( - !viewStore.isContentEmpty || viewStore - .isProcessing ? 1 : 0 - ) - .animation( - featureFlag: \.animationCCrashSuggestion, - .easeInOut(duration: 1), - value: processingProgress - ) - } - } - .onChange(of: viewStore.isProcessing) { _ in - viewStore.send(._refreshRing) - } - .onChange(of: viewStore.isContentEmpty) { _ in - viewStore.send(._refreshRing) + // how do I stop the repeatForever animation without removing the view? + // I tried many solutions found on stackoverflow but non of them works. + WithViewStore( + store, + observe: { + OverlayCircleState( + isProcessing: $0.isProcessing, + isContentEmpty: $0.isContentEmpty + ) + } + ) { viewStore in + Group { + if viewStore.isProcessing { + Circle() + .stroke( + Color.accentColor, + style: .init(lineWidth: lineWidth) + ) + .padding(minimumLineWidth / 2) + .scaleEffect(x: scale, y: scale) + .opacity( + !viewStore.isContentEmpty || viewStore.isProcessing ? 1 : 0 + ) + .animation( + featureFlag: \.animationCCrashSuggestion, + .easeInOut(duration: 1) + .repeatForever(autoreverses: true), + value: processingProgress + ) + } else { + Circle() + .stroke( + Color.accentColor, + style: .init(lineWidth: lineWidth) + ) + .padding(minimumLineWidth / 2) + .scaleEffect(x: scale, y: scale) + .opacity( + !viewStore.isContentEmpty || viewStore + .isProcessing ? 1 : 0 + ) + .animation( + featureFlag: \.animationCCrashSuggestion, + .easeInOut(duration: 1), + value: processingProgress + ) } } + .onChange(of: viewStore.isProcessing) { _ in + refreshRing( + isProcessing: viewStore.state.isProcessing, + isContentEmpty: viewStore.state.isContentEmpty + ) + } + .onChange(of: viewStore.isContentEmpty) { _ in + refreshRing( + isProcessing: viewStore.state.isProcessing, + isContentEmpty: viewStore.state.isContentEmpty + ) + } } } } + + func refreshRing(isProcessing: Bool, isContentEmpty: Bool) { + if isProcessing { + processingProgress = 1 - processingProgress + } else { + processingProgress = isContentEmpty ? 0 : 1 + } + } } struct WidgetContextMenu: View { diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift new file mode 100644 index 000000000..f294b545a --- /dev/null +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -0,0 +1,744 @@ +import AppKit +import AsyncAlgorithms +import ChatTab +import Combine +import ComposableArchitecture +import Dependencies +import Foundation +import SwiftUI +import XcodeInspector + +actor WidgetWindowsController: NSObject { + let userDefaultsObservers = WidgetUserDefaultsObservers() + var xcodeInspector: XcodeInspector { .shared } + + let windows: WidgetWindows + let store: StoreOf + let chatTabPool: ChatTabPool + + var currentApplicationProcessIdentifier: pid_t? + + var cancellable: Set = [] + var observeToAppTask: Task? + var observeToFocusedEditorTask: Task? + + var updateWindowOpacityTask: Task? + var lastUpdateWindowOpacityTime = Date(timeIntervalSince1970: 0) + + var updateWindowLocationTask: Task? + var lastUpdateWindowLocationTime = Date(timeIntervalSince1970: 0) + + deinit { + userDefaultsObservers.presentationModeChangeObserver.onChange = {} + observeToAppTask?.cancel() + observeToFocusedEditorTask?.cancel() + } + + init(store: StoreOf, chatTabPool: ChatTabPool) { + self.store = store + self.chatTabPool = chatTabPool + windows = .init(store: store, chatTabPool: chatTabPool) + super.init() + windows.controller = self + } + + @MainActor func send(_ action: WidgetFeature.Action) { + store.send(action) + } + + func start() { + cancellable.removeAll() + + xcodeInspector.$activeApplication.sink { [weak self] app in + guard let app else { return } + Task { [weak self] in await self?.activate(app) } + }.store(in: &cancellable) + + xcodeInspector.$focusedEditor.sink { [weak self] editor in + guard let editor else { return } + Task { [weak self] in await self?.observe(to: editor) } + }.store(in: &cancellable) + + xcodeInspector.$completionPanel.sink { [weak self] newValue in + Task { [weak self] in + if newValue == nil { + // so that the buttons on the suggestion panel could be + // clicked + // before the completion panel updates the location of the + // suggestion panel + try await Task.sleep(nanoseconds: 400_000_000) + } + await self?.updateWindowLocation(animated: false, immediately: false) + await self?.updateWindowOpacity(immediately: false) + } + }.store(in: &cancellable) + + userDefaultsObservers.presentationModeChangeObserver.onChange = { [weak self] in + Task { [weak self] in + await self?.updateWindowLocation(animated: false, immediately: false) + await self?.send(.updateColorScheme) + } + } + } + + func updatePanelState(_ location: WidgetLocation) async { + await send(.updatePanelStateToMatch(location)) + } + + func updateWindowOpacity(immediately: Bool) async { + let state = store.withState { $0 } + + let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow + let hasChat = !state.chatPanelState.chatTabGroup.tabInfo.isEmpty + let shouldDebounce = !immediately && + !(Date().timeIntervalSince(lastUpdateWindowOpacityTime) > 5) + lastUpdateWindowOpacityTime = Date() + let activeApp = xcodeInspector.activeApplication + + updateWindowOpacityTask?.cancel() + + let task = Task { + if shouldDebounce { + try await Task.sleep(nanoseconds: 200_000_000) + } + try Task.checkCancellation() + let xcodeInspector = self.xcodeInspector + await MainActor.run { + if let activeApp, activeApp.isXcode { + let application = AXUIElementCreateApplication( + activeApp.processIdentifier + ) + /// We need this to hide the windows when Xcode is minimized. + let noFocus = application.focusedWindow == nil + windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 + windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 + windows.widgetWindow.alphaValue = noFocus ? 0 : 1 + windows.toastWindow.alphaValue = noFocus ? 0 : 1 + + if isChatPanelDetached { + windows.chatPanelWindow.isWindowHidden = !hasChat + } else { + windows.chatPanelWindow.isWindowHidden = noFocus + } + } else if let activeApp, activeApp.isExtensionService { + let noFocus = { + guard let xcode = xcodeInspector.latestActiveXcode + else { return true } + if let window = xcode.appElement.focusedWindow, + window.role == "AXWindow" + { + return false + } + return true + }() + + windows.sharedPanelWindow.alphaValue = noFocus ? 0 : 1 + windows.suggestionPanelWindow.alphaValue = noFocus ? 0 : 1 + windows.widgetWindow.alphaValue = noFocus ? 0 : 1 + windows.toastWindow.alphaValue = noFocus ? 0 : 1 + if isChatPanelDetached { + windows.chatPanelWindow.isWindowHidden = !hasChat + } else { + windows.chatPanelWindow.isWindowHidden = noFocus && !windows + .chatPanelWindow.isKeyWindow + } + } else { + windows.sharedPanelWindow.alphaValue = 0 + windows.suggestionPanelWindow.alphaValue = 0 + windows.widgetWindow.alphaValue = 0 + windows.toastWindow.alphaValue = 0 + if !isChatPanelDetached { + windows.chatPanelWindow.isWindowHidden = true + } + } + } + } + + updateWindowOpacityTask = task + } + + func updateWindowLocation( + animated: Bool, + immediately: Bool, + function: StaticString = #function, + line: UInt = #line + ) async { + @Sendable @MainActor + func update() async { + let state = store.withState { $0 } + let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow + guard let widgetLocation = await generateWidgetLocation() else { return } + await updatePanelState(widgetLocation) + + windows.widgetWindow.setFrame( + widgetLocation.widgetFrame, + display: false, + animate: animated + ) + windows.toastWindow.setFrame( + widgetLocation.defaultPanelLocation.frame, + display: false, + animate: animated + ) + windows.sharedPanelWindow.setFrame( + widgetLocation.defaultPanelLocation.frame, + display: false, + animate: animated + ) + + if let suggestionPanelLocation = widgetLocation.suggestionPanelLocation { + windows.suggestionPanelWindow.setFrame( + suggestionPanelLocation.frame, + display: false, + animate: animated + ) + } + + if isChatPanelDetached { + // don't update it! + } else { + windows.chatPanelWindow.setFrame( + widgetLocation.defaultPanelLocation.frame, + display: false, + animate: animated + ) + } + } + + let now = Date() + let shouldThrottle = !immediately && + !(now.timeIntervalSince(lastUpdateWindowLocationTime) > 5) + + updateWindowLocationTask?.cancel() + let interval: TimeInterval = 0.1 + + if shouldThrottle { + let delay = max( + 0, + interval - now.timeIntervalSince(lastUpdateWindowLocationTime) + ) + + updateWindowLocationTask = Task { + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + try Task.checkCancellation() + await update() + } + } else { + Task { + await update() + } + } + lastUpdateWindowLocationTime = Date() + } +} + +extension WidgetWindowsController: NSWindowDelegate { + nonisolated + func windowWillMove(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + Task { @MainActor in + guard window === windows.chatPanelWindow else { return } + await Task.yield() + store.send(.chatPanel(.detachChatPanel)) + } + } + + nonisolated + func windowWillEnterFullScreen(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + Task { @MainActor in + guard window === windows.chatPanelWindow else { return } + await Task.yield() + store.send(.chatPanel(.enterFullScreen)) + } + } + + nonisolated + func windowWillExitFullScreen(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + Task { @MainActor in + guard window === windows.chatPanelWindow else { return } + await Task.yield() + store.send(.chatPanel(.exitFullScreen)) + } + } +} + +private extension WidgetWindowsController { + func activate(_ app: AppInstanceInspector) { + Task { + if app.isXcode { + await updateWindowLocation(animated: false, immediately: true) + await updateWindowOpacity(immediately: false) + } else { + await updateWindowOpacity(immediately: true) + await updateWindowLocation(animated: false, immediately: false) + } + } + guard currentApplicationProcessIdentifier != app.processIdentifier else { return } + currentApplicationProcessIdentifier = app.processIdentifier + observe(to: app) + } + + func observe(to app: AppInstanceInspector) { + guard let app = app as? XcodeAppInstanceInspector else { return } + let notifications = app.axNotifications + observeToAppTask?.cancel() + observeToAppTask = Task { + await windows.orderFront() + + let documentURL = await MainActor.run { store.withState { $0.focusingDocumentURL } } + for await notification in notifications { + try Task.checkCancellation() + + /// Hide the widgets before switching to another window/editor + /// so the transition looks better. + func hideWidgetForTransitions() async { + let newDocumentURL = xcodeInspector.realtimeActiveDocumentURL + if documentURL != newDocumentURL { + await send(.panel(.removeDisplayedContent)) + await hidePanelWindows() + } + await send(.updateFocusingDocumentURL) + } + + func updateWidgetsAndNotifyChangeOfEditor() async { + await updateWindowLocation(animated: false, immediately: false) + await updateWindowOpacity(immediately: false) + await send(.panel(.switchToAnotherEditorAndUpdateContent)) + } + + func updateWidgets() async { + await updateWindowLocation(animated: false, immediately: false) + await updateWindowOpacity(immediately: false) + } + + switch notification.kind { + case .focusedWindowChanged, .focusedUIElementChanged: + await hideWidgetForTransitions() + await updateWidgetsAndNotifyChangeOfEditor() + case .applicationActivated, .mainWindowChanged: + await updateWidgetsAndNotifyChangeOfEditor() + case .applicationDeactivated, + .moved, + .resized, + .windowMoved, + .windowResized, + .windowMiniaturized, + .windowDeminiaturized: + await updateWidgets() + case .created, .uiElementDestroyed, .xcodeCompletionPanelChanged: + continue + } + } + } + } + + func observe(to editor: SourceEditor) { + observeToFocusedEditorTask?.cancel() + observeToFocusedEditorTask = Task { + let selectionRangeChange = editor.axNotifications + .filter { $0.kind == .selectedTextChanged } + let scroll = editor.axNotifications + .filter { $0.kind == .scrollPositionChanged } + + if #available(macOS 13.0, *) { + for await notification in merge( + selectionRangeChange.debounce(for: Duration.milliseconds(500)), + scroll + ) { + guard xcodeInspector.latestActiveXcode != nil else { return } + try Task.checkCancellation() + + // for better looking + if notification.kind == .scrollPositionChanged { + await hideSuggestionPanelWindow() + } + + await updateWindowLocation(animated: false, immediately: false) + await updateWindowOpacity(immediately: false) + } + } else { + for await notification in merge(selectionRangeChange, scroll) { + guard xcodeInspector.latestActiveXcode != nil else { return } + try Task.checkCancellation() + + // for better looking + if notification.kind == .scrollPositionChanged { + await hideSuggestionPanelWindow() + } + + await updateWindowLocation(animated: false, immediately: false) + await updateWindowOpacity(immediately: false) + } + } + } + } +} + +extension WidgetWindowsController { + @MainActor + func hidePanelWindows() { + windows.sharedPanelWindow.alphaValue = 0 + windows.suggestionPanelWindow.alphaValue = 0 + } + + @MainActor + func hideSuggestionPanelWindow() { + windows.suggestionPanelWindow.alphaValue = 0 + } + + func generateWidgetLocation() -> WidgetLocation? { + if let application = xcodeInspector.latestActiveXcode?.appElement { + if let focusElement = xcodeInspector.focusedEditor?.element, + let parent = focusElement.parent, + let frame = parent.rect, + let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), + let firstScreen = NSScreen.main + { + let positionMode = UserDefaults.shared + .value(for: \.suggestionWidgetPositionMode) + let suggestionMode = UserDefaults.shared + .value(for: \.suggestionPresentationMode) + + switch positionMode { + case .fixedToBottom: + var result = UpdateLocationStrategy.FixedToBottom().framesForWindows( + editorFrame: frame, + mainScreen: screen, + activeScreen: firstScreen + ) + switch suggestionMode { + case .nearbyTextCursor: + result.suggestionPanelLocation = UpdateLocationStrategy + .NearbyTextCursor() + .framesForSuggestionWindow( + editorFrame: frame, mainScreen: screen, + activeScreen: firstScreen, + editor: focusElement, + completionPanel: xcodeInspector.completionPanel + ) + default: + break + } + return result + case .alignToTextCursor: + var result = UpdateLocationStrategy.AlignToTextCursor().framesForWindows( + editorFrame: frame, + mainScreen: screen, + activeScreen: firstScreen, + editor: focusElement + ) + switch suggestionMode { + case .nearbyTextCursor: + result.suggestionPanelLocation = UpdateLocationStrategy + .NearbyTextCursor() + .framesForSuggestionWindow( + editorFrame: frame, mainScreen: screen, + activeScreen: firstScreen, + editor: focusElement, + completionPanel: xcodeInspector.completionPanel + ) + default: + break + } + return result + } + } else if var window = application.focusedWindow, + var frame = application.focusedWindow?.rect, + !["menu bar", "menu bar item"].contains(window.description), + frame.size.height > 300, + let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), + let firstScreen = NSScreen.main + { + if ["open_quickly"].contains(window.identifier) + || ["alert"].contains(window.label) + { + // fallback to use workspace window + guard let workspaceWindow = application.windows + .first(where: { $0.identifier == "Xcode.WorkspaceWindow" }), + let rect = workspaceWindow.rect + else { + return WidgetLocation( + widgetFrame: .zero, + tabFrame: .zero, + defaultPanelLocation: .init(frame: .zero, alignPanelTop: false) + ) + } + + window = workspaceWindow + frame = rect + } + + if ["Xcode.WorkspaceWindow"].contains(window.identifier) { + // extra padding to bottom so buttons won't be covered + frame.size.height -= 40 + } else { + // move a bit away from the window so buttons won't be covered + frame.origin.x -= Style.widgetPadding + Style.widgetWidth / 2 + frame.size.width += Style.widgetPadding * 2 + Style.widgetWidth + } + + return UpdateLocationStrategy.FixedToBottom().framesForWindows( + editorFrame: frame, + mainScreen: screen, + activeScreen: firstScreen, + preferredInsideEditorMinWidth: 9_999_999_999 // never + ) + } + } + return nil + } +} + +public final class WidgetWindows { + let store: StoreOf + let chatTabPool: ChatTabPool + weak var controller: WidgetWindowsController? + + // you should make these window `.transient` so they never show up in the mission control. + + @MainActor + lazy var fullscreenDetector = { + let it = CanBecomeKeyWindow( + contentRect: .zero, + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + it.hasShadow = false + it.setIsVisible(false) + it.canBecomeKeyChecker = { false } + return it + }() + + @MainActor + lazy var widgetWindow = { + let it = CanBecomeKeyWindow( + contentRect: .zero, + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.level = .floating + it.collectionBehavior = [.fullScreenAuxiliary, .transient] + it.hasShadow = true + it.contentView = NSHostingView( + rootView: WidgetView( + store: store.scope( + state: \._circularWidgetState, + action: WidgetFeature.Action.circularWidget + ) + ) + ) + it.setIsVisible(true) + it.canBecomeKeyChecker = { false } + return it + }() + + @MainActor + lazy var sharedPanelWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.level = .init(NSWindow.Level.floating.rawValue + 2) + it.collectionBehavior = [.fullScreenAuxiliary, .transient] + it.hasShadow = true + it.contentView = NSHostingView( + rootView: SharedPanelView( + store: store.scope( + state: \.panelState, + action: WidgetFeature.Action.panel + ).scope( + state: \.sharedPanelState, + action: PanelFeature.Action.sharedPanel + ) + ) + ) + it.setIsVisible(true) + it.canBecomeKeyChecker = { [store] in + store.withState { state in + state.panelState.sharedPanelState.content.promptToCode != nil + } + } + return it + }() + + @MainActor + lazy var suggestionPanelWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.level = .init(NSWindow.Level.floating.rawValue + 2) + it.collectionBehavior = [.fullScreenAuxiliary, .transient] + it.hasShadow = true + it.contentView = NSHostingView( + rootView: SuggestionPanelView( + store: store.scope( + state: \.panelState, + action: WidgetFeature.Action.panel + ).scope( + state: \.suggestionPanelState, + action: PanelFeature.Action.suggestionPanel + ) + ) + ) + it.canBecomeKeyChecker = { false } + it.setIsVisible(true) + return it + }() + + @MainActor + lazy var chatPanelWindow = { + let it = ChatWindow( + contentRect: .zero, + styleMask: [.resizable, .titled, .miniaturizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + it.minimizeWindow = { [weak self] in + self?.store.send(.chatPanel(.hideButtonClicked)) + } + it.titleVisibility = .hidden + it.addTitlebarAccessoryViewController({ + let controller = NSTitlebarAccessoryViewController() + let view = NSHostingView(rootView: ChatTitleBar(store: store.scope( + state: \.chatPanelState, + action: WidgetFeature.Action.chatPanel + ))) + controller.view = view + view.frame = .init(x: 0, y: 0, width: 100, height: 40) + controller.layoutAttribute = .right + return controller + }()) + it.titlebarAppearsTransparent = true + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.level = .init(NSWindow.Level.floating.rawValue + 1) + it.collectionBehavior = [ + .fullScreenAuxiliary, + .transient, + .fullScreenPrimary, + .fullScreenAllowsTiling, + ] + it.hasShadow = true + it.contentView = NSHostingView( + rootView: ChatWindowView( + store: store.scope( + state: \.chatPanelState, + action: WidgetFeature.Action.chatPanel + ), + toggleVisibility: { [weak it] isDisplayed in + guard let window = it else { return } + window.isPanelDisplayed = isDisplayed + } + ) + .environment(\.chatTabPool, chatTabPool) + ) + it.setIsVisible(true) + it.isPanelDisplayed = false + it.delegate = controller + return it + }() + + @MainActor + lazy var toastWindow = { + let it = CanBecomeKeyWindow( + contentRect: .zero, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.isOpaque = true + it.backgroundColor = .clear + it.level = .floating + it.collectionBehavior = [.fullScreenAuxiliary, .transient] + it.hasShadow = false + it.contentView = NSHostingView( + rootView: ToastPanelView(store: store.scope( + state: \.toastPanel, + action: WidgetFeature.Action.toastPanel + )) + ) + it.setIsVisible(true) + it.ignoresMouseEvents = true + it.canBecomeKeyChecker = { false } + return it + }() + + init( + store: StoreOf, + chatTabPool: ChatTabPool + ) { + self.store = store + self.chatTabPool = chatTabPool + } + + @MainActor + func orderFront() { + widgetWindow.orderFrontRegardless() + toastWindow.orderFrontRegardless() + sharedPanelWindow.orderFrontRegardless() + suggestionPanelWindow.orderFrontRegardless() + chatPanelWindow.orderFrontRegardless() + } +} + +// MARK: - Window Subclasses + +class CanBecomeKeyWindow: NSWindow { + var canBecomeKeyChecker: () -> Bool = { true } + override var canBecomeKey: Bool { canBecomeKeyChecker() } + override var canBecomeMain: Bool { canBecomeKeyChecker() } +} + +class ChatWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + + var minimizeWindow: () -> Void = {} + + var isWindowHidden: Bool = false { + didSet { + alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0 + } + } + + var isPanelDisplayed: Bool = false { + didSet { + alphaValue = isPanelDisplayed && !isWindowHidden ? 1 : 0 + } + } + + override var alphaValue: CGFloat { + didSet { + ignoresMouseEvents = alphaValue <= 0 + } + } + + override func miniaturize(_: Any?) { + minimizeWindow() + } +} + diff --git a/Pro b/Pro index 4059899cf..b53b44249 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 4059899cf333c828dad9668ae66760e3a7372cb5 +Subproject commit b53b44249d4eea82f09089753dfeca6116ad5a44 diff --git a/Tool/Package.swift b/Tool/Package.swift index 3c6e080e0..dcbf7f6e3 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -42,6 +42,7 @@ let package = Package( ] ), .library(name: "GitIgnoreCheck", targets: ["GitIgnoreCheck"]), + .library(name: "DebounceFunction", targets: ["DebounceFunction"]), ], dependencies: [ // A fork of https://github.com/aespinilla/Tiktoken to allow loading from local files. @@ -102,6 +103,8 @@ let package = Package( )] ), + .target(name: "DebounceFunction"), + .target( name: "AppActivator", dependencies: [ diff --git a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift index 9d3534a11..6287944ae 100644 --- a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift +++ b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift @@ -106,6 +106,7 @@ public final class AXNotificationStream: AsyncSequence { guard let self else { return } retry += 1 for name in notificationNames { + await Task.yield() let e = withUnsafeMutablePointer(to: &self.continuation) { pointer in AXObserverAddNotification( observer, diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift index 3907568a4..4861294bb 100644 --- a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift @@ -3,6 +3,6 @@ import SuggestionModel import XcodeInspector func getEditorInformation() -> EditorInformation? { - return XcodeInspector.shared.focusedEditorContent + return XcodeInspector.shared.getFocusedEditorContent() } diff --git a/Tool/Sources/DebounceFunction/DebounceFunction.swift b/Tool/Sources/DebounceFunction/DebounceFunction.swift new file mode 100644 index 000000000..3d6e26e5e --- /dev/null +++ b/Tool/Sources/DebounceFunction/DebounceFunction.swift @@ -0,0 +1,22 @@ +import Foundation + +public actor DebounceFunction { + let duration: TimeInterval + let block: (T) async -> Void + + var task: Task? + + public init(duration: TimeInterval, block: @escaping (T) async -> Void) { + self.duration = duration + self.block = block + } + + public func callAsFunction(_ t: T) async { + task?.cancel() + task = Task { [block, duration] in + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + await block(t) + } + } +} + diff --git a/Tool/Sources/DebounceFunction/ThrottleFunction.swift b/Tool/Sources/DebounceFunction/ThrottleFunction.swift new file mode 100644 index 000000000..3a0771c4a --- /dev/null +++ b/Tool/Sources/DebounceFunction/ThrottleFunction.swift @@ -0,0 +1,42 @@ +import Foundation + +public actor ThrottleFunction { + let duration: TimeInterval + let block: (T) async -> Void + + var task: Task? + var lastFinishTime: Date = .init(timeIntervalSince1970: 0) + var now: () -> Date = { Date() } + + public init(duration: TimeInterval, block: @escaping (T) async -> Void) { + self.duration = duration + self.block = block + } + + public func callAsFunction(_ t: T) async { + if task == nil { + scheduleTask(t, wait: now().timeIntervalSince(lastFinishTime) < duration) + } + } + + func scheduleTask(_ t: T, wait: Bool) { + task = Task.detached { [weak self] in + guard let self else { return } + do { + if wait { + try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + } + await block(t) + await finishTask() + } catch { + await finishTask() + } + } + } + + func finishTask() { + task = nil + lastFinishTime = now() + } +} + diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift index 109ece318..b24912ed4 100644 --- a/Tool/Sources/Logger/Logger.swift +++ b/Tool/Sources/Logger/Logger.swift @@ -99,14 +99,47 @@ public final class Logger { function: function ) } - - public func signpost( - _ type: OSSignpostType, + + public func signpostBegin( name: StaticString, file: StaticString = #file, line: UInt = #line, function: StaticString = #function - ) { - os_signpost(type, log: osLog, name: name) + ) -> Signposter { + let poster = OSSignposter(logHandle: osLog) + let id = poster.makeSignpostID() + let state = poster.beginInterval(name, id: id) + return .init(log: osLog, id: id, name: name, signposter: poster, beginState: state) + } + + public struct Signposter { + let log: OSLog + let id: OSSignpostID + let name: StaticString + let signposter: OSSignposter + let state: OSSignpostIntervalState + + init( + log: OSLog, + id: OSSignpostID, + name: StaticString, + signposter: OSSignposter, + beginState: OSSignpostIntervalState + ) { + self.id = id + self.log = log + self.name = name + self.signposter = signposter + state = beginState + } + + public func end() { + signposter.endInterval(name, state) + } + + public func event(_ text: String) { + signposter.emitEvent(name, id: id, "\(text, privacy: .public)") + } } } + diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index d5cfe80ff..ac05f68bf 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -130,7 +130,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { override init(runningApplication: NSRunningApplication) { super.init(runningApplication: runningApplication) - Task { @MainActor in + Task { @XcodeInspectorActor in observeFocusedWindow() observeAXNotifications() @@ -142,7 +142,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } } - @MainActor + @XcodeInspectorActor func refresh() { if let focusedWindow = focusedWindow as? WorkspaceXcodeWindowInspector { focusedWindow.refresh() @@ -151,7 +151,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } } - @MainActor + @XcodeInspectorActor private func observeFocusedWindow() { if let window = appElement.focusedWindow { if window.identifier == "Xcode.WorkspaceWindow" { @@ -160,14 +160,16 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { uiElement: window, axNotifications: axNotifications ) - focusedWindow = window focusedWindowObservations.forEach { $0.cancel() } focusedWindowObservations.removeAll() - documentURL = window.documentURL - workspaceURL = window.workspaceURL - projectRootURL = window.projectRootURL + Task { @MainActor in + focusedWindow = window + documentURL = window.documentURL + workspaceURL = window.workspaceURL + projectRootURL = window.projectRootURL + } window.$documentURL .filter { $0 != .init(fileURLWithPath: "/") } @@ -190,14 +192,18 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } else { let window = XcodeWindowInspector(uiElement: window) - focusedWindow = window + Task { @MainActor in + focusedWindow = window + } } } else { - focusedWindow = nil + Task { @MainActor in + focusedWindow = nil + } } } - @MainActor + @XcodeInspectorActor func observeAXNotifications() { longRunningTasks.forEach { $0.cancel() } longRunningTasks = [] @@ -220,13 +226,14 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { kAXUIElementDestroyedNotification ) - let observeAXNotificationTask = Task { @MainActor [weak self] in + let observeAXNotificationTask = Task { @XcodeInspectorActor [weak self] in var updateWorkspaceInfoTask: Task? for await notification in axNotificationStream { guard let self else { return } try Task.checkCancellation() - + await Task.yield() + guard let event = AXNotificationKind(rawValue: notification.name) else { continue } @@ -258,19 +265,23 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { switch event { case .created: if isCompletionPanel() { - completionPanel = notification.element - self.axNotifications.send(.init( - kind: .xcodeCompletionPanelChanged, - element: notification.element - )) + await MainActor.run { + self.completionPanel = notification.element + self.axNotifications.send(.init( + kind: .xcodeCompletionPanelChanged, + element: notification.element + )) + } } case .uiElementDestroyed: if isCompletionPanel() { - completionPanel = nil - self.axNotifications.send(.init( - kind: .xcodeCompletionPanelChanged, - element: notification.element - )) + await MainActor.run { + self.completionPanel = nil + self.axNotifications.send(.init( + kind: .xcodeCompletionPanelChanged, + element: notification.element + )) + } } default: continue } @@ -319,7 +330,10 @@ extension XcodeAppInstanceInspector { func updateWorkspaceInfo() { let workspaceInfoInVisibleSpace = Self.fetchVisibleWorkspaces(runningApplication) - workspaces = Self.updateWorkspace(workspaces, with: workspaceInfoInVisibleSpace) + let workspaces = Self.updateWorkspace(workspaces, with: workspaceInfoInVisibleSpace) + Task { @MainActor in + self.workspaces = workspaces + } } /// Use the project path as the workspace identifier. diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 2b9e0491d..dd2e8621f 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -9,12 +9,16 @@ import SuggestionModel public class SourceEditor { public typealias Content = EditorInformation.SourceEditorContent - public struct AXNotification { + public struct AXNotification: Hashable { public var kind: AXNotificationKind public var element: AXUIElement + + public func hash(into hasher: inout Hasher) { + kind.hash(into: &hasher) + } } - public enum AXNotificationKind { + public enum AXNotificationKind: Hashable, Equatable { case selectedTextChanged case valueChanged case scrollPositionChanged @@ -30,7 +34,8 @@ public class SourceEditor { /// Get the content of the source editor. /// - /// - note: This method is expensive. + /// - note: This method is expensive. It needs to convert index based ranges to line based + /// ranges. public func getContent() -> Content { let content = element.value let selectionRange = element.selectedTextRange @@ -56,7 +61,7 @@ public class SourceEditor { private func observeAXNotifications() { observeAXNotificationsTask?.cancel() - observeAXNotificationsTask = Task { @MainActor [weak self] in + observeAXNotificationsTask = Task { @XcodeInspectorActor [weak self] in guard let self else { return } await withThrowingTaskGroup(of: Void.self) { [weak self] group in guard let self else { return } @@ -71,6 +76,7 @@ public class SourceEditor { group.addTask { [weak self] in for await notification in editorNotifications { try Task.checkCancellation() + await Task.yield() guard let self else { return } if let kind: AXNotificationKind = { switch notification.name { @@ -97,6 +103,7 @@ public class SourceEditor { group.addTask { [weak self] in for await notification in scrollViewNotifications { try Task.checkCancellation() + await Task.yield() guard let self else { return } self.axNotifications.send(.init( kind: .scrollPositionChanged, diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index ddb5ac840..074698009 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -12,6 +12,12 @@ public extension Notification.Name { static let accessibilityAPIMalfunctioning = Notification.Name("accessibilityAPIMalfunctioning") } +@globalActor +public enum XcodeInspectorActor: GlobalActor { + public actor Actor {} + public static let shared = Actor() +} + public final class XcodeInspector: ObservableObject { public static let shared = XcodeInspector() @@ -35,8 +41,11 @@ public final class XcodeInspector: ObservableObject { @Published public fileprivate(set) var focusedElement: AXUIElement? @Published public fileprivate(set) var completionPanel: AXUIElement? - #warning("TODO: make it a function and mark it as expensive") - public var focusedEditorContent: EditorInformation? { + /// Get the content of the source editor. + /// + /// - note: This method is expensive. It needs to convert index based ranges to line based + /// ranges. + public func getFocusedEditorContent() -> EditorInformation? { guard let documentURL = XcodeInspector.shared.realtimeActiveDocumentURL, let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL, let projectURL = XcodeInspector.shared.activeProjectRootURL @@ -142,19 +151,19 @@ public final class XcodeInspector: ObservableObject { if let existed = xcodes.first(where: { $0.processIdentifier == app.processIdentifier && !$0.isTerminated }) { - await MainActor.run { + Task { @XcodeInspectorActor in self.setActiveXcode(existed) } } else { let new = XcodeAppInstanceInspector(runningApplication: app) - await MainActor.run { + Task { @XcodeInspectorActor in self.xcodes.append(new) self.setActiveXcode(new) } } } else { let appInspector = AppInstanceInspector(runningApplication: app) - await MainActor.run { + Task { @XcodeInspectorActor in self.previousActiveApplication = self.activeApplication self.activeApplication = appInspector } @@ -173,7 +182,7 @@ public final class XcodeInspector: ObservableObject { else { continue } if app.isXcode { let processIdentifier = app.processIdentifier - await MainActor.run { + Task { @XcodeInspectorActor in self.xcodes.removeAll { $0.processIdentifier == processIdentifier || $0.isTerminated } @@ -204,7 +213,7 @@ public final class XcodeInspector: ObservableObject { } try await Task.sleep(nanoseconds: 10_000_000_000) - await MainActor.run { + Task { @XcodeInspectorActor in self.checkForAccessibilityMalfunction("Timer") } } @@ -228,7 +237,7 @@ public final class XcodeInspector: ObservableObject { } public func reactivateObservationsToXcode() { - Task { @MainActor in + Task { @XcodeInspectorActor in if let activeXcode { setActiveXcode(activeXcode) activeXcode.observeAXNotifications() @@ -236,7 +245,7 @@ public final class XcodeInspector: ObservableObject { } } - @MainActor + @XcodeInspectorActor private func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { previousActiveApplication = activeApplication activeApplication = xcode @@ -255,7 +264,7 @@ public final class XcodeInspector: ObservableObject { activeWorkspaceURL = xcode.workspaceURL focusedWindow = xcode.focusedWindow - let setFocusedElement = { @MainActor [weak self] in + let setFocusedElement = { @XcodeInspectorActor [weak self] in guard let self else { return } focusedElement = xcode.appElement.focusedElement if let editorElement = focusedElement, editorElement.isSourceEditor { @@ -276,7 +285,7 @@ public final class XcodeInspector: ObservableObject { } setFocusedElement() - let focusedElementChanged = Task { @MainActor in + let focusedElementChanged = Task { @XcodeInspectorActor in for await notification in xcode.axNotifications { if notification.kind == .focusedUIElementChanged { try Task.checkCancellation() @@ -290,7 +299,7 @@ public final class XcodeInspector: ObservableObject { if UserDefaults.shared .value(for: \.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) { - let malfunctionCheck = Task { @MainActor [weak self] in + let malfunctionCheck = Task { @XcodeInspectorActor [weak self] in if #available(macOS 13.0, *) { let notifications = xcode.axNotifications.filter { $0.kind == .uiElementDestroyed @@ -331,7 +340,7 @@ public final class XcodeInspector: ObservableObject { private var lastRecoveryFromAccessibilityMalfunctioningTimeStamp = Date() - @MainActor + @XcodeInspectorActor private func checkForAccessibilityMalfunction(_ source: String) { guard Date().timeIntervalSince(lastRecoveryFromAccessibilityMalfunctioningTimeStamp) > 5 else { return } @@ -353,7 +362,7 @@ public final class XcodeInspector: ObservableObject { } } - @MainActor + @XcodeInspectorActor private func recoverFromAccessibilityMalfunctioning(_ source: String?) { let message = """ Accessibility API malfunction detected: \ diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index 00a777baf..92a34e979 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -26,7 +26,7 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { } public func refresh() { - Task { @MainActor in updateURLs() } + Task { @XcodeInspectorActor in updateURLs() } } public init( @@ -55,6 +55,7 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { guard notification.kind == .focusedUIElementChanged else { continue } guard let self else { return } try Task.checkCancellation() + await Task.yield() await self.updateURLs() } } @@ -62,22 +63,28 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { } } - @MainActor + @XcodeInspectorActor func updateURLs() { let documentURL = Self.extractDocumentURL(windowElement: uiElement) if let documentURL { - self.documentURL = documentURL + Task { @MainActor in + self.documentURL = documentURL + } } let workspaceURL = Self.extractWorkspaceURL(windowElement: uiElement) if let workspaceURL { - self.workspaceURL = workspaceURL + Task { @MainActor in + self.workspaceURL = workspaceURL + } } let projectURL = Self.extractProjectURL( workspaceURL: workspaceURL, documentURL: documentURL ) if let projectURL { - projectRootURL = projectURL + Task { @MainActor in + self.projectRootURL = projectURL + } } } diff --git a/Version.xcconfig b/Version.xcconfig index ea6385ef1..7f0f9ca85 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ -APP_VERSION = 0.30.3 -APP_BUILD = 316 +APP_VERSION = 0.30.4 +APP_BUILD = 320 diff --git a/appcast.xml b/appcast.xml index d5fae9aca..abb6dfc3e 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,7 +2,19 @@ Copilot for Xcode - + + + 0.30.4 + Sat, 17 Feb 2024 16:25:04 +0800 + 320 + 0.30.4 + 12.0 + + https://github.com/intitni/CopilotForXcode/releases/tag/0.30.4 + + + + 0.30.4 Fri, 16 Feb 2024 01:49:49 +0800