From 481863427305726ef890d5eaa89f8faf02bdc0c0 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 25 Jan 2024 15:27:47 +0800 Subject: [PATCH 01/45] Prevent the chat window size and position being reset when it's hidden --- .../SuggestionWidget/FeatureReducers/WidgetFeature.swift | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 86943827..9be10fc5 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -539,13 +539,7 @@ public struct WidgetFeature: ReducerProtocol { } if isChatPanelDetached { - if windows.chatPanelWindow.alphaValue == 0 { - windows.chatPanelWindow.setFrame( - widgetLocation.defaultPanelLocation.frame, - display: false, - animate: animated - ) - } + // don't update it! } else { windows.chatPanelWindow.setFrame( widgetLocation.defaultPanelLocation.frame, From 23b7f3cb205a66cd7bbb097a550762508f529b99 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 25 Jan 2024 15:28:13 +0800 Subject: [PATCH 02/45] Use isWindowHidden to control the opacity of chat window --- .../SuggestionWidget/FeatureReducers/WidgetFeature.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 9be10fc5..5e17481c 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -208,7 +208,7 @@ public struct WidgetFeature: ReducerProtocol { await send(.updateWindowOpacity(immediately: false)) if isDetached { Task { @MainActor in - windows.chatPanelWindow.alphaValue = 1 + windows.chatPanelWindow.isWindowHidden = false } } } From 6e6bc9641d2a52fd2dcdd1d08a6f8da79994f178 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 26 Jan 2024 13:13:13 +0800 Subject: [PATCH 03/45] Add a dismiss button to non-compact mode suggestion --- .../CodeBlockSuggestionPanel.swift | 218 +++++++----------- 1 file changed, 86 insertions(+), 132 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift index 91e3c364..b42e37ea 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift @@ -31,6 +31,12 @@ struct CodeBlockSuggestionPanel: View { Spacer() + Button(action: { + suggestion.dismissSuggestion() + }) { + Text("Dismiss").foregroundStyle(.tertiary).padding(.trailing, 4) + }.buttonStyle(.plain) + Button(action: { suggestion.rejectSuggestion() }) { @@ -41,7 +47,7 @@ struct CodeBlockSuggestionPanel: View { suggestion.acceptSuggestion() }) { Text("Accept") - }.buttonStyle(CommandButtonStyle(color: .indigo)) + }.buttonStyle(CommandButtonStyle(color: .accentColor)) } .padding() .foregroundColor(.secondary) @@ -72,7 +78,7 @@ struct CodeBlockSuggestionPanel: View { }.buttonStyle(.plain) Spacer() - + Button(action: { suggestion.dismissSuggestion() }) { @@ -112,146 +118,94 @@ struct CodeBlockSuggestionPanel: View { // MARK: - Previews -struct CodeBlockSuggestionPanel_Dark_Preview: PreviewProvider { - static var previews: some View { - CodeBlockSuggestionPanel(suggestion: CodeSuggestionProvider( - code: """ - LazyVGrid(columns: [GridItem(.fixed(30)), GridItem(.flexible())]) { - ForEach(0.. Date: Fri, 26 Jan 2024 13:17:28 +0800 Subject: [PATCH 04/45] Truncate file name in the middle when it's too long --- .../PromptToCodePanel.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index 31c71b9e..db664de9 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -73,6 +73,8 @@ extension PromptToCodePanel { if isAttached { HStack(spacing: 4) { Text(viewStore.state.attachedToFilename) + .lineLimit(1) + .truncationMode(.middle) if let range = viewStore.state.selectionRange { Text(range.description) } @@ -437,6 +439,37 @@ struct PromptToCodePanel_Preview: PreviewProvider { } } +#Preview("Prompt to Code Panel Super Long File Name") { + PromptToCodePanel(store: .init(initialState: .init( + code: """ + ForEach(0.. Date: Fri, 26 Jan 2024 13:18:37 +0800 Subject: [PATCH 05/45] Use accent color instead of indigo --- Core/Sources/HostApp/TabContainer.swift | 2 +- .../SuggestionPanelContent/PromptToCodePanel.swift | 4 ++-- .../SuggestionPanelContent/ToastPanelView.swift | 2 +- Pro | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift index 02083499..720ddcf4 100644 --- a/Core/Sources/HostApp/TabContainer.swift +++ b/Core/Sources/HostApp/TabContainer.swift @@ -83,7 +83,7 @@ public struct TabContainer: View { .padding(8) .background({ switch message.type { - case .info: return Color(nsColor: .systemIndigo) + case .info: return Color.accentColor case .error: return Color(nsColor: .systemRed) case .warning: return Color(nsColor: .systemOrange) } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift index db664de9..98b5707f 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift @@ -52,7 +52,7 @@ extension PromptToCodePanel { ) } ) { viewStore in let isAttached = viewStore.state.isAttachedToSelectionRange - let color: Color = isAttached ? .indigo : .secondary.opacity(0.6) + let color: Color = isAttached ? .accentColor : .secondary.opacity(0.6) HStack(spacing: 4) { Image( systemName: isAttached ? "link" : "character.cursor.ibeam" @@ -180,7 +180,7 @@ extension PromptToCodePanel { }) { Text("Accept(⌘ + ⏎)") } - .buttonStyle(CommandButtonStyle(color: .indigo)) + .buttonStyle(CommandButtonStyle(color: .accentColor)) .keyboardShortcut(KeyEquivalent.return, modifiers: [.command]) } } diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift index efd98429..d759c298 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift @@ -32,7 +32,7 @@ struct ToastPanelView: View { .frame(maxWidth: .infinity) .background({ switch message.type { - case .info: return Color(nsColor: .systemIndigo) + case .info: return Color.accentColor case .error: return Color(nsColor: .systemRed) case .warning: return Color(nsColor: .systemOrange) } diff --git a/Pro b/Pro index 59d36209..861b86a4 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 59d3620976c45a2dafdf50332317cca266a32488 +Subproject commit 861b86a4a1e8eb444293035e4d4db7e0097db0f1 From 94bee9d6d223b5aa95e477368c88ce5165496fd6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 26 Jan 2024 17:34:05 +0800 Subject: [PATCH 06/45] Bump GitHub Copilot version to 1.17.0 --- .../GitHubCopilotService/GitHubCopilotInstallationManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift index 2e24b6d9..723c0ede 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotInstallationManager.swift @@ -10,7 +10,7 @@ public struct GitHubCopilotInstallationManager { return URL(string: link)! } - static let latestSupportedVersion = "1.12.1" + static let latestSupportedVersion = "1.17.0" public init() {} From b51651396054ea17173a0d1ea50a7a2c964b3603 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 26 Jan 2024 17:40:01 +0800 Subject: [PATCH 07/45] Bump Codeium language server to 1.6.9 --- Tool/Sources/CodeiumService/CodeiumInstallationManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift b/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift index 62e6e0b5..7574c31b 100644 --- a/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift +++ b/Tool/Sources/CodeiumService/CodeiumInstallationManager.swift @@ -3,7 +3,7 @@ import Terminal public struct CodeiumInstallationManager { private static var isInstalling = false - static let latestSupportedVersion = "1.6.6" + static let latestSupportedVersion = "1.6.9" public init() {} From 9f1d0cf0596a4afb010aab4dbe970b008bf3715d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 26 Jan 2024 00:15:30 +0800 Subject: [PATCH 08/45] Add AsyncExtensions to use a concurrency version of passthrough subject --- Tool/Package.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tool/Package.swift b/Tool/Package.swift index 558e51e2..8c48dfe7 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -63,6 +63,7 @@ let package = Package( .package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"), .package(url: "https://github.com/krzyzanowskim/STTextView", from: "0.8.21"), .package(url: "https://github.com/google/generative-ai-swift", from: "0.4.4"), + .package(url: "https://github.com/sideeffect-io/AsyncExtensions", from: "0.5.2"), // TreeSitter .package(url: "https://github.com/intitni/SwiftTreeSitter.git", branch: "main"), @@ -167,6 +168,7 @@ let package = Package( "SuggestionModel", "AXNotificationStream", "Logger", + .product(name: "AsyncExtensions", package: "AsyncExtensions"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ] ), From 53d49efdcdc9922666572fe2f7bb193ea2458de7 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 26 Jan 2024 00:16:02 +0800 Subject: [PATCH 09/45] Adjust AXNotificationStream --- .../AXNotificationStream.swift | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift index 77df9b66..9d3534a1 100644 --- a/Tool/Sources/AXNotificationStream/AXNotificationStream.swift +++ b/Tool/Sources/AXNotificationStream/AXNotificationStream.swift @@ -12,6 +12,10 @@ public final class AXNotificationStream: AsyncSequence { private var continuation: Continuation private let stream: Stream + private let file: StaticString + private let line: UInt + private let function: StaticString + public func makeAsyncIterator() -> Stream.AsyncIterator { stream.makeAsyncIterator() } @@ -23,16 +27,32 @@ public final class AXNotificationStream: AsyncSequence { public convenience init( app: NSRunningApplication, element: AXUIElement? = nil, - notificationNames: String... + notificationNames: String..., + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function ) { - self.init(app: app, element: element, notificationNames: notificationNames) + self.init( + app: app, + element: element, + notificationNames: notificationNames, + file: file, + line: line, + function: function + ) } public init( app: NSRunningApplication, element: AXUIElement? = nil, - notificationNames: [String] + notificationNames: [String], + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function ) { + self.file = file + self.line = line + self.function = function var cont: Continuation! stream = Stream { continuation in cont = continuation @@ -74,7 +94,7 @@ public final class AXNotificationStream: AsyncSequence { ) } - Task { [weak self] in + Task { @MainActor [weak self] in CFRunLoopAddSource( CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), @@ -101,10 +121,12 @@ public final class AXNotificationStream: AsyncSequence { Logger.service.error("AXObserver: Action unsupported: \(name)") pendingRegistrationNames.remove(name) case .apiDisabled: - Logger.service.error("AXObserver: Accessibility API disabled, will try again later") + Logger.service + .error("AXObserver: Accessibility API disabled, will try again later") retry -= 1 case .invalidUIElement: - Logger.service.error("AXObserver: Invalid UI element") + Logger.service + .error("AXObserver: Invalid UI element, notification name \(name)") pendingRegistrationNames.remove(name) case .invalidUIElementObserver: Logger.service.error("AXObserver: Invalid UI element observer") @@ -116,10 +138,13 @@ public final class AXNotificationStream: AsyncSequence { Logger.service.error("AXObserver: Notification unsupported: \(name)") pendingRegistrationNames.remove(name) case .notificationAlreadyRegistered: + Logger.service.info("AXObserver: Notification already registered: \(name)") pendingRegistrationNames.remove(name) default: Logger.service - .error("AXObserver: Unrecognized error \(e) when registering \(name), will try again later") + .error( + "AXObserver: Unrecognized error \(e) when registering \(name), will try again later" + ) } } try await Task.sleep(nanoseconds: 1_500_000_000) From 930780fff323fc158ac7061ca432df0bdcc06951 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 26 Jan 2024 00:16:39 +0800 Subject: [PATCH 10/45] Handle AXNotification on app in XcodeAppInstanceInspector --- .../Apps/XcodeAppInstanceInspector.swift | 186 ++++++++++++------ 1 file changed, 125 insertions(+), 61 deletions(-) diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index 7de0eb03..9f373923 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -1,4 +1,5 @@ import AppKit +import AsyncExtensions import AXExtension import AXNotificationStream import Combine @@ -15,6 +16,63 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { return workspaces.mapValues(\.info) } + public struct AXNotification { + public var kind: AXNotificationKind + public var element: AXUIElement + } + + public enum AXNotificationKind { + case applicationActivated + case applicationDeactivated + case moved + case resized + case mainWindowChanged + case focusedWindowChanged + case focusedUIElementChanged + case windowMoved + case windowResized + case windowMiniaturized + case windowDeminiaturized + case created + case uiElementDestroyed + case xcodeCompletionPanelChanged + + public init?(rawValue: String) { + switch rawValue { + case kAXApplicationActivatedNotification: + self = .applicationActivated + case kAXApplicationDeactivatedNotification: + self = .applicationDeactivated + case kAXMovedNotification: + self = .moved + case kAXResizedNotification: + self = .resized + case kAXMainWindowChangedNotification: + self = .mainWindowChanged + case kAXFocusedWindowChangedNotification: + self = .focusedWindowChanged + case kAXFocusedUIElementChangedNotification: + self = .focusedUIElementChanged + case kAXWindowMovedNotification: + self = .windowMoved + case kAXWindowResizedNotification: + self = .windowResized + case kAXWindowMiniaturizedNotification: + self = .windowMiniaturized + case kAXWindowDeminiaturizedNotification: + self = .windowDeminiaturized + case kAXCreatedNotification: + self = .created + case kAXUIElementDestroyedNotification: + self = .uiElementDestroyed + default: + return nil + } + } + } + + public let axNotifications = AsyncPassthroughSubject() + @Published public private(set) var completionPanel: AXUIElement? public var realtimeDocumentURL: URL? { @@ -66,6 +124,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { private var focusedWindowObservations = Set() deinit { + axNotifications.send(.finished) for task in longRunningTasks { task.cancel() } } @@ -75,9 +134,9 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { Task { @MainActor in observeFocusedWindow() observeAXNotifications() - + try await Task.sleep(nanoseconds: 3_000_000_000) - // Sometimes the focused window may not be ready on app launch. + // Sometimes the focused window may not be rea?dy on app launch. if !(focusedWindow is WorkspaceXcodeWindowInspector) { observeFocusedWindow() } @@ -90,7 +149,8 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { if window.identifier == "Xcode.WorkspaceWindow" { let window = WorkspaceXcodeWindowInspector( app: runningApplication, - uiElement: window + uiElement: window, + axNotifications: axNotifications ) focusedWindow = window @@ -145,81 +205,85 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { longRunningTasks.forEach { $0.cancel() } longRunningTasks = [] - let windowChangeNotification = AXNotificationStream( + let axNotificationStream = AXNotificationStream( app: runningApplication, - notificationNames: kAXFocusedWindowChangedNotification + notificationNames: + kAXApplicationActivatedNotification, + kAXApplicationDeactivatedNotification, + kAXMovedNotification, + kAXResizedNotification, + kAXMainWindowChangedNotification, + kAXFocusedWindowChangedNotification, + kAXFocusedUIElementChangedNotification, + kAXWindowMovedNotification, + kAXWindowResizedNotification, + kAXWindowMiniaturizedNotification, + kAXWindowDeminiaturizedNotification, + kAXCreatedNotification, + kAXUIElementDestroyedNotification ) - let focusedWindowChanged = Task { @MainActor [weak self] in - for await _ in windowChangeNotification { + let observeAXNotificationTask = Task { @MainActor [weak self] in + var updateWorkspaceInfoTask: Task? + + for await notification in axNotificationStream { guard let self else { return } try Task.checkCancellation() - observeFocusedWindow() - } - } - - longRunningTasks.insert(focusedWindowChanged) - - updateWorkspaceInfo() - - let elementChangeNotification = AXNotificationStream( - app: runningApplication, - notificationNames: kAXFocusedUIElementChangedNotification, - kAXApplicationDeactivatedNotification - ) - let updateTabsTask = Task { @MainActor [weak self] in - if #available(macOS 13.0, *) { - for await _ in elementChangeNotification.debounce(for: .seconds(2)) { - guard let self else { return } - try Task.checkCancellation() - updateWorkspaceInfo() - } - } else { - for await _ in elementChangeNotification { - guard let self else { return } - try Task.checkCancellation() - updateWorkspaceInfo() + guard let event = AXNotificationKind(rawValue: notification.name) else { + continue } - } - } - longRunningTasks.insert(updateTabsTask) + self.axNotifications.send(.init(kind: event, element: notification.element)) - let completionPanelNotification = AXNotificationStream( - app: runningApplication, - notificationNames: kAXCreatedNotification, kAXUIElementDestroyedNotification - ) - - let completionPanelTask = Task { @MainActor [weak self] in - for await event in completionPanelNotification { - guard let self else { return } + if event == .focusedWindowChanged { + observeFocusedWindow() + } - // We can only observe the creation and closing of the parent - // of the completion panel. - let isCompletionPanel = { - event.element.identifier == "_XC_COMPLETION_TABLE_" - || event.element.firstChild { element in - element.identifier == "_XC_COMPLETION_TABLE_" - } != nil + if event == .focusedUIElementChanged || event == .applicationDeactivated { + updateWorkspaceInfoTask?.cancel() + updateWorkspaceInfoTask = Task { [weak self] in + guard let self else { return } + try await Task.sleep(nanoseconds: 2_000_000_000) + try Task.checkCancellation() + self.updateWorkspaceInfo() + } } - switch event.name { - case kAXCreatedNotification: - if isCompletionPanel() { - completionPanel = event.element + + if event == .created || event == .uiElementDestroyed { + let isCompletionPanel = { + notification.element.identifier == "_XC_COMPLETION_TABLE_" + || notification.element.firstChild { element in + element.identifier == "_XC_COMPLETION_TABLE_" + } != nil } - case kAXUIElementDestroyedNotification: - if isCompletionPanel() { - completionPanel = nil + + switch event { + case .created: + if isCompletionPanel() { + 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 + )) + } + default: continue } - default: break } - - try Task.checkCancellation() } } - longRunningTasks.insert(completionPanelTask) + longRunningTasks.insert(observeAXNotificationTask) + + updateWorkspaceInfo() } } From a464a493c5c580be47a5f26128ce788d57f6c122 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 26 Jan 2024 00:17:06 +0800 Subject: [PATCH 11/45] Handle source editor AXNotifications in SourceEditor --- .../Sources/XcodeInspector/SourceEditor.swift | 87 +++++++++++++++---- 1 file changed, 71 insertions(+), 16 deletions(-) diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index d7d1a561..8d5bf04e 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -1,4 +1,5 @@ import AppKit +import AsyncExtensions import AXNotificationStream import Foundation import SuggestionModel @@ -7,8 +8,21 @@ import SuggestionModel public class SourceEditor { public typealias Content = EditorInformation.SourceEditorContent + public struct AXNotification { + public var kind: AXNotificationKind + public var element: AXUIElement + } + + public enum AXNotificationKind { + case selectedTextChanged + case valueChanged + case scrollPositionChanged + } + + public let axNotifications = AsyncPassthroughSubject() let runningApplication: NSRunningApplication public let element: AXUIElement + var observeAXNotificationsTask: Task? /// The content of the source editor. public var content: Content { @@ -39,25 +53,66 @@ public class SourceEditor { public init(runningApplication: NSRunningApplication, element: AXUIElement) { self.runningApplication = runningApplication self.element = element - } - /// Observe to changes in the source editor. - public func observe(notificationNames: String...) -> AXNotificationStream { - return AXNotificationStream( - app: runningApplication, - element: element, - notificationNames: notificationNames - ) + Task { @MainActor in + observeAXNotifications() + } } - /// Observe to changes in the source editor scroll view. - public func observeScrollView(notificationNames: String...) -> AXNotificationStream? { - guard let scrollView = element.parent else { return nil } - return AXNotificationStream( - app: runningApplication, - element: scrollView, - notificationNames: notificationNames - ) + @MainActor + func observeAXNotifications() { + observeAXNotificationsTask?.cancel() + observeAXNotificationsTask = Task { @MainActor [weak self] in + guard let self else { return } + await withTaskGroup(of: Void.self) { [weak self] group in + guard let self else { return } + let editorNotifications = AXNotificationStream( + app: runningApplication, + element: element, + notificationNames: + kAXSelectedTextChangedNotification, + kAXValueChangedNotification + ) + + group.addTask { [weak self] in + for await notification in editorNotifications { + guard let self else { return } + if let kind: AXNotificationKind = { + switch notification.name { + case kAXSelectedTextChangedNotification: return .selectedTextChanged + case kAXValueChangedNotification: return .valueChanged + default: return nil + } + }() { + self.axNotifications.send(.init( + kind: kind, + element: notification.element + )) + } + } + } + + if let scrollView = element.parent, let scrollBar = scrollView.verticalScrollBar { + let scrollViewNotifications = AXNotificationStream( + app: runningApplication, + element: scrollBar, + notificationNames: kAXValueChangedNotification + ) + + group.addTask { [weak self] in + for await notification in scrollViewNotifications { + guard let self else { return } + self.axNotifications.send(.init( + kind: .scrollPositionChanged, + element: notification.element + )) + } + } + } + + await group.waitForAll() + } + } } } From efdd979066bbba77f4271816ff51b3a1aa483ed2 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 26 Jan 2024 00:18:27 +0800 Subject: [PATCH 12/45] Replace AXNotificationStream creation --- .../RealtimeSuggestionController.swift | 101 +++++------------- .../FeatureReducers/WidgetFeature.swift | 67 ++++-------- .../SuggestionWidgetController.swift | 1 - Pro | 2 +- .../XcodeInspector/XcodeInspector.swift | 22 ++-- .../XcodeInspector/XcodeWindowInspector.swift | 22 ++-- 6 files changed, 69 insertions(+), 146 deletions(-) diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index d702fa87..58b2833d 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -2,7 +2,7 @@ import ActiveApplicationMonitor import AppKit import AsyncAlgorithms import AXExtension -import AXNotificationStream +import Combine import Foundation import Logger import Preferences @@ -11,21 +11,16 @@ import Workspace import XcodeInspector public actor RealtimeSuggestionController { - private var task: Task? + private var cancellable: Set = [] private var inflightPrefetchTask: Task? - private var windowChangeObservationTask: Task? - private var activeApplicationMonitorTask: Task? private var editorObservationTask: Task? - private var focusedUIElement: AXUIElement? private var sourceEditor: SourceEditor? init() {} deinit { - task?.cancel() + cancellable.forEach { $0.cancel() } inflightPrefetchTask?.cancel() - windowChangeObservationTask?.cancel() - activeApplicationMonitorTask?.cancel() editorObservationTask?.cancel() } @@ -35,58 +30,19 @@ public actor RealtimeSuggestionController { } private func observeXcodeChange() { - task?.cancel() - task = Task { [weak self] in - if ActiveApplicationMonitor.shared.activeXcode != nil { - await self?.handleXcodeChanged() - } - var previousApp = ActiveApplicationMonitor.shared.activeXcode?.info - for await app in ActiveApplicationMonitor.shared.createInfoStream() { + cancellable.forEach { $0.cancel() } + + XcodeInspector.shared.$focusedEditor + .sink { [weak self] editor in guard let self else { return } - try Task.checkCancellation() - defer { previousApp = app } - - if let app = ActiveApplicationMonitor.shared.activeXcode, - app.processIdentifier != previousApp?.processIdentifier - { - await self.handleXcodeChanged() + Task { + guard let editor else { return } + await self.handleFocusElementChange(editor) } - } - } - } - - private func handleXcodeChanged() { - guard let app = ActiveApplicationMonitor.shared.activeXcode else { return } - windowChangeObservationTask?.cancel() - windowChangeObservationTask = nil - observeXcodeWindowChangeIfNeeded(app) - } - - private func observeXcodeWindowChangeIfNeeded(_ app: NSRunningApplication) { - guard windowChangeObservationTask == nil else { return } - handleFocusElementChange() - - let notifications = AXNotificationStream( - app: app, - notificationNames: kAXFocusedUIElementChangedNotification, - kAXMainWindowChangedNotification - ) - windowChangeObservationTask = Task { [weak self] in - for await _ in notifications { - guard let self else { return } - try Task.checkCancellation() - await self.handleFocusElementChange() - } - } + }.store(in: &cancellable) } - private func handleFocusElementChange() { - guard let activeXcode = ActiveApplicationMonitor.shared.activeXcode else { return } - let application = AXUIElementCreateApplication(activeXcode.processIdentifier) - guard let focusElement = application.focusedElement else { return } - let focusElementType = focusElement.description - focusedUIElement = focusElement - + private func handleFocusElementChange(_ sourceEditor: SourceEditor) { Task { // Notify suggestion service for open file. try await Task.sleep(nanoseconds: 500_000_000) guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } @@ -94,21 +50,15 @@ public actor RealtimeSuggestionController { .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) } - guard focusElementType == "Source Editor" else { return } - sourceEditor = SourceEditor(runningApplication: activeXcode, element: focusElement) + self.sourceEditor = sourceEditor + + let notificationsFromEditor = sourceEditor.axNotifications editorObservationTask?.cancel() editorObservationTask = nil - let notificationsFromEditor = AXNotificationStream( - app: activeXcode, - element: focusElement, - notificationNames: kAXValueChangedNotification, kAXSelectedTextChangedNotification - ) - editorObservationTask = Task { [weak self] in - guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } - if let sourceEditor = await self?.sourceEditor { + if let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL { await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( fileURL: fileURL, sourceEditor: sourceEditor @@ -119,21 +69,22 @@ public actor RealtimeSuggestionController { guard let self else { return } try Task.checkCancellation() - switch notification.name { - case kAXValueChangedNotification: + switch notification.kind { + case .valueChanged: + Logger.service.debug("Receive valueChanged from editor") await cancelInFlightTasks() await self.triggerPrefetchDebounced() - await self.notifyEditingFileChange(editor: focusElement) - case kAXSelectedTextChangedNotification: - guard let sourceEditor = await sourceEditor, - let fileURL = XcodeInspector.shared.activeDocumentURL - else { continue } + await self.notifyEditingFileChange(editor: sourceEditor.element) + case .selectedTextChanged: + Logger.service.debug("Receive selectedTextChanged from editor") + guard let fileURL = XcodeInspector.shared.activeDocumentURL + else { break } await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( fileURL: fileURL, sourceEditor: sourceEditor ) default: - continue + break } } } @@ -179,8 +130,6 @@ public actor RealtimeSuggestionController { } if Task.isCancelled { return } -// Logger.service.info("Prefetch suggestions.") - // So the editor won't be blocked (after information are cached)! await PseudoCommandHandler().generateRealtimeSuggestions(sourceEditor: sourceEditor) } diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 5e17481c..5c90ced5 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -1,9 +1,9 @@ import ActiveApplicationMonitor import AppActivator import AsyncAlgorithms -import AXNotificationStream import ComposableArchitecture import Foundation +import Logger import Preferences import SwiftUI import Toast @@ -345,39 +345,27 @@ public struct WidgetFeature: ReducerProtocol { }.cancellable(id: CancelID.observeUserDefaults, cancelInFlight: true) case .observeWindowChange: - guard let app = xcodeInspector.activeApplication else { return .none } - guard app.isXcode else { return .none } + guard let app = xcodeInspector.activeXcode else { return .none } let documentURL = state.focusingDocumentURL - let notifications = AXNotificationStream( - app: app.runningApplication, - notificationNames: - kAXApplicationActivatedNotification, - kAXMovedNotification, - kAXResizedNotification, - kAXMainWindowChangedNotification, - kAXFocusedWindowChangedNotification, - kAXFocusedUIElementChangedNotification, - kAXWindowMovedNotification, - kAXWindowResizedNotification, - kAXWindowMiniaturizedNotification, - kAXWindowDeminiaturizedNotification - ) + let notifications = app.axNotifications return .run { send in await send(.observeEditorChange) await send(.panel(.switchToAnotherEditorAndUpdateContent)) for await notification in notifications { + Logger.service.debug("Receive \(notification.kind) from Xcode") + try Task.checkCancellation() // Hide the widgets before switching to another window/editor // so the transition looks better. if [ - kAXFocusedUIElementChangedNotification, - kAXFocusedWindowChangedNotification, - ].contains(notification.name) { + .focusedUIElementChanged, + .focusedWindowChanged, + ].contains(notification.kind) { let newDocumentURL = xcodeInspector.realtimeActiveDocumentURL if documentURL != newDocumentURL { await send(.panel(.removeDisplayedContent)) @@ -388,11 +376,11 @@ public struct WidgetFeature: ReducerProtocol { // update widgets. if [ - kAXFocusedUIElementChangedNotification, - kAXApplicationActivatedNotification, - kAXMainWindowChangedNotification, - kAXFocusedWindowChangedNotification, - ].contains(notification.name) { + .focusedUIElementChanged, + .applicationActivated, + .mainWindowChanged, + .focusedWindowChanged, + ].contains(notification.kind) { await send(.updateWindowLocation(animated: false)) await send(.updateWindowOpacity(immediately: false)) await send(.observeEditorChange) @@ -405,33 +393,20 @@ public struct WidgetFeature: ReducerProtocol { }.cancellable(id: CancelID.observeWindowChange, cancelInFlight: true) case .observeEditorChange: - guard let app = xcodeInspector.activeApplication else { return .none } - let appElement = AXUIElementCreateApplication( - app.runningApplication.processIdentifier - ) - guard let focusedElement = appElement.focusedElement, - focusedElement.description == "Source Editor", - let scrollView = focusedElement.parent, - let scrollBar = scrollView.verticalScrollBar - else { return .none } - - let selectionRangeChange = AXNotificationStream( - app: app.runningApplication, - element: focusedElement, - notificationNames: kAXSelectedTextChangedNotification - ) - let scroll = AXNotificationStream( - app: app.runningApplication, - element: scrollBar, - notificationNames: kAXValueChangedNotification - ) + 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( + for await notification in merge( selectionRangeChange.debounce(for: Duration.milliseconds(500)), scroll ) { + Logger.service.debug("Receive \(notification.kind) from editor") guard xcodeInspector.latestActiveXcode != nil else { return } try Task.checkCancellation() await send(.updateWindowLocation(animated: false)) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 102c2e64..505abddb 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -1,7 +1,6 @@ import ActiveApplicationMonitor import AppKit import AsyncAlgorithms -import AXNotificationStream import ChatTab import Combine import ComposableArchitecture diff --git a/Pro b/Pro index 861b86a4..b8c7a3a6 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 861b86a4a1e8eb444293035e4d4db7e0097db0f1 +Subproject commit b8c7a3a60946b15ff00440a16a25ce5b743f7f11 diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 917b6f4f..7e6012c5 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -1,7 +1,6 @@ import AppKit import AsyncAlgorithms import AXExtension -import AXNotificationStream import Combine import Foundation import Logger @@ -83,7 +82,7 @@ public final class XcodeInspector: ObservableObject { init() { restart() } - + public func restart(cleanUp: Bool = false) { if cleanUp { activeXcodeObservations.forEach { $0.cancel() } @@ -111,7 +110,7 @@ public final class XcodeInspector: ObservableObject { activeApplication = activeXcode ?? runningApplications .first(where: \.isActive) .map(AppInstanceInspector.init(runningApplication:)) - + appChangeObservations.forEach { $0.cancel() } appChangeObservations.removeAll() @@ -120,9 +119,9 @@ public final class XcodeInspector: ObservableObject { if let activeXcode { await setActiveXcode(activeXcode) } - + await withThrowingTaskGroup(of: Void.self) { [weak self] group in - group.addTask { [weak self] in // Did activate app + group.addTask { [weak self] in // Did activate app let sequence = NSWorkspace.shared.notificationCenter .notifications(named: NSWorkspace.didActivateApplicationNotification) for await notification in sequence { @@ -154,7 +153,7 @@ public final class XcodeInspector: ObservableObject { } } } - + group.addTask { [weak self] in // Did terminate app let sequence = NSWorkspace.shared.notificationCenter .notifications(named: NSWorkspace.didTerminateApplicationNotification) @@ -185,11 +184,10 @@ public final class XcodeInspector: ObservableObject { } } } - + appChangeObservations.insert(appChangeTask) } - @MainActor func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { previousActiveApplication = activeApplication @@ -231,11 +229,9 @@ public final class XcodeInspector: ObservableObject { setFocusedElement() let focusedElementChanged = Task { @MainActor in - let notification = AXNotificationStream( - app: xcode.runningApplication, - notificationNames: kAXFocusedUIElementChangedNotification - ) - for await _ in notification { + for await notification in xcode.axNotifications { + guard notification.kind == .focusedUIElementChanged else { continue } + Logger.service.debug("Update focused element") try Task.checkCancellation() setFocusedElement() } diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index 137fc434..a10b308c 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -1,8 +1,9 @@ import AppKit +import AsyncExtensions import AXExtension -import AXNotificationStream import Combine import Foundation +import Logger public class XcodeWindowInspector: ObservableObject { public let uiElement: AXUIElement @@ -18,6 +19,7 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { @Published var workspaceURL: URL = .init(fileURLWithPath: "/") @Published var projectRootURL: URL = .init(fileURLWithPath: "/") private var focusedElementChangedTask: Task? + let axNotifications: AsyncPassthroughSubject deinit { focusedElementChangedTask?.cancel() @@ -27,16 +29,16 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { Task { @MainActor in updateURLs() } } - public init(app: NSRunningApplication, uiElement: AXUIElement) { + public init( + app: NSRunningApplication, + uiElement: AXUIElement, + axNotifications: AsyncPassthroughSubject + ) { self.app = app + self.axNotifications = axNotifications super.init(uiElement: uiElement) - let notifications = AXNotificationStream( - app: app, - notificationNames: kAXFocusedUIElementChangedNotification - ) - - focusedElementChangedTask = Task { [weak self] in + focusedElementChangedTask = Task { [weak self, axNotifications] in await self?.updateURLs() await withThrowingTaskGroup(of: Void.self) { [weak self] group in @@ -49,8 +51,10 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { } group.addTask { [weak self] in - for await _ in notifications { + for await notification in axNotifications { + guard notification.kind == .focusedUIElementChanged else { continue } guard let self else { return } + Logger.service.debug("Workspace refresh") try Task.checkCancellation() await self.updateURLs() } From e564d0f85db671be901a244672dc7351794e9879 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 26 Jan 2024 00:18:36 +0800 Subject: [PATCH 13/45] Update Logger --- Tool/Sources/Logger/Logger.swift | 63 +++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/Tool/Sources/Logger/Logger.swift b/Tool/Sources/Logger/Logger.swift index 840244fd..109ece31 100644 --- a/Tool/Sources/Logger/Logger.swift +++ b/Tool/Sources/Logger/Logger.swift @@ -33,7 +33,13 @@ public final class Logger { osLog = OSLog(subsystem: subsystem, category: category) } - func log(level: LogLevel, message: String) { + func log( + level: LogLevel, + message: String, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function + ) { let osLogType: OSLogType switch level { case .debug: @@ -47,23 +53,60 @@ public final class Logger { os_log("%{public}@", log: osLog, type: osLogType, message as CVarArg) } - public func debug(_ message: String) { - log(level: .debug, message: message) + public func debug( + _ message: String, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function + ) { + log(level: .debug, message: """ + \(message) + file: \(file) + line: \(line) + function: \(function) + """, file: file, line: line, function: function) } - public func info(_ message: String) { - log(level: .info, message: message) + public func info( + _ message: String, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function + ) { + log(level: .info, message: message, file: file, line: line, function: function) } - public func error(_ message: String) { - log(level: .error, message: message) + public func error( + _ message: String, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function + ) { + log(level: .error, message: message, file: file, line: line, function: function) } - public func error(_ error: Error) { - log(level: .error, message: error.localizedDescription) + public func error( + _ error: Error, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function + ) { + log( + level: .error, + message: error.localizedDescription, + file: file, + line: line, + function: function + ) } - public func signpost(_ type: OSSignpostType, name: StaticString) { + public func signpost( + _ type: OSSignpostType, + name: StaticString, + file: StaticString = #file, + line: UInt = #line, + function: StaticString = #function + ) { os_signpost(type, log: osLog, name: name) } } From 0c8c20f1ebbdf4db2f1bb7a7e7a69a6130a8fda8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 26 Jan 2024 12:58:59 +0800 Subject: [PATCH 14/45] Minor adjustment --- .../Apps/XcodeAppInstanceInspector.swift | 45 +++++++++---------- .../Sources/XcodeInspector/SourceEditor.swift | 16 +++---- .../XcodeInspector/XcodeInspector.swift | 2 +- 3 files changed, 30 insertions(+), 33 deletions(-) diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index 9f373923..c7211975 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -6,16 +6,6 @@ import Combine import Foundation public final class XcodeAppInstanceInspector: AppInstanceInspector { - @Published public fileprivate(set) var focusedWindow: XcodeWindowInspector? - @Published public fileprivate(set) var documentURL: URL? = nil - @Published public fileprivate(set) var workspaceURL: URL? = nil - @Published public fileprivate(set) var projectRootURL: URL? = nil - @Published public fileprivate(set) var workspaces = [WorkspaceIdentifier: Workspace]() - public var realtimeWorkspaces: [WorkspaceIdentifier: WorkspaceInfo] { - updateWorkspaceInfo() - return workspaces.mapValues(\.info) - } - public struct AXNotification { public var kind: AXNotificationKind public var element: AXUIElement @@ -71,9 +61,18 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } } - public let axNotifications = AsyncPassthroughSubject() - + @Published public fileprivate(set) var focusedWindow: XcodeWindowInspector? + @Published public fileprivate(set) var documentURL: URL? = nil + @Published public fileprivate(set) var workspaceURL: URL? = nil + @Published public fileprivate(set) var projectRootURL: URL? = nil + @Published public fileprivate(set) var workspaces = [WorkspaceIdentifier: Workspace]() @Published public private(set) var completionPanel: AXUIElement? + public var realtimeWorkspaces: [WorkspaceIdentifier: WorkspaceInfo] { + updateWorkspaceInfo() + return workspaces.mapValues(\.info) + } + + public let axNotifications = AsyncPassthroughSubject() public var realtimeDocumentURL: URL? { guard let window = appElement.focusedWindow, @@ -144,7 +143,16 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } @MainActor - func observeFocusedWindow() { + func refresh() { + if let focusedWindow = focusedWindow as? WorkspaceXcodeWindowInspector { + focusedWindow.refresh() + } else { + observeFocusedWindow() + } + } + + @MainActor + private func observeFocusedWindow() { if let window = appElement.focusedWindow { if window.identifier == "Xcode.WorkspaceWindow" { let window = WorkspaceXcodeWindowInspector( @@ -192,16 +200,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } @MainActor - func refresh() { - if let focusedWindow = focusedWindow as? WorkspaceXcodeWindowInspector { - focusedWindow.refresh() - } else { - observeFocusedWindow() - } - } - - @MainActor - func observeAXNotifications() { + private func observeAXNotifications() { longRunningTasks.forEach { $0.cancel() } longRunningTasks = [] diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 8d5bf04e..a019bd33 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -19,10 +19,10 @@ public class SourceEditor { case scrollPositionChanged } - public let axNotifications = AsyncPassthroughSubject() let runningApplication: NSRunningApplication public let element: AXUIElement var observeAXNotificationsTask: Task? + public let axNotifications = AsyncPassthroughSubject() /// The content of the source editor. public var content: Content { @@ -53,18 +53,14 @@ public class SourceEditor { public init(runningApplication: NSRunningApplication, element: AXUIElement) { self.runningApplication = runningApplication self.element = element - - Task { @MainActor in - observeAXNotifications() - } + observeAXNotifications() } - @MainActor - func observeAXNotifications() { + private func observeAXNotifications() { observeAXNotificationsTask?.cancel() observeAXNotificationsTask = Task { @MainActor [weak self] in guard let self else { return } - await withTaskGroup(of: Void.self) { [weak self] group in + await withThrowingTaskGroup(of: Void.self) { [weak self] group in guard let self else { return } let editorNotifications = AXNotificationStream( app: runningApplication, @@ -76,6 +72,7 @@ public class SourceEditor { group.addTask { [weak self] in for await notification in editorNotifications { + try Task.checkCancellation() guard let self else { return } if let kind: AXNotificationKind = { switch notification.name { @@ -101,6 +98,7 @@ public class SourceEditor { group.addTask { [weak self] in for await notification in scrollViewNotifications { + try Task.checkCancellation() guard let self else { return } self.axNotifications.send(.init( kind: .scrollPositionChanged, @@ -110,7 +108,7 @@ public class SourceEditor { } } - await group.waitForAll() + try? await group.waitForAll() } } } diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 7e6012c5..04ce5f30 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -189,7 +189,7 @@ public final class XcodeInspector: ObservableObject { } @MainActor - func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { + private func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { previousActiveApplication = activeApplication activeApplication = xcode xcode.refresh() From a8537c0653c8f3f7d6f2906cb5e3f4a56aff6637 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 26 Jan 2024 17:47:24 +0800 Subject: [PATCH 15/45] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index b8c7a3a6..24610361 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit b8c7a3a60946b15ff00440a16a25ce5b743f7f11 +Subproject commit 2461036116ff5f867eb1c688c74a3836a76e7dc8 From 5bdc5388ec6f4ba120a91f456248389e13acb56a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 26 Jan 2024 21:49:13 +0800 Subject: [PATCH 16/45] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 24610361..31e43f17 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 2461036116ff5f867eb1c688c74a3836a76e7dc8 +Subproject commit 31e43f17622241ef48cbe36d095dbb22916b1847 From 535cbba14b28e7cff45344890d16d6e0c3ae44e0 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 24 Jan 2024 11:53:11 +0800 Subject: [PATCH 17/45] WIP --- Core/Sources/HostApp/DebugView.swift | 6 + ExtensionService/AppDelegate+Menu.swift | 10 +- Tool/Sources/Preferences/Keys.swift | 7 ++ .../XcodeInspector/XcodeInspector.swift | 115 ++++++++++++++++++ 4 files changed, 133 insertions(+), 5 deletions(-) diff --git a/Core/Sources/HostApp/DebugView.swift b/Core/Sources/HostApp/DebugView.swift index e4054dd1..3c3b2111 100644 --- a/Core/Sources/HostApp/DebugView.swift +++ b/Core/Sources/HostApp/DebugView.swift @@ -19,6 +19,8 @@ final class DebugSettings: ObservableObject { @AppStorage(\.disableGitIgnoreCheck) var disableGitIgnoreCheck @AppStorage(\.disableFileContentManipulationByCheatsheet) var disableFileContentManipulationByCheatsheet + @AppStorage(\.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) + var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning init() {} } @@ -74,6 +76,10 @@ struct DebugSettingsView: View { Toggle(isOn: $settings.disableFileContentManipulationByCheatsheet) { Text("Disable file content manipulation by cheatsheet") } + + Toggle(isOn: $settings.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) { + Text("Re-activate Xcode Inspector when Accessibility API malfunctioning detected") + } Button("Reset migration version to 0") { UserDefaults.shared.set(nil, forKey: "OldMigrationVersion") diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift index 199a5ab4..1eea6ba9 100644 --- a/ExtensionService/AppDelegate+Menu.swift +++ b/ExtensionService/AppDelegate+Menu.swift @@ -126,7 +126,7 @@ extension AppDelegate: NSMenuDelegate { .append(.text("Active Workspace: \(inspector.activeWorkspaceURL?.path ?? "N/A")")) menu.items .append(.text("Active Document: \(inspector.activeDocumentURL?.path ?? "N/A")")) - + if let focusedWindow = inspector.focusedWindow { menu.items.append(.text( "Active Window: \(focusedWindow.uiElement.identifier)" @@ -134,7 +134,7 @@ extension AppDelegate: NSMenuDelegate { } else { menu.items.append(.text("Active Window: N/A")) } - + if let focusedElement = inspector.focusedElement { menu.items.append(.text( "Focused Element: \(focusedElement.description)" @@ -144,9 +144,9 @@ extension AppDelegate: NSMenuDelegate { } if let sourceEditor = inspector.focusedEditor { - menu.items.append(.text( - "Active Source Editor: \(sourceEditor.element.isSourceEditor ? "Found" : "Error")" - )) + let label = sourceEditor.element.description + menu.items + .append(.text("Active Source Editor: \(label.isEmpty ? "Unknown" : label)")) } else { menu.items.append(.text("Active Source Editor: N/A")) } diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 09ae7248..b482e584 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -569,5 +569,12 @@ public extension UserDefaultPreferenceKeys { key: "FeatureFlag-DisableEnhancedWorkspace" ) } + + var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning: FeatureFlag { + .init( + defaultValue: false, + key: "FeatureFlag-RestartXcodeInspectorIfAccessibilityAPIIsMalfunctioning" + ) + } } diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 04ce5f30..baa18303 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -6,10 +6,17 @@ import Foundation import Logger import Preferences import SuggestionModel +import Toast + +public extension Notification.Name { + static let accessibilityAPIMalfunctioning = Notification.Name("accessibilityAPIMalfunctioning") +} public final class XcodeInspector: ObservableObject { public static let shared = XcodeInspector() + private var toast: ToastController { ToastControllerDependencyKey.liveValue } + private var cancellable = Set() private var activeXcodeObservations = Set>() private var appChangeObservations = Set>() @@ -182,6 +189,110 @@ public final class XcodeInspector: ObservableObject { } } } + + if UserDefaults.shared + .value(for: \.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) + { + group.addTask { [weak self] in + while true { + guard let self else { return } + try await Task.sleep(nanoseconds: 10_000_000_000) + Logger.service.debug(""" + Check for Accessibility Malfunctioning: + Source Editor: \({ + if let editor = self.focusedEditor { + return editor.element.description + } + return "Not Found" + }()) + Focused Element: \({ + if let element = self.focusedElement { + return "\(element.description), \(element.identifier), \(element.role)" + } + return "Not Found" + }()) + + Accessibility API Permission: \( + AXIsProcessTrusted() ? "Granted" : + "Not Granted" + ) + App: \( + self.activeApplication?.runningApplication + .bundleIdentifier ?? "" + ) + Focused Element: \({ + guard let element = self.activeApplication?.appElement + .focusedElement + else { + return "Not Found" + } + return "\(element.description), \(element.identifier), \(element.role)" + }()) + First Source Editor: \({ + guard let element = self.activeApplication?.appElement + .firstChild(where: \.isSourceEditor) + else { + return "Not Found" + } + return "\(element.description), \(element.identifier), \(element.role)" + }()) + """) + + if let editor = self.focusedEditor, !editor.element.isSourceEditor { + NSWorkspace.shared.notificationCenter.post( + name: .accessibilityAPIMalfunctioning, + object: "Source Editor Element Corrupted" + ) + } else if let element = self.activeXcode?.appElement.focusedElement { + if element.description != self.focusedElement?.description || + element.identifier != self.focusedElement?.role + { + NSWorkspace.shared.notificationCenter.post( + name: .accessibilityAPIMalfunctioning, + object: "Element Inconsistency" + ) + } + } + } + } + + group.addTask { + let sequence = DistributedNotificationCenter.default() + .notifications(named: .init("com.apple.accessibility.api")) + for await notification in sequence { + if AXIsProcessTrusted() { + Logger.service.debug("Accessibility API Permission Granted") + } else { + Logger.service.debug("Accessibility API Permission Not Granted") + NSWorkspace.shared.notificationCenter.post( + name: .accessibilityAPIMalfunctioning, + object: "Accessibility API Permission Check" + ) + } + } + } + } + + + + group.addTask { [weak self] in // malfunctioning + let sequence = NSWorkspace.shared.notificationCenter + .notifications(named: .accessibilityAPIMalfunctioning) + for await notification in sequence { + guard let self else { return } + let toast = self.toast + toast.toast( + content: "Accessibility API malfunction detected: \(notification.object as? String ?? "")", + type: .warning + ) + if let activeXcode { + toast.toast(content: "Resetting active Xcode", type: .warning) + await MainActor.run { + self.setActiveXcode(activeXcode) + } + } + } + } } } @@ -210,7 +321,9 @@ public final class XcodeInspector: ObservableObject { let setFocusedElement = { [weak self] in guard let self else { return } focusedElement = xcode.appElement.focusedElement + Logger.service.debug("Update focused element.") if let editorElement = focusedElement, editorElement.isSourceEditor { + Logger.service.debug("Focused on source editor.") focusedEditor = .init( runningApplication: xcode.runningApplication, element: editorElement @@ -218,11 +331,13 @@ public final class XcodeInspector: ObservableObject { } else if let element = focusedElement, let editorElement = element.firstParent(where: \.isSourceEditor) { + Logger.service.debug("Focused on child of source editor.") focusedEditor = .init( runningApplication: xcode.runningApplication, element: editorElement ) } else { + Logger.service.debug("No source editor found.") focusedEditor = nil } } From eeff0686fd660491c4e095637d0abd091979428d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 24 Jan 2024 23:08:26 +0800 Subject: [PATCH 18/45] Add accessibility api malfunction check --- Core/Sources/HostApp/DebugView.swift | 31 ++- .../RealtimeSuggestionController.swift | 2 - .../FeatureReducers/WidgetFeature.swift | 5 +- Tool/Package.swift | 2 + Tool/Sources/Preferences/Keys.swift | 16 +- .../Apps/XcodeAppInstanceInspector.swift | 4 +- .../XcodeInspector/XcodeInspector.swift | 183 ++++++++++-------- 7 files changed, 146 insertions(+), 97 deletions(-) diff --git a/Core/Sources/HostApp/DebugView.swift b/Core/Sources/HostApp/DebugView.swift index 3c3b2111..08d46bbc 100644 --- a/Core/Sources/HostApp/DebugView.swift +++ b/Core/Sources/HostApp/DebugView.swift @@ -21,6 +21,10 @@ final class DebugSettings: ObservableObject { var disableFileContentManipulationByCheatsheet @AppStorage(\.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning + @AppStorage(\.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer) + var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer + @AppStorage(\.toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted) + var toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted init() {} } @@ -76,9 +80,30 @@ struct DebugSettingsView: View { Toggle(isOn: $settings.disableFileContentManipulationByCheatsheet) { Text("Disable file content manipulation by cheatsheet") } - - Toggle(isOn: $settings.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) { - Text("Re-activate Xcode Inspector when Accessibility API malfunctioning detected") + + Group { + Toggle( + isOn: $settings + .restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning + ) { + Text( + "Re-activate Xcode Inspector when Accessibility API malfunctioning detected" + ) + } + + Toggle( + isOn: $settings + .restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer + ) { + Text("Trigger malfunctioning detection only with events") + } + + Toggle( + isOn: $settings + .toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted + ) { + Text("Toast for the reason of re-activation of Xcode Inspector") + } } Button("Reset migration version to 0") { diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 58b2833d..dce61bc8 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -71,12 +71,10 @@ public actor RealtimeSuggestionController { switch notification.kind { case .valueChanged: - Logger.service.debug("Receive valueChanged from editor") await cancelInFlightTasks() await self.triggerPrefetchDebounced() await self.notifyEditingFileChange(editor: sourceEditor.element) case .selectedTextChanged: - Logger.service.debug("Receive selectedTextChanged from editor") guard let fileURL = XcodeInspector.shared.activeDocumentURL else { break } await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 5c90ced5..174e8c06 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -356,8 +356,6 @@ public struct WidgetFeature: ReducerProtocol { await send(.panel(.switchToAnotherEditorAndUpdateContent)) for await notification in notifications { - Logger.service.debug("Receive \(notification.kind) from Xcode") - try Task.checkCancellation() // Hide the widgets before switching to another window/editor @@ -402,11 +400,10 @@ public struct WidgetFeature: ReducerProtocol { return .run { send in if #available(macOS 13.0, *) { - for await notification in merge( + for await _ in merge( selectionRangeChange.debounce(for: Duration.milliseconds(500)), scroll ) { - Logger.service.debug("Receive \(notification.kind) from editor") guard xcodeInspector.latestActiveXcode != nil else { return } try Task.checkCancellation() await send(.updateWindowLocation(animated: false)) diff --git a/Tool/Package.swift b/Tool/Package.swift index 8c48dfe7..db61e838 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -168,6 +168,8 @@ let package = Package( "SuggestionModel", "AXNotificationStream", "Logger", + "Toast", + "Preferences", .product(name: "AsyncExtensions", package: "AsyncExtensions"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ] diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index b482e584..c6af2c78 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -572,9 +572,23 @@ public extension UserDefaultPreferenceKeys { var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning: FeatureFlag { .init( - defaultValue: false, + defaultValue: true, key: "FeatureFlag-RestartXcodeInspectorIfAccessibilityAPIIsMalfunctioning" ) } + + var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer: FeatureFlag { + .init( + defaultValue: true, + key: "FeatureFlag-RestartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer" + ) + } + + var toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted: FeatureFlag { + .init( + defaultValue: false, + key: "FeatureFlag-ToastForTheReasonWhyXcodeInspectorNeedsToBeRestarted" + ) + } } diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index c7211975..823c3f69 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -135,7 +135,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { observeAXNotifications() try await Task.sleep(nanoseconds: 3_000_000_000) - // Sometimes the focused window may not be rea?dy on app launch. + // Sometimes the focused window may not be ready on app launch. if !(focusedWindow is WorkspaceXcodeWindowInspector) { observeFocusedWindow() } @@ -200,7 +200,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } @MainActor - private func observeAXNotifications() { + func observeAXNotifications() { longRunningTasks.forEach { $0.cancel() } longRunningTasks = [] diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index baa18303..be75364e 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -196,84 +196,19 @@ public final class XcodeInspector: ObservableObject { group.addTask { [weak self] in while true { guard let self else { return } - try await Task.sleep(nanoseconds: 10_000_000_000) - Logger.service.debug(""" - Check for Accessibility Malfunctioning: - Source Editor: \({ - if let editor = self.focusedEditor { - return editor.element.description - } - return "Not Found" - }()) - Focused Element: \({ - if let element = self.focusedElement { - return "\(element.description), \(element.identifier), \(element.role)" - } - return "Not Found" - }()) - - Accessibility API Permission: \( - AXIsProcessTrusted() ? "Granted" : - "Not Granted" - ) - App: \( - self.activeApplication?.runningApplication - .bundleIdentifier ?? "" - ) - Focused Element: \({ - guard let element = self.activeApplication?.appElement - .focusedElement - else { - return "Not Found" - } - return "\(element.description), \(element.identifier), \(element.role)" - }()) - First Source Editor: \({ - guard let element = self.activeApplication?.appElement - .firstChild(where: \.isSourceEditor) - else { - return "Not Found" - } - return "\(element.description), \(element.identifier), \(element.role)" - }()) - """) - - if let editor = self.focusedEditor, !editor.element.isSourceEditor { - NSWorkspace.shared.notificationCenter.post( - name: .accessibilityAPIMalfunctioning, - object: "Source Editor Element Corrupted" - ) - } else if let element = self.activeXcode?.appElement.focusedElement { - if element.description != self.focusedElement?.description || - element.identifier != self.focusedElement?.role - { - NSWorkspace.shared.notificationCenter.post( - name: .accessibilityAPIMalfunctioning, - object: "Element Inconsistency" - ) - } + if UserDefaults.shared.value( + for: \.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer + ) { + return } - } - } - - group.addTask { - let sequence = DistributedNotificationCenter.default() - .notifications(named: .init("com.apple.accessibility.api")) - for await notification in sequence { - if AXIsProcessTrusted() { - Logger.service.debug("Accessibility API Permission Granted") - } else { - Logger.service.debug("Accessibility API Permission Not Granted") - NSWorkspace.shared.notificationCenter.post( - name: .accessibilityAPIMalfunctioning, - object: "Accessibility API Permission Check" - ) + + try await Task.sleep(nanoseconds: 10_000_000_000) + await MainActor.run { + self.checkForAccessibilityMalfunction("Timer") } } } } - - group.addTask { [weak self] in // malfunctioning let sequence = NSWorkspace.shared.notificationCenter @@ -281,14 +216,22 @@ public final class XcodeInspector: ObservableObject { for await notification in sequence { guard let self else { return } let toast = self.toast - toast.toast( - content: "Accessibility API malfunction detected: \(notification.object as? String ?? "")", - type: .warning - ) + if UserDefaults.shared + .value(for: \.toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted) + { + toast.toast( + content: """ + Accessibility API malfunction detected: \ + \(notification.object as? String ?? ""). + Resetting active Xcode. + """, + type: .warning + ) + } if let activeXcode { - toast.toast(content: "Resetting active Xcode", type: .warning) await MainActor.run { self.setActiveXcode(activeXcode) + activeXcode.observeAXNotifications() } } } @@ -318,12 +261,10 @@ public final class XcodeInspector: ObservableObject { activeWorkspaceURL = xcode.workspaceURL focusedWindow = xcode.focusedWindow - let setFocusedElement = { [weak self] in + let setFocusedElement = { @MainActor [weak self] in guard let self else { return } focusedElement = xcode.appElement.focusedElement - Logger.service.debug("Update focused element.") if let editorElement = focusedElement, editorElement.isSourceEditor { - Logger.service.debug("Focused on source editor.") focusedEditor = .init( runningApplication: xcode.runningApplication, element: editorElement @@ -345,15 +286,35 @@ public final class XcodeInspector: ObservableObject { setFocusedElement() let focusedElementChanged = Task { @MainActor in for await notification in xcode.axNotifications { - guard notification.kind == .focusedUIElementChanged else { continue } - Logger.service.debug("Update focused element") - try Task.checkCancellation() - setFocusedElement() + if notification.kind == .focusedUIElementChanged { + Logger.service.debug("Update focused element") + try Task.checkCancellation() + setFocusedElement() + } } } activeXcodeObservations.insert(focusedElementChanged) + if UserDefaults.shared + .value(for: \.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) + { + let malfunctionCheck = Task { @MainActor [weak self] in + if #available(macOS 13.0, *) { + let notifications = xcode.axNotifications.filter { + $0.kind == .uiElementDestroyed + }.debounce(for: .milliseconds(500)) + for await _ in notifications { + guard let self else { return } + try Task.checkCancellation() + self.checkForAccessibilityMalfunction("Element Destroyed") + } + } + } + + activeXcodeObservations.insert(malfunctionCheck) + } + xcode.$completionPanel.receive(on: DispatchQueue.main).sink { [weak self] element in self?.completionPanel = element }.store(in: &activeXcodeCancellable) @@ -374,5 +335,57 @@ public final class XcodeInspector: ObservableObject { self?.focusedWindow = window }.store(in: &activeXcodeCancellable) } + + @MainActor + private func checkForAccessibilityMalfunction(_ source: String) { + Logger.service.debug(""" + Check for Accessibility Malfunctioning: + Source Editor: \({ + if let editor = self.focusedEditor { + return editor.element.description + } + return "Not Found" + }()) + Focused Element: \({ + if let element = self.focusedElement { + return "\(element.description), \(element.identifier), \(element.role)" + } + return "Not Found" + }()) + + Accessibility API Permission: \( + AXIsProcessTrusted() ? "Granted" : + "Not Granted" + ) + App: \( + activeApplication?.runningApplication + .bundleIdentifier ?? "" + ) + Focused Element: \({ + guard let element = self.activeApplication?.appElement + .focusedElement + else { + return "Not Found" + } + return "\(element.description), \(element.identifier), \(element.role)" + }()) + """) + + if let editor = focusedEditor, !editor.element.isSourceEditor { + NSWorkspace.shared.notificationCenter.post( + name: .accessibilityAPIMalfunctioning, + object: "Source Editor Element Corrupted: \(source)" + ) + } else if let element = activeXcode?.appElement.focusedElement { + if element.description != focusedElement?.description || + element.role != focusedElement?.role + { + NSWorkspace.shared.notificationCenter.post( + name: .accessibilityAPIMalfunctioning, + object: "Element Inconsistency: \(source)" + ) + } + } + } } From 143db5ac2d036130079742d264bb32e3d28f9aed Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 27 Jan 2024 13:07:24 +0800 Subject: [PATCH 19/45] Skip checks within 5 seconds since last recovery --- .../XcodeInspector/XcodeInspector.swift | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index be75364e..76a58787 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -216,24 +216,8 @@ public final class XcodeInspector: ObservableObject { for await notification in sequence { guard let self else { return } let toast = self.toast - if UserDefaults.shared - .value(for: \.toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted) - { - toast.toast( - content: """ - Accessibility API malfunction detected: \ - \(notification.object as? String ?? ""). - Resetting active Xcode. - """, - type: .warning - ) - } - if let activeXcode { - await MainActor.run { - self.setActiveXcode(activeXcode) - activeXcode.observeAXNotifications() - } - } + await self + .recoverFromAccessibilityMalfunctioning(notification.object as? String) } } } @@ -335,9 +319,14 @@ public final class XcodeInspector: ObservableObject { self?.focusedWindow = window }.store(in: &activeXcodeCancellable) } + + private var lastRecoveryFromAccessibilityMalfunctioningTimeStamp = Date() @MainActor private func checkForAccessibilityMalfunction(_ source: String) { + guard Date().timeIntervalSince(lastRecoveryFromAccessibilityMalfunctioningTimeStamp) > 5 + else { return } + Logger.service.debug(""" Check for Accessibility Malfunctioning: Source Editor: \({ @@ -387,5 +376,24 @@ public final class XcodeInspector: ObservableObject { } } } + + @MainActor + private func recoverFromAccessibilityMalfunctioning(_ source: String?) { + if UserDefaults.shared.value(for: \.toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted) { + toast.toast( + content: """ + Accessibility API malfunction detected: \ + \(source ?? ""). + Resetting active Xcode. + """, + type: .warning + ) + } + if let activeXcode { + lastRecoveryFromAccessibilityMalfunctioningTimeStamp = Date() + setActiveXcode(activeXcode) + activeXcode.observeAXNotifications() + } + } } From 85ddfe443d56b4d103dcc8ce18cbc983d0a086cd Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 27 Jan 2024 13:08:15 +0800 Subject: [PATCH 20/45] Extend debounce interval --- Tool/Sources/XcodeInspector/XcodeInspector.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 76a58787..0cb1bb43 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -287,7 +287,7 @@ public final class XcodeInspector: ObservableObject { if #available(macOS 13.0, *) { let notifications = xcode.axNotifications.filter { $0.kind == .uiElementDestroyed - }.debounce(for: .milliseconds(500)) + }.debounce(for: .milliseconds(1000)) for await _ in notifications { guard let self else { return } try Task.checkCancellation() From 44e4634d86f251c6977db0d25a8e72116792c2d3 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 27 Jan 2024 13:14:07 +0800 Subject: [PATCH 21/45] Add check when activate Xcode --- Tool/Sources/XcodeInspector/XcodeInspector.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 0cb1bb43..9ee019bd 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -215,7 +215,6 @@ public final class XcodeInspector: ObservableObject { .notifications(named: .accessibilityAPIMalfunctioning) for await notification in sequence { guard let self else { return } - let toast = self.toast await self .recoverFromAccessibilityMalfunctioning(notification.object as? String) } @@ -297,8 +296,10 @@ public final class XcodeInspector: ObservableObject { } activeXcodeObservations.insert(malfunctionCheck) + + checkForAccessibilityMalfunction("Reactivate Xcode") } - + xcode.$completionPanel.receive(on: DispatchQueue.main).sink { [weak self] element in self?.completionPanel = element }.store(in: &activeXcodeCancellable) From a491f452feceec04396375189975de715901a3a6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 27 Jan 2024 16:30:26 +0800 Subject: [PATCH 22/45] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 31e43f17..fa449bfd 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 31e43f17622241ef48cbe36d095dbb22916b1847 +Subproject commit fa449bfdb2bd40c3a88a32ff156d6ca262951f21 From 5421b6edd734e7fbeb08387bf3f3940e3a118b02 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 27 Jan 2024 21:14:07 +0800 Subject: [PATCH 23/45] Adjust logs --- Core/Sources/Service/RealtimeSuggestionController.swift | 2 +- Pro | 2 +- Tool/Sources/XcodeInspector/XcodeWindowInspector.swift | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index dce61bc8..5d3b10f5 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -94,7 +94,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 { diff --git a/Pro b/Pro index fa449bfd..4b948bb8 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit fa449bfdb2bd40c3a88a32ff156d6ca262951f21 +Subproject commit 4b948bb8dcf04521b543c450220096cf7ea885b5 diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index a10b308c..00a777ba 100644 --- a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -54,7 +54,6 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { for await notification in axNotifications { guard notification.kind == .focusedUIElementChanged else { continue } guard let self else { return } - Logger.service.debug("Workspace refresh") try Task.checkCancellation() await self.updateURLs() } From 1c2ee318e6c265d87b7ecf97312a33b6401facb9 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 27 Jan 2024 21:14:47 +0800 Subject: [PATCH 24/45] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index 4b948bb8..58572b0c 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 4b948bb8dcf04521b543c450220096cf7ea885b5 +Subproject commit 58572b0c85afa7efb656aec8bf950f5902ddb942 From 3e8ff227b3f9f1ac9b931d9c3275348fdd16d569 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 27 Jan 2024 21:25:45 +0800 Subject: [PATCH 25/45] Prevent getting multiple content from SourceEditor in a single run --- .../CustomCommandTemplateProcessor.swift | 2 +- Core/Sources/Service/GUI/ChatTabFactory.swift | 2 +- .../PseudoCommandHandler.swift | 12 ++++++----- Pro | 2 +- .../Sources/XcodeInspector/SourceEditor.swift | 21 ++++++++++++++++--- .../XcodeInspector/XcodeInspector.swift | 2 +- 6 files changed, 29 insertions(+), 12 deletions(-) diff --git a/Core/Sources/ChatService/CustomCommandTemplateProcessor.swift b/Core/Sources/ChatService/CustomCommandTemplateProcessor.swift index 01c592f4..0fa3abfe 100644 --- a/Core/Sources/ChatService/CustomCommandTemplateProcessor.swift +++ b/Core/Sources/ChatService/CustomCommandTemplateProcessor.swift @@ -39,7 +39,7 @@ public struct CustomCommandTemplateProcessor { } func getEditorInformation() -> EditorInformation { - let editorContent = XcodeInspector.shared.focusedEditor?.content + let editorContent = XcodeInspector.shared.focusedEditor?.getContent() let documentURL = XcodeInspector.shared.activeDocumentURL let language = documentURL.map(languageIdentifierFromFileURL) ?? .plaintext diff --git a/Core/Sources/Service/GUI/ChatTabFactory.swift b/Core/Sources/Service/GUI/ChatTabFactory.swift index 0e68fb4b..18af0372 100644 --- a/Core/Sources/Service/GUI/ChatTabFactory.swift +++ b/Core/Sources/Service/GUI/ChatTabFactory.swift @@ -43,7 +43,7 @@ enum ChatTabFactory { guard let editor = XcodeInspector.shared.focusedEditor else { return .init(selectedText: "", language: "", fileContent: "") } - let content = editor.content + let content = editor.getContent() return .init( selectedText: content.selectedContent, language: ( diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index f0a1fd47..c3a73a59 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -78,9 +78,10 @@ struct PseudoCommandHandler { editor: editor ) if let sourceEditor { + let editorContent = sourceEditor.getContent() _ = filespace.validateSuggestions( - lines: sourceEditor.content.lines, - cursorPosition: sourceEditor.content.cursorPosition + lines: editorContent.lines, + cursorPosition: editorContent.cursorPosition ) } if filespace.presentingSuggestion != nil { @@ -98,9 +99,10 @@ struct PseudoCommandHandler { guard let (_, filespace) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return } + let content = sourceEditor.getContent() if !filespace.validateSuggestions( - lines: sourceEditor.content.lines, - cursorPosition: sourceEditor.content.cursorPosition + lines: content.lines, + cursorPosition: content.cursorPosition ) { PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: fileURL) } @@ -351,7 +353,7 @@ extension PseudoCommandHandler { guard let filespace = await getFilespace(), let sourceEditor = sourceEditor ?? XcodeInspector.shared.focusedEditor else { return nil } - let content = sourceEditor.content + let content = sourceEditor.getContent() let uti = filespace.codeMetadata.uti ?? "" let tabSize = filespace.codeMetadata.tabSize ?? 4 let indentSize = filespace.codeMetadata.indentSize ?? 4 diff --git a/Pro b/Pro index 58572b0c..80b755f4 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 58572b0c85afa7efb656aec8bf950f5902ddb942 +Subproject commit 80b755f41adf3196f8d023b1525352560100e648 diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index a019bd33..76a3eb42 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -24,10 +24,25 @@ public class SourceEditor { var observeAXNotificationsTask: Task? public let axNotifications = AsyncPassthroughSubject() - /// The content of the source editor. - public var content: Content { + + private var cachedContent: String? + private var cachedLines = [String]() + + /// Get the content of the source editor. + /// + /// - note: This method is expensive. + public func getContent() -> Content { let content = element.value - let split = content.breakLines(appendLineBreakToLastLine: false) + + let split = if element.hashValue == cachedContent?.hashValue { + cachedLines + } else { + content.breakLines(appendLineBreakToLastLine: false) + } + + cachedContent = content + cachedLines = split + let lineAnnotationElements = element.children.filter { $0.identifier == "Line Annotation" } let lineAnnotations = lineAnnotationElements.map(\.description) diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 9ee019bd..6f25adda 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -41,7 +41,7 @@ public final class XcodeInspector: ObservableObject { let projectURL = XcodeInspector.shared.activeProjectRootURL else { return nil } - let editorContent = XcodeInspector.shared.focusedEditor?.content + let editorContent = XcodeInspector.shared.focusedEditor?.getContent() let language = languageIdentifierFromFileURL(documentURL) let relativePath = documentURL.path.replacingOccurrences(of: projectURL.path, with: "") From cd124aaa0c86f014bbac398b2e709ee4fa090f99 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 27 Jan 2024 21:56:24 +0800 Subject: [PATCH 26/45] Implement cache for SourceEditor --- .../Sources/XcodeInspector/SourceEditor.swift | 85 +++++++++++++------ 1 file changed, 59 insertions(+), 26 deletions(-) diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 76a3eb42..035c6caf 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -2,6 +2,7 @@ import AppKit import AsyncExtensions import AXNotificationStream import Foundation +import Logger import SuggestionModel /// Representing a source editor inside Xcode. @@ -24,43 +25,25 @@ public class SourceEditor { var observeAXNotificationsTask: Task? public let axNotifications = AsyncPassthroughSubject() + /// To prevent expensive calculations in ``getContent()``. + private let cache = Cache() - private var cachedContent: String? - private var cachedLines = [String]() - /// Get the content of the source editor. /// /// - note: This method is expensive. public func getContent() -> Content { let content = element.value - - let split = if element.hashValue == cachedContent?.hashValue { - cachedLines - } else { - content.breakLines(appendLineBreakToLastLine: false) - } - - cachedContent = content - cachedLines = split - + let selectionRange = element.selectedTextRange + let (lines, selections) = cache.get(content: content, selectedTextRange: selectionRange) + let lineAnnotationElements = element.children.filter { $0.identifier == "Line Annotation" } let lineAnnotations = lineAnnotationElements.map(\.description) - if let selectionRange = element.selectedTextRange { - let range = Self.convertRangeToCursorRange(selectionRange, in: split) - return .init( - content: content, - lines: split, - selections: [range], - cursorPosition: range.start, - lineAnnotations: lineAnnotations - ) - } return .init( content: content, - lines: split, - selections: [], - cursorPosition: .outOfScope, + lines: lines, + selections: selections, + cursorPosition: selections.first?.start ?? .outOfScope, lineAnnotations: lineAnnotations ) } @@ -129,6 +112,56 @@ public class SourceEditor { } } +extension SourceEditor { + final class Cache { + static let queue = DispatchQueue(label: "SourceEditor.Cache") + + private var sourceContent: String? + private var cachedLines = [String]() + private var sourceSelectedTextRange: ClosedRange? + private var cachedSelections = [CursorRange]() + + func get(content: String, selectedTextRange: ClosedRange?) -> ( + lines: [String], + selections: [CursorRange] + ) { + Self.queue.sync { + let contentMatch = content.hashValue == sourceContent?.hashValue + let selectedRangeMatch = selectedTextRange == sourceSelectedTextRange + let lines: [String] = { + if contentMatch { + Logger.service.debug("Cache Hit: Lines") + return cachedLines + } + Logger.service.debug("Cache Missed: Lines") + return content.breakLines(appendLineBreakToLastLine: false) + }() + let selections: [CursorRange] = { + if contentMatch, selectedRangeMatch { + Logger.service.debug("Cache Hit: Selections") + return cachedSelections + } + Logger.service.debug("Cache Missed: Selections") + if let selectedTextRange { + return [SourceEditor.convertRangeToCursorRange( + selectedTextRange, + in: lines + )] + } + return [] + }() + + sourceContent = content + cachedLines = lines + sourceSelectedTextRange = selectedTextRange + cachedSelections = selections + + return (lines, selections) + } + } + } +} + // MARK: - Helpers public extension SourceEditor { From 8b3ad696d925362b407bb21bfbb05aecb7a05fac Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 27 Jan 2024 22:12:12 +0800 Subject: [PATCH 27/45] Skip invalidating suggestion early if no suggestion presented --- .../SuggestionCommandHandler/PseudoCommandHandler.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index c3a73a59..a43e62fb 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -99,6 +99,10 @@ struct PseudoCommandHandler { guard let (_, filespace) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) else { return } + if filespace.presentingSuggestion == nil { + return // skip if there's no suggestion presented. + } + let content = sourceEditor.getContent() if !filespace.validateSuggestions( lines: content.lines, From 786e19ac7e3ed01287332e6009dcff5a1d1ecb37 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 27 Jan 2024 22:50:06 +0800 Subject: [PATCH 28/45] Prevent using split by \.newLine for faster speed --- Pro | 2 +- .../SyntaxHighlighting.swift | 3 +- .../SuggestionModel/String+LineEnding.swift | 33 ++++++++++++++++--- .../BreakLinePerformanceTests.swift | 18 ++++++++++ 4 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 Tool/Tests/SuggestionModelTests/BreakLinePerformanceTests.swift diff --git a/Pro b/Pro index 80b755f4..b442fe8a 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit 80b755f41adf3196f8d023b1525352560100e648 +Subproject commit b442fe8adb70e3325bee6d61e217f4a09780a240 diff --git a/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift b/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift index f85649fa..844a243f 100644 --- a/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift +++ b/Tool/Sources/SharedUIComponents/SyntaxHighlighting.swift @@ -1,6 +1,7 @@ import AppKit import Foundation import Highlightr +import SuggestionModel import SwiftUI public func highlightedCodeBlock( @@ -82,7 +83,7 @@ func convertToCodeLines( return false } - let separatedInput = input.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + let separatedInput = input.splitByNewLine(omittingEmptySubsequences: false) .map { String($0) } let commonLeadingSpaceCount = { if !droppingLeadingSpaces { return 0 } diff --git a/Tool/Sources/SuggestionModel/String+LineEnding.swift b/Tool/Sources/SuggestionModel/String+LineEnding.swift index 319c936e..35c72d30 100644 --- a/Tool/Sources/SuggestionModel/String+LineEnding.swift +++ b/Tool/Sources/SuggestionModel/String+LineEnding.swift @@ -2,8 +2,31 @@ import Foundation public extension String { /// The line ending of the string. + /// + /// We are pretty safe to just check the last character here, in most case, a line ending + /// will be in the end of the string. + /// + /// For other situations, we can assume that they are "\n". var lineEnding: Character? { - last(where: \.isNewline) + if let last, last.isNewline { return last } + return "\n" + } + + func splitByNewLine( + omittingEmptySubsequences: Bool = true, + fast: Bool = true + ) -> [Substring] { + if fast { + let lineEndingInText = lineEnding ?? "\n" + return split( + separator: lineEndingInText, + omittingEmptySubsequences: omittingEmptySubsequences + ) + } + return split( + omittingEmptySubsequences: omittingEmptySubsequences, + whereSeparator: \.isNewline + ) } /// Break a string into lines. @@ -11,14 +34,16 @@ public extension String { proposedLineEnding: String? = nil, appendLineBreakToLastLine: Bool = false ) -> [String] { - let lineEnding = proposedLineEnding ?? String(lineEnding ?? "\n") - let lines = split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + let lineEndingInText = lineEnding ?? "\n" + let lineEnding = proposedLineEnding ?? String(lineEndingInText) + // Split on character for better performance. + let lines = split(separator: lineEndingInText, omittingEmptySubsequences: false) var all = [String]() for (index, line) in lines.enumerated() { if !appendLineBreakToLastLine, index == lines.endIndex - 1 { all.append(String(line)) } else { - all.append(String(line) + String(lineEnding)) + all.append(String(line) + lineEnding) } } return all diff --git a/Tool/Tests/SuggestionModelTests/BreakLinePerformanceTests.swift b/Tool/Tests/SuggestionModelTests/BreakLinePerformanceTests.swift new file mode 100644 index 00000000..62683392 --- /dev/null +++ b/Tool/Tests/SuggestionModelTests/BreakLinePerformanceTests.swift @@ -0,0 +1,18 @@ +import Foundation +import XCTest +@testable import SuggestionModel + +final class BreakLinePerformanceTests: XCTestCase { + func test_breakLines() { + let string = String(repeating: """ + Hello + World + + """, count: 50000) + + measure { + let _ = string.breakLines() + } + } +} + From 6654aac6ba10fe06680b468d599cb385b6e0bb3b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 27 Jan 2024 23:20:59 +0800 Subject: [PATCH 29/45] Prevent events to be sent to a store rapidly --- .../PseudoCommandHandler.swift | 18 ++++++++++-------- .../WindowBaseCommandHandler.swift | 14 -------------- .../SuggestionWidgetController.swift | 12 +++++++----- 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index a43e62fb..31a0b7b7 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -55,14 +55,16 @@ struct PseudoCommandHandler { presenter.markAsProcessing(true) defer { presenter.markAsProcessing(false) } - // Check if the current suggestion is still valid. - if filespace.validateSuggestions( - lines: editor.lines, - cursorPosition: editor.cursorPosition - ) { - return - } else { - presenter.discardSuggestion(fileURL: filespace.fileURL) + if filespace.presentingSuggestion != nil { + // Check if the current suggestion is still valid. + if filespace.validateSuggestions( + lines: editor.lines, + cursorPosition: editor.cursorPosition + ) { + return + } else { + presenter.discardSuggestion(fileURL: filespace.fileURL) + } } let snapshot = FilespaceSuggestionSnapshot( diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 7702b8cc..6e7abe7f 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -80,8 +80,6 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor private func _presentNextSuggestion(editor: EditorContent) async throws { - presenter.markAsProcessing(true) - defer { presenter.markAsProcessing(false) } guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -107,8 +105,6 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor private func _presentPreviousSuggestion(editor: EditorContent) async throws { - presenter.markAsProcessing(true) - defer { presenter.markAsProcessing(false) } guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -134,8 +130,6 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor private func _rejectSuggestion(editor: EditorContent) async throws { - presenter.markAsProcessing(true) - defer { presenter.markAsProcessing(false) } guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, _) = try await Service.shared.workspacePool @@ -146,9 +140,6 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { @WorkspaceActor func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? { - presenter.markAsProcessing(true) - defer { presenter.markAsProcessing(false) } - guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return nil } let (workspace, _) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) @@ -182,9 +173,6 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler { } func acceptPromptToCode(editor: EditorContent) async throws -> UpdatedContent? { - presenter.markAsProcessing(true) - defer { presenter.markAsProcessing(false) } - guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return nil } let injector = SuggestionInjector() @@ -363,8 +351,6 @@ extension WindowBaseCommandHandler { generateDescription: Bool?, name: String? ) async throws { - presenter.markAsProcessing(true) - defer { presenter.markAsProcessing(false) } guard let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL else { return } let (workspace, filespace) = try await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 505abddb..bdfb9aba 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -223,7 +223,7 @@ public final class SuggestionWidgetController: NSObject { dependency.windows.suggestionPanelWindow = suggestionPanelWindow dependency.windows.fullscreenDetector = fullscreenDetector dependency.windows.widgetWindow = widgetWindow - + store.send(.startup) } } @@ -240,10 +240,12 @@ public extension SuggestionWidgetController { } func markAsProcessing(_ isProcessing: Bool) { - if isProcessing { - store.send(.circularWidget(.markIsProcessing)) - } else { - store.send(.circularWidget(.endIsProcessing)) + store.withState { state in + if isProcessing, !state.circularWidgetState.isProcessing { + store.send(.circularWidget(.markIsProcessing)) + } else if !isProcessing, state.circularWidgetState.isProcessing { + store.send(.circularWidget(.endIsProcessing)) + } } } From cf9cbd8a55c9a70241e8a56b71a339d7fe9768e9 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 27 Jan 2024 23:38:28 +0800 Subject: [PATCH 30/45] Make minimum suggestion debounce to 0.15 --- .../HostApp/FeatureSettings/SuggestionSettingsView.swift | 2 +- Core/Sources/Service/RealtimeSuggestionController.swift | 6 ++++-- Tool/Sources/Preferences/Keys.swift | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift index cdb6f81e..17761adb 100644 --- a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift @@ -218,7 +218,7 @@ struct SuggestionSettingsView: View { } HStack { - Slider(value: $settings.realtimeSuggestionDebounce, in: 0...2, step: 0.1) { + Slider(value: $settings.realtimeSuggestionDebounce, in: 0.1...2, step: 0.1) { Text("Real-time Suggestion Debounce") } diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index 5d3b10f5..52f05d07 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -110,10 +110,12 @@ public actor RealtimeSuggestionController { } func triggerPrefetchDebounced(force: Bool = false) { - inflightPrefetchTask = Task { @WorkspaceActor in + inflightPrefetchTask = Task(priority: .utility) { @WorkspaceActor in try? await Task.sleep(nanoseconds: UInt64(( - UserDefaults.shared.value(for: \.realtimeSuggestionDebounce) + max(UserDefaults.shared.value(for: \.realtimeSuggestionDebounce), 0.15) ) * 1_000_000_000)) + + if Task.isCancelled { return } guard UserDefaults.shared.value(for: \.realtimeSuggestionToggle) else { return } diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index c6af2c78..4cc88a08 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -327,7 +327,7 @@ public extension UserDefaultPreferenceKeys { } var realtimeSuggestionDebounce: PreferenceKey { - .init(defaultValue: 0, key: "RealtimeSuggestionDebounce") + .init(defaultValue: 0.2, key: "RealtimeSuggestionDebounce") } var acceptSuggestionWithTab: PreferenceKey { From e1231f1916a7cfaef331122e2ec0e2a84bdb91da Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 27 Jan 2024 23:38:47 +0800 Subject: [PATCH 31/45] Get content after we check cancellation --- .../PseudoCommandHandler.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 31a0b7b7..00198557 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -42,12 +42,15 @@ struct PseudoCommandHandler { @WorkspaceActor func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async { - // Can't use handler if content is not available. - guard - let editor = await getEditorContent(sourceEditor: sourceEditor), - let filespace = await getFilespace(), + guard let filespace = await getFilespace(), let (workspace, _) = try? await Service.shared.workspacePool .fetchOrCreateWorkspaceAndFilespace(fileURL: filespace.fileURL) else { return } + + if Task.isCancelled { return } + + // Can't use handler if content is not available. + guard let editor = await getEditorContent(sourceEditor: sourceEditor) + else { return } let fileURL = filespace.fileURL let presenter = PresentInWindowSuggestionPresenter() @@ -359,6 +362,7 @@ extension PseudoCommandHandler { guard let filespace = await getFilespace(), let sourceEditor = sourceEditor ?? XcodeInspector.shared.focusedEditor else { return nil } + if Task.isCancelled { return nil } let content = sourceEditor.getContent() let uti = filespace.codeMetadata.uti ?? "" let tabSize = filespace.codeMetadata.tabSize ?? 4 From 569fb715981e51490911eba80fb4f40d8f71be0b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 27 Jan 2024 23:39:46 +0800 Subject: [PATCH 32/45] Remove logs --- Tool/Sources/XcodeInspector/SourceEditor.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 035c6caf..965a8756 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -130,18 +130,14 @@ extension SourceEditor { let selectedRangeMatch = selectedTextRange == sourceSelectedTextRange let lines: [String] = { if contentMatch { - Logger.service.debug("Cache Hit: Lines") return cachedLines } - Logger.service.debug("Cache Missed: Lines") return content.breakLines(appendLineBreakToLastLine: false) }() let selections: [CursorRange] = { if contentMatch, selectedRangeMatch { - Logger.service.debug("Cache Hit: Selections") return cachedSelections } - Logger.service.debug("Cache Missed: Selections") if let selectedTextRange { return [SourceEditor.convertRangeToCursorRange( selectedTextRange, From 95f29633e0c9a9ffe0a90a5730574ee0e6c2e9b7 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 28 Jan 2024 00:05:50 +0800 Subject: [PATCH 33/45] Update dependency --- Tool/Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tool/Package.swift b/Tool/Package.swift index db61e838..6b87a868 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -182,6 +182,7 @@ let package = Package( dependencies: [ "Highlightr", "Preferences", + "SuggestionModel", .product(name: "STTextView", package: "STTextView"), ] ), From 26b8ebec54fbbac42f646ff30f8521c23389a603 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 28 Jan 2024 00:14:21 +0800 Subject: [PATCH 34/45] Adjust logs --- Pro | 2 +- .../SuggestionServiceMiddleware.swift | 6 ++-- .../XcodeInspector/XcodeInspector.swift | 33 ------------------- 3 files changed, 4 insertions(+), 37 deletions(-) diff --git a/Pro b/Pro index b442fe8a..b1452a0f 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit b442fe8adb70e3325bee6d61e217f4a09780a240 +Subproject commit b1452a0f390647d8f66474f8df5f21f3458b58f0 diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift index c7e13e61..f7ba1742 100644 --- a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift +++ b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift @@ -52,18 +52,18 @@ public struct DebugSuggestionServiceMiddleware: SuggestionServiceMiddleware { _ request: SuggestionRequest, next: Next ) async throws -> [CodeSuggestion] { - Logger.service.debug(""" + Logger.service.info(""" Get suggestion for \(request.fileURL) at \(request.cursorPosition) """) do { let suggestions = try await next(request) - Logger.service.debug(""" + Logger.service.info(""" Receive \(suggestions.count) suggestions for \(request.fileURL) \ at \(request.cursorPosition) """) return suggestions } catch { - Logger.service.debug(""" + Logger.service.info(""" Error: \(error.localizedDescription) """) throw error diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 6f25adda..72792128 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -327,39 +327,6 @@ public final class XcodeInspector: ObservableObject { private func checkForAccessibilityMalfunction(_ source: String) { guard Date().timeIntervalSince(lastRecoveryFromAccessibilityMalfunctioningTimeStamp) > 5 else { return } - - Logger.service.debug(""" - Check for Accessibility Malfunctioning: - Source Editor: \({ - if let editor = self.focusedEditor { - return editor.element.description - } - return "Not Found" - }()) - Focused Element: \({ - if let element = self.focusedElement { - return "\(element.description), \(element.identifier), \(element.role)" - } - return "Not Found" - }()) - - Accessibility API Permission: \( - AXIsProcessTrusted() ? "Granted" : - "Not Granted" - ) - App: \( - activeApplication?.runningApplication - .bundleIdentifier ?? "" - ) - Focused Element: \({ - guard let element = self.activeApplication?.appElement - .focusedElement - else { - return "Not Found" - } - return "\(element.description), \(element.identifier), \(element.role)" - }()) - """) if let editor = focusedEditor, !editor.element.isSourceEditor { NSWorkspace.shared.notificationCenter.post( From b482d8211e59e58b23c8a6990a31d55b28b82e02 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 28 Jan 2024 01:36:19 +0800 Subject: [PATCH 35/45] Remove optional --- Tool/Sources/SuggestionModel/String+LineEnding.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tool/Sources/SuggestionModel/String+LineEnding.swift b/Tool/Sources/SuggestionModel/String+LineEnding.swift index 35c72d30..ddbe9903 100644 --- a/Tool/Sources/SuggestionModel/String+LineEnding.swift +++ b/Tool/Sources/SuggestionModel/String+LineEnding.swift @@ -7,7 +7,7 @@ public extension String { /// will be in the end of the string. /// /// For other situations, we can assume that they are "\n". - var lineEnding: Character? { + var lineEnding: Character { if let last, last.isNewline { return last } return "\n" } @@ -17,7 +17,7 @@ public extension String { fast: Bool = true ) -> [Substring] { if fast { - let lineEndingInText = lineEnding ?? "\n" + let lineEndingInText = lineEnding return split( separator: lineEndingInText, omittingEmptySubsequences: omittingEmptySubsequences @@ -34,7 +34,7 @@ public extension String { proposedLineEnding: String? = nil, appendLineBreakToLastLine: Bool = false ) -> [String] { - let lineEndingInText = lineEnding ?? "\n" + let lineEndingInText = lineEnding let lineEnding = proposedLineEnding ?? String(lineEndingInText) // Split on character for better performance. let lines = split(separator: lineEndingInText, omittingEmptySubsequences: false) From bdc9c0b7e1a0d376d18c23a47a52924c89074bb7 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 28 Jan 2024 01:38:21 +0800 Subject: [PATCH 36/45] Update --- Pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pro b/Pro index b1452a0f..53c37b45 160000 --- a/Pro +++ b/Pro @@ -1 +1 @@ -Subproject commit b1452a0f390647d8f66474f8df5f21f3458b58f0 +Subproject commit 53c37b4555353fd4b33c3905b596ca2821ab3a0f From eda2e8aa5795818ae92d83e7f370e9a8b4747ba5 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 28 Jan 2024 02:28:22 +0800 Subject: [PATCH 37/45] Remove optional --- Tool/Sources/XcodeInspector/SourceEditor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 965a8756..1d52169a 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -202,7 +202,7 @@ public extension SourceEditor { var cursorRange = CursorRange(start: .zero, end: .outOfScope) for (i, line) in lines.enumerated() { // The range is counted in UTF8, which causes line endings like \r\n to be of length 2. - let lineEndingAddition = (line.lineEnding?.utf8.count ?? 1) - 1 + let lineEndingAddition = line.lineEnding.utf8.count - 1 if countS <= range.lowerBound, range.lowerBound < countS + line.count + lineEndingAddition { From 8d9b257dbb3b4a5fb39da6ceb4bb68075aad80b8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 28 Jan 2024 15:24:25 +0800 Subject: [PATCH 38/45] Bump version to 0.30.1 --- Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Version.xcconfig b/Version.xcconfig index 675f7aaa..71265973 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,3 +1,3 @@ -APP_VERSION = 0.30.0 -APP_BUILD = 311 +APP_VERSION = 0.30.1 +APP_BUILD = 313 From 20b560ff1989dc830430fd8f60c39d7d0c6bc16c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 28 Jan 2024 15:48:33 +0800 Subject: [PATCH 39/45] Add tests for SourceEditor.Cache --- TestPlan.xctestplan | 7 +++ Tool/Package.swift | 2 + .../Sources/XcodeInspector/SourceEditor.swift | 14 +++++- .../SourceEditorCachePerformanceTests.swift | 23 ++++++++++ .../SourceEditorCacheTests.swift | 45 +++++++++++++++++++ 5 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 Tool/Tests/XcodeInspectorTests/SourceEditorCachePerformanceTests.swift create mode 100644 Tool/Tests/XcodeInspectorTests/SourceEditorCacheTests.swift diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index 293a3428..b668dedd 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -133,6 +133,13 @@ "identifier" : "FocusedCodeFinderTests", "name" : "FocusedCodeFinderTests" } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "XcodeInspectorTests", + "name" : "XcodeInspectorTests" + } } ], "version" : 1 diff --git a/Tool/Package.swift b/Tool/Package.swift index 6b87a868..3c6e080e 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -175,6 +175,8 @@ let package = Package( ] ), + .testTarget(name: "XcodeInspectorTests", dependencies: ["XcodeInspector"]), + .target(name: "UserDefaultsObserver"), .target( diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index 1d52169a..2b9e0491 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -121,12 +121,24 @@ extension SourceEditor { private var sourceSelectedTextRange: ClosedRange? private var cachedSelections = [CursorRange]() + init( + sourceContent: String? = nil, + cachedLines: [String] = [String](), + sourceSelectedTextRange: ClosedRange? = nil, + cachedSelections: [CursorRange] = [CursorRange]() + ) { + self.sourceContent = sourceContent + self.cachedLines = cachedLines + self.sourceSelectedTextRange = sourceSelectedTextRange + self.cachedSelections = cachedSelections + } + func get(content: String, selectedTextRange: ClosedRange?) -> ( lines: [String], selections: [CursorRange] ) { Self.queue.sync { - let contentMatch = content.hashValue == sourceContent?.hashValue + let contentMatch = content == sourceContent let selectedRangeMatch = selectedTextRange == sourceSelectedTextRange let lines: [String] = { if contentMatch { diff --git a/Tool/Tests/XcodeInspectorTests/SourceEditorCachePerformanceTests.swift b/Tool/Tests/XcodeInspectorTests/SourceEditorCachePerformanceTests.swift new file mode 100644 index 00000000..f3632d83 --- /dev/null +++ b/Tool/Tests/XcodeInspectorTests/SourceEditorCachePerformanceTests.swift @@ -0,0 +1,23 @@ +import Foundation +import XCTest + +@testable import XcodeInspector + +class SourceEditorCachePerformanceTests: XCTestCase { + func test_source_editor_cache_get_content_comparison() { + let content = String(repeating: """ + struct Cat: Animal { + var name: String + } + + """, count: 500) + let cache = SourceEditor.Cache(sourceContent: content + "Yes") + + measure { + for _ in 1 ... 10000 { + _ = cache.get(content: content, selectedTextRange: nil) + } + } + } +} + diff --git a/Tool/Tests/XcodeInspectorTests/SourceEditorCacheTests.swift b/Tool/Tests/XcodeInspectorTests/SourceEditorCacheTests.swift new file mode 100644 index 00000000..3649e3ef --- /dev/null +++ b/Tool/Tests/XcodeInspectorTests/SourceEditorCacheTests.swift @@ -0,0 +1,45 @@ +import Foundation +import XCTest + +@testable import XcodeInspector + +class SourceEditorCacheTests: XCTestCase { + func test_source_editor_cache_get_content_thread_safe() { + func randomContent() -> String { + String(repeating: """ + struct Cat: Animal { + var name: String + } + + """, count: Int.random(in: 2...10)) + } + + func randomSelectionRange() -> ClosedRange { + let random = Int.random(in: 0...20) + return random...random + } + + let cache = SourceEditor.Cache() + + let max = 5000 + let exp = expectation(description: "test_source_editor_cache_get_content_thread_safe") + DispatchQueue.concurrentPerform(iterations: max) { count in + let content = randomContent() + let selectionRange = randomSelectionRange() + let result = cache.get(content: content, selectedTextRange: selectionRange) + + XCTAssertEqual(result.lines, content.breakLines(appendLineBreakToLastLine: false)) + XCTAssertEqual(result.selections, [SourceEditor.convertRangeToCursorRange( + selectionRange, + in: result.lines + )]) + + if max == count + 1 { + exp.fulfill() + } + } + + wait(for: [exp], timeout: 10) + } +} + From 9b75b4340b223ab02e017bf229f3b4c8da3f9054 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 28 Jan 2024 15:51:35 +0800 Subject: [PATCH 40/45] Add todos --- .../SuggestionWidget/FeatureReducers/WidgetFeature.swift | 3 ++- Core/Sources/SuggestionWidget/SuggestionWidgetController.swift | 1 + .../Filespace+SuggestionService.swift | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 174e8c06..b55bdfea 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift @@ -350,6 +350,8 @@ public struct WidgetFeature: ReducerProtocol { 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) @@ -522,7 +524,6 @@ public struct WidgetFeature: ReducerProtocol { } } - #warning("TODO: control windows in their dedicated reducers.") case let .updateWindowOpacity(immediately): let isChatPanelDetached = state.chatPanelState.chatPanelInASeparateWindow let hasChat = !state.chatPanelState.chatTabGroup.tabInfo.isEmpty diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index bdfb9aba..c72a605c 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -239,6 +239,7 @@ public extension SuggestionWidgetController { store.send(.panel(.discardSuggestion)) } + #warning("TODO: Make a progress controller that doesn't use TCA.") func markAsProcessing(_ isProcessing: Bool) { store.withState { state in if isProcessing, !state.circularWidgetState.isProcessing { diff --git a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift index 1b14fea4..73897d0d 100644 --- a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift +++ b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift @@ -3,6 +3,7 @@ import SuggestionModel import Workspace public struct FilespaceSuggestionSnapshot: Equatable { + #warning("TODO: Can we remove it?") public var linesHash: Int public var cursorPosition: CursorPosition From 3c7267528e8fc1a58542e5229b94698cb231c811 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 28 Jan 2024 16:44:34 +0800 Subject: [PATCH 41/45] Update prompt --- .../ActiveDocumentChatContextCollector.swift | 55 +++++++++++++------ .../GetCodeCodeAroundLineFunction.swift | 2 +- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift index 88b1339c..39b38126 100644 --- a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/ActiveDocumentChatContextCollector.swift @@ -45,9 +45,28 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { var functions = [any ChatGPTFunction]() if !isSensitive { + var functionPrompt = """ + ONLY call it when one of the following conditions are satisfied: + - the user ask you about specific line from the latest message, \ + which is not included in the focused range. + """ + + if let annotations = context.focusedContext?.otherLineAnnotations, + !annotations.isEmpty + { + functionPrompt += """ + + - the user ask about annotations at line \( + Set(annotations.map(\.line)).map(String.init).joined(separator: ",") + ). + """ + } + + print(functionPrompt) + functions.append(GetCodeCodeAroundLineFunction( contextCollector: self, - additionalDescription: "You already have the code in focusing range, don't get it again!" + additionalDescription: functionPrompt )) } @@ -91,20 +110,20 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { let relativePath = "Document Relative Path: \(context.relativePath)" let language = "Language: \(context.language.rawValue)" - let focusingContextExplanation = + let focusedContextExplanation = "Below is the code inside the active document that the user is looking at right now:" - if let focusingContext = context.focusedContext { - let codeContext = focusingContext.context.isEmpty || isSensitive + if let focusedContext = context.focusedContext { + let codeContext = focusedContext.context.isEmpty || isSensitive ? "" : """ - Focusing Context: + Focused Context: ``` - \(focusingContext.context.map(\.signature).joined(separator: "\n")) + \(focusedContext.context.map(\.signature).joined(separator: "\n")) ``` """ - let codeRange = "Focusing Range [line, character]: \(focusingContext.codeRange)" + let codeRange = "Focused Range [line, character]: \(focusedContext.codeRange)" let code = context.selectionRange.isEmpty && isSensitive ? """ @@ -112,33 +131,33 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { Ask the user to select the code in the editor to get help. Also tell them the file is in gitignore. """ : """ - Focusing Code (from line \( - focusingContext.codeRange.start.line + 1 - ) to line \(focusingContext.codeRange.end.line + 1)): + Focused Code (from line \( + focusedContext.codeRange.start.line + 1 + ) to line \(focusedContext.codeRange.end.line + 1)): ```\(context.language.rawValue) - \(focusingContext.code) + \(focusedContext.code) ``` """ - let fileAnnotations = focusingContext.otherLineAnnotations.isEmpty || isSensitive + let fileAnnotations = focusedContext.otherLineAnnotations.isEmpty || isSensitive ? "" : """ Out-of-scope Annotations:\""" - (They are not inside the focusing code. You can get the code at the line for details) + (The related code are not inside the focused code.) \( - focusingContext.otherLineAnnotations + focusedContext.otherLineAnnotations .map(convertAnnotationToText) .joined(separator: "\n") ) \""" """ - let codeAnnotations = focusingContext.lineAnnotations.isEmpty || isSensitive + let codeAnnotations = focusedContext.lineAnnotations.isEmpty || isSensitive ? "" : """ - Annotations Inside Focusing Range:\""" + Annotations Inside Focused Range:\""" \( - focusingContext.lineAnnotations + focusedContext.lineAnnotations .map(convertAnnotationToText) .joined(separator: "\n") ) @@ -149,7 +168,7 @@ public final class ActiveDocumentChatContextCollector: ChatContextCollector { start, relativePath, language, - focusingContextExplanation, + focusedContextExplanation, codeContext, codeRange, code, diff --git a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift index 29726320..fd72e225 100644 --- a/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift +++ b/Tool/Sources/ChatContextCollectors/ActiveDocumentChatContextCollector/Functions/GetCodeCodeAroundLineFunction.swift @@ -32,7 +32,7 @@ struct GetCodeCodeAroundLineFunction: ChatGPTFunction { } var description: String { - "Get the code at the given line. You must ONLY call it when the user give you a specific line or the user ask about an out of scope annotation. \n\(additionalDescription)" + "Get the code at the given line. \(additionalDescription)" } var argumentSchema: JSONSchemaValue { [ From d580cd47c1db9ea0708c7f6e33894230a73a8d4a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 28 Jan 2024 16:51:50 +0800 Subject: [PATCH 42/45] Add missing task cancellation check --- .../XcodeInspector/XcodeInspector.swift | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 72792128..d111dedb 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -214,6 +214,7 @@ public final class XcodeInspector: ObservableObject { let sequence = NSWorkspace.shared.notificationCenter .notifications(named: .accessibilityAPIMalfunctioning) for await notification in sequence { + try Task.checkCancellation() guard let self else { return } await self .recoverFromAccessibilityMalfunctioning(notification.object as? String) @@ -296,10 +297,10 @@ public final class XcodeInspector: ObservableObject { } activeXcodeObservations.insert(malfunctionCheck) - + checkForAccessibilityMalfunction("Reactivate Xcode") } - + xcode.$completionPanel.receive(on: DispatchQueue.main).sink { [weak self] element in self?.completionPanel = element }.store(in: &activeXcodeCancellable) @@ -320,7 +321,7 @@ public final class XcodeInspector: ObservableObject { self?.focusedWindow = window }.store(in: &activeXcodeCancellable) } - + private var lastRecoveryFromAccessibilityMalfunctioningTimeStamp = Date() @MainActor @@ -339,23 +340,24 @@ public final class XcodeInspector: ObservableObject { { NSWorkspace.shared.notificationCenter.post( name: .accessibilityAPIMalfunctioning, - object: "Element Inconsistency: \(source)" + object: "Element Inconsistency: \(source)" ) } } } - + @MainActor private func recoverFromAccessibilityMalfunctioning(_ source: String?) { + let message = """ + Accessibility API malfunction detected: \ + \(source ?? ""). + Resetting active Xcode. + """ + if UserDefaults.shared.value(for: \.toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted) { - toast.toast( - content: """ - Accessibility API malfunction detected: \ - \(source ?? ""). - Resetting active Xcode. - """, - type: .warning - ) + toast.toast(content: message, type: .warning) + } else { + Logger.service.info(message) } if let activeXcode { lastRecoveryFromAccessibilityMalfunctioningTimeStamp = Date() From da24d8b282453140491887d2cf35d431ddeb7820 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 28 Jan 2024 16:54:45 +0800 Subject: [PATCH 43/45] Add todo --- Tool/Sources/XcodeInspector/XcodeInspector.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index d111dedb..c44c5143 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -35,6 +35,7 @@ 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? { guard let documentURL = XcodeInspector.shared.realtimeActiveDocumentURL, let workspaceURL = XcodeInspector.shared.realtimeActiveWorkspaceURL, From cf9aa83a42ed75638a91ad3008463dafe0f351aa Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 28 Jan 2024 17:15:45 +0800 Subject: [PATCH 44/45] Update appcasts.xml --- appcast.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/appcast.xml b/appcast.xml index 127ace1b..be5859df 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,6 +3,18 @@ Copilot for Xcode + + 0.30.1 + Sun, 28 Jan 2024 17:12:35 +0800 + 313 + 0.30.1 + 12.0 + + https://github.com/intitni/CopilotForXcode/releases/tag/0.30.1 + + + + 0.30.0 Mon, 22 Jan 2024 16:01:13 +0800 From 5a628275021f2821f0f76b7d3134c2fb1937da44 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 28 Jan 2024 17:21:05 +0800 Subject: [PATCH 45/45] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 30c8d598..598f68f1 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,7 @@ Since the app needs to manage license keys, it will send network request to `htt - when you activate the license key - when you deactivate the license key +- when you validate the license key manually - when you open the host app or the service app if a license key is available - every 24 hours if a license key is available