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/HostApp/DebugView.swift b/Core/Sources/HostApp/DebugView.swift index e4054dd1..08d46bbc 100644 --- a/Core/Sources/HostApp/DebugView.swift +++ b/Core/Sources/HostApp/DebugView.swift @@ -19,6 +19,12 @@ final class DebugSettings: ObservableObject { @AppStorage(\.disableGitIgnoreCheck) var disableGitIgnoreCheck @AppStorage(\.disableFileContentManipulationByCheatsheet) var disableFileContentManipulationByCheatsheet + @AppStorage(\.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) + var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning + @AppStorage(\.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer) + var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer + @AppStorage(\.toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted) + var toastForTheReasonWhyXcodeInspectorNeedsToBeRestarted init() {} } @@ -75,6 +81,31 @@ struct DebugSettingsView: View { Text("Disable file content manipulation by cheatsheet") } + 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") { UserDefaults.shared.set(nil, forKey: "OldMigrationVersion") } 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/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/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/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index d702fa87..52f05d07 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) } - } - } + }.store(in: &cancellable) } - 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() - } - } - } - - 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,20 @@ public actor RealtimeSuggestionController { guard let self else { return } try Task.checkCancellation() - switch notification.name { - case kAXValueChangedNotification: + switch notification.kind { + case .valueChanged: 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: + guard let fileURL = XcodeInspector.shared.activeDocumentURL + else { break } await PseudoCommandHandler().invalidateRealtimeSuggestionsIfNeeded( fileURL: fileURL, sourceEditor: sourceEditor ) default: - continue + break } } } @@ -145,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 { @@ -161,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 } @@ -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/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index f0a1fd47..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() @@ -55,14 +58,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( @@ -78,9 +83,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 +104,14 @@ 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: sourceEditor.content.lines, - cursorPosition: sourceEditor.content.cursorPosition + lines: content.lines, + cursorPosition: content.cursorPosition ) { PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: fileURL) } @@ -351,7 +362,8 @@ extension PseudoCommandHandler { guard let filespace = await getFilespace(), let sourceEditor = sourceEditor ?? XcodeInspector.shared.focusedEditor else { return nil } - let content = sourceEditor.content + if Task.isCancelled { return nil } + let content = sourceEditor.getContent() let uti = filespace.codeMetadata.uti ?? "" let tabSize = filespace.codeMetadata.tabSize ?? 4 let indentSize = filespace.codeMetadata.indentSize ?? 4 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/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift index 86943827..b55bdfea 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 @@ -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 } } } @@ -345,25 +345,13 @@ 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 + + #warning("TODO: Handling events outside of TCA because the fire rate is too high.") return .run { send in await send(.observeEditorChange) @@ -375,9 +363,9 @@ public struct WidgetFeature: ReducerProtocol { // 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,26 +393,12 @@ 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, *) { @@ -539,13 +513,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, @@ -556,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/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.. 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) 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 { [ 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() {} 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() {} 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) } } diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 09ae7248..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 { @@ -569,5 +569,26 @@ public extension UserDefaultPreferenceKeys { key: "FeatureFlag-DisableEnhancedWorkspace" ) } + + var restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning: FeatureFlag { + .init( + 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/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..ddbe9903 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. - var lineEnding: Character? { - last(where: \.isNewline) + /// + /// 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 { + if let last, last.isNewline { return last } + return "\n" + } + + func splitByNewLine( + omittingEmptySubsequences: Bool = true, + fast: Bool = true + ) -> [Substring] { + if fast { + let lineEndingInText = lineEnding + 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 + 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/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/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 diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift index 7de0eb03..823c3f69 100644 --- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift +++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift @@ -1,21 +1,78 @@ import AppKit +import AsyncExtensions import AXExtension import AXNotificationStream import Combine import Foundation public final class XcodeAppInstanceInspector: AppInstanceInspector { + 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 + } + } + } + @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) } - @Published public private(set) var completionPanel: AXUIElement? + public let axNotifications = AsyncPassthroughSubject() public var realtimeDocumentURL: URL? { guard let window = appElement.focusedWindow, @@ -66,6 +123,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { private var focusedWindowObservations = Set() deinit { + axNotifications.send(.finished) for task in longRunningTasks { task.cancel() } } @@ -75,7 +133,7 @@ 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. if !(focusedWindow is WorkspaceXcodeWindowInspector) { @@ -85,12 +143,22 @@ 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( app: runningApplication, - uiElement: window + uiElement: window, + axNotifications: axNotifications ) focusedWindow = window @@ -131,95 +199,90 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } } - @MainActor - func refresh() { - if let focusedWindow = focusedWindow as? WorkspaceXcodeWindowInspector { - focusedWindow.refresh() - } else { - observeFocusedWindow() - } - } - @MainActor func observeAXNotifications() { 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() + guard let event = AXNotificationKind(rawValue: notification.name) else { + continue } - } else { - for await _ in elementChangeNotification { - guard let self else { return } - try Task.checkCancellation() - updateWorkspaceInfo() - } - } - } - - longRunningTasks.insert(updateTabsTask) - let completionPanelNotification = AXNotificationStream( - app: runningApplication, - notificationNames: kAXCreatedNotification, kAXUIElementDestroyedNotification - ) + self.axNotifications.send(.init(kind: event, element: notification.element)) - 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() } } diff --git a/Tool/Sources/XcodeInspector/SourceEditor.swift b/Tool/Sources/XcodeInspector/SourceEditor.swift index d7d1a561..2b9e0491 100644 --- a/Tool/Sources/XcodeInspector/SourceEditor.swift +++ b/Tool/Sources/XcodeInspector/SourceEditor.swift @@ -1,37 +1,49 @@ import AppKit +import AsyncExtensions import AXNotificationStream import Foundation +import Logger import SuggestionModel /// Representing a source editor inside Xcode. 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 + } + let runningApplication: NSRunningApplication public let element: AXUIElement + var observeAXNotificationsTask: Task? + public let axNotifications = AsyncPassthroughSubject() + + /// To prevent expensive calculations in ``getContent()``. + private let cache = Cache() - /// The content of the source editor. - public var content: Content { + /// 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 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 ) } @@ -39,25 +51,122 @@ public class SourceEditor { public init(runningApplication: NSRunningApplication, element: AXUIElement) { self.runningApplication = runningApplication self.element = element + observeAXNotifications() } - /// Observe to changes in the source editor. - public func observe(notificationNames: String...) -> AXNotificationStream { - return AXNotificationStream( - app: runningApplication, - element: element, - notificationNames: notificationNames - ) + private func observeAXNotifications() { + observeAXNotificationsTask?.cancel() + observeAXNotificationsTask = Task { @MainActor [weak self] in + guard let self else { return } + await withThrowingTaskGroup(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 { + try Task.checkCancellation() + 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 { + try Task.checkCancellation() + guard let self else { return } + self.axNotifications.send(.init( + kind: .scrollPositionChanged, + element: notification.element + )) + } + } + } + + try? await group.waitForAll() + } + } } +} - /// 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 - ) +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]() + + 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 == sourceContent + let selectedRangeMatch = selectedTextRange == sourceSelectedTextRange + let lines: [String] = { + if contentMatch { + return cachedLines + } + return content.breakLines(appendLineBreakToLastLine: false) + }() + let selections: [CursorRange] = { + if contentMatch, selectedRangeMatch { + return cachedSelections + } + if let selectedTextRange { + return [SourceEditor.convertRangeToCursorRange( + selectedTextRange, + in: lines + )] + } + return [] + }() + + sourceContent = content + cachedLines = lines + sourceSelectedTextRange = selectedTextRange + cachedSelections = selections + + return (lines, selections) + } + } } } @@ -105,7 +214,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 { diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 917b6f4f..c44c5143 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -1,16 +1,22 @@ import AppKit import AsyncAlgorithms import AXExtension -import AXNotificationStream import Combine 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>() @@ -29,13 +35,14 @@ 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, 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: "") @@ -83,7 +90,7 @@ public final class XcodeInspector: ObservableObject { init() { restart() } - + public func restart(cleanUp: Bool = false) { if cleanUp { activeXcodeObservations.forEach { $0.cancel() } @@ -111,7 +118,7 @@ public final class XcodeInspector: ObservableObject { activeApplication = activeXcode ?? runningApplications .first(where: \.isActive) .map(AppInstanceInspector.init(runningApplication:)) - + appChangeObservations.forEach { $0.cancel() } appChangeObservations.removeAll() @@ -120,9 +127,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 +161,7 @@ public final class XcodeInspector: ObservableObject { } } } - + group.addTask { [weak self] in // Did terminate app let sequence = NSWorkspace.shared.notificationCenter .notifications(named: NSWorkspace.didTerminateApplicationNotification) @@ -183,15 +190,45 @@ public final class XcodeInspector: ObservableObject { } } } + + if UserDefaults.shared + .value(for: \.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioning) + { + group.addTask { [weak self] in + while true { + guard let self else { return } + if UserDefaults.shared.value( + for: \.restartXcodeInspectorIfAccessibilityAPIIsMalfunctioningNoTimer + ) { + return + } + + 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 + .notifications(named: .accessibilityAPIMalfunctioning) + for await notification in sequence { + try Task.checkCancellation() + guard let self else { return } + await self + .recoverFromAccessibilityMalfunctioning(notification.object as? String) + } + } } } - + appChangeObservations.insert(appChangeTask) } - @MainActor - func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { + private func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { previousActiveApplication = activeApplication activeApplication = xcode xcode.refresh() @@ -209,7 +246,7 @@ 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 if let editorElement = focusedElement, editorElement.isSourceEditor { @@ -220,29 +257,51 @@ 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 } } setFocusedElement() let focusedElementChanged = Task { @MainActor in - let notification = AXNotificationStream( - app: xcode.runningApplication, - notificationNames: kAXFocusedUIElementChangedNotification - ) - for await _ in notification { - try Task.checkCancellation() - setFocusedElement() + for await notification in xcode.axNotifications { + 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(1000)) + for await _ in notifications { + guard let self else { return } + try Task.checkCancellation() + self.checkForAccessibilityMalfunction("Element Destroyed") + } + } + } + + activeXcodeObservations.insert(malfunctionCheck) + + checkForAccessibilityMalfunction("Reactivate Xcode") + } + xcode.$completionPanel.receive(on: DispatchQueue.main).sink { [weak self] element in self?.completionPanel = element }.store(in: &activeXcodeCancellable) @@ -263,5 +322,49 @@ 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 } + + 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)" + ) + } + } + } + + @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: message, type: .warning) + } else { + Logger.service.info(message) + } + if let activeXcode { + lastRecoveryFromAccessibilityMalfunctioningTimeStamp = Date() + setActiveXcode(activeXcode) + activeXcode.observeAXNotifications() + } + } } diff --git a/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift b/Tool/Sources/XcodeInspector/XcodeWindowInspector.swift index 137fc434..00a777ba 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,7 +51,8 @@ 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 } try Task.checkCancellation() await self.updateURLs() 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() + } + } +} + 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) + } +} + 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 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