From c3d4dff3e835f3e2c0c22840368df6f609db9f5a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 7 Feb 2024 22:39:00 +0800 Subject: [PATCH 01/27] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.yaml | 10 +--- .github/ISSUE_TEMPLATE/z_bug_report_beta.yaml | 56 +++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/z_bug_report_beta.yaml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index ec05061a..86a41f25 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 00000000..2f80eaaf --- /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 + From 73cf3abbe14e554db077fba29ae1c2f9d6220eea Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 14 Feb 2024 00:41:16 +0800 Subject: [PATCH 02/27] Add WindowsController --- .../SuggestionWidget/WindowsController.swift | 673 ++++++++++++++++++ 1 file changed, 673 insertions(+) create mode 100644 Core/Sources/SuggestionWidget/WindowsController.swift diff --git a/Core/Sources/SuggestionWidget/WindowsController.swift b/Core/Sources/SuggestionWidget/WindowsController.swift new file mode 100644 index 00000000..cbbfd923 --- /dev/null +++ b/Core/Sources/SuggestionWidget/WindowsController.swift @@ -0,0 +1,673 @@ +import AppKit +import AsyncAlgorithms +import ChatTab +import Combine +import ComposableArchitecture +import Dependencies +import Foundation +import SwiftUI +import XcodeInspector + +final class WindowsController: 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) + + 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 } + self?.activate(app) + }.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) + await self?.updateWindowOpacity(immediately: false) + } + }.store(in: &cancellable) + + userDefaultsObservers.presentationModeChangeObserver.onChange = { [weak self] in + Task { [weak self] in + await self?.updateWindowLocation(animated: 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) < 1 + lastUpdateWindowOpacityTime = Date() + let activeApp = xcodeInspector.activeApplication + + updateWindowOpacityTask?.cancel() + + let task = Task { + if shouldDebounce { + try await Task.sleep(nanoseconds: 200_000_000) + } + try Task.checkCancellation() + 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 + } + } + } + } + _ = try? await task.value + } + + func updateWindowLocation(animated: Bool) async { + let state = store.withState { $0 } + + guard let widgetLocation = generateWidgetLocation() else { return } + await updatePanelState(widgetLocation) + + let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow + + await MainActor.run { + 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 + ) + } + } + } +} + +extension WindowsController: NSWindowDelegate { + func windowWillMove(_ notification: Notification) { + guard (notification.object as? NSWindow) === windows.chatPanelWindow else { return } + Task { @MainActor in + await Task.yield() + store.send(.chatPanel(.detachChatPanel)) + } + } + + func windowWillEnterFullScreen(_ notification: Notification) { + guard (notification.object as? NSWindow) === windows.chatPanelWindow else { return } + Task { @MainActor in + await Task.yield() + store.send(.chatPanel(.enterFullScreen)) + } + } + + func windowWillExitFullScreen(_ notification: Notification) { + guard (notification.object as? NSWindow) === windows.chatPanelWindow else { return } + Task { @MainActor in + await Task.yield() + store.send(.chatPanel(.exitFullScreen)) + } + } +} + +private extension WindowsController { + func activate(_ app: AppInstanceInspector) { + guard currentApplicationProcessIdentifier != app.processIdentifier else { return } + currentApplicationProcessIdentifier = app.processIdentifier + observe(to: app) + } + + func observe(to app: AppInstanceInspector) { + guard let app = app as? XcodeAppInstanceInspector else { + Task { + await updateWindowLocation(animated: false) + await updateWindowOpacity(immediately: true) + } + return + } + let notifications = app.axNotifications + if let focusedEditor = xcodeInspector.focusedEditor { + observe(to: focusedEditor) + } + 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. + 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 updateWindowLocation(animated: false) + await updateWindowOpacity(immediately: false) + if let editor = xcodeInspector.focusedEditor { + observe(to: editor) + } + await send(.panel(.switchToAnotherEditorAndUpdateContent)) + } else { + await updateWindowLocation(animated: false) + await updateWindowOpacity(immediately: false) + } + } + } + } + + 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 _ in merge( + selectionRangeChange.debounce(for: Duration.milliseconds(500)), + scroll + ) { + guard xcodeInspector.latestActiveXcode != nil else { return } + try Task.checkCancellation() + await updateWindowLocation(animated: false) + await updateWindowOpacity(immediately: false) + } + } else { + for await _ in merge(selectionRangeChange, scroll) { + guard xcodeInspector.latestActiveXcode != nil else { return } + try Task.checkCancellation() + await updateWindowLocation(animated: false) + await updateWindowOpacity(immediately: false) + } + } + } + } +} + +extension WindowsController { + @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 + } +} + +public final class WidgetWindows { + let store: StoreOf + let chatTabPool: ChatTabPool + weak var controller: WindowsController? + + // 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() + } +} + From 78ce489a1724dcd00cd91e3b45e7b4450753bf7f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 14 Feb 2024 00:41:30 +0800 Subject: [PATCH 03/27] Make WidgetLocation Equatable --- Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index b46c705c..a53d68ae 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 } From 3c9dfc4440b175842db9813e62653c2d105846d6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 14 Feb 2024 00:42:18 +0800 Subject: [PATCH 04/27] Move windows to WindowsController --- .../SuggestionWidgetController.swift | 263 +----------------- 1 file changed, 5 insertions(+), 258 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index c72a605c..6401c580 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: WindowsController private var cancellable = Set() public let dependency: SuggestionWidgetControllerDependency @@ -212,19 +28,16 @@ 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) + windowsController.start() } } @@ -267,69 +80,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() - } -} - From 163f8d56414e62de5d23fabfee5b3c041e39004e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 14 Feb 2024 00:42:34 +0800 Subject: [PATCH 05/27] Update the dependency to hold a WindowsController instead --- .../SuggestionWidget/ModuleDependency.swift | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/Core/Sources/SuggestionWidget/ModuleDependency.swift b/Core/Sources/SuggestionWidget/ModuleDependency.swift index af322303..3ea4ce1d 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: WindowsController? 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, From eabb6478ad0cf639f336e1b64f66906e0b3cd1e9 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 14 Feb 2024 00:42:48 +0800 Subject: [PATCH 06/27] Remove useless actions --- .../FeatureReducers/WidgetFeature.swift | 404 ++---------------- 1 file changed, 26 insertions(+), 378 deletions(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 503ee8db..574d1248 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -80,8 +80,6 @@ public struct WidgetFeature: ReducerProtocol { } } - var lastUpdateWindowOpacityTime = Date(timeIntervalSince1970: 0) - public init() {} } @@ -97,21 +95,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 +111,8 @@ public struct WidgetFeature: ReducerProtocol { case circularWidget(CircularWidgetFeature.Action) } - var windows: WidgetWindows { - suggestionWidgetControllerDependency.windows + var windowsController: WindowsController? { + suggestionWidgetControllerDependency.windowsController } @Dependency(\.suggestionWidgetUserDefaultsObservers) var userDefaultsObservers @@ -203,20 +194,21 @@ 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) + 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) + await windowsController?.updateWindowOpacity(immediately: false) } default: return .none } @@ -229,10 +221,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 +248,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 +255,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 +291,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 +327,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 +345,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 +377,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 - } -} - From f3275a1acd414de360c764ba3c2bba60a968fee6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 14 Feb 2024 00:42:57 +0800 Subject: [PATCH 07/27] Update windows getter --- .../SuggestionWidget/FeatureReducers/ChatPanelFeature.swift | 2 +- .../SuggestionWidget/FeatureReducers/PanelFeature.swift | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift index b1742cfa..fae36a70 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/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift index 5af9f2ba..5208bf3b 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)) From 098e7e322a84f2ed78945898ac07dee64b82cb4d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 14 Feb 2024 22:46:36 +0800 Subject: [PATCH 08/27] Make focusedEditorContent a function marked as expensive --- .../PromptToCodeService/OpenAIPromptToCodeService.swift | 2 +- Pro | 2 +- .../ActiveDocumentChatContextCollector/GetEditorInfo.swift | 2 +- Tool/Sources/XcodeInspector/SourceEditor.swift | 3 ++- Tool/Sources/XcodeInspector/XcodeInspector.swift | 7 +++++-- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift index c2fad543..d2bcea30 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/Pro b/Pro index 4059899c..8c9ccfae 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 4059899cf333c828dad9668ae66760e3a7372cb5 +Subproject commit 8c9ccfae70362dc13b5456e3fe05c414bedabb47 diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/GetEditorInfo.swift index 3907568a..4861294b 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/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 2b9e0491..1ebf7f2b 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -30,7 +30,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 diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index ddb5ac84..8846a542 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -35,8 +35,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 From 342ab60229d0d486ac444d7a6bbedc1d2a5f6820 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 14 Feb 2024 22:47:52 +0800 Subject: [PATCH 09/27] Rename WindowsController to WidgetWindowsController --- .../FeatureReducers/WidgetFeature.swift | 2 +- Core/Sources/SuggestionWidget/ModuleDependency.swift | 2 +- .../SuggestionWidget/SuggestionWidgetController.swift | 2 +- ...sController.swift => WidgetWindowsController.swift} | 10 +++++----- 4 files changed, 8 insertions(+), 8 deletions(-) rename Core/Sources/SuggestionWidget/{WindowsController.swift => WidgetWindowsController.swift} (99%) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 574d1248..29b69a12 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -111,7 +111,7 @@ public struct WidgetFeature: ReducerProtocol { case circularWidget(CircularWidgetFeature.Action) } - var windowsController: WindowsController? { + var windowsController: WidgetWindowsController? { suggestionWidgetControllerDependency.windowsController } diff --git a/Core/Sources/SuggestionWidget/ModuleDependency.swift b/Core/Sources/SuggestionWidget/ModuleDependency.swift index 3ea4ce1d..0e83df6a 100644 --- a/Core/Sources/SuggestionWidget/ModuleDependency.swift +++ b/Core/Sources/SuggestionWidget/ModuleDependency.swift @@ -13,7 +13,7 @@ public final class SuggestionWidgetControllerDependency { public var suggestionWidgetDataSource: SuggestionWidgetDataSource? public var onOpenChatClicked: () -> Void = {} public var onCustomCommandClicked: (CustomCommand) -> Void = { _ in } - var windowsController: WindowsController? + var windowsController: WidgetWindowsController? public init() {} } diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 6401c580..e63a627a 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -14,7 +14,7 @@ public final class SuggestionWidgetController: NSObject { let store: StoreOf let viewStore: ViewStoreOf let chatTabPool: ChatTabPool - let windowsController: WindowsController + let windowsController: WidgetWindowsController private var cancellable = Set() public let dependency: SuggestionWidgetControllerDependency diff --git a/Core/Sources/SuggestionWidget/WindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift similarity index 99% rename from Core/Sources/SuggestionWidget/WindowsController.swift rename to Core/Sources/SuggestionWidget/WidgetWindowsController.swift index cbbfd923..fa32919b 100644 --- a/Core/Sources/SuggestionWidget/WindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -8,7 +8,7 @@ import Foundation import SwiftUI import XcodeInspector -final class WindowsController: NSObject { +final class WidgetWindowsController: NSObject { let userDefaultsObservers = WidgetUserDefaultsObservers() var xcodeInspector: XcodeInspector { .shared } @@ -192,7 +192,7 @@ final class WindowsController: NSObject { } } -extension WindowsController: NSWindowDelegate { +extension WidgetWindowsController: NSWindowDelegate { func windowWillMove(_ notification: Notification) { guard (notification.object as? NSWindow) === windows.chatPanelWindow else { return } Task { @MainActor in @@ -218,7 +218,7 @@ extension WindowsController: NSWindowDelegate { } } -private extension WindowsController { +private extension WidgetWindowsController { func activate(_ app: AppInstanceInspector) { guard currentApplicationProcessIdentifier != app.processIdentifier else { return } currentApplicationProcessIdentifier = app.processIdentifier @@ -310,7 +310,7 @@ private extension WindowsController { } } -extension WindowsController { +extension WidgetWindowsController { @MainActor func hidePanelWindows() { windows.sharedPanelWindow.alphaValue = 0 @@ -423,7 +423,7 @@ extension WindowsController { public final class WidgetWindows { let store: StoreOf let chatTabPool: ChatTabPool - weak var controller: WindowsController? + weak var controller: WidgetWindowsController? // you should make these window `.transient` so they never show up in the mission control. From 6d65d5731b3fd08c70427026174e2f8f50dfddea Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 14 Feb 2024 23:43:30 +0800 Subject: [PATCH 10/27] Fix that a task is not stored --- Core/Sources/SuggestionWidget/WidgetWindowsController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index fa32919b..36131a26 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -143,6 +143,7 @@ final class WidgetWindowsController: NSObject { } } } + updateWindowOpacityTask = task _ = try? await task.value } From a78ed537fd234c9d1d0197728a3437613c8f3d41 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 14 Feb 2024 23:44:11 +0800 Subject: [PATCH 11/27] Add throttler to RealtimeSuggestionController --- .../RealtimeSuggestionController.swift | 80 +++++++++++++------ .../Sources/XcodeInspector/SourceEditor.swift | 8 +- 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 52f05d07..c6fa353b 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/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 1ebf7f2b..6133cc9c 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 From 20bdfbd4497a407881323e9a782828209680197c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 15 Feb 2024 01:24:40 +0800 Subject: [PATCH 12/27] Throttle location generation --- .../FeatureReducers/WidgetFeature.swift | 10 +- .../WidgetWindowsController.swift | 138 +++++++++++++----- 2 files changed, 112 insertions(+), 36 deletions(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 29b69a12..d3ec9c2f 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -195,7 +195,10 @@ public struct WidgetFeature: ReducerProtocol { case .chatPanel(.presentChatPanel): let isDetached = state.chatPanelState.chatPanelInASeparateWindow return .run { _ in - await windowsController?.updateWindowLocation(animated: false) + await windowsController?.updateWindowLocation( + animated: false, + immediately: false + ) await windowsController?.updateWindowOpacity(immediately: false) if isDetached { Task { @MainActor in @@ -207,7 +210,10 @@ public struct WidgetFeature: ReducerProtocol { case .chatPanel(.toggleChatPanelDetachedButtonClicked): let isDetached = state.chatPanelState.chatPanelInASeparateWindow return .run { _ in - await windowsController?.updateWindowLocation(animated: !isDetached) + await windowsController?.updateWindowLocation( + animated: !isDetached, + immediately: false + ) await windowsController?.updateWindowOpacity(immediately: false) } default: return .none diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 36131a26..0ffa955d 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -19,11 +19,21 @@ final class WidgetWindowsController: NSObject { var currentApplicationProcessIdentifier: pid_t? var cancellable: Set = [] + @MainActor var observeToAppTask: Task? + @MainActor var observeToFocusedEditorTask: Task? + + @MainActor var updateWindowOpacityTask: Task? + @MainActor var lastUpdateWindowOpacityTime = Date(timeIntervalSince1970: 0) + @MainActor + var updateWindowLocationTask: Task? + @MainActor + var lastUpdateWindowLocationTime = Date(timeIntervalSince1970: 0) + deinit { userDefaultsObservers.presentationModeChangeObserver.onChange = {} observeToAppTask?.cancel() @@ -47,7 +57,7 @@ final class WidgetWindowsController: NSObject { xcodeInspector.$activeApplication.sink { [weak self] app in guard let app else { return } - self?.activate(app) + Task { [weak self] in await self?.activate(app) } }.store(in: &cancellable) xcodeInspector.$completionPanel.sink { [weak self] newValue in @@ -59,14 +69,14 @@ final class WidgetWindowsController: NSObject { // suggestion panel try await Task.sleep(nanoseconds: 400_000_000) } - await self?.updateWindowLocation(animated: false) + 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) + await self?.updateWindowLocation(animated: false, immediately: false) await self?.send(.updateColorScheme) } } @@ -81,18 +91,21 @@ final class WidgetWindowsController: NSObject { let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow let hasChat = !state.chatPanelState.chatTabGroup.tabInfo.isEmpty - let shouldDebounce = !immediately && - Date().timeIntervalSince(lastUpdateWindowOpacityTime) < 1 - lastUpdateWindowOpacityTime = Date() + let shouldDebounce = await MainActor.run { + defer {lastUpdateWindowOpacityTime = Date() } + return (!immediately && + !(Date().timeIntervalSince(lastUpdateWindowOpacityTime) > 5)) + } let activeApp = xcodeInspector.activeApplication - updateWindowOpacityTask?.cancel() + await 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( @@ -143,19 +156,26 @@ final class WidgetWindowsController: NSObject { } } } - updateWindowOpacityTask = task - _ = try? await task.value + + await MainActor.run { + updateWindowOpacityTask = task + } } - func updateWindowLocation(animated: Bool) async { + func updateWindowLocation( + animated: Bool, + immediately: Bool, + function: StaticString = #function, + line: UInt = #line + ) async { let state = store.withState { $0 } - - guard let widgetLocation = generateWidgetLocation() else { return } - await updatePanelState(widgetLocation) - let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow - await MainActor.run { + @Sendable @MainActor + func update() async { + guard let widgetLocation = generateWidgetLocation() else { return } + await updatePanelState(widgetLocation) + windows.widgetWindow.setFrame( widgetLocation.widgetFrame, display: false, @@ -190,29 +210,67 @@ final class WidgetWindowsController: NSObject { ) } } + + let now = Date() + let shouldThrottle = await MainActor.run { + !immediately && + !(now.timeIntervalSince(lastUpdateWindowLocationTime) > 5) + } + + await updateWindowLocationTask?.cancel() + let interval: TimeInterval = 0.1 + + if shouldThrottle { + let delay = await max( + 0, + interval - now.timeIntervalSince(lastUpdateWindowLocationTime) + ) + + let task = Task { @MainActor in + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + try Task.checkCancellation() + await update() + } + await MainActor.run { + updateWindowLocationTask = task + } + } else { + Task { + await update() + } + } + await MainActor.run { + lastUpdateWindowLocationTime = Date() + } } } extension WidgetWindowsController: NSWindowDelegate { + nonisolated func windowWillMove(_ notification: Notification) { - guard (notification.object as? NSWindow) === windows.chatPanelWindow else { return } + 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 (notification.object as? NSWindow) === windows.chatPanelWindow else { return } + 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 (notification.object as? NSWindow) === windows.chatPanelWindow else { return } + 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)) } @@ -220,30 +278,33 @@ extension WidgetWindowsController: NSWindowDelegate { } private extension WidgetWindowsController { - func activate(_ app: AppInstanceInspector) { + func activate(_ app: AppInstanceInspector) async { guard currentApplicationProcessIdentifier != app.processIdentifier else { return } currentApplicationProcessIdentifier = app.processIdentifier - observe(to: app) + await observe(to: app) } - func observe(to app: AppInstanceInspector) { + func observe(to app: AppInstanceInspector) async { guard let app = app as? XcodeAppInstanceInspector else { Task { - await updateWindowLocation(animated: false) + await updateWindowLocation(animated: false, immediately: true) await updateWindowOpacity(immediately: true) } return } let notifications = app.axNotifications if let focusedEditor = xcodeInspector.focusedEditor { - observe(to: focusedEditor) + await observe(to: focusedEditor) } - observeToAppTask?.cancel() - observeToAppTask = Task { + + let task = Task { await windows.orderFront() let documentURL = await MainActor.run { store.withState { $0.focusingDocumentURL } } for await notification in notifications { + if [.uiElementDestroyed, .created, .xcodeCompletionPanelChanged] + .contains(notification.kind) { continue } + try Task.checkCancellation() // Hide the widgets before switching to another window/editor @@ -267,23 +328,27 @@ private extension WidgetWindowsController { .mainWindowChanged, .focusedWindowChanged, ].contains(notification.kind) { - await updateWindowLocation(animated: false) + await updateWindowLocation(animated: false, immediately: false) await updateWindowOpacity(immediately: false) if let editor = xcodeInspector.focusedEditor { - observe(to: editor) + await observe(to: editor) } await send(.panel(.switchToAnotherEditorAndUpdateContent)) } else { - await updateWindowLocation(animated: false) + await updateWindowLocation(animated: false, immediately: false) await updateWindowOpacity(immediately: false) } } } + + await MainActor.run { + observeToAppTask?.cancel() + observeToAppTask = task + } } - func observe(to editor: SourceEditor) { - observeToFocusedEditorTask?.cancel() - observeToFocusedEditorTask = Task { + func observe(to editor: SourceEditor) async { + let task = Task { let selectionRangeChange = editor.axNotifications .filter { $0.kind == .selectedTextChanged } let scroll = editor.axNotifications @@ -292,22 +357,27 @@ private extension WidgetWindowsController { if #available(macOS 13.0, *) { for await _ in merge( selectionRangeChange.debounce(for: Duration.milliseconds(500)), - scroll + scroll.throttle(for: .milliseconds(200)) ) { guard xcodeInspector.latestActiveXcode != nil else { return } try Task.checkCancellation() - await updateWindowLocation(animated: false) + await updateWindowLocation(animated: false, immediately: false) await updateWindowOpacity(immediately: false) } } else { for await _ in merge(selectionRangeChange, scroll) { guard xcodeInspector.latestActiveXcode != nil else { return } try Task.checkCancellation() - await updateWindowLocation(animated: false) + await updateWindowLocation(animated: false, immediately: false) await updateWindowOpacity(immediately: false) } } } + + await MainActor.run { + observeToFocusedEditorTask?.cancel() + observeToFocusedEditorTask = task + } } } From 9a7ce06455f638b5c82c3186cccd95ff7cc150af Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 15 Feb 2024 01:25:16 +0800 Subject: [PATCH 13/27] Bump version to 0.30.4 --- Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Version.xcconfig b/Version.xcconfig index ea6385ef..d5b60ff2 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 = 317 From 250bdf946f0723eb553825df165ef5b7f94c29ae Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 15 Feb 2024 18:22:04 +0800 Subject: [PATCH 14/27] Remove scroll event throttle --- .../SuggestionWidget/WidgetWindowsController.swift | 2 +- Tool/Sources/DebounceFunction/DebounceFunction.swift | 8 ++++++++ Tool/Sources/DebounceFunction/ThrottleFunction.swift | 8 ++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 Tool/Sources/DebounceFunction/DebounceFunction.swift create mode 100644 Tool/Sources/DebounceFunction/ThrottleFunction.swift diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 0ffa955d..d9d94e04 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -357,7 +357,7 @@ private extension WidgetWindowsController { if #available(macOS 13.0, *) { for await _ in merge( selectionRangeChange.debounce(for: Duration.milliseconds(500)), - scroll.throttle(for: .milliseconds(200)) + scroll ) { guard xcodeInspector.latestActiveXcode != nil else { return } try Task.checkCancellation() diff --git a/Tool/Sources/DebounceFunction/DebounceFunction.swift b/Tool/Sources/DebounceFunction/DebounceFunction.swift new file mode 100644 index 00000000..84484853 --- /dev/null +++ b/Tool/Sources/DebounceFunction/DebounceFunction.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Shangxin Guo on 2024/2/15. +// + +import Foundation diff --git a/Tool/Sources/DebounceFunction/ThrottleFunction.swift b/Tool/Sources/DebounceFunction/ThrottleFunction.swift new file mode 100644 index 00000000..84484853 --- /dev/null +++ b/Tool/Sources/DebounceFunction/ThrottleFunction.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Shangxin Guo on 2024/2/15. +// + +import Foundation From 10f2a93a22024d42c2fdc6972cb32be7d06d72c1 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 15 Feb 2024 18:22:24 +0800 Subject: [PATCH 15/27] Add DebounceFunction and ThrottleFunction --- Pro | 2 +- Tool/Package.swift | 3 ++ .../DebounceFunction/DebounceFunction.swift | 28 ++++++++--- .../DebounceFunction/ThrottleFunction.swift | 48 ++++++++++++++++--- 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/Pro b/Pro index 8c9ccfae..39b2b445 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 8c9ccfae70362dc13b5456e3fe05c414bedabb47 +Subproject commit 39b2b4459c41dcfeb8c20fdcd416c84a804ae082 diff --git a/Tool/Package.swift b/Tool/Package.swift index 3c6e080e..dcbf7f6e 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/DebounceFunction/DebounceFunction.swift b/Tool/Sources/DebounceFunction/DebounceFunction.swift index 84484853..3d6e26e5 100644 --- a/Tool/Sources/DebounceFunction/DebounceFunction.swift +++ b/Tool/Sources/DebounceFunction/DebounceFunction.swift @@ -1,8 +1,22 @@ -// -// File.swift -// -// -// Created by Shangxin Guo on 2024/2/15. -// - 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 index 84484853..3a0771c4 100644 --- a/Tool/Sources/DebounceFunction/ThrottleFunction.swift +++ b/Tool/Sources/DebounceFunction/ThrottleFunction.swift @@ -1,8 +1,42 @@ -// -// File.swift -// -// -// Created by Shangxin Guo on 2024/2/15. -// - 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() + } +} + From 12ff5731c4478556cbb87a4a2c17312226bbdf47 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 15 Feb 2024 22:36:50 +0800 Subject: [PATCH 16/27] Remove some use of MainActor --- .../WidgetWindowsController.swift | 48 +++++++------------ Pro | 2 +- .../AXNotificationStream.swift | 1 + .../Apps/XcodeAppInstanceInspector.swift | 10 ++-- .../Sources/XcodeInspector/SourceEditor.swift | 2 +- .../XcodeInspector/XcodeInspector.swift | 34 +++++++------ .../XcodeInspector/XcodeWindowInspector.swift | 4 +- 7 files changed, 47 insertions(+), 54 deletions(-) diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index d9d94e04..894ca4fb 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -19,19 +19,13 @@ final class WidgetWindowsController: NSObject { var currentApplicationProcessIdentifier: pid_t? var cancellable: Set = [] - @MainActor var observeToAppTask: Task? - @MainActor var observeToFocusedEditorTask: Task? - @MainActor var updateWindowOpacityTask: Task? - @MainActor var lastUpdateWindowOpacityTime = Date(timeIntervalSince1970: 0) - @MainActor var updateWindowLocationTask: Task? - @MainActor var lastUpdateWindowLocationTime = Date(timeIntervalSince1970: 0) deinit { @@ -92,13 +86,13 @@ final class WidgetWindowsController: NSObject { let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow let hasChat = !state.chatPanelState.chatTabGroup.tabInfo.isEmpty let shouldDebounce = await MainActor.run { - defer {lastUpdateWindowOpacityTime = Date() } - return (!immediately && - !(Date().timeIntervalSince(lastUpdateWindowOpacityTime) > 5)) + defer { lastUpdateWindowOpacityTime = Date() } + return !immediately && + !(Date().timeIntervalSince(lastUpdateWindowOpacityTime) > 5) } let activeApp = xcodeInspector.activeApplication - await updateWindowOpacityTask?.cancel() + updateWindowOpacityTask?.cancel() let task = Task { if shouldDebounce { @@ -156,10 +150,8 @@ final class WidgetWindowsController: NSObject { } } } - - await MainActor.run { - updateWindowOpacityTask = task - } + + updateWindowOpacityTask = task } func updateWindowLocation( @@ -217,16 +209,16 @@ final class WidgetWindowsController: NSObject { !(now.timeIntervalSince(lastUpdateWindowLocationTime) > 5) } - await updateWindowLocationTask?.cancel() + updateWindowLocationTask?.cancel() let interval: TimeInterval = 0.1 if shouldThrottle { - let delay = await max( + let delay = max( 0, interval - now.timeIntervalSince(lastUpdateWindowLocationTime) ) - let task = Task { @MainActor in + let task = Task { try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) try Task.checkCancellation() await update() @@ -239,9 +231,7 @@ final class WidgetWindowsController: NSObject { await update() } } - await MainActor.run { - lastUpdateWindowLocationTime = Date() - } + lastUpdateWindowLocationTime = Date() } } @@ -296,7 +286,7 @@ private extension WidgetWindowsController { if let focusedEditor = xcodeInspector.focusedEditor { await observe(to: focusedEditor) } - + let task = Task { await windows.orderFront() @@ -340,11 +330,9 @@ private extension WidgetWindowsController { } } } - - await MainActor.run { - observeToAppTask?.cancel() - observeToAppTask = task - } + + observeToAppTask?.cancel() + observeToAppTask = task } func observe(to editor: SourceEditor) async { @@ -373,11 +361,9 @@ private extension WidgetWindowsController { } } } - - await MainActor.run { - observeToFocusedEditorTask?.cancel() - observeToFocusedEditorTask = task - } + + observeToFocusedEditorTask?.cancel() + observeToFocusedEditorTask = task } } diff --git a/Pro b/Pro index 39b2b445..6fcfa82a 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 39b2b4459c41dcfeb8c20fdcd416c84a804ae082 +Subproject commit 6fcfa82a16be6df1733259824e5b99e9f957dcf8 diff --git a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift index 9d3534a1..6287944a 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/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index d5cfe80f..0aa07cc9 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" { @@ -197,7 +197,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } } - @MainActor + @XcodeInspectorActor func observeAXNotifications() { longRunningTasks.forEach { $0.cancel() } longRunningTasks = [] @@ -220,7 +220,7 @@ 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 { diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 6133cc9c..49913616 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -61,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 } diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 8846a542..fb1b9ac9 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() @@ -145,19 +151,19 @@ public final class XcodeInspector: ObservableObject { if let existed = xcodes.first(where: { $0.processIdentifier == app.processIdentifier && !$0.isTerminated }) { - await MainActor.run { - self.setActiveXcode(existed) + Task { @XcodeInspectorActor in + await self.setActiveXcode(existed) } } else { let new = XcodeAppInstanceInspector(runningApplication: app) - await MainActor.run { + Task { @XcodeInspectorActor in self.xcodes.append(new) - self.setActiveXcode(new) + await self.setActiveXcode(new) } } } else { let appInspector = AppInstanceInspector(runningApplication: app) - await MainActor.run { + Task { @XcodeInspectorActor in self.previousActiveApplication = self.activeApplication self.activeApplication = appInspector } @@ -176,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 } @@ -207,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") } } @@ -231,7 +237,7 @@ public final class XcodeInspector: ObservableObject { } public func reactivateObservationsToXcode() { - Task { @MainActor in + Task { @XcodeInspectorActor in if let activeXcode { setActiveXcode(activeXcode) activeXcode.observeAXNotifications() @@ -239,7 +245,7 @@ public final class XcodeInspector: ObservableObject { } } - @MainActor + @XcodeInspectorActor private func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { previousActiveApplication = activeApplication activeApplication = xcode @@ -258,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 { @@ -279,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() @@ -293,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 @@ -334,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 } @@ -356,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 00a777ba..4248093d 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( @@ -62,7 +62,7 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { } } - @MainActor + @XcodeInspectorActor func updateURLs() { let documentURL = Self.extractDocumentURL(windowElement: uiElement) if let documentURL { From 3a1f4c37c538d4dfb010c7735515120962bb314c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 15 Feb 2024 22:37:15 +0800 Subject: [PATCH 17/27] Update circular widget implementation --- .../CircularWidgetFeature.swift | 10 -- .../FeatureReducers/WidgetFeature.swift | 7 +- .../Sources/SuggestionWidget/WidgetView.swift | 153 ++++++++++-------- 3 files changed, 86 insertions(+), 84 deletions(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift index abbf302a..8c09a769 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/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index d3ec9c2f..cbb1979f 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,15 +66,13 @@ 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 ) } } diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index d8f12097..04c11c86 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 { From d4805276cb9075db32ce4fd18395200661b7c7cd Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 15 Feb 2024 22:50:40 +0800 Subject: [PATCH 18/27] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 6fcfa82a..95460422 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 6fcfa82a16be6df1733259824e5b99e9f957dcf8 +Subproject commit 95460422e7dcdb12cfee5f163706b4dca16e35b1 From 02ab5cab96878b4acf83f39c97b19fa8b32469aa Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 15 Feb 2024 23:53:31 +0800 Subject: [PATCH 19/27] Update signpost API --- Pro | 2 +- Tool/Sources/Logger/Logger.swift | 43 ++++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/Pro b/Pro index 95460422..3d7a24e9 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 95460422e7dcdb12cfee5f163706b4dca16e35b1 +Subproject commit 3d7a24e91fef70e125dfc588b373fbb95f518a9e diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift index 109ece31..b24912ed 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)") + } } } + From 72a8565c4dc42b76e1108f5a758971c0b0b01ffd Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 15 Feb 2024 23:53:39 +0800 Subject: [PATCH 20/27] Fix dependecy --- Core/Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Core/Package.swift b/Core/Package.swift index 32d85d13..0eab77ee 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"), ] From 7c5c105883b48fdcb9ae5107543f1d39784614c7 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 15 Feb 2024 23:54:06 +0800 Subject: [PATCH 21/27] Bump build number --- Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Version.xcconfig b/Version.xcconfig index d5b60ff2..68ab50f7 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ APP_VERSION = 0.30.4 -APP_BUILD = 317 +APP_BUILD = 318 From d1d118e7b99974e724f15dccb1bd1bc09d2452fb Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 16 Feb 2024 00:47:43 +0800 Subject: [PATCH 22/27] Tweak widget behavior --- .../WidgetWindowsController.swift | 88 ++++++++++++------- .../XcodeInspector/XcodeInspector.swift | 4 +- 2 files changed, 57 insertions(+), 35 deletions(-) diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 894ca4fb..8862ba10 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -53,6 +53,11 @@ final class WidgetWindowsController: NSObject { 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 @@ -160,11 +165,10 @@ final class WidgetWindowsController: NSObject { function: StaticString = #function, line: UInt = #line ) async { - let state = store.withState { $0 } - let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow - @Sendable @MainActor func update() async { + let state = store.withState { $0 } + let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow guard let widgetLocation = generateWidgetLocation() else { return } await updatePanelState(widgetLocation) @@ -275,34 +279,24 @@ private extension WidgetWindowsController { } func observe(to app: AppInstanceInspector) async { + Task { + await updateWindowLocation(animated: false, immediately: true) + await updateWindowOpacity(immediately: true) + } guard let app = app as? XcodeAppInstanceInspector else { - Task { - await updateWindowLocation(animated: false, immediately: true) - await updateWindowOpacity(immediately: true) - } return } let notifications = app.axNotifications - if let focusedEditor = xcodeInspector.focusedEditor { - await observe(to: focusedEditor) - } - let task = Task { await windows.orderFront() let documentURL = await MainActor.run { store.withState { $0.focusingDocumentURL } } for await notification in notifications { - if [.uiElementDestroyed, .created, .xcodeCompletionPanelChanged] - .contains(notification.kind) { continue } - try Task.checkCancellation() - // Hide the widgets before switching to another window/editor - // so the transition looks better. - if [ - .focusedUIElementChanged, - .focusedWindowChanged, - ].contains(notification.kind) { + /// 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)) @@ -311,23 +305,34 @@ private extension WidgetWindowsController { await send(.updateFocusingDocumentURL) } - // update widgets. - if [ - .focusedUIElementChanged, - .applicationActivated, - .mainWindowChanged, - .focusedWindowChanged, - ].contains(notification.kind) { + func updateWidgetsAndNotifyChangeOfEditor() async { await updateWindowLocation(animated: false, immediately: false) await updateWindowOpacity(immediately: false) - if let editor = xcodeInspector.focusedEditor { - await observe(to: editor) - } await send(.panel(.switchToAnotherEditorAndUpdateContent)) - } else { + } + + 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 + } } } @@ -343,19 +348,31 @@ private extension WidgetWindowsController { .filter { $0.kind == .scrollPositionChanged } if #available(macOS 13.0, *) { - for await _ in merge( + 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 _ in merge(selectionRangeChange, scroll) { + 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) } @@ -373,6 +390,11 @@ extension WidgetWindowsController { windows.sharedPanelWindow.alphaValue = 0 windows.suggestionPanelWindow.alphaValue = 0 } + + @MainActor + func hideSuggestionPanelWindow() { + windows.suggestionPanelWindow.alphaValue = 0 + } func generateWidgetLocation() -> WidgetLocation? { if let application = xcodeInspector.latestActiveXcode?.appElement { diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index fb1b9ac9..07469800 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -152,13 +152,13 @@ public final class XcodeInspector: ObservableObject { $0.processIdentifier == app.processIdentifier && !$0.isTerminated }) { Task { @XcodeInspectorActor in - await self.setActiveXcode(existed) + self.setActiveXcode(existed) } } else { let new = XcodeAppInstanceInspector(runningApplication: app) Task { @XcodeInspectorActor in self.xcodes.append(new) - await self.setActiveXcode(new) + self.setActiveXcode(new) } } } else { From 060e08c1aa3357dfa11e1c58ac34e25b9600a7b4 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 16 Feb 2024 00:55:13 +0800 Subject: [PATCH 23/27] Make WidgetWindowsController an actor --- .../SuggestionWidgetController.swift | 4 +- .../WidgetWindowsController.swift | 43 +++++++------------ 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index e63a627a..0c1cc709 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -37,7 +37,9 @@ public final class SuggestionWidgetController: NSObject { dependency.windowsController = windowsController store.send(.startup) - windowsController.start() + Task { + await windowsController.start() + } } } diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 8862ba10..86f93a69 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -8,7 +8,7 @@ import Foundation import SwiftUI import XcodeInspector -final class WidgetWindowsController: NSObject { +actor WidgetWindowsController: NSObject { let userDefaultsObservers = WidgetUserDefaultsObservers() var xcodeInspector: XcodeInspector { .shared } @@ -90,11 +90,9 @@ final class WidgetWindowsController: NSObject { let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow let hasChat = !state.chatPanelState.chatTabGroup.tabInfo.isEmpty - let shouldDebounce = await MainActor.run { - defer { lastUpdateWindowOpacityTime = Date() } - return !immediately && - !(Date().timeIntervalSince(lastUpdateWindowOpacityTime) > 5) - } + let shouldDebounce = !immediately && + !(Date().timeIntervalSince(lastUpdateWindowOpacityTime) > 5) + lastUpdateWindowOpacityTime = Date() let activeApp = xcodeInspector.activeApplication updateWindowOpacityTask?.cancel() @@ -169,7 +167,7 @@ final class WidgetWindowsController: NSObject { func update() async { let state = store.withState { $0 } let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow - guard let widgetLocation = generateWidgetLocation() else { return } + guard let widgetLocation = await generateWidgetLocation() else { return } await updatePanelState(widgetLocation) windows.widgetWindow.setFrame( @@ -208,10 +206,8 @@ final class WidgetWindowsController: NSObject { } let now = Date() - let shouldThrottle = await MainActor.run { - !immediately && - !(now.timeIntervalSince(lastUpdateWindowLocationTime) > 5) - } + let shouldThrottle = !immediately && + !(now.timeIntervalSince(lastUpdateWindowLocationTime) > 5) updateWindowLocationTask?.cancel() let interval: TimeInterval = 0.1 @@ -222,14 +218,11 @@ final class WidgetWindowsController: NSObject { interval - now.timeIntervalSince(lastUpdateWindowLocationTime) ) - let task = Task { + updateWindowLocationTask = Task { try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) try Task.checkCancellation() await update() } - await MainActor.run { - updateWindowLocationTask = task - } } else { Task { await update() @@ -272,13 +265,13 @@ extension WidgetWindowsController: NSWindowDelegate { } private extension WidgetWindowsController { - func activate(_ app: AppInstanceInspector) async { + func activate(_ app: AppInstanceInspector) { guard currentApplicationProcessIdentifier != app.processIdentifier else { return } currentApplicationProcessIdentifier = app.processIdentifier - await observe(to: app) + observe(to: app) } - func observe(to app: AppInstanceInspector) async { + func observe(to app: AppInstanceInspector) { Task { await updateWindowLocation(animated: false, immediately: true) await updateWindowOpacity(immediately: true) @@ -287,7 +280,8 @@ private extension WidgetWindowsController { return } let notifications = app.axNotifications - let task = Task { + observeToAppTask?.cancel() + observeToAppTask = Task { await windows.orderFront() let documentURL = await MainActor.run { store.withState { $0.focusingDocumentURL } } @@ -335,13 +329,11 @@ private extension WidgetWindowsController { } } } - - observeToAppTask?.cancel() - observeToAppTask = task } - func observe(to editor: SourceEditor) async { - let task = Task { + func observe(to editor: SourceEditor) { + observeToFocusedEditorTask?.cancel() + observeToFocusedEditorTask = Task { let selectionRangeChange = editor.axNotifications .filter { $0.kind == .selectedTextChanged } let scroll = editor.axNotifications @@ -378,9 +370,6 @@ private extension WidgetWindowsController { } } } - - observeToFocusedEditorTask?.cancel() - observeToFocusedEditorTask = task } } From df7ad31a58b84bb5f87d9b3ebafae729f17dc701 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 16 Feb 2024 01:38:05 +0800 Subject: [PATCH 24/27] Bring back some main actors --- Pro | 2 +- .../Apps/XcodeAppInstanceInspector.swift | 50 ++++++++++++------- .../Sources/XcodeInspector/SourceEditor.swift | 2 + .../XcodeInspector/XcodeWindowInspector.swift | 13 +++-- 4 files changed, 45 insertions(+), 22 deletions(-) diff --git a/Pro b/Pro index 3d7a24e9..b53b4424 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 3d7a24e91fef70e125dfc588b373fbb95f518a9e +Subproject commit b53b44249d4eea82f09089753dfeca6116ad5a44 diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index 0aa07cc9..ac05f68b 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -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,10 +192,14 @@ 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 + } } } @@ -226,7 +232,8 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { 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 49913616..dd2e8621 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -76,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 { @@ -102,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/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index 4248093d..92a34e97 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -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() } } @@ -66,18 +67,24 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { 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 + } } } From 1973c990b3d6e3782b40cd165542ef99029e0607 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 17 Feb 2024 01:17:34 +0800 Subject: [PATCH 25/27] Adjust widget transition when switching apps --- .../WidgetWindowsController.swift | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index 86f93a69..f294b545 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -53,7 +53,7 @@ actor WidgetWindowsController: NSObject { 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) } @@ -91,7 +91,7 @@ actor WidgetWindowsController: NSObject { let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow let hasChat = !state.chatPanelState.chatTabGroup.tabInfo.isEmpty let shouldDebounce = !immediately && - !(Date().timeIntervalSince(lastUpdateWindowOpacityTime) > 5) + !(Date().timeIntervalSince(lastUpdateWindowOpacityTime) > 5) lastUpdateWindowOpacityTime = Date() let activeApp = xcodeInspector.activeApplication @@ -207,7 +207,7 @@ actor WidgetWindowsController: NSObject { let now = Date() let shouldThrottle = !immediately && - !(now.timeIntervalSince(lastUpdateWindowLocationTime) > 5) + !(now.timeIntervalSince(lastUpdateWindowLocationTime) > 5) updateWindowLocationTask?.cancel() let interval: TimeInterval = 0.1 @@ -266,19 +266,22 @@ extension WidgetWindowsController: NSWindowDelegate { 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) { - Task { - await updateWindowLocation(animated: false, immediately: true) - await updateWindowOpacity(immediately: true) - } - guard let app = app as? XcodeAppInstanceInspector else { - return - } + guard let app = app as? XcodeAppInstanceInspector else { return } let notifications = app.axNotifications observeToAppTask?.cancel() observeToAppTask = Task { @@ -346,12 +349,12 @@ private extension WidgetWindowsController { ) { 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) } @@ -359,12 +362,12 @@ private extension WidgetWindowsController { 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) } @@ -379,7 +382,7 @@ extension WidgetWindowsController { windows.sharedPanelWindow.alphaValue = 0 windows.suggestionPanelWindow.alphaValue = 0 } - + @MainActor func hideSuggestionPanelWindow() { windows.suggestionPanelWindow.alphaValue = 0 From 919c7adf45e5907c5201c32b5e35d537fceb6455 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 17 Feb 2024 11:59:54 +0800 Subject: [PATCH 26/27] Bump version --- Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Version.xcconfig b/Version.xcconfig index 68ab50f7..7f0f9ca8 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ APP_VERSION = 0.30.4 -APP_BUILD = 318 +APP_BUILD = 320 From 653459c5c9794f87f76d3e23e0d8e93e318e9df7 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 17 Feb 2024 16:40:33 +0800 Subject: [PATCH 27/27] Update appcast.xml --- appcast.xml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/appcast.xml b/appcast.xml index d5fae9ac..abb6dfc3 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