From 595b415dc67e2e4225a16b195537e54da02c680c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 20 Jun 2023 10:15:58 +0800 Subject: [PATCH 01/35] Fix chat not stopping responding on errors --- .../ShortcutChatPlugin/ShortcutInputChatPlugin.swift | 8 ++++++-- Core/Sources/ChatService/ChatService.swift | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift index d30b5ce8..7f827150 100644 --- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift +++ b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift @@ -90,13 +90,17 @@ public actor ShortcutInputChatPlugin: ChatPlugin { if let text = String(data: data, encoding: .utf8) { if text.isEmpty { return } let stream = try await chatGPTService.send(content: text, summary: nil) - for try await _ in stream {} + do { + for try await _ in stream {} + } catch {} } else { let text = """ [View File](\(temporaryOutputFileURL)) """ let stream = try await chatGPTService.send(content: text, summary: nil) - for try await _ in stream {} + do { + for try await _ in stream {} + } catch {} } return diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift index d482d458..c7673758 100644 --- a/Core/Sources/ChatService/ChatService.swift +++ b/Core/Sources/ChatService/ChatService.swift @@ -40,8 +40,12 @@ public final class ChatService: ObservableObject { let stream = try await chatGPTService.send(content: content, summary: nil) isReceivingMessage = true - for try await _ in stream {} - isReceivingMessage = false + do { + for try await _ in stream {} + isReceivingMessage = false + } catch { + isReceivingMessage = false + } } public func stopReceivingMessage() async { From d8867ba6141960566156561935f464763b8fdc2a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 20 Jun 2023 10:17:01 +0800 Subject: [PATCH 02/35] Update --- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8afaa8b0..9121e5f3 100644 --- a/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -120,10 +120,10 @@ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", + "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "4ad606ba5d7673ea60679a61ff867cc1ff8c8e86", - "version" : "1.2.1" + "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", + "version" : "1.2.2" } }, { From ebfcd34e9d1f962dfb83d54276c54fe15dd63a8a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 20 Jun 2023 14:49:38 +0800 Subject: [PATCH 03/35] Fix trigger command to correctly throw error --- Core/Sources/Environment/Environment.swift | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/Core/Sources/Environment/Environment.swift b/Core/Sources/Environment/Environment.swift index 8bf6d022..3afdbaa6 100644 --- a/Core/Sources/Environment/Environment.swift +++ b/Core/Sources/Environment/Environment.swift @@ -163,6 +163,8 @@ public enum Environment { Logger.service .error("Trigger command \(name) failed: \(error.localizedDescription)") throw error + } else { + return } } } else if let commandMenu = app.menuBar?.child(title: bundleName), @@ -173,17 +175,18 @@ public enum Environment { Logger.service .error("Trigger command \(name) failed: \(error.localizedDescription)") throw error + } else { + return } - } else { - struct CantRunCommand: Error, LocalizedError { - let name: String - var errorDescription: String? { - "Can't run command \(name)." - } + } + struct CantRunCommand: Error, LocalizedError { + let name: String + var errorDescription: String? { + "Can't run command \(name)." } - - throw CantRunCommand(name: name) } + + throw CantRunCommand(name: name) } else { /// check if menu is open, if not, click the menu item. let appleScript = """ From db1cf1e75b71a7d022cda7b3ac9a8f055baa4078 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 20 Jun 2023 14:50:11 +0800 Subject: [PATCH 04/35] Update accept suggestion to automatically fallback to Accessibility API solution when command not available --- .../PseudoCommandHandler.swift | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index ee4e22cb..dcc72e16 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -136,14 +136,21 @@ struct PseudoCommandHandler { } func acceptSuggestion() async { - if UserDefaults.shared.value(for: \.acceptSuggestionWithAccessibilityAPI) { + do { + try await Environment.triggerAction("Accept Suggestion") + } catch { guard let xcode = ActiveApplicationMonitor.activeXcode ?? ActiveApplicationMonitor .latestXcode else { return } let application = AXUIElementCreateApplication(xcode.processIdentifier) guard let focusElement = application.focusedElement, focusElement.description == "Source Editor" else { return } - guard let (content, lines, _, cursorPosition) = await getFileContent(sourceEditor: nil) + guard let ( + content, + lines, + _, + cursorPosition + ) = await getFileContent(sourceEditor: nil) else { PresentInWindowSuggestionPresenter() .presentErrorMessage("Unable to get file content.") @@ -199,21 +206,15 @@ struct PseudoCommandHandler { } } - if let oldScrollPosition, let scrollBar = focusElement.parent?.verticalScrollBar { + if let oldScrollPosition, + let scrollBar = focusElement.parent?.verticalScrollBar + { AXUIElementSetAttributeValue( scrollBar, kAXValueAttribute as CFString, oldScrollPosition as CFTypeRef ) } - - } catch { - PresentInWindowSuggestionPresenter().presentError(error) - } - } else { - do { - try await Environment.triggerAction("Accept Suggestion") - return } catch { PresentInWindowSuggestionPresenter().presentError(error) } From eb51ecc6c216e94e298753569427bd10a423cf00 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 20 Jun 2023 14:51:49 +0800 Subject: [PATCH 05/35] Remove acceptSuggestionWithAccessibilityAPI --- .../FeatureSettings/PromptToCodeSettingsView.swift | 8 -------- .../FeatureSettings/SuggestionSettingsView.swift | 12 ------------ Core/Sources/SuggestionWidget/WidgetView.swift | 10 ---------- Tool/Sources/Preferences/Keys.swift | 4 ---- 4 files changed, 34 deletions(-) diff --git a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift index ecb0a5c4..126c19ad 100644 --- a/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/PromptToCodeSettingsView.swift @@ -6,8 +6,6 @@ struct PromptToCodeSettingsView: View { var hideCommonPrecedingSpacesInSuggestion @AppStorage(\.suggestionCodeFontSize) var suggestionCodeFontSize - @AppStorage(\.acceptSuggestionWithAccessibilityAPI) - var acceptSuggestionWithAccessibilityAPI @AppStorage(\.promptToCodeGenerateDescription) var promptToCodeGenerateDescription @AppStorage(\.promptToCodeGenerateDescriptionInUserPreferredLanguage) @@ -57,12 +55,6 @@ struct PromptToCodeSettingsView: View { Text("pt") }.disabled(true) - - Divider() - - Toggle(isOn: $settings.acceptSuggestionWithAccessibilityAPI) { - Text("Use accessibility API to accept suggestion in widget") - }.disabled(true) } } } diff --git a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift index f24ee2b3..3af60c3d 100644 --- a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift @@ -9,8 +9,6 @@ struct SuggestionSettingsView: View { var realtimeSuggestionDebounce @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode - @AppStorage(\.acceptSuggestionWithAccessibilityAPI) - var acceptSuggestionWithAccessibilityAPI @AppStorage(\.disableSuggestionFeatureGlobally) var disableSuggestionFeatureGlobally @AppStorage(\.suggestionFeatureEnabledProjectList) @@ -125,16 +123,6 @@ struct SuggestionSettingsView: View { } Divider() } - - Group { - Toggle(isOn: $settings.acceptSuggestionWithAccessibilityAPI) { - Text("Use accessibility API to accept suggestion in widget") - } - - Text("You can turn it on if the accept button is not working for you.") - .font(.caption) - .foregroundStyle(.secondary) - } } } } diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index 87e1faf8..db3e651f 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -161,7 +161,6 @@ struct WidgetView: View { struct WidgetContextMenu: View { @AppStorage(\.useGlobalChat) var useGlobalChat @AppStorage(\.realtimeSuggestionToggle) var realtimeSuggestionToggle - @AppStorage(\.acceptSuggestionWithAccessibilityAPI) var acceptSuggestionWithAccessibilityAPI @AppStorage(\.hideCommonPrecedingSpacesInSuggestion) var hideCommonPrecedingSpacesInSuggestion @AppStorage(\.disableSuggestionFeatureGlobally) var disableSuggestionFeatureGlobally @AppStorage(\.suggestionFeatureEnabledProjectList) var suggestionFeatureEnabledProjectList @@ -227,15 +226,6 @@ struct WidgetContextMenu: View { } } - Button(action: { - acceptSuggestionWithAccessibilityAPI.toggle() - }, label: { - Text("Accept Suggestion with Accessibility API") - if acceptSuggestionWithAccessibilityAPI { - Image(systemName: "checkmark") - } - }) - Button(action: { hideCommonPrecedingSpacesInSuggestion.toggle() }, label: { diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 60cbbe7a..64e2178a 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -206,10 +206,6 @@ public extension UserDefaultPreferenceKeys { .init(defaultValue: true, key: "HideCommonPrecedingSpacesInSuggestion") } - var acceptSuggestionWithAccessibilityAPI: PreferenceKey { - .init(defaultValue: false, key: "AcceptSuggestionWithAccessibilityAPI") - } - var suggestionPresentationMode: PreferenceKey { .init(defaultValue: .floatingWidget, key: "SuggestionPresentationMode") } From f73af0e99e871038da5427ca83d998dc097e437f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 22 Jun 2023 14:20:54 +0800 Subject: [PATCH 06/35] Fix dependency --- Core/Package.swift | 4 +++- Core/Sources/Environment/Environment.swift | 10 +--------- Core/Sources/Service/Workspace.swift | 2 +- Core/Tests/ServiceTests/Environment.swift | 4 ---- Tool/Package.swift | 3 ++- 5 files changed, 7 insertions(+), 16 deletions(-) diff --git a/Core/Package.swift b/Core/Package.swift index 1e05f08c..21320904 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -107,7 +107,7 @@ let package = Package( dependencies: [ "ActiveApplicationMonitor", "AXExtension", - "SuggestionService", + .product(name: "Preferences", package: "Tool"), ] ), @@ -256,6 +256,7 @@ let package = Package( name: "XcodeInspector", dependencies: [ "AXExtension", + "SuggestionModel", "Environment", "AXNotificationStream", .product(name: "Logger", package: "Tool"), @@ -289,6 +290,7 @@ let package = Package( "LanguageClient", "SuggestionModel", "KeychainAccess", + "XcodeInspector", .product(name: "Preferences", package: "Tool"), .product(name: "Terminal", package: "Tool"), ] diff --git a/Core/Sources/Environment/Environment.swift b/Core/Sources/Environment/Environment.swift index 3afdbaa6..b36fc35c 100644 --- a/Core/Sources/Environment/Environment.swift +++ b/Core/Sources/Environment/Environment.swift @@ -2,9 +2,8 @@ import ActiveApplicationMonitor import AppKit import AXExtension import Foundation -import GitHubCopilotService import Logger -import SuggestionService +import Preferences public struct NoAccessToAccessibilityAPIError: Error, LocalizedError { public var errorDescription: String? { @@ -134,13 +133,6 @@ public enum Environment { } } - public static var createSuggestionService: ( - _ projectRootURL: URL, - _ onServiceLaunched: @escaping (SuggestionServiceType) -> Void - ) -> SuggestionServiceType = { projectRootURL, onServiceLaunched in - SuggestionService(projectRootURL: projectRootURL, onServiceLaunched: onServiceLaunched) - } - public static var triggerAction: (_ name: String) async throws -> Void = { name in guard let activeXcode = ActiveApplicationMonitor.activeXcode ?? ActiveApplicationMonitor.latestXcode diff --git a/Core/Sources/Service/Workspace.swift b/Core/Sources/Service/Workspace.swift index 4f6357b8..8ee3da4a 100644 --- a/Core/Sources/Service/Workspace.swift +++ b/Core/Sources/Service/Workspace.swift @@ -135,7 +135,7 @@ final class Workspace { } if _suggestionService == nil { - _suggestionService = Environment.createSuggestionService(projectRootURL) { + _suggestionService = SuggestionService(projectRootURL: projectRootURL) { [weak self] _ in guard let self else { return } for (_, filespace) in filespaces { diff --git a/Core/Tests/ServiceTests/Environment.swift b/Core/Tests/ServiceTests/Environment.swift index cad9bcf6..823f7302 100644 --- a/Core/Tests/ServiceTests/Environment.swift +++ b/Core/Tests/ServiceTests/Environment.swift @@ -22,10 +22,6 @@ import XPCShared URL(fileURLWithPath: "/path/to/project/file.swift") } - Environment.createSuggestionService = { - _, _ in fatalError("") - } - Environment.triggerAction = { _ in } } diff --git a/Tool/Package.swift b/Tool/Package.swift index 1088767f..337eed1f 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -15,6 +15,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/pvieito/PythonKit.git", branch: "master"), + // TODO: Switch to Tiktoken. https://github.com/aespinilla/Tiktoken .package(url: "https://github.com/alfianlosari/GPTEncoder", from: "1.0.4"), .package(url: "https://github.com/apple/swift-async-algorithms", from: "0.1.0"), .package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1") @@ -56,9 +57,9 @@ let package = Package( .target( name: "OpenAIService", dependencies: [ - "GPTEncoder", "Logger", "Preferences", + .product(name: "GPTEncoder", package: "GPTEncoder"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ] ), From d157fa6c2d0cb4b93ea1a76302f715d8fe4f83e5 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 22 Jun 2023 14:22:13 +0800 Subject: [PATCH 07/35] Get Xcode version from the Xcode bundle version.plist --- .../CodeiumService/CodeiumService.swift | 31 ++++++++++--------- .../XcodeInspector/XcodeInspector.swift | 20 ++++++++++++ 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/Core/Sources/CodeiumService/CodeiumService.swift b/Core/Sources/CodeiumService/CodeiumService.swift index 7b501270..f5f783da 100644 --- a/Core/Sources/CodeiumService/CodeiumService.swift +++ b/Core/Sources/CodeiumService/CodeiumService.swift @@ -3,6 +3,7 @@ import LanguageClient import LanguageServerProtocol import Logger import SuggestionModel +import XcodeInspector public protocol CodeiumSuggestionServiceType { func getCompletions( @@ -54,9 +55,9 @@ public class CodeiumSuggestionService { let authService = CodeiumAuthService() - var xcodeVersion = "14.0.0" + var fallbackXcodeVersion = "14.0.0" var languageServerVersion = CodeiumInstallationManager.latestSupportedVersion - + private var ongoingTasks = Set>() init(designatedServer: CodeiumLSP) { @@ -95,13 +96,6 @@ public class CodeiumSuggestionService { } let metadata = try getMetadata() - xcodeVersion = (try? await getXcodeVersion()) ?? xcodeVersion - let versionNumberSegmentCount = xcodeVersion.split(separator: ".").count - if versionNumberSegmentCount == 2 { - xcodeVersion += ".0" - } else if versionNumberSegmentCount == 1 { - xcodeVersion += ".0.0" - } let tempFolderURL = FileManager.default.temporaryDirectory let managerDirectoryURL = tempFolderURL .appendingPathComponent("com.intii.CopilotForXcode") @@ -192,9 +186,16 @@ extension CodeiumSuggestionService { } throw E() } + var ideVersion = XcodeInspector.shared.latestActiveXcode?.version ?? fallbackXcodeVersion + let versionNumberSegmentCount = ideVersion.split(separator: ".").count + if versionNumberSegmentCount == 2 { + ideVersion += ".0" + } else if versionNumberSegmentCount == 1 { + ideVersion += ".0.0" + } return Metadata( ide_name: "xcode", - ide_version: xcodeVersion, + ide_version: ideVersion, extension_version: languageServerVersion, api_key: key, session_id: CodeiumSuggestionService.sessionId, @@ -231,11 +232,11 @@ extension CodeiumSuggestionService: CodeiumSuggestionServiceType { ongoingTasks.forEach { $0.cancel() } ongoingTasks.removeAll() await cancelRequest() - + requestCounter += 1 let languageId = languageIdentifierFromFileURL(fileURL) let relativePath = getRelativePath(of: fileURL) - + let task = Task { let request = await CodeiumRequest.GetCompletion(requestBody: .init( metadata: try getMetadata(), @@ -263,11 +264,11 @@ extension CodeiumSuggestionService: CodeiumSuggestionServiceType { ) } )) - + try Task.checkCancellation() let result = try await (try await setupServerIfNeeded()).sendRequest(request) - + try Task.checkCancellation() return result.completionItems?.filter { item in @@ -294,7 +295,7 @@ extension CodeiumSuggestionService: CodeiumSuggestionServiceType { ) } ?? [] } - + ongoingTasks.insert(task) return try await task.value diff --git a/Core/Sources/XcodeInspector/XcodeInspector.swift b/Core/Sources/XcodeInspector/XcodeInspector.swift index e8d74578..c13be815 100644 --- a/Core/Sources/XcodeInspector/XcodeInspector.swift +++ b/Core/Sources/XcodeInspector/XcodeInspector.swift @@ -167,6 +167,26 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { @Published public var documentURL: URL = .init(fileURLWithPath: "/") @Published public var projectURL: URL = .init(fileURLWithPath: "/") @Published public var workspaces = [WorkspaceIdentifier: WorkspaceInfo]() + var _version: String? + public var version: String? { + if let _version { return _version } + guard let plistPath = runningApplication.bundleURL? + .appendingPathComponent("Contents") + .appendingPathComponent("version.plist") + .path + else { return nil } + guard let plistData = FileManager.default.contents(atPath: plistPath) else { return nil } + var format = PropertyListSerialization.PropertyListFormat.xml + guard let plistDict = try? PropertyListSerialization.propertyList( + from: plistData, + options: .mutableContainersAndLeaves, + format: &format + ) as? [String: AnyObject] else { return nil } + let result = plistDict["CFBundleShortVersionString"] as? String + _version = result + return result + } + private var longRunningTasks = Set>() private var focusedWindowObservations = Set() From 35d38951ea00c37ad0a60c1468fbb8cb260a76c6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 22 Jun 2023 15:46:06 +0800 Subject: [PATCH 08/35] Add completionPanel to XcodeAppInstanceInspector --- Core/Sources/AXExtension/AXUIElement.swift | 4 ++ .../AXNotificationStream.swift | 4 +- .../Sources/XcodeInspector/SourceEditor.swift | 2 +- .../XcodeInspector/XcodeInspector.swift | 37 +++++++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/Core/Sources/AXExtension/AXUIElement.swift b/Core/Sources/AXExtension/AXUIElement.swift index 62dfe3b2..940de2e1 100644 --- a/Core/Sources/AXExtension/AXUIElement.swift +++ b/Core/Sources/AXExtension/AXUIElement.swift @@ -63,6 +63,10 @@ public extension AXUIElement { var isEnabled: Bool { (try? copyValue(key: kAXEnabledAttribute)) ?? false } + + var isHidden: Bool { + (try? copyValue(key: kAXHiddenAttribute)) ?? false + } } // MARK: - Rect diff --git a/Core/Sources/AXNotificationStream/AXNotificationStream.swift b/Core/Sources/AXNotificationStream/AXNotificationStream.swift index d0a9d07a..607999a1 100644 --- a/Core/Sources/AXNotificationStream/AXNotificationStream.swift +++ b/Core/Sources/AXNotificationStream/AXNotificationStream.swift @@ -6,7 +6,7 @@ public final class AXNotificationStream: AsyncSequence { public typealias Stream = AsyncStream public typealias Continuation = Stream.Continuation public typealias AsyncIterator = Stream.AsyncIterator - public typealias Element = (name: String, info: CFDictionary) + public typealias Element = (name: String, element: AXUIElement, info: CFDictionary) private var continuation: Continuation private let stream: Stream @@ -48,7 +48,7 @@ public final class AXNotificationStream: AsyncSequence { ) { guard let pointer = pointer?.assumingMemoryBound(to: Continuation.self) else { return } - pointer.pointee.yield((notificationName as String, userInfo)) + pointer.pointee.yield((notificationName as String, element, userInfo)) } _ = AXObserverCreateWithInfoCallback( diff --git a/Core/Sources/XcodeInspector/SourceEditor.swift b/Core/Sources/XcodeInspector/SourceEditor.swift index 1d5c8668..93ad8410 100644 --- a/Core/Sources/XcodeInspector/SourceEditor.swift +++ b/Core/Sources/XcodeInspector/SourceEditor.swift @@ -16,7 +16,7 @@ public class SourceEditor { public var cursorPosition: CursorPosition /// Line annotations of the source editor. public var lineAnnotations: [String] - + public var selectedContent: String { if let range = selections.first { let startIndex = min( diff --git a/Core/Sources/XcodeInspector/XcodeInspector.swift b/Core/Sources/XcodeInspector/XcodeInspector.swift index c13be815..b1c593aa 100644 --- a/Core/Sources/XcodeInspector/XcodeInspector.swift +++ b/Core/Sources/XcodeInspector/XcodeInspector.swift @@ -167,6 +167,8 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { @Published public var documentURL: URL = .init(fileURLWithPath: "/") @Published public var projectURL: URL = .init(fileURLWithPath: "/") @Published public var workspaces = [WorkspaceIdentifier: WorkspaceInfo]() + @Published public private(set) var completionPanel: AXUIElement? + var _version: String? public var version: String? { if let _version { return _version } @@ -232,6 +234,41 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } longRunningTasks.insert(updateTabsTask) + + completionPanel = appElement.firstChild { element in + element.identifier == "_XC_COMPLETION_TABLE_" + }?.parent + + let completionPanelTask = Task { + let stream = AXNotificationStream( + app: runningApplication, + element: appElement, + notificationNames: kAXCreatedNotification, kAXUIElementDestroyedNotification + ) + + for await event in stream { + let isCompletionPanel = { + event.element.firstChild { element in + element.identifier == "_XC_COMPLETION_TABLE_" + } != nil + } + switch event.name { + case kAXCreatedNotification: + if isCompletionPanel() { + completionPanel = event.element + } + case kAXUIElementDestroyedNotification: + if isCompletionPanel() { + completionPanel = nil + } + default: break + } + + try Task.checkCancellation() + } + } + + longRunningTasks.insert(completionPanelTask) } func observeFocusedWindow() { From 0557b5462c44933944c87040bf87fea105e19219 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 22 Jun 2023 21:42:52 +0800 Subject: [PATCH 09/35] Say goodbye to comment mode --- ...ealtimeSuggestionIndicatorController.swift | 7 ++--- .../RealtimeSuggestionController.swift | 19 ------------ .../PseudoCommandHandler.swift | 11 ++----- Core/Sources/Service/XPCService.swift | 9 +----- EditorExtension/GetSuggestionsCommand.swift | 27 +++-------------- EditorExtension/NextSuggestionCommand.swift | 29 +++---------------- .../PreviousSuggestionCommand.swift | 27 +++-------------- .../RealtimeSuggestionCommand.swift | 27 +++-------------- EditorExtension/RejectSuggestionCommand.swift | 29 +++---------------- .../Preferences/PresentationMode.swift | 2 +- 10 files changed, 27 insertions(+), 160 deletions(-) diff --git a/Core/Sources/Service/GUI/RealtimeSuggestionIndicatorController.swift b/Core/Sources/Service/GUI/RealtimeSuggestionIndicatorController.swift index 8b898e55..6f228265 100644 --- a/Core/Sources/Service/GUI/RealtimeSuggestionIndicatorController.swift +++ b/Core/Sources/Service/GUI/RealtimeSuggestionIndicatorController.swift @@ -9,7 +9,8 @@ import QuartzCore import SwiftUI import UserDefaultsObserver -/// Present a tiny dot next to mouse cursor if real-time suggestion is enabled. +/// Deprecated: This class is no longer in use. +@available(*, deprecated, message: "This class is no longer in use.") @MainActor final class RealtimeSuggestionIndicatorController { class IndicatorContentViewModel: ObservableObject { @@ -226,10 +227,8 @@ final class RealtimeSuggestionIndicatorController { private func updateIndicatorVisibility() async { let isVisible = await { let isOn = UserDefaults.shared.value(for: \.realtimeSuggestionToggle) - let isCommentMode = UserDefaults.shared - .value(for: \.suggestionPresentationMode) == .comment let isXcodeActive = await Environment.isXcodeActive() - return isOn && isXcodeActive && isCommentMode + return isOn && isXcodeActive }() guard window.isVisible != isVisible else { return } diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift index a5eae5e7..ab6827fa 100644 --- a/Core/Sources/Service/RealtimeSuggestionController.swift +++ b/Core/Sources/Service/RealtimeSuggestionController.swift @@ -28,10 +28,6 @@ public class RealtimeSuggestionController { private var focusedUIElement: AXUIElement? private var sourceEditor: SourceEditor? - var isCommentMode: Bool { - UserDefaults.shared.value(for: \.suggestionPresentationMode) == .comment - } - private nonisolated init() { Task { [weak self] in @@ -179,16 +175,6 @@ public class RealtimeSuggestionController { if keycode == escape { if event.type == .keyDown { await cancelInFlightTasks() - } else { - Task { - #warning( - "TODO: Any method to avoid using AppleScript to check that completion panel is presented?" - ) - if isCommentMode, await Environment.frontmostXcodeWindowIsEditor() { - if Task.isCancelled { return } - self.triggerPrefetchDebounced(force: true) - } - } } } } @@ -214,11 +200,6 @@ public class RealtimeSuggestionController { Logger.service.info("Prefetch suggestions.") - if !force, isCommentMode, await !Environment.frontmostXcodeWindowIsEditor() { - Logger.service.info("Completion panel is open, blocked.") - return - } - // 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 dcc72e16..5d5694fb 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -56,15 +56,8 @@ struct PseudoCommandHandler { } // Otherwise, get it from pseudo handler directly. - let mode = UserDefaults.shared.value(for: \.suggestionPresentationMode) - switch mode { - case .comment: - let handler = CommentBaseCommandHandler() - _ = try? await handler.generateRealtimeSuggestions(editor: editor) - case .floatingWidget: - let handler = WindowBaseCommandHandler() - _ = try? await handler.generateRealtimeSuggestions(editor: editor) - } + let handler = WindowBaseCommandHandler() + _ = try? await handler.generateRealtimeSuggestions(editor: editor) } func invalidateRealtimeSuggestionsIfNeeded(sourceEditor: SourceEditor) async { diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 036afbb0..5723b330 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -47,14 +47,7 @@ public class XPCService: NSObject, XPCServiceProtocol { do { let editor = try JSONDecoder().decode(EditorContent.self, from: editorContent) let mode = UserDefaults.shared.value(for: \.suggestionPresentationMode) - let handler: SuggestionCommandHandler = { - switch mode { - case .comment: - return CommentBaseCommandHandler() - case .floatingWidget: - return WindowBaseCommandHandler() - } - }() + let handler: SuggestionCommandHandler = WindowBaseCommandHandler() try Task.checkCancellation() guard let updatedContent = try await getUpdatedContent(handler, editor) else { reply(nil, nil) diff --git a/EditorExtension/GetSuggestionsCommand.swift b/EditorExtension/GetSuggestionsCommand.swift index e0c5e12c..f1138270 100644 --- a/EditorExtension/GetSuggestionsCommand.swift +++ b/EditorExtension/GetSuggestionsCommand.swift @@ -10,29 +10,10 @@ class GetSuggestionsCommand: NSObject, XCSourceEditorCommand, CommandType { with invocation: XCSourceEditorCommandInvocation, completionHandler: @escaping (Error?) -> Void ) { - switch UserDefaults.shared.value(for: \.suggestionPresentationMode) { - case .comment: - Task { - do { - let service = try getService() - if let content = try await service.getSuggestedCode( - editorContent: .init(invocation) - ) { - invocation.accept(content) - } - completionHandler(nil) - } catch is CancellationError { - completionHandler(nil) - } catch { - completionHandler(error) - } - } - case .floatingWidget: - completionHandler(nil) - Task { - let service = try getService() - _ = try await service.getSuggestedCode(editorContent: .init(invocation)) - } + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.getSuggestedCode(editorContent: .init(invocation)) } } } diff --git a/EditorExtension/NextSuggestionCommand.swift b/EditorExtension/NextSuggestionCommand.swift index f5b9bd70..401fdcd3 100644 --- a/EditorExtension/NextSuggestionCommand.swift +++ b/EditorExtension/NextSuggestionCommand.swift @@ -10,31 +10,10 @@ class NextSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { with invocation: XCSourceEditorCommandInvocation, completionHandler: @escaping (Error?) -> Void ) { - switch UserDefaults.shared.value(for: \.suggestionPresentationMode) { - case .comment: - Task { - do { - try await (Task(timeout: 7) { - let service = try getService() - if let content = try await service.getNextSuggestedCode( - editorContent: .init(invocation) - ) { - invocation.accept(content) - } - completionHandler(nil) - }.value) - } catch is CancellationError { - completionHandler(nil) - } catch { - completionHandler(error) - } - } - case .floatingWidget: - completionHandler(nil) - Task { - let service = try getService() - _ = try await service.getNextSuggestedCode(editorContent: .init(invocation)) - } + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.getNextSuggestedCode(editorContent: .init(invocation)) } } } diff --git a/EditorExtension/PreviousSuggestionCommand.swift b/EditorExtension/PreviousSuggestionCommand.swift index d5b452b5..e2b1a47e 100644 --- a/EditorExtension/PreviousSuggestionCommand.swift +++ b/EditorExtension/PreviousSuggestionCommand.swift @@ -10,29 +10,10 @@ class PreviousSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { with invocation: XCSourceEditorCommandInvocation, completionHandler: @escaping (Error?) -> Void ) { - switch UserDefaults.shared.value(for: \.suggestionPresentationMode) { - case .comment: - Task { - do { - let service = try getService() - if let content = try await service.getPreviousSuggestedCode( - editorContent: .init(invocation) - ) { - invocation.accept(content) - } - completionHandler(nil) - } catch is CancellationError { - completionHandler(nil) - } catch { - completionHandler(error) - } - } - case .floatingWidget: - completionHandler(nil) - Task { - let service = try getService() - _ = try await service.getPreviousSuggestedCode(editorContent: .init(invocation)) - } + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.getPreviousSuggestedCode(editorContent: .init(invocation)) } } } diff --git a/EditorExtension/RealtimeSuggestionCommand.swift b/EditorExtension/RealtimeSuggestionCommand.swift index ed14945b..daa79734 100644 --- a/EditorExtension/RealtimeSuggestionCommand.swift +++ b/EditorExtension/RealtimeSuggestionCommand.swift @@ -10,29 +10,10 @@ class RealtimeSuggestionsCommand: NSObject, XCSourceEditorCommand, CommandType { with invocation: XCSourceEditorCommandInvocation, completionHandler: @escaping (Error?) -> Void ) { - switch UserDefaults.shared.value(for: \.suggestionPresentationMode) { - case .comment: - Task { - do { - let service = try getService() - if let content = try await service.getRealtimeSuggestedCode( - editorContent: .init(invocation) - ) { - invocation.accept(content) - } - completionHandler(nil) - } catch is CancellationError { - completionHandler(nil) - } catch { - completionHandler(error) - } - } - case .floatingWidget: - completionHandler(nil) - Task { - let service = try getService() - _ = try await service.getRealtimeSuggestedCode(editorContent: .init(invocation)) - } + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.getRealtimeSuggestedCode(editorContent: .init(invocation)) } } } diff --git a/EditorExtension/RejectSuggestionCommand.swift b/EditorExtension/RejectSuggestionCommand.swift index 2ce0cf43..c19dcf5a 100644 --- a/EditorExtension/RejectSuggestionCommand.swift +++ b/EditorExtension/RejectSuggestionCommand.swift @@ -10,31 +10,10 @@ class RejectSuggestionCommand: NSObject, XCSourceEditorCommand, CommandType { with invocation: XCSourceEditorCommandInvocation, completionHandler: @escaping (Error?) -> Void ) { - switch UserDefaults.shared.value(for: \.suggestionPresentationMode) { - case .comment: - Task { - do { - try await (Task(timeout: 7) { - let service = try getService() - if let content = try await service.getSuggestionRejectedCode( - editorContent: .init(invocation) - ) { - invocation.accept(content) - } - completionHandler(nil) - }.value) - } catch is CancellationError { - completionHandler(nil) - } catch { - completionHandler(error) - } - } - case .floatingWidget: - completionHandler(nil) - Task { - let service = try getService() - _ = try await service.getSuggestionRejectedCode(editorContent: .init(invocation)) - } + completionHandler(nil) + Task { + let service = try getService() + _ = try await service.getSuggestionRejectedCode(editorContent: .init(invocation)) } } } diff --git a/Tool/Sources/Preferences/PresentationMode.swift b/Tool/Sources/Preferences/PresentationMode.swift index 34185912..66fd9a76 100644 --- a/Tool/Sources/Preferences/PresentationMode.swift +++ b/Tool/Sources/Preferences/PresentationMode.swift @@ -1,4 +1,4 @@ public enum PresentationMode: Int, CaseIterable { - case comment = 0 + case nearbyTextCursor = 0 case floatingWidget = 1 } From fa794b75c540d1056402f7d55fb49fc4d48f3060 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 22 Jun 2023 21:43:28 +0800 Subject: [PATCH 10/35] Update Xcode inspector observation --- .../XcodeInspector/XcodeInspector.swift | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/Core/Sources/XcodeInspector/XcodeInspector.swift b/Core/Sources/XcodeInspector/XcodeInspector.swift index b1c593aa..cd0ecc52 100644 --- a/Core/Sources/XcodeInspector/XcodeInspector.swift +++ b/Core/Sources/XcodeInspector/XcodeInspector.swift @@ -10,6 +10,7 @@ public final class XcodeInspector: ObservableObject { private var cancellable = Set() private var activeXcodeObservations = Set>() + private var activeXcodeCancellable = Set() @Published public internal(set) var activeApplication: AppInstanceInspector? @Published public internal(set) var activeXcode: XcodeAppInstanceInspector? @@ -20,6 +21,7 @@ public final class XcodeInspector: ObservableObject { @Published public internal(set) var focusedWindow: XcodeWindowInspector? @Published public internal(set) var focusedEditor: SourceEditor? @Published public internal(set) var focusedElement: AXUIElement? + @Published public internal(set) var completionPanel: AXUIElement? init() { let runningApplications = NSWorkspace.shared.runningApplications @@ -31,10 +33,6 @@ public final class XcodeInspector: ObservableObject { .first(where: \.isActive) .map(AppInstanceInspector.init(runningApplication:)) - for xcode in xcodes { - observeXcode(xcode) - } - if let activeXcode { setActiveXcode(activeXcode) } @@ -56,7 +54,6 @@ public final class XcodeInspector: ObservableObject { let new = XcodeAppInstanceInspector(runningApplication: app) xcodes.append(new) setActiveXcode(new) - observeXcode(new) } } else { activeApplication = AppInstanceInspector(runningApplication: app) @@ -90,24 +87,19 @@ public final class XcodeInspector: ObservableObject { } } - func observeXcode(_ xcode: XcodeAppInstanceInspector) { - activeDocumentURL = xcode.documentURL - activeProjectURL = xcode.projectURL - focusedWindow = xcode.focusedWindow - - xcode.$documentURL.filter { _ in xcode.isActive }.assign(to: &$activeDocumentURL) - xcode.$projectURL.filter { _ in xcode.isActive }.assign(to: &$activeProjectURL) - xcode.$focusedWindow.filter { _ in xcode.isActive }.assign(to: &$focusedWindow) - } - func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { for task in activeXcodeObservations { task.cancel() } + for cancellable in activeXcodeCancellable { cancellable.cancel() } activeXcodeObservations.removeAll() + activeXcodeCancellable.removeAll() activeXcode = xcode latestActiveXcode = xcode activeDocumentURL = xcode.documentURL focusedWindow = xcode.focusedWindow + completionPanel = xcode.completionPanel + activeProjectURL = xcode.projectURL + focusedWindow = xcode.focusedWindow let setFocusedElement = { [weak self] in guard let self else { return } @@ -135,6 +127,22 @@ public final class XcodeInspector: ObservableObject { } activeXcodeObservations.insert(focusedElementChanged) + + xcode.$completionPanel.sink { [weak self] element in + self?.completionPanel = element + }.store(in: &activeXcodeCancellable) + + xcode.$documentURL.sink { [weak self] url in + self?.activeDocumentURL = url + }.store(in: &activeXcodeCancellable) + + xcode.$projectURL.sink { [weak self] url in + self?.activeProjectURL = url + }.store(in: &activeXcodeCancellable) + + xcode.$focusedWindow.sink { [weak self] window in + self?.focusedWindow = window + }.store(in: &activeXcodeCancellable) } } @@ -168,7 +176,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { @Published public var projectURL: URL = .init(fileURLWithPath: "/") @Published public var workspaces = [WorkspaceIdentifier: WorkspaceInfo]() @Published public private(set) var completionPanel: AXUIElement? - + var _version: String? public var version: String? { if let _version { return _version } @@ -234,7 +242,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } longRunningTasks.insert(updateTabsTask) - + completionPanel = appElement.firstChild { element in element.identifier == "_XC_COMPLETION_TABLE_" }?.parent @@ -245,7 +253,7 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { element: appElement, notificationNames: kAXCreatedNotification, kAXUIElementDestroyedNotification ) - + for await event in stream { let isCompletionPanel = { event.element.firstChild { element in @@ -263,11 +271,11 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } default: break } - + try Task.checkCancellation() } } - + longRunningTasks.insert(completionPanelTask) } From 02d1fbb88ba2c1a026514067533a36907e87bc47 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 22 Jun 2023 21:45:12 +0800 Subject: [PATCH 11/35] Remove CommentBaseCommandHandler and RealtimeSuggestionIndicatorController --- ...ealtimeSuggestionIndicatorController.swift | 285 ------------------ .../CommentBaseCommandHandler.swift | 197 ------------ 2 files changed, 482 deletions(-) delete mode 100644 Core/Sources/Service/GUI/RealtimeSuggestionIndicatorController.swift delete mode 100644 Core/Sources/Service/SuggestionCommandHandler/CommentBaseCommandHandler.swift diff --git a/Core/Sources/Service/GUI/RealtimeSuggestionIndicatorController.swift b/Core/Sources/Service/GUI/RealtimeSuggestionIndicatorController.swift deleted file mode 100644 index 6f228265..00000000 --- a/Core/Sources/Service/GUI/RealtimeSuggestionIndicatorController.swift +++ /dev/null @@ -1,285 +0,0 @@ -import ActiveApplicationMonitor -import AppKit -import AsyncAlgorithms -import AXNotificationStream -import DisplayLink -import Environment -import Preferences -import QuartzCore -import SwiftUI -import UserDefaultsObserver - -/// Deprecated: This class is no longer in use. -@available(*, deprecated, message: "This class is no longer in use.") -@MainActor -final class RealtimeSuggestionIndicatorController { - class IndicatorContentViewModel: ObservableObject { - @Published var isPrefetching = false - @Published var progress: Double = 1 - private var prefetchTask: Task? - - @MainActor - func prefetch() { - prefetchTask?.cancel() - withAnimation(.easeIn(duration: 0.2)) { - isPrefetching = true - } - prefetchTask = Task { - try await Task.sleep(nanoseconds: 5 * 1_000_000_000) - if isPrefetching { - endPrefetch() - } - } - } - - @MainActor - func endPrefetch() { - withAnimation(.easeOut(duration: 0.2)) { - isPrefetching = false - } - } - } - - struct IndicatorContentView: View { - @ObservedObject var viewModel: IndicatorContentViewModel - var opacityA: CGFloat { min(viewModel.progress, 0.7) } - var opacityB: CGFloat { 1 - viewModel.progress } - var scaleA: CGFloat { viewModel.progress / 2 + 0.5 } - var scaleB: CGFloat { max(1 - viewModel.progress, 0.01) } - - var body: some View { - Circle() - .fill(Color.accentColor.opacity(opacityA)) - .opacity(0.7) - .scaleEffect(.init(width: scaleA, height: scaleA)) - .frame(width: 8, height: 8) - .overlay { - if viewModel.isPrefetching { - Circle() - .fill(Color.white.opacity(opacityB)) - .scaleEffect(.init(width: scaleB, height: scaleB)) - .frame(width: 8, height: 8) - .onAppear { - Task { - await Task.yield() - withAnimation( - .easeInOut(duration: 0.4) - .repeatForever( - autoreverses: true - ) - ) { - viewModel.progress = 0 - } - } - }.onDisappear { - withAnimation(.default) { - viewModel.progress = 1 - } - } - } - } - } - } - - private let viewModel = IndicatorContentViewModel() - private var userDefaultsObserver = UserDefaultsObserver( - object: UserDefaults.shared, - forKeyPaths: [UserDefaultPreferenceKeys().realtimeSuggestionToggle.key], - context: nil - ) - private var windowChangeObservationTask: Task? - private var activeApplicationMonitorTask: Task? - private var editorObservationTask: Task? - var isObserving = false { - didSet { - Task { - await updateIndicatorVisibility() - } - } - } - - @MainActor - lazy var window = { - let it = NSWindow( - contentRect: .zero, - styleMask: .borderless, - backing: .buffered, - defer: false - ) - it.isReleasedWhenClosed = false - it.isOpaque = false - it.backgroundColor = .white.withAlphaComponent(0) - it.level = .floating - it.contentView = NSHostingView( - rootView: IndicatorContentView(viewModel: self.viewModel) - .frame(minWidth: 10, minHeight: 10) - ) - return it - }() - - nonisolated init() { - if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return } - - Task { @MainActor in - observeEditorChangeIfNeeded() - activeApplicationMonitorTask = Task { [weak self] in - var previousApp: NSRunningApplication? - for await app in ActiveApplicationMonitor.createStream() { - guard let self else { return } - try Task.checkCancellation() - defer { previousApp = app } - if let app = ActiveApplicationMonitor.activeXcode { - if app != previousApp { - windowChangeObservationTask?.cancel() - windowChangeObservationTask = nil - self.observeXcodeWindowChangeIfNeeded(app) - } - await self.updateIndicatorVisibility() - self.updateIndicatorLocation() - } else { - await self.updateIndicatorVisibility() - } - } - } - } - - Task { @MainActor in - userDefaultsObserver.onChange = { [weak self] in - Task { @MainActor [weak self] in - await self?.updateIndicatorVisibility() - self?.updateIndicatorLocation() - } - } - } - } - - private func observeXcodeWindowChangeIfNeeded(_ app: NSRunningApplication) { - guard windowChangeObservationTask == nil else { return } - windowChangeObservationTask = Task { [weak self] in - let notifications = AXNotificationStream( - app: app, - notificationNames: - kAXMovedNotification, - kAXResizedNotification, - kAXFocusedWindowChangedNotification, - kAXFocusedUIElementChangedNotification - ) - self?.observeEditorChangeIfNeeded() - for await notification in notifications { - guard let self else { return } - try Task.checkCancellation() - self.updateIndicatorLocation() - - switch notification.name { - case kAXFocusedUIElementChangedNotification, kAXFocusedWindowChangedNotification: - self.editorObservationTask?.cancel() - self.editorObservationTask = nil - self.observeEditorChangeIfNeeded() - default: - continue - } - } - } - } - - private func observeEditorChangeIfNeeded() { - guard editorObservationTask == nil, - let activeXcode = ActiveApplicationMonitor.activeXcode - else { return } - let application = AXUIElementCreateApplication(activeXcode.processIdentifier) - guard let focusElement: AXUIElement = try? application - .copyValue(key: kAXFocusedUIElementAttribute), - let focusElementType: String = try? focusElement - .copyValue(key: kAXDescriptionAttribute), - focusElementType == "Source Editor", - let scrollView: AXUIElement = try? focusElement - .copyValue(key: kAXParentAttribute), - let scrollBar: AXUIElement = try? scrollView - .copyValue(key: kAXVerticalScrollBarAttribute) - else { return } - - updateIndicatorLocation() - editorObservationTask = Task { [weak self] in - let notificationsFromEditor = AXNotificationStream( - app: activeXcode, - element: focusElement, - notificationNames: - kAXResizedNotification, - kAXMovedNotification, - kAXLayoutChangedNotification, - kAXSelectedTextChangedNotification - ) - - let notificationsFromScrollBar = AXNotificationStream( - app: activeXcode, - element: scrollBar, - notificationNames: kAXValueChangedNotification - ) - - for await _ in merge(notificationsFromEditor, notificationsFromScrollBar) { - guard let self else { return } - try Task.checkCancellation() - self.updateIndicatorLocation() - } - } - } - - private func updateIndicatorVisibility() async { - let isVisible = await { - let isOn = UserDefaults.shared.value(for: \.realtimeSuggestionToggle) - let isXcodeActive = await Environment.isXcodeActive() - return isOn && isXcodeActive - }() - - guard window.isVisible != isVisible else { return } - window.setIsVisible(isVisible) - } - - private func updateIndicatorLocation() { - if !window.isVisible { - return - } - - if let activeXcode = ActiveApplicationMonitor.activeXcode { - let application = AXUIElementCreateApplication(activeXcode.processIdentifier) - if let focusElement: AXUIElement = try? application - .copyValue(key: kAXFocusedUIElementAttribute), - let focusElementType: String = try? focusElement - .copyValue(key: kAXDescriptionAttribute), - focusElementType == "Source Editor", - let selectedRange: AXValue = try? focusElement - .copyValue(key: kAXSelectedTextRangeAttribute), - let rect: AXValue = try? focusElement.copyParameterizedValue( - key: kAXBoundsForRangeParameterizedAttribute, - parameters: selectedRange - ) - { - var frame: CGRect = .zero - let found = AXValueGetValue(rect, .cgRect, &frame) - let screen = NSScreen.screens.first - if found, let screen { - frame.origin = .init( - x: frame.maxX + 2, - y: screen.frame.height - frame.minY - 4 - ) - frame.size = .init(width: 10, height: 10) - window.alphaValue = 1 - window.setFrame(frame, display: true) - window.orderFront(nil) - return - } - } - } - - window.alphaValue = 0 - } - - func triggerPrefetchAnimation() { - viewModel.prefetch() - } - - func endPrefetchAnimation() { - viewModel.endPrefetch() - } -} - diff --git a/Core/Sources/Service/SuggestionCommandHandler/CommentBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/CommentBaseCommandHandler.swift deleted file mode 100644 index cda83672..00000000 --- a/Core/Sources/Service/SuggestionCommandHandler/CommentBaseCommandHandler.swift +++ /dev/null @@ -1,197 +0,0 @@ -import SuggestionModel -import Environment -import Foundation -import SuggestionInjector -import XPCShared - -@ServiceActor -struct CommentBaseCommandHandler: SuggestionCommandHandler { - nonisolated init() {} - - func presentSuggestions(editor: EditorContent) async throws -> UpdatedContent? { - let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) - try await workspace.generateSuggestions( - forFileAt: fileURL, - editor: editor - ) - - let presenter = PresentInCommentSuggestionPresenter() - return try await presenter.presentSuggestion( - for: filespace, - in: workspace, - originalContent: editor.content, - lines: editor.lines, - cursorPosition: editor.cursorPosition - ) - } - - func presentNextSuggestion(editor: EditorContent) async throws -> UpdatedContent? { - let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) - workspace.selectNextSuggestion(forFileAt: fileURL) - - let presenter = PresentInCommentSuggestionPresenter() - return try await presenter.presentSuggestion( - for: filespace, - in: workspace, - originalContent: editor.content, - lines: editor.lines, - cursorPosition: editor.cursorPosition - ) - } - - func presentPreviousSuggestion(editor: EditorContent) async throws -> UpdatedContent? { - let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) - workspace.selectPreviousSuggestion(forFileAt: fileURL) - - let presenter = PresentInCommentSuggestionPresenter() - return try await presenter.presentSuggestion( - for: filespace, - in: workspace, - originalContent: editor.content, - lines: editor.lines, - cursorPosition: editor.cursorPosition - ) - } - - func rejectSuggestion(editor: EditorContent) async throws -> UpdatedContent? { - let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) - workspace.rejectSuggestion(forFileAt: fileURL, editor: editor) - - let presenter = PresentInCommentSuggestionPresenter() - return try await presenter.discardSuggestion( - for: filespace, - in: workspace, - originalContent: editor.content, - lines: editor.lines, - cursorPosition: editor.cursorPosition - ) - } - - func acceptSuggestion(editor: EditorContent) async throws -> UpdatedContent? { - let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, _) = try await Workspace.fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) - - guard let acceptedSuggestion = workspace.acceptSuggestion( - forFileAt: fileURL, - editor: editor - ) - else { return nil } - - let injector = SuggestionInjector() - var lines = editor.lines - var cursorPosition = editor.cursorPosition - var extraInfo = SuggestionInjector.ExtraInfo() - injector.rejectCurrentSuggestions( - from: &lines, - cursorPosition: &cursorPosition, - extraInfo: &extraInfo - ) - injector.acceptSuggestion( - intoContentWithoutSuggestion: &lines, - cursorPosition: &cursorPosition, - completion: acceptedSuggestion, - extraInfo: &extraInfo - ) - - return .init( - content: String(lines.joined(separator: "")), - newSelection: .cursor(cursorPosition), - modifications: extraInfo.modifications - ) - } - - func presentRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? { - defer { - if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] != "YES" { - Task { - await GraphicalUserInterfaceController.shared.realtimeSuggestionIndicatorController - .endPrefetchAnimation() - } - } - } - - let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) - - try Task.checkCancellation() - - let snapshot = Filespace.Snapshot( - linesHash: editor.lines.hashValue, - cursorPosition: editor.cursorPosition - ) - - // If the generated suggestions are for this editor content, present it. - guard filespace.suggestionSourceSnapshot == snapshot else { return nil } - - let presenter = PresentInCommentSuggestionPresenter() - - return try await presenter.presentSuggestion( - for: filespace, - in: workspace, - originalContent: editor.content, - lines: editor.lines, - cursorPosition: editor.cursorPosition - ) - } - - func generateRealtimeSuggestions(editor: EditorContent) async throws -> UpdatedContent? { - let fileURL = try await Environment.fetchCurrentFileURL() - let (workspace, filespace) = try await Workspace - .fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) - - try Task.checkCancellation() - - let snapshot = Filespace.Snapshot( - linesHash: editor.lines.hashValue, - cursorPosition: editor.cursorPosition - ) - - // There is no need to regenerate suggestions for the same editor content. - guard filespace.suggestionSourceSnapshot != snapshot else { return nil } - - let suggestions = try await workspace.generateSuggestions( - forFileAt: fileURL, - editor: editor - ) - - try Task.checkCancellation() - - // If there is a suggestion available, call another command to present it. - guard !suggestions.isEmpty else { return nil } - if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return nil } - try await Environment.triggerAction("Real-time Suggestions") - await GraphicalUserInterfaceController.shared.realtimeSuggestionIndicatorController - .triggerPrefetchAnimation() - - return nil - } - - func chatWithSelection(editor: EditorContent) async throws -> UpdatedContent? { - throw NotSupportedInCommentMode() - } - - func promptToCode(editor: XPCShared.EditorContent) async throws -> XPCShared.UpdatedContent? { - throw NotSupportedInCommentMode() - } - - func customCommand(id: String, editor: EditorContent) async throws -> UpdatedContent? { - throw NotSupportedInCommentMode() - } -} - -// MARK: - Unsupported - -extension CommentBaseCommandHandler { - struct NotSupportedInCommentMode: Error, LocalizedError { - var errorDescription: String { "This command is not supported in comment mode." } - } -} From e5f560061c5e03a6cd954536bfcaf4a13364add8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 22 Jun 2023 21:50:19 +0800 Subject: [PATCH 12/35] More on remove comment mode --- .../HostApp/FeatureSettings/SuggestionSettingsView.swift | 4 ++-- .../GUI/GraphicalUserInterfaceController.swift.swift | 1 - .../SuggestionWidget/SuggestionWidgetController.swift | 9 --------- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift index 3af60c3d..75efee3a 100644 --- a/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettings/SuggestionSettingsView.swift @@ -32,8 +32,8 @@ struct SuggestionSettingsView: View { Picker(selection: $settings.suggestionPresentationMode) { ForEach(PresentationMode.allCases, id: \.rawValue) { switch $0 { - case .comment: - Text("Comment (Deprecating Soon)").tag($0) + case .nearbyTextCursor: + Text("Nearby Text Cursor").tag($0) case .floatingWidget: Text("Floating Widget").tag($0) } diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift index 0acbdc85..bcc0ec4f 100644 --- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift +++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift @@ -5,7 +5,6 @@ import SuggestionWidget @MainActor public final class GraphicalUserInterfaceController { public nonisolated static let shared = GraphicalUserInterfaceController() - nonisolated let realtimeSuggestionIndicatorController = RealtimeSuggestionIndicatorController() nonisolated let suggestionWidget = SuggestionWidgetController() private nonisolated init() { Task { @MainActor in diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 8c9b458f..1357f832 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -474,15 +474,6 @@ extension SuggestionWidgetController { /// - note: It's possible to get the scroll view's position by getting position on the focus /// element. private func updateWindowLocation(animated: Bool = false) { - guard UserDefaults.shared.value(for: \.suggestionPresentationMode) == .floatingWidget - else { - panelWindow.alphaValue = 0 - widgetWindow.alphaValue = 0 - tabWindow.alphaValue = 0 - chatWindow.alphaValue = 0 - return - } - let detachChat = chatWindowViewModel.chatPanelInASeparateWindow if let widgetFrames = { From ada1289374a0e874f29076ba953395cf93a6b6df Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 22 Jun 2023 21:52:31 +0800 Subject: [PATCH 13/35] Support nearby text cursor when completion panel is not visible --- Core/Sources/SuggestionWidget/Styles.swift | 1 + .../SuggestionWidgetController.swift | 199 ++++++++++++------ .../WidgetPositionStrategy.swift | 176 ++++++++++++++-- 3 files changed, 285 insertions(+), 91 deletions(-) diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index bd1f84b3..8df70e73 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -5,6 +5,7 @@ import SwiftUI enum Style { static let panelHeight: Double = 500 static let panelWidth: Double = 454 + static let inlineSuggestionMinWidth: Double = 540 static let widgetHeight: Double = 24 static var widgetWidth: Double { widgetHeight } static let widgetPadding: Double = 4 diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 1357f832..cff296ab 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -476,80 +476,32 @@ extension SuggestionWidgetController { private func updateWindowLocation(animated: Bool = false) { let detachChat = chatWindowViewModel.chatPanelInASeparateWindow - if let widgetFrames = { - if let application = XcodeInspector.shared.latestActiveXcode?.appElement { - if let focusElement = application.focusedElement, - focusElement.description == "Source Editor", - let parent = focusElement.parent, - let frame = parent.rect, - let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), - let firstScreen = NSScreen.main - { - let mode = UserDefaults.shared.value(for: \.suggestionWidgetPositionMode) - switch mode { - case .fixedToBottom: - return UpdateLocationStrategy.FixedToBottom().framesForWindows( - editorFrame: frame, - mainScreen: screen, - activeScreen: firstScreen - ) - case .alignToTextCursor: - return UpdateLocationStrategy.AlignToTextCursor().framesForWindows( - editorFrame: frame, - mainScreen: screen, - activeScreen: firstScreen, - editor: focusElement - ) - } - } else if var window = application.focusedWindow, - var frame = application.focusedWindow?.rect, - !["menu bar", "menu bar item"].contains(window.description), - frame.size.height > 300, - let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), - let firstScreen = NSScreen.main - { - if ["open_quickly"].contains(window.identifier) - || ["alert"].contains(window.label) - { - // fallback to use workspace window - guard let workspaceWindow = application.windows - .first(where: { $0.identifier == "Xcode.WorkspaceWindow" }), - let rect = workspaceWindow.rect - else { return (.zero, .zero, .zero, false) } - - window = workspaceWindow - frame = rect - } - - if ["Xcode.WorkspaceWindow"].contains(window.identifier) { - // extra padding to bottom so buttons won't be covered - frame.size.height -= 40 - } else { - // move a bit away from the window so buttons won't be covered - frame.origin.x -= Style.widgetPadding + Style.widgetWidth / 2 - frame.size.width += Style.widgetPadding * 2 + Style.widgetWidth - } - - return UpdateLocationStrategy.FixedToBottom().framesForWindows( - editorFrame: frame, - mainScreen: screen, - activeScreen: firstScreen, - preferredInsideEditorMinWidth: 9_999_999_999 // never - ) - } - } - return nil - }() { - widgetWindow.setFrame(widgetFrames.widgetFrame, display: false, animate: animated) - panelWindow.setFrame(widgetFrames.panelFrame, display: false, animate: animated) - tabWindow.setFrame(widgetFrames.tabFrame, display: false, animate: animated) - suggestionPanelViewModel.alignTopToAnchor = widgetFrames.alignPanelTopToAnchor + if let widgetLocation = generateWidgetLocation() { + widgetWindow.setFrame(widgetLocation.widgetFrame, display: false, animate: animated) + tabWindow.setFrame(widgetLocation.tabFrame, display: false, animate: animated) + panelWindow.setFrame( + (widgetLocation.suggestionPanelLocation ?? widgetLocation.defaultPanelLocation) + .frame, + display: false, + animate: animated + ) + suggestionPanelViewModel.alignTopToAnchor = ( + widgetLocation.suggestionPanelLocation ?? widgetLocation.defaultPanelLocation + ).alignPanelTop if detachChat { if chatWindow.alphaValue == 0 { - chatWindow.setFrame(panelWindow.frame, display: false, animate: animated) + chatWindow.setFrame( + widgetLocation.defaultPanelLocation.frame, + display: false, + animate: animated + ) } } else { - chatWindow.setFrame(panelWindow.frame, display: false, animate: animated) + chatWindow.setFrame( + widgetLocation.defaultPanelLocation.frame, + display: false, + animate: animated + ) } } @@ -622,8 +574,113 @@ extension SuggestionWidgetController { suggestionPanelViewModel.content = nil } } + + private func generateWidgetLocation() -> WidgetLocation? { + if let application = XcodeInspector.shared.latestActiveXcode?.appElement { + if let focusElement = application.focusedElement, + focusElement.description == "Source Editor", + let parent = focusElement.parent, + let frame = parent.rect, + let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), + let firstScreen = NSScreen.main + { + let positionMode = UserDefaults.shared + .value(for: \.suggestionWidgetPositionMode) + let suggestionMode = UserDefaults.shared + .value(for: \.suggestionPresentationMode) + + switch positionMode { + case .fixedToBottom: + var result = UpdateLocationStrategy.FixedToBottom().framesForWindows( + editorFrame: frame, + mainScreen: screen, + activeScreen: firstScreen + ) + switch suggestionMode { + case .nearbyTextCursor: + result.suggestionPanelLocation = UpdateLocationStrategy + .NearbyTextCursor() + .framesForSuggestionWindow( + editorFrame: frame, mainScreen: screen, + activeScreen: firstScreen, + editor: focusElement, + completionPanel: XcodeInspector.shared.completionPanel + ) + default: + break + } + return result + case .alignToTextCursor: + var result = UpdateLocationStrategy.AlignToTextCursor().framesForWindows( + editorFrame: frame, + mainScreen: screen, + activeScreen: firstScreen, + editor: focusElement + ) + switch suggestionMode { + case .nearbyTextCursor: + result.suggestionPanelLocation = UpdateLocationStrategy + .NearbyTextCursor() + .framesForSuggestionWindow( + editorFrame: frame, mainScreen: screen, + activeScreen: firstScreen, + editor: focusElement, + completionPanel: XcodeInspector.shared.completionPanel + ) + default: + break + } + return result + } + } else if var window = application.focusedWindow, + var frame = application.focusedWindow?.rect, + !["menu bar", "menu bar item"].contains(window.description), + frame.size.height > 300, + let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), + let firstScreen = NSScreen.main + { + if ["open_quickly"].contains(window.identifier) + || ["alert"].contains(window.label) + { + // fallback to use workspace window + guard let workspaceWindow = application.windows + .first(where: { $0.identifier == "Xcode.WorkspaceWindow" }), + let rect = workspaceWindow.rect + else { + return WidgetLocation( + widgetFrame: .zero, + tabFrame: .zero, + defaultPanelLocation: .init(frame: .zero, alignPanelTop: false) + ) + } + + window = workspaceWindow + frame = rect + } + + if ["Xcode.WorkspaceWindow"].contains(window.identifier) { + // extra padding to bottom so buttons won't be covered + frame.size.height -= 40 + } else { + // move a bit away from the window so buttons won't be covered + frame.origin.x -= Style.widgetPadding + Style.widgetWidth / 2 + frame.size.width += Style.widgetPadding * 2 + Style.widgetWidth + } + + return UpdateLocationStrategy.FixedToBottom().framesForWindows( + editorFrame: frame, + mainScreen: screen, + activeScreen: firstScreen, + preferredInsideEditorMinWidth: 9_999_999_999 // never + ) + } + } + return nil + } } +// MARK: - NSWindowDelegate + extension SuggestionWidgetController: NSWindowDelegate { public func windowWillMove(_ notification: Notification) { guard (notification.object as? NSWindow) === chatWindow else { return } @@ -659,6 +716,8 @@ extension SuggestionWidgetController: NSWindowDelegate { } } +// MARK: - Window Subclasses + class CanBecomeKeyWindow: NSWindow { var canBecomeKeyChecker: () -> Bool = { true } override var canBecomeKey: Bool { canBecomeKeyChecker() } diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index 7909a393..02d2c11d 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -1,6 +1,18 @@ import AppKit import Foundation +struct WidgetLocation { + struct PanelLocation { + var frame: CGRect + var alignPanelTop: Bool + } + + var widgetFrame: CGRect + var tabFrame: CGRect + var defaultPanelLocation: PanelLocation + var suggestionPanelLocation: PanelLocation? +} + enum UpdateLocationStrategy { struct AlignToTextCursor { func framesForWindows( @@ -10,12 +22,7 @@ enum UpdateLocationStrategy { editor: AXUIElement, preferredInsideEditorMinWidth: Double = UserDefaults.shared .value(for: \.preferWidgetToStayInsideEditorWhenWidthGreaterThan) - ) -> ( - widgetFrame: CGRect, - panelFrame: CGRect, - tabFrame: CGRect, - alignPanelTopToAnchor: Bool - ) { + ) -> WidgetLocation { guard let selectedRange: AXValue = try? editor .copyValue(key: kAXSelectedTextRangeAttribute), let rect: AXValue = try? editor.copyParameterizedValue( @@ -56,12 +63,7 @@ enum UpdateLocationStrategy { activeScreen: NSScreen, preferredInsideEditorMinWidth: Double = UserDefaults.shared .value(for: \.preferWidgetToStayInsideEditorWhenWidthGreaterThan) - ) -> ( - widgetFrame: CGRect, - panelFrame: CGRect, - tabFrame: CGRect, - alignPanelTopToAnchor: Bool - ) { + ) -> WidgetLocation { return HorizontalMovable().framesForWindows( y: mainScreen.frame.height - editorFrame.maxY + Style.widgetPadding, alignPanelTopToAnchor: false, @@ -81,12 +83,7 @@ enum UpdateLocationStrategy { mainScreen: NSScreen, activeScreen: NSScreen, preferredInsideEditorMinWidth: Double - ) -> ( - widgetFrame: CGRect, - panelFrame: CGRect, - tabFrame: CGRect, - alignPanelTopToAnchor: Bool - ) { + ) -> WidgetLocation { let maxY = max( y, mainScreen.frame.height - editorFrame.maxY + Style.widgetPadding, @@ -132,7 +129,15 @@ enum UpdateLocationStrategy { height: Style.widgetHeight ) - return (anchorFrame, panelFrame, tabFrame, alignPanelTopToAnchor) + return .init( + widgetFrame: anchorFrame, + tabFrame: tabFrame, + defaultPanelLocation: .init( + frame: panelFrame, + alignPanelTop: alignPanelTopToAnchor + ), + suggestionPanelLocation: nil + ) } else { let proposedAnchorFrameOnTheLeftSide = CGRect( x: editorFrame.minX + Style.widgetPadding, @@ -169,7 +174,15 @@ enum UpdateLocationStrategy { width: Style.widgetWidth, height: Style.widgetHeight ) - return (anchorFrame, panelFrame, tabFrame, alignPanelTopToAnchor) + return .init( + widgetFrame: anchorFrame, + tabFrame: tabFrame, + defaultPanelLocation: .init( + frame: panelFrame, + alignPanelTop: alignPanelTopToAnchor + ), + suggestionPanelLocation: nil + ) } else { let anchorFrame = proposedAnchorFrameOnTheRightSide let panelFrame = CGRect( @@ -186,9 +199,130 @@ enum UpdateLocationStrategy { width: Style.widgetWidth, height: Style.widgetHeight ) - return (anchorFrame, panelFrame, tabFrame, alignPanelTopToAnchor) + return .init( + widgetFrame: anchorFrame, + tabFrame: tabFrame, + defaultPanelLocation: .init( + frame: panelFrame, + alignPanelTop: alignPanelTopToAnchor + ), + suggestionPanelLocation: nil + ) } } } } + + struct NearbyTextCursor { + func framesForSuggestionWindow( + editorFrame: CGRect, + mainScreen: NSScreen, + activeScreen: NSScreen, + editor: AXUIElement, + completionPanel: AXUIElement? + ) -> WidgetLocation.PanelLocation? { + guard let selectionFrame = UpdateLocationStrategy + .getSelectionFirstLineFrame(editor: editor) else { return nil } + + let proposedY = mainScreen.frame.height - selectionFrame.maxY + let proposedX = selectionFrame.maxX - 40 + let maxY = max( + proposedY, + mainScreen.frame.height - editorFrame.maxY, + 4 + activeScreen.frame.minY + ) + let y = min( + maxY, + activeScreen.frame.maxY - 4, + mainScreen.frame.height - editorFrame.minY + ) + print(y, activeScreen.frame.minY, activeScreen.frame.maxY) + let alignPanelTopToAnchor = y - Style.panelHeight >= activeScreen.frame.minY + + if let completionPanel, let completionPanelRect = completionPanel.rect { + return .init( + frame: completionPanelRect, + alignPanelTop: alignPanelTopToAnchor + ) + } else { + if alignPanelTopToAnchor { + // case: present below selection + return .init( + frame: .init( + x: proposedX, + y: y - Style.panelHeight, + width: Style.inlineSuggestionMinWidth, + height: Style.panelHeight + ), + alignPanelTop: alignPanelTopToAnchor + ) + } else { + // case: present above selection + return .init( + frame: .init( + x: proposedX, + y: y + selectionFrame.height, + width: Style.inlineSuggestionMinWidth, + height: Style.panelHeight + ), + alignPanelTop: alignPanelTopToAnchor + ) + } + } + } + } + + /// Get the frame of the selection. + static func getSelectionFrame(editor: AXUIElement) -> CGRect? { + guard let selectedRange: AXValue = try? editor + .copyValue(key: kAXSelectedTextRangeAttribute), + let rect: AXValue = try? editor.copyParameterizedValue( + key: kAXBoundsForRangeParameterizedAttribute, + parameters: selectedRange + ) + else { + return nil + } + var selectionFrame: CGRect = .zero + let found = AXValueGetValue(rect, .cgRect, &selectionFrame) + guard found else { return nil } + return selectionFrame + } + + /// Get the frame of the first line of the selection. + static func getSelectionFirstLineFrame(editor: AXUIElement) -> CGRect? { + // Find selection range rect + guard let selectedRange: AXValue = try? editor + .copyValue(key: kAXSelectedTextRangeAttribute), + let rect: AXValue = try? editor.copyParameterizedValue( + key: kAXBoundsForRangeParameterizedAttribute, + parameters: selectedRange + ) + else { + return nil + } + var selectionFrame: CGRect = .zero + let found = AXValueGetValue(rect, .cgRect, &selectionFrame) + guard found else { return nil } + + var firstLineRange: CFRange = .init() + let foundFirstLine = AXValueGetValue(selectedRange, .cfRange, &firstLineRange) + firstLineRange.length = 0 + if foundFirstLine, + let firstLineSelectionRange = AXValueCreate(.cfRange, &firstLineRange), + let firstLineRect: AXValue = try? editor.copyParameterizedValue( + key: kAXBoundsForRangeParameterizedAttribute, + parameters: firstLineSelectionRange + ) + { + var firstLineFrame: CGRect = .zero + let foundFirstLineFrame = AXValueGetValue(firstLineRect, .cgRect, &firstLineFrame) + if foundFirstLineFrame { + selectionFrame = firstLineFrame + } + } + + return selectionFrame + } } + From c55fbaa9d6800ab12d741f9d0ec5b89efa1e51e0 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 22 Jun 2023 23:35:52 +0800 Subject: [PATCH 14/35] Support displaying next to the completion panel --- .../WidgetPositionStrategy.swift | 80 ++++++++++++++----- 1 file changed, 62 insertions(+), 18 deletions(-) diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index 02d2c11d..d5afd024 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -228,47 +228,91 @@ enum UpdateLocationStrategy { let proposedX = selectionFrame.maxX - 40 let maxY = max( proposedY, - mainScreen.frame.height - editorFrame.maxY, 4 + activeScreen.frame.minY ) let y = min( maxY, - activeScreen.frame.maxY - 4, - mainScreen.frame.height - editorFrame.minY + activeScreen.frame.maxY - 4 ) - print(y, activeScreen.frame.minY, activeScreen.frame.maxY) - let alignPanelTopToAnchor = y - Style.panelHeight >= activeScreen.frame.minY - if let completionPanel, let completionPanelRect = completionPanel.rect { - return .init( - frame: completionPanelRect, - alignPanelTop: alignPanelTopToAnchor - ) - } else { + let alignPanelTopToAnchor = y - Style.inlineSuggestionMaxWidth >= activeScreen.frame.minY + + let caseIgnoreCompletionPanel = { + (alignPanelTopToAnchor: Bool) -> WidgetLocation.PanelLocation? in + let x: Double = { + if proposedX + Style.inlineSuggestionMinWidth <= activeScreen.frame.maxX { + return proposedX + } + return activeScreen.frame.maxX - Style.inlineSuggestionMinWidth + }() if alignPanelTopToAnchor { // case: present below selection return .init( frame: .init( - x: proposedX, - y: y - Style.panelHeight, + x: x, + y: y - Style.inlineSuggestionMaxWidth, width: Style.inlineSuggestionMinWidth, - height: Style.panelHeight + height: Style.inlineSuggestionMaxWidth ), alignPanelTop: alignPanelTopToAnchor ) } else { - // case: present above selection return .init( frame: .init( - x: proposedX, - y: y + selectionFrame.height, + x: x, + y: y + selectionFrame.height - Style.widgetPadding, width: Style.inlineSuggestionMinWidth, - height: Style.panelHeight + height: Style.inlineSuggestionMaxWidth ), alignPanelTop: alignPanelTopToAnchor ) } } + + let caseConsiderCompletionPanel = { + (completionPanelRect: CGRect) -> WidgetLocation.PanelLocation? in + let completionPanelBelowCursor = completionPanelRect.minY >= selectionFrame.midY + + switch (completionPanelBelowCursor, alignPanelTopToAnchor) { + case (true, false), (false, true): + return caseIgnoreCompletionPanel(alignPanelTopToAnchor) + case (true, true), (false, false): + let y = completionPanelBelowCursor + ? y - Style.inlineSuggestionMaxWidth + : y + selectionFrame.height - Style.widgetPadding + if let x = { + let proposedX = completionPanelRect.maxX + Style.widgetPadding + if proposedX + Style.inlineSuggestionMinWidth <= activeScreen.frame.maxX { + return proposedX + } + let leftSideX = completionPanelRect.minX + - Style.widgetPadding + - Style.inlineSuggestionMinWidth + if leftSideX >= activeScreen.frame.minX { + return leftSideX + } + return nil + }() { + print(mainScreen.frame, completionPanelRect, y) + return .init( + frame: .init( + x: x, + y: y, + width: Style.inlineSuggestionMinWidth, + height: Style.inlineSuggestionMaxWidth + ), + alignPanelTop: alignPanelTopToAnchor + ) + } + return caseIgnoreCompletionPanel(!alignPanelTopToAnchor) + } + } + + if let completionPanel, let completionPanelRect = completionPanel.rect { + return caseConsiderCompletionPanel(completionPanelRect) + } else { + return caseIgnoreCompletionPanel(alignPanelTopToAnchor) + } } } From ce262fcdcfd3ce1c0bbfbcdcb18b8db584359a1a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 22 Jun 2023 23:36:00 +0800 Subject: [PATCH 15/35] Adjust size --- Core/Sources/SuggestionWidget/Styles.swift | 1 + .../SuggestionWidget/WidgetPositionStrategy.swift | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index 8df70e73..ba9a4798 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -6,6 +6,7 @@ enum Style { static let panelHeight: Double = 500 static let panelWidth: Double = 454 static let inlineSuggestionMinWidth: Double = 540 + static let inlineSuggestionMaxHeight: Double = 400 static let widgetHeight: Double = 24 static var widgetWidth: Double { widgetHeight } static let widgetPadding: Double = 4 diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index d5afd024..6b97b31d 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -235,7 +235,7 @@ enum UpdateLocationStrategy { activeScreen.frame.maxY - 4 ) - let alignPanelTopToAnchor = y - Style.inlineSuggestionMaxWidth >= activeScreen.frame.minY + let alignPanelTopToAnchor = y - Style.inlineSuggestionMaxHeight >= activeScreen.frame.minY let caseIgnoreCompletionPanel = { (alignPanelTopToAnchor: Bool) -> WidgetLocation.PanelLocation? in @@ -250,9 +250,9 @@ enum UpdateLocationStrategy { return .init( frame: .init( x: x, - y: y - Style.inlineSuggestionMaxWidth, + y: y - Style.inlineSuggestionMaxHeight, width: Style.inlineSuggestionMinWidth, - height: Style.inlineSuggestionMaxWidth + height: Style.inlineSuggestionMaxHeight ), alignPanelTop: alignPanelTopToAnchor ) @@ -262,7 +262,7 @@ enum UpdateLocationStrategy { x: x, y: y + selectionFrame.height - Style.widgetPadding, width: Style.inlineSuggestionMinWidth, - height: Style.inlineSuggestionMaxWidth + height: Style.inlineSuggestionMaxHeight ), alignPanelTop: alignPanelTopToAnchor ) @@ -278,7 +278,7 @@ enum UpdateLocationStrategy { return caseIgnoreCompletionPanel(alignPanelTopToAnchor) case (true, true), (false, false): let y = completionPanelBelowCursor - ? y - Style.inlineSuggestionMaxWidth + ? y - Style.inlineSuggestionMaxHeight : y + selectionFrame.height - Style.widgetPadding if let x = { let proposedX = completionPanelRect.maxX + Style.widgetPadding @@ -299,7 +299,7 @@ enum UpdateLocationStrategy { x: x, y: y, width: Style.inlineSuggestionMinWidth, - height: Style.inlineSuggestionMaxWidth + height: Style.inlineSuggestionMaxHeight ), alignPanelTop: alignPanelTopToAnchor ) From bea3627dc2b393ad94af79cc59076e2b5e5ec816 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 22 Jun 2023 23:37:31 +0800 Subject: [PATCH 16/35] Display inline suggestions in a separate window --- .../SuggestionWidget/SharedPanelView.swift | 190 ++++++++++++++++++ .../SuggestionPanelContent/ErrorPanel.swift | 5 +- .../SuggestionPanelView.swift | 144 +++---------- .../SuggestionWidgetController.swift | 113 ++++++++--- .../Sources/SuggestionWidget/WidgetView.swift | 17 +- .../Sources/XcodeInspector/SourceEditor.swift | 2 +- 6 files changed, 320 insertions(+), 151 deletions(-) create mode 100644 Core/Sources/SuggestionWidget/SharedPanelView.swift diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift new file mode 100644 index 00000000..55367db6 --- /dev/null +++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift @@ -0,0 +1,190 @@ +import Environment +import Preferences +import SwiftUI + +@MainActor +final class SharedPanelViewModel: ObservableObject { + enum Content { + case suggestion(SuggestionProvider) + case promptToCode(PromptToCodeProvider) + case error(String) + + var contentHash: String { + switch self { + case let .error(e): + return "error: \(e)" + case let .suggestion(provider): + return "suggestion: \(provider.code.hashValue)" + case let .promptToCode(provider): + return "provider: \(provider.id)" + } + } + } + + @Published var content: Content? + @Published var colorScheme: ColorScheme + + init( + content: Content? = nil, + colorScheme: ColorScheme = .dark + ) { + self.content = content + self.colorScheme = colorScheme + } +} + +@MainActor +final class SharedPanelDisplayController: ObservableObject { + @Published var alignTopToAnchor = false + @Published var isPanelDisplayed: Bool = false + + init( + alignTopToAnchor: Bool = false, + isPanelDisplayed: Bool = false + ) { + self.alignTopToAnchor = alignTopToAnchor + self.isPanelDisplayed = isPanelDisplayed + } +} + +extension View { + @ViewBuilder + func animation( + featureFlag: KeyPath, + _ animation: Animation?, + value: V + ) -> some View { + let isOn = UserDefaults.shared.value(for: featureFlag) + if isOn { + self.animation(animation, value: value) + } else { + self + } + } +} + +struct SharedPanelView: View { + @ObservedObject var viewModel: SharedPanelViewModel + @ObservedObject var displayController: SharedPanelDisplayController + @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode + + var body: some View { + VStack(spacing: 0) { + if !displayController.alignTopToAnchor { + Spacer() + .frame(minHeight: 0, maxHeight: .infinity) + .allowsHitTesting(false) + } + + VStack { + if let content = viewModel.content { + ZStack(alignment: .topLeading) { + switch content { + case let .suggestion(suggestion): + switch suggestionPresentationMode { + case .nearbyTextCursor: + EmptyView() + case .floatingWidget: + CodeBlockSuggestionPanel(suggestion: suggestion) + } + case let .promptToCode(provider): + PromptToCodePanel(provider: provider) + case let .error(description): + ErrorPanel( + viewModel: viewModel, + displayController: displayController, + description: description + ) + } + } + .frame(maxWidth: .infinity, maxHeight: Style.panelHeight) + .fixedSize(horizontal: false, vertical: true) + .allowsHitTesting(displayController.isPanelDisplayed) + } + } + .frame(maxWidth: .infinity) + + if displayController.alignTopToAnchor { + Spacer() + .frame(minHeight: 0, maxHeight: .infinity) + .allowsHitTesting(false) + } + } + .preferredColorScheme(viewModel.colorScheme) + .opacity({ + guard displayController.isPanelDisplayed else { return 0 } + guard viewModel.content != nil else { return 0 } + return 1 + }()) + .animation( + featureFlag: \.animationACrashSuggestion, + .easeInOut(duration: 0.2), + value: viewModel.content?.contentHash + ) + .animation( + featureFlag: \.animationBCrashSuggestion, + .easeInOut(duration: 0.2), + value: displayController.isPanelDisplayed + ) + .frame(maxWidth: Style.panelWidth, maxHeight: Style.panelHeight) + } +} + +struct CommandButtonStyle: ButtonStyle { + let color: Color + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.vertical, 4) + .padding(.horizontal, 8) + .foregroundColor(.white) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(color.opacity(configuration.isPressed ? 0.8 : 1)) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + ) + .overlay { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1)) + } + } +} + +// MARK: - Previews + +struct SuggestionPanelView_Error_Preview: PreviewProvider { + static var previews: some View { + SharedPanelView(viewModel: .init( + content: .error("This is an error\nerror") + ), displayController: .init(isPanelDisplayed: true)) + .frame(width: 450, height: 200) + } +} + +struct SuggestionPanelView_Both_DisplayingSuggestion_Preview: PreviewProvider { + static var previews: some View { + SharedPanelView(viewModel: .init( + content: .suggestion(SuggestionProvider( + code: """ + - (void)addSubview:(UIView *)view { + [self addSubview:view]; + } + """, + language: "objective-c", + startLineIndex: 8, + suggestionCount: 2, + currentSuggestionIndex: 0 + )), + colorScheme: .dark + ), displayController: .init(isPanelDisplayed: true)) + .frame(width: 450, height: 200) + .background { + HStack { + Color.red + Color.green + Color.blue + } + } + } +} + diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift index e16f0c56..a0750523 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ErrorPanel.swift @@ -1,7 +1,8 @@ import SwiftUI struct ErrorPanel: View { - var viewModel: SuggestionPanelViewModel + var viewModel: SharedPanelViewModel + var displayController: SharedPanelDisplayController var description: String var body: some View { @@ -15,7 +16,7 @@ struct ErrorPanel: View { // close button Button(action: { - viewModel.isPanelDisplayed = false + displayController.isPanelDisplayed = false viewModel.content = nil }) { Image(systemName: "xmark") diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift index 2eea432d..36d1c8e8 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift @@ -1,64 +1,28 @@ -import Environment -import Preferences +import Foundation import SwiftUI @MainActor -final class SuggestionPanelViewModel: ObservableObject { - enum Content { - case suggestion(SuggestionProvider) - case promptToCode(PromptToCodeProvider) - case error(String) - - var contentHash: String { - switch self { - case let .error(e): - return "error: \(e)" - case let .suggestion(provider): - return "suggestion: \(provider.code.hashValue)" - case let .promptToCode(provider): - return "provider: \(provider.id)" - } - } - } - - @Published var content: Content? - @Published var isPanelDisplayed: Bool +final class SuggestionPanelDisplayController: ObservableObject { @Published var alignTopToAnchor = false - @Published var colorScheme: ColorScheme + @Published var isPanelDisplayed: Bool = false - public init( - content: Content? = nil, - isPanelDisplayed: Bool = false, - colorScheme: ColorScheme = .dark + init( + alignTopToAnchor: Bool = false, + isPanelDisplayed: Bool = false ) { - self.content = content + self.alignTopToAnchor = alignTopToAnchor self.isPanelDisplayed = isPanelDisplayed - self.colorScheme = colorScheme - } -} - -extension View { - @ViewBuilder - func animation( - featureFlag: KeyPath, - _ animation: Animation?, - value: V - ) -> some View { - let isOn = UserDefaults.shared.value(for: featureFlag) - if isOn { - self.animation(animation, value: value) - } else { - self - } } } struct SuggestionPanelView: View { - @ObservedObject var viewModel: SuggestionPanelViewModel + @ObservedObject var viewModel: SharedPanelViewModel + @ObservedObject var displayController: SuggestionPanelDisplayController + @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode var body: some View { VStack(spacing: 0) { - if !viewModel.alignTopToAnchor { + if !displayController.alignTopToAnchor { Spacer() .frame(minHeight: 0, maxHeight: .infinity) .allowsHitTesting(false) @@ -69,21 +33,24 @@ struct SuggestionPanelView: View { ZStack(alignment: .topLeading) { switch content { case let .suggestion(suggestion): - CodeBlockSuggestionPanel(suggestion: suggestion) - case let .promptToCode(provider): - PromptToCodePanel(provider: provider) - case let .error(description): - ErrorPanel(viewModel: viewModel, description: description) + switch suggestionPresentationMode { + case .nearbyTextCursor: + CodeBlockSuggestionPanel(suggestion: suggestion) + case .floatingWidget: + EmptyView() + } + default: + EmptyView() } } - .frame(maxWidth: .infinity, maxHeight: Style.panelHeight) + .frame(maxWidth: .infinity, maxHeight: Style.inlineSuggestionMaxHeight) .fixedSize(horizontal: false, vertical: true) - .allowsHitTesting(viewModel.isPanelDisplayed) + .allowsHitTesting(displayController.isPanelDisplayed) } } .frame(maxWidth: .infinity) - if viewModel.alignTopToAnchor { + if displayController.alignTopToAnchor { Spacer() .frame(minHeight: 0, maxHeight: .infinity) .allowsHitTesting(false) @@ -91,7 +58,7 @@ struct SuggestionPanelView: View { } .preferredColorScheme(viewModel.colorScheme) .opacity({ - guard viewModel.isPanelDisplayed else { return 0 } + guard displayController.isPanelDisplayed else { return 0 } guard viewModel.content != nil else { return 0 } return 1 }()) @@ -103,69 +70,8 @@ struct SuggestionPanelView: View { .animation( featureFlag: \.animationBCrashSuggestion, .easeInOut(duration: 0.2), - value: viewModel.isPanelDisplayed + value: displayController.isPanelDisplayed ) - .frame(maxWidth: Style.panelWidth, maxHeight: Style.panelHeight) + .frame(maxWidth: Style.inlineSuggestionMinWidth, maxHeight: Style.inlineSuggestionMaxHeight) } } - -struct CommandButtonStyle: ButtonStyle { - let color: Color - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .padding(.vertical, 4) - .padding(.horizontal, 8) - .foregroundColor(.white) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(color.opacity(configuration.isPressed ? 0.8 : 1)) - .animation(.easeOut(duration: 0.1), value: configuration.isPressed) - ) - .overlay { - RoundedRectangle(cornerRadius: 4, style: .continuous) - .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1)) - } - } -} - -// MARK: - Previews - -struct SuggestionPanelView_Error_Preview: PreviewProvider { - static var previews: some View { - SuggestionPanelView(viewModel: .init( - content: .error("This is an error\nerror"), - isPanelDisplayed: true - )) - .frame(width: 450, height: 200) - } -} - -struct SuggestionPanelView_Both_DisplayingSuggestion_Preview: PreviewProvider { - static var previews: some View { - SuggestionPanelView(viewModel: .init( - content: .suggestion(SuggestionProvider( - code: """ - - (void)addSubview:(UIView *)view { - [self addSubview:view]; - } - """, - language: "objective-c", - startLineIndex: 8, - suggestionCount: 2, - currentSuggestionIndex: 0 - )), - isPanelDisplayed: true, - colorScheme: .dark - )) - .frame(width: 450, height: 200) - .background { - HStack { - Color.red - Color.green - Color.blue - } - } - } -} - diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index cff296ab..2d61fe16 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -44,8 +44,10 @@ public final class SuggestionWidgetController: NSObject { it.contentView = NSHostingView( rootView: WidgetView( viewModel: widgetViewModel, - panelViewModel: suggestionPanelViewModel, + panelViewModel: sharedPanelViewModel, chatWindowViewModel: chatWindowViewModel, + sharedPanelDisplayController: sharedPanelDisplayController, + suggestionPanelDisplayController: suggestionPanelDisplayController, onOpenChatClicked: { [weak self] in self?.onOpenChatClicked() }, @@ -94,11 +96,41 @@ public final class SuggestionWidgetController: NSObject { it.collectionBehavior = [.fullScreenAuxiliary] it.hasShadow = true it.contentView = NSHostingView( - rootView: SuggestionPanelView(viewModel: suggestionPanelViewModel) + rootView: SharedPanelView( + viewModel: sharedPanelViewModel, + displayController: sharedPanelDisplayController + ) + ) + it.setIsVisible(true) + it.canBecomeKeyChecker = { [sharedPanelViewModel] in + if case .promptToCode = sharedPanelViewModel.content { return true } + return false + } + return it + }() + + private lazy var suggestionWindow = { + let it = CanBecomeKeyWindow( + contentRect: .init(x: 0, y: 0, width: Style.panelWidth, height: Style.panelHeight), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + it.isReleasedWhenClosed = false + it.isOpaque = false + it.backgroundColor = .clear + it.level = .init(NSWindow.Level.floating.rawValue + 1) + it.collectionBehavior = [.fullScreenAuxiliary] + it.hasShadow = true + it.contentView = NSHostingView( + rootView: SuggestionPanelView( + viewModel: sharedPanelViewModel, + displayController: suggestionPanelDisplayController + ) ) it.setIsVisible(true) - it.canBecomeKeyChecker = { [suggestionPanelViewModel] in - if case .promptToCode = suggestionPanelViewModel.content { return true } + it.canBecomeKeyChecker = { [sharedPanelViewModel] in + if case .promptToCode = sharedPanelViewModel.content { return true } return false } return it @@ -126,8 +158,10 @@ public final class SuggestionWidgetController: NSObject { }() let widgetViewModel = WidgetViewModel() - let suggestionPanelViewModel = SuggestionPanelViewModel() + let sharedPanelViewModel = SharedPanelViewModel() let chatWindowViewModel = ChatWindowViewModel() + let sharedPanelDisplayController = SharedPanelDisplayController() + let suggestionPanelDisplayController = SuggestionPanelDisplayController() private var presentationModeChangeObserver = UserDefaultsObserver( object: UserDefaults.shared, @@ -185,6 +219,7 @@ public final class SuggestionWidgetController: NSObject { self.widgetWindow.alphaValue = 0 self.panelWindow.alphaValue = 0 self.tabWindow.alphaValue = 0 + self.suggestionWindow.alphaValue = 0 if !chatWindowViewModel.chatPanelInASeparateWindow { self.chatWindow.alphaValue = 0 } @@ -246,7 +281,7 @@ public final class SuggestionWidgetController: NSObject { return .light } }() - self.suggestionPanelViewModel.colorScheme = self.colorScheme + self.sharedPanelViewModel.colorScheme = self.colorScheme self.chatWindowViewModel.colorScheme = self.colorScheme Task { await self.updateContentForActiveEditor() @@ -261,12 +296,21 @@ public final class SuggestionWidgetController: NSObject { updateColorScheme() } } + + Task { @MainActor in + XcodeInspector.shared.$completionPanel.sink { [weak self] _ in + Task { @MainActor in + self?.updateWindowLocation() + } + }.store(in: &cancellable) + } } func orderFront() { widgetWindow.orderFrontRegardless() tabWindow.orderFrontRegardless() panelWindow.orderFrontRegardless() + suggestionWindow.orderFrontRegardless() chatWindow.orderFrontRegardless() } } @@ -279,8 +323,13 @@ public extension SuggestionWidgetController { markAsProcessing(true) defer { markAsProcessing(false) } if let suggestion = await dataSource?.suggestionForFile(at: fileURL) { - suggestionPanelViewModel.content = .suggestion(suggestion) - suggestionPanelViewModel.isPanelDisplayed = true + sharedPanelViewModel.content = .suggestion(suggestion) + switch UserDefaults.shared.value(for: \.suggestionPresentationMode) { + case .nearbyTextCursor: + suggestionPanelDisplayController.isPanelDisplayed = true + case .floatingWidget: + sharedPanelDisplayController.isPanelDisplayed = true + } } } } @@ -300,8 +349,8 @@ public extension SuggestionWidgetController { } func presentError(_ errorDescription: String) { - suggestionPanelViewModel.content = .error(errorDescription) - suggestionPanelViewModel.isPanelDisplayed = true + sharedPanelViewModel.content = .error(errorDescription) + sharedPanelDisplayController.isPanelDisplayed = true } func presentChatRoom(fileURL: URL) { @@ -357,8 +406,8 @@ public extension SuggestionWidgetController { markAsProcessing(true) defer { markAsProcessing(false) } if let provider = await dataSource?.promptToCodeForFile(at: fileURL) { - suggestionPanelViewModel.content = .promptToCode(provider) - suggestionPanelViewModel.isPanelDisplayed = true + sharedPanelViewModel.content = .promptToCode(provider) + sharedPanelDisplayController.isPanelDisplayed = true Task { @MainActor in // looks like we need a delay. @@ -451,14 +500,14 @@ extension SuggestionWidgetController { scroll ) { guard let self else { return } - guard ActiveApplicationMonitor.activeXcode != nil else { return } + guard ActiveApplicationMonitor.latestXcode != nil else { return } try Task.checkCancellation() self.updateWindowLocation(animated: false) } } else { for await _ in merge(selectionRangeChange, scroll) { guard let self else { return } - guard ActiveApplicationMonitor.activeXcode != nil else { return } + guard ActiveApplicationMonitor.latestXcode != nil else { return } try Task.checkCancellation() let mode = UserDefaults.shared.value(for: \.suggestionWidgetPositionMode) if mode != .alignToTextCursor { break } @@ -480,14 +529,24 @@ extension SuggestionWidgetController { widgetWindow.setFrame(widgetLocation.widgetFrame, display: false, animate: animated) tabWindow.setFrame(widgetLocation.tabFrame, display: false, animate: animated) panelWindow.setFrame( - (widgetLocation.suggestionPanelLocation ?? widgetLocation.defaultPanelLocation) - .frame, + widgetLocation.defaultPanelLocation.frame, + display: false, + animate: animated + ) + + sharedPanelDisplayController.alignTopToAnchor = widgetLocation.defaultPanelLocation + .alignPanelTop + + let suggestionPanelLocation = widgetLocation.suggestionPanelLocation ?? widgetLocation + .defaultPanelLocation + suggestionWindow.setFrame( + suggestionPanelLocation.frame, display: false, animate: animated ) - suggestionPanelViewModel.alignTopToAnchor = ( - widgetLocation.suggestionPanelLocation ?? widgetLocation.defaultPanelLocation - ).alignPanelTop + suggestionPanelDisplayController.alignTopToAnchor = suggestionPanelLocation + .alignPanelTop + if detachChat { if chatWindow.alphaValue == 0 { chatWindow.setFrame( @@ -510,6 +569,7 @@ extension SuggestionWidgetController { /// We need this to hide the windows when Xcode is minimized. let noFocus = application.focusedWindow == nil panelWindow.alphaValue = noFocus ? 0 : 1 + suggestionWindow.alphaValue = noFocus ? 0 : 1 widgetWindow.alphaValue = noFocus ? 0 : 1 tabWindow.alphaValue = noFocus ? 0 : 1 @@ -529,6 +589,7 @@ extension SuggestionWidgetController { }() panelWindow.alphaValue = noFocus ? 0 : 1 + suggestionWindow.alphaValue = noFocus ? 0 : 1 widgetWindow.alphaValue = noFocus ? 0 : 1 tabWindow.alphaValue = noFocus ? 0 : 1 if detachChat { @@ -538,6 +599,7 @@ extension SuggestionWidgetController { } } else { panelWindow.alphaValue = 0 + suggestionWindow.alphaValue = 0 widgetWindow.alphaValue = 0 tabWindow.alphaValue = 0 if !detachChat { @@ -551,7 +613,7 @@ extension SuggestionWidgetController { if let fileURL { return fileURL } return try? await Environment.fetchCurrentFileURL() }() else { - suggestionPanelViewModel.content = nil + sharedPanelViewModel.content = nil chatWindowViewModel.chat = nil return } @@ -565,20 +627,19 @@ extension SuggestionWidgetController { } if let provider = await dataSource?.promptToCodeForFile(at: fileURL) { - if case let .promptToCode(currentProvider) = suggestionPanelViewModel.content, + if case let .promptToCode(currentProvider) = sharedPanelViewModel.content, currentProvider.id == provider.id { return } - suggestionPanelViewModel.content = .promptToCode(provider) + sharedPanelViewModel.content = .promptToCode(provider) } else if let suggestion = await dataSource?.suggestionForFile(at: fileURL) { - suggestionPanelViewModel.content = .suggestion(suggestion) + sharedPanelViewModel.content = .suggestion(suggestion) } else { - suggestionPanelViewModel.content = nil + sharedPanelViewModel.content = nil } } private func generateWidgetLocation() -> WidgetLocation? { if let application = XcodeInspector.shared.latestActiveXcode?.appElement { - if let focusElement = application.focusedElement, - focusElement.description == "Source Editor", + if let focusElement = XcodeInspector.shared.focusedEditor?.element, let parent = focusElement.parent, let frame = parent.rect, let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index db3e651f..231053c1 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -47,8 +47,10 @@ final class WidgetViewModel: ObservableObject { struct WidgetView: View { @ObservedObject var viewModel: WidgetViewModel - @ObservedObject var panelViewModel: SuggestionPanelViewModel + @ObservedObject var panelViewModel: SharedPanelViewModel @ObservedObject var chatWindowViewModel: ChatWindowViewModel + @ObservedObject var sharedPanelDisplayController: SharedPanelDisplayController + @ObservedObject var suggestionPanelDisplayController: SuggestionPanelDisplayController @State var isHovering: Bool = false @State var processingProgress: Double = 0 var onOpenChatClicked: () -> Void = {} @@ -59,13 +61,14 @@ struct WidgetView: View { .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { let wasDisplayed = { - if panelViewModel.isPanelDisplayed, + if (sharedPanelDisplayController.isPanelDisplayed || suggestionPanelDisplayController.isPanelDisplayed), panelViewModel.content != nil { return true } if chatWindowViewModel.isPanelDisplayed, chatWindowViewModel.chat != nil { return true } return false }() - panelViewModel.isPanelDisplayed = !wasDisplayed + sharedPanelDisplayController.isPanelDisplayed = !wasDisplayed + suggestionPanelDisplayController.isPanelDisplayed = !wasDisplayed chatWindowViewModel.isPanelDisplayed = !wasDisplayed let isDisplayed = !wasDisplayed @@ -332,6 +335,8 @@ struct WidgetView_Preview: PreviewProvider { viewModel: .init(isProcessing: false), panelViewModel: .init(), chatWindowViewModel: .init(), + sharedPanelDisplayController: .init(), + suggestionPanelDisplayController: .init(), isHovering: false ) @@ -339,6 +344,8 @@ struct WidgetView_Preview: PreviewProvider { viewModel: .init(isProcessing: false), panelViewModel: .init(), chatWindowViewModel: .init(), + sharedPanelDisplayController: .init(), + suggestionPanelDisplayController: .init(), isHovering: true ) @@ -346,6 +353,8 @@ struct WidgetView_Preview: PreviewProvider { viewModel: .init(isProcessing: true), panelViewModel: .init(), chatWindowViewModel: .init(), + sharedPanelDisplayController: .init(), + suggestionPanelDisplayController: .init(), isHovering: false ) @@ -360,6 +369,8 @@ struct WidgetView_Preview: PreviewProvider { )) ), chatWindowViewModel: .init(), + sharedPanelDisplayController: .init(), + suggestionPanelDisplayController: .init(), isHovering: false ) } diff --git a/Core/Sources/XcodeInspector/SourceEditor.swift b/Core/Sources/XcodeInspector/SourceEditor.swift index 93ad8410..3557a6ad 100644 --- a/Core/Sources/XcodeInspector/SourceEditor.swift +++ b/Core/Sources/XcodeInspector/SourceEditor.swift @@ -35,7 +35,7 @@ public class SourceEditor { } let runningApplication: NSRunningApplication - let element: AXUIElement + public let element: AXUIElement /// The content of the source editor. public var content: Content { From 8245c31e4657a941a9d7a46064436f99c110fc96 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 23 Jun 2023 15:28:48 +0800 Subject: [PATCH 17/35] Update launch agent template --- Core/Sources/HostApp/LaunchAgentManager.swift | 4 +++- .../LaunchAgentManager/LaunchAgentManager.swift | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Core/Sources/HostApp/LaunchAgentManager.swift b/Core/Sources/HostApp/LaunchAgentManager.swift index b83d8589..db4d48b9 100644 --- a/Core/Sources/HostApp/LaunchAgentManager.swift +++ b/Core/Sources/HostApp/LaunchAgentManager.swift @@ -13,7 +13,9 @@ extension LaunchAgentManager { .appendingPathComponent( "CopilotForXcodeExtensionService.app/Contents/MacOS/CopilotForXcodeExtensionService" ) - .path + .path, + bundleIdentifier: Bundle.main + .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String ) } } diff --git a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift index 0ace19cc..4addc6bb 100644 --- a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift +++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift @@ -1,9 +1,11 @@ import Foundation +#warning("TODO: Migrate to SMAppService") public struct LaunchAgentManager { let lastLaunchAgentVersionKey = "LastLaunchAgentVersion" let serviceIdentifier: String let executablePath: String + let bundleIdentifier: String var launchAgentDirURL: URL { FileManager.default.homeDirectoryForCurrentUser @@ -14,9 +16,10 @@ public struct LaunchAgentManager { launchAgentDirURL.appendingPathComponent("\(serviceIdentifier).plist").path } - public init(serviceIdentifier: String, executablePath: String) { + public init(serviceIdentifier: String, executablePath: String, bundleIdentifier: String) { self.serviceIdentifier = serviceIdentifier self.executablePath = executablePath + self.bundleIdentifier = bundleIdentifier } public func setupLaunchAgentForTheFirstTimeIfNeeded() async throws { @@ -44,6 +47,11 @@ public struct LaunchAgentManager { \(serviceIdentifier) + AssociatedBundleIdentifiers + + \(bundleIdentifier) + \(serviceIdentifier) + """ From e5d8152cb6be71b2f32432f0abea82c1653b2e78 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 23 Jun 2023 15:55:26 +0800 Subject: [PATCH 18/35] Fix that the widget will be brought to front when switching spaces --- .../SuggestionWidgetController.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 2d61fe16..a5072778 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -39,7 +39,7 @@ public final class SuggestionWidgetController: NSObject { it.isOpaque = false it.backgroundColor = .clear it.level = .init(19) - it.collectionBehavior = [.fullScreenAuxiliary] + it.collectionBehavior = [.fullScreenAuxiliary, .transient] it.hasShadow = true it.contentView = NSHostingView( rootView: WidgetView( @@ -72,7 +72,7 @@ public final class SuggestionWidgetController: NSObject { it.isOpaque = false it.backgroundColor = .clear it.level = .init(19) - it.collectionBehavior = [.fullScreenAuxiliary] + it.collectionBehavior = [.fullScreenAuxiliary, .transient] it.hasShadow = true it.contentView = NSHostingView( rootView: TabView(chatWindowViewModel: chatWindowViewModel) @@ -93,7 +93,7 @@ public final class SuggestionWidgetController: NSObject { it.isOpaque = false it.backgroundColor = .clear it.level = .init(NSWindow.Level.floating.rawValue + 1) - it.collectionBehavior = [.fullScreenAuxiliary] + it.collectionBehavior = [.fullScreenAuxiliary, .transient] it.hasShadow = true it.contentView = NSHostingView( rootView: SharedPanelView( @@ -120,7 +120,7 @@ public final class SuggestionWidgetController: NSObject { it.isOpaque = false it.backgroundColor = .clear it.level = .init(NSWindow.Level.floating.rawValue + 1) - it.collectionBehavior = [.fullScreenAuxiliary] + it.collectionBehavior = [.fullScreenAuxiliary, .transient] it.hasShadow = true it.contentView = NSHostingView( rootView: SuggestionPanelView( @@ -147,7 +147,7 @@ public final class SuggestionWidgetController: NSObject { it.isOpaque = false it.backgroundColor = .clear it.level = .floating - it.collectionBehavior = [.fullScreenAuxiliary] + it.collectionBehavior = [.fullScreenAuxiliary, .transient] it.hasShadow = true it.contentView = NSHostingView( rootView: ChatWindowView(viewModel: chatWindowViewModel) @@ -240,7 +240,7 @@ public final class SuggestionWidgetController: NSObject { guard let activeXcode = ActiveApplicationMonitor.activeXcode else { continue } guard fullscreenDetector.isOnActiveSpace else { continue } let app = AXUIElementCreateApplication(activeXcode.processIdentifier) - if app.focusedWindow != nil { + if let window = app.focusedWindow, window.isFullScreen { orderFront() } } From 9d7a7353eb026ee6ae5d2c988ffdd25e70473228 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 23 Jun 2023 16:13:41 +0800 Subject: [PATCH 19/35] Bump Codeium version to 1.2.40 --- Core/Sources/CodeiumService/CodeiumInstallationManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/CodeiumService/CodeiumInstallationManager.swift b/Core/Sources/CodeiumService/CodeiumInstallationManager.swift index 2f8190da..7ac7cc7f 100644 --- a/Core/Sources/CodeiumService/CodeiumInstallationManager.swift +++ b/Core/Sources/CodeiumService/CodeiumInstallationManager.swift @@ -3,7 +3,7 @@ import Terminal public struct CodeiumInstallationManager { private static var isInstalling = false - static let latestSupportedVersion = "1.2.25" + static let latestSupportedVersion = "1.2.40" public init() {} From 4f0fd6067da4727b9a9e3b8c9eee9c4bdc04282b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 23 Jun 2023 16:18:30 +0800 Subject: [PATCH 20/35] Remove unwanted prints --- Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index 6b97b31d..65c6c8f4 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -293,7 +293,6 @@ enum UpdateLocationStrategy { } return nil }() { - print(mainScreen.frame, completionPanelRect, y) return .init( frame: .init( x: x, From b6e55d9a92a4765dab4ea1cadbc1ece1ec57be51 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 23 Jun 2023 18:00:36 +0800 Subject: [PATCH 21/35] Hide nearby cursor suggestion when the line of code is out of frame --- .../SuggestionPanelView.swift | 14 ++++++++++++- .../SuggestionWidgetController.swift | 21 +++++++++++-------- .../WidgetPositionStrategy.swift | 17 +++++++++++++-- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift index 36d1c8e8..4d4e1c9d 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift @@ -4,14 +4,17 @@ import SwiftUI @MainActor final class SuggestionPanelDisplayController: ObservableObject { @Published var alignTopToAnchor = false + @Published var isPanelOutOfFrame: Bool = false @Published var isPanelDisplayed: Bool = false init( alignTopToAnchor: Bool = false, + isPanelOutOfFrame: Bool = false, isPanelDisplayed: Bool = false ) { self.alignTopToAnchor = alignTopToAnchor self.isPanelDisplayed = isPanelDisplayed + self.isPanelOutOfFrame = isPanelOutOfFrame } } @@ -45,7 +48,9 @@ struct SuggestionPanelView: View { } .frame(maxWidth: .infinity, maxHeight: Style.inlineSuggestionMaxHeight) .fixedSize(horizontal: false, vertical: true) - .allowsHitTesting(displayController.isPanelDisplayed) + .allowsHitTesting( + displayController.isPanelDisplayed && !displayController.isPanelOutOfFrame + ) } } .frame(maxWidth: .infinity) @@ -59,6 +64,7 @@ struct SuggestionPanelView: View { .preferredColorScheme(viewModel.colorScheme) .opacity({ guard displayController.isPanelDisplayed else { return 0 } + guard !displayController.isPanelOutOfFrame else { return 0 } guard viewModel.content != nil else { return 0 } return 1 }()) @@ -72,6 +78,12 @@ struct SuggestionPanelView: View { .easeInOut(duration: 0.2), value: displayController.isPanelDisplayed ) + .animation( + featureFlag: \.animationBCrashSuggestion, + .easeInOut(duration: 0.2), + value: displayController.isPanelOutOfFrame + ) .frame(maxWidth: Style.inlineSuggestionMinWidth, maxHeight: Style.inlineSuggestionMaxHeight) } } + diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index a5072778..5be25a9a 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -537,15 +537,18 @@ extension SuggestionWidgetController { sharedPanelDisplayController.alignTopToAnchor = widgetLocation.defaultPanelLocation .alignPanelTop - let suggestionPanelLocation = widgetLocation.suggestionPanelLocation ?? widgetLocation - .defaultPanelLocation - suggestionWindow.setFrame( - suggestionPanelLocation.frame, - display: false, - animate: animated - ) - suggestionPanelDisplayController.alignTopToAnchor = suggestionPanelLocation - .alignPanelTop + if let suggestionPanelLocation = widgetLocation.suggestionPanelLocation { + suggestionWindow.setFrame( + suggestionPanelLocation.frame, + display: false, + animate: animated + ) + suggestionPanelDisplayController.isPanelOutOfFrame = false + suggestionPanelDisplayController.alignTopToAnchor = suggestionPanelLocation + .alignPanelTop + } else { + suggestionPanelDisplayController.isPanelOutOfFrame = true + } if detachChat { if chatWindow.alphaValue == 0 { diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index 65c6c8f4..4604ae95 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -224,6 +224,11 @@ enum UpdateLocationStrategy { guard let selectionFrame = UpdateLocationStrategy .getSelectionFirstLineFrame(editor: editor) else { return nil } + // hide it when the line of code is outside of the editor visible rect + if selectionFrame.maxY < editorFrame.minY || selectionFrame.minY > editorFrame.maxY { + return nil + } + let proposedY = mainScreen.frame.height - selectionFrame.maxY let proposedX = selectionFrame.maxX - 40 let maxY = max( @@ -235,7 +240,11 @@ enum UpdateLocationStrategy { activeScreen.frame.maxY - 4 ) - let alignPanelTopToAnchor = y - Style.inlineSuggestionMaxHeight >= activeScreen.frame.minY + // align panel to top == place under the selection frame. + // we initially try to place it at the bottom side, but if there is no enough space + // we move it to the top of the selection frame. + let alignPanelTopToAnchor = y - Style.inlineSuggestionMaxHeight + >= activeScreen.frame.minY let caseIgnoreCompletionPanel = { (alignPanelTopToAnchor: Bool) -> WidgetLocation.PanelLocation? in @@ -246,7 +255,7 @@ enum UpdateLocationStrategy { return activeScreen.frame.maxX - Style.inlineSuggestionMinWidth }() if alignPanelTopToAnchor { - // case: present below selection + // case: present under selection return .init( frame: .init( x: x, @@ -257,6 +266,7 @@ enum UpdateLocationStrategy { alignPanelTop: alignPanelTopToAnchor ) } else { + // case: present above selection return .init( frame: .init( x: x, @@ -275,8 +285,10 @@ enum UpdateLocationStrategy { switch (completionPanelBelowCursor, alignPanelTopToAnchor) { case (true, false), (false, true): + // case: different position, place the suggestion as it should be return caseIgnoreCompletionPanel(alignPanelTopToAnchor) case (true, true), (false, false): + // case: same position, place the suggestion next to the completion panel let y = completionPanelBelowCursor ? y - Style.inlineSuggestionMaxHeight : y + selectionFrame.height - Style.widgetPadding @@ -303,6 +315,7 @@ enum UpdateLocationStrategy { alignPanelTop: alignPanelTopToAnchor ) } + // case: no enough horizontal space, place the suggestion on the other side return caseIgnoreCompletionPanel(!alignPanelTopToAnchor) } } From 123728da6fdd49a3ab674ec00a25d2c410bc90e8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 23 Jun 2023 18:00:59 +0800 Subject: [PATCH 22/35] Add alwaysAcceptSuggestionWithAccessibilityAPI just in case --- Core/Sources/HostApp/DebugView.swift | 5 +++++ .../SuggestionCommandHandler/PseudoCommandHandler.swift | 3 +++ Tool/Sources/Preferences/Keys.swift | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/Core/Sources/HostApp/DebugView.swift b/Core/Sources/HostApp/DebugView.swift index 02d354e6..6da0793e 100644 --- a/Core/Sources/HostApp/DebugView.swift +++ b/Core/Sources/HostApp/DebugView.swift @@ -8,6 +8,8 @@ final class DebugSettings: ObservableObject { @AppStorage(\.preCacheOnFileOpen) var preCacheOnFileOpen @AppStorage(\.useCustomScrollViewWorkaround) var useCustomScrollViewWorkaround @AppStorage(\.triggerActionWithAccessibilityAPI) var triggerActionWithAccessibilityAPI + @AppStorage(\.alwaysAcceptSuggestionWithAccessibilityAPI) + var alwaysAcceptSuggestionWithAccessibilityAPI init() {} } @@ -35,6 +37,9 @@ struct DebugSettingsView: View { Toggle(isOn: $settings.triggerActionWithAccessibilityAPI) { Text("Trigger command with AccessibilityAPI") } + Toggle(isOn: $settings.alwaysAcceptSuggestionWithAccessibilityAPI) { + Text("Always accept suggestion with AccessibilityAPI") + } } .padding() } diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index 5d5694fb..0f6fcbfb 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift @@ -130,6 +130,9 @@ struct PseudoCommandHandler { func acceptSuggestion() async { do { + if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { + throw CancellationError() + } try await Environment.triggerAction("Accept Suggestion") } catch { guard let xcode = ActiveApplicationMonitor.activeXcode ?? ActiveApplicationMonitor diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 64e2178a..95adc247 100644 --- a/Tool/Sources/Preferences/Keys.swift +++ b/Tool/Sources/Preferences/Keys.swift @@ -345,6 +345,10 @@ public extension UserDefaultPreferenceKeys { var triggerActionWithAccessibilityAPI: FeatureFlag { .init(defaultValue: true, key: "FeatureFlag-TriggerActionWithAccessibilityAPI") } + + var alwaysAcceptSuggestionWithAccessibilityAPI: FeatureFlag { + .init(defaultValue: false, key: "FeatureFlag-AlwaysAcceptSuggestionWithAccessibilityAPI") + } var animationACrashSuggestion: FeatureFlag { .init(defaultValue: true, key: "FeatureFlag-AnimationACrashSuggestion") From 2f4dbf1aaf8062e230b6087d3d0f63a8a15ab752 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 23 Jun 2023 18:01:17 +0800 Subject: [PATCH 23/35] Update --- Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Version.xcconfig b/Version.xcconfig index 827d79cf..3a637c79 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,2 +1,2 @@ -APP_VERSION = 0.18.2 -APP_BUILD = 182 +APP_VERSION = 0.19.0 +APP_BUILD = 190 From aad59b7a8e6304f2fbcb08e7ed137b9f88e70fc8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 23 Jun 2023 18:02:40 +0800 Subject: [PATCH 24/35] Make fullscreenDetector transient --- .../Sources/SuggestionWidget/SuggestionWidgetController.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 5be25a9a..5fea9bd3 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -11,6 +11,8 @@ import XcodeInspector @MainActor public final class SuggestionWidgetController: NSObject { + // you should make these window `.transient` so they never show up in the mission control. + private lazy var fullscreenDetector = { let it = CanBecomeKeyWindow( contentRect: .zero, @@ -21,7 +23,7 @@ public final class SuggestionWidgetController: NSObject { it.isReleasedWhenClosed = false it.isOpaque = false it.backgroundColor = .clear - it.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + it.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] it.hasShadow = false it.setIsVisible(true) it.canBecomeKeyChecker = { false } From 59e64f1ea54c8f667f4ee543f41c31bd787d0aa3 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 23 Jun 2023 18:35:14 +0800 Subject: [PATCH 25/35] Make fullscreenDetector also invisible --- Core/Sources/SuggestionWidget/SuggestionWidgetController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 5fea9bd3..3571916a 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -25,7 +25,7 @@ public final class SuggestionWidgetController: NSObject { it.backgroundColor = .clear it.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] it.hasShadow = false - it.setIsVisible(true) + it.setIsVisible(false) it.canBecomeKeyChecker = { false } return it }() From 3fc5ba7ac966219d7c0ceccc518a032273f15e18 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 23 Jun 2023 22:03:04 +0800 Subject: [PATCH 26/35] Move suggestion panel window lower in z level --- .../Sources/SuggestionWidget/SuggestionWidgetController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 3571916a..5d2aa389 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -12,7 +12,7 @@ import XcodeInspector @MainActor public final class SuggestionWidgetController: NSObject { // you should make these window `.transient` so they never show up in the mission control. - + private lazy var fullscreenDetector = { let it = CanBecomeKeyWindow( contentRect: .zero, @@ -121,7 +121,7 @@ public final class SuggestionWidgetController: NSObject { it.isReleasedWhenClosed = false it.isOpaque = false it.backgroundColor = .clear - it.level = .init(NSWindow.Level.floating.rawValue + 1) + it.level = .init(NSWindow.Level.floating.rawValue - 1) it.collectionBehavior = [.fullScreenAuxiliary, .transient] it.hasShadow = true it.contentView = NSHostingView( From 420191e4068a50c644f997a5f3824e79245a4321 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 23 Jun 2023 22:03:40 +0800 Subject: [PATCH 27/35] Remove the meaningless Task { @MainActor in } in initializer --- .../SuggestionWidget/SuggestionWidgetController.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index 5d2aa389..d682cf30 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -229,9 +229,7 @@ public final class SuggestionWidgetController: NSObject { } } } - } - Task { @MainActor in fullscreenDetectingTask = Task { [weak self] in let sequence = NSWorkspace.shared.notificationCenter .notifications(named: NSWorkspace.activeSpaceDidChangeNotification) @@ -247,16 +245,12 @@ public final class SuggestionWidgetController: NSObject { } } } - } - Task { @MainActor in presentationModeChangeObserver.onChange = { [weak self] in guard let self else { return } self.updateWindowLocation() } - } - Task { @MainActor in chatWindowViewModel.$chatPanelInASeparateWindow.dropFirst().removeDuplicates() .sink { [weak self] _ in guard let self else { return } @@ -264,9 +258,7 @@ public final class SuggestionWidgetController: NSObject { self.updateWindowLocation(animated: true) } }.store(in: &cancellable) - } - Task { @MainActor in let updateColorScheme = { @MainActor [weak self] in guard let self else { return } let widgetColorScheme = UserDefaults.shared.value(for: \.widgetColorScheme) From fe8e82ac57b70cea4895afd1b6d52d8f770b257b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 23 Jun 2023 22:04:07 +0800 Subject: [PATCH 28/35] Fix that suggestion panel buttons can't be clicked when completion panel is displayed --- .../SuggestionWidget/SuggestionWidgetController.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index d682cf30..aa5f9127 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -289,11 +289,14 @@ public final class SuggestionWidgetController: NSObject { systemColorSchemeChangeObserver.onChange = { updateColorScheme() } - } - Task { @MainActor in - XcodeInspector.shared.$completionPanel.sink { [weak self] _ in + XcodeInspector.shared.$completionPanel.sink { [weak self] newValue in Task { @MainActor in + if newValue == nil { + // so that the buttons on the suggestion panel could be clicked + // before the completion panel updates the location of the suggestion panel + try await Task.sleep(nanoseconds: 200_000_000) + } self?.updateWindowLocation() } }.store(in: &cancellable) From 788b1979bbfb6d86c5a191969bc564fef42e6054 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 23 Jun 2023 22:04:48 +0800 Subject: [PATCH 29/35] Fix that widget window was mistakenly hidden is some cases --- .../SuggestionWidgetController.swift | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index aa5f9127..b0d30811 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -515,10 +515,7 @@ extension SuggestionWidgetController { } } - /// Update the window location. - /// - /// - note: It's possible to get the scroll view's position by getting position on the focus - /// element. + /// Update the window location and opacity. private func updateWindowLocation(animated: Bool = false) { let detachChat = chatWindowViewModel.chatPanelInASeparateWindow @@ -582,10 +579,11 @@ extension SuggestionWidgetController { app.bundleIdentifier == Bundle.main.bundleIdentifier { let noFocus = { - guard let xcode = ActiveApplicationMonitor.latestXcode else { return true } - let application = AXUIElementCreateApplication(xcode.processIdentifier) - return application - .focusedWindow == nil || (application.focusedWindow?.role == "AXWindow") + guard let xcode = XcodeInspector.shared.latestActiveXcode else { return true } + if let window = xcode.appElement.focusedWindow, window.role == "AXWindow" { + return false + } + return true }() panelWindow.alphaValue = noFocus ? 0 : 1 From 9c58fa37a210dc28e3b88e63aac2ec30f691f6b6 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 23 Jun 2023 22:14:46 +0800 Subject: [PATCH 30/35] Invalidate suggestion when the current line is empty or space only --- Core/Sources/Service/Workspace.swift | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/Core/Sources/Service/Workspace.swift b/Core/Sources/Service/Workspace.swift index 8ee3da4a..8afd4224 100644 --- a/Core/Sources/Service/Workspace.swift +++ b/Core/Sources/Service/Workspace.swift @@ -67,25 +67,30 @@ final class Filespace { func refreshUpdateTime() { lastSuggestionUpdateTime = Environment.now() } - + func validateSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool { if cursorPosition.line != suggestionSourceSnapshot.cursorPosition.line { reset() return false } - + guard cursorPosition.line >= 0, cursorPosition.line < lines.count else { reset() return false } - + let editingLine = lines[cursorPosition.line].dropLast(1) // dropping \n let suggestionFirstLine = presentingSuggestion?.text.split(separator: "\n").first ?? "" if !suggestionFirstLine.hasPrefix(editingLine) { reset() return false } - + + if editingLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + reset() + return false + } + return true } } @@ -166,7 +171,7 @@ final class Workspace { guard let self else { return } _ = self.suggestionService } - + let openedFiles = openedFileRecoverableStorage.openedFiles for fileURL in openedFiles { _ = createFilespaceIfNeeded(fileURL: fileURL) @@ -199,13 +204,13 @@ final class Workspace { let filespace = existed.createFilespaceIfNeeded(fileURL: fileURL) return (existed, filespace) } - + let new = Workspace(projectRootURL: currentProjectURL) workspaces[currentProjectURL] = new let filespace = new.createFilespaceIfNeeded(fileURL: fileURL) return (new, filespace) } - + // If not, we try to reuse a filespace if found. // // Sometimes, we can't get the project root path from Xcode window, for example, when the @@ -218,7 +223,7 @@ final class Workspace { // If we can't find an existed one, we will try to guess it. // Most of the time we won't enter this branch, just incase. - + let workspaceURL = try await Environment.guessProjectRootURLForFile(fileURL) let workspace = { @@ -428,8 +433,9 @@ extension Workspace { guard let suggestionService else { return } await suggestionService.cancelRequest() } - + func terminateSuggestionService() async { await _suggestionService?.terminate() } } + From e2389f75aef1ff9702f459de122395678bab97b5 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 23 Jun 2023 23:48:06 +0800 Subject: [PATCH 31/35] Fix that completion panel change is not observed if Xcode is launched after the extension --- .../AXNotificationStream.swift | 25 +++-- .../XcodeInspector/XcodeInspector.swift | 94 ++++++++++--------- .../XcodeInspector/XcodeWindowInspector.swift | 41 +++++--- 3 files changed, 97 insertions(+), 63 deletions(-) diff --git a/Core/Sources/AXNotificationStream/AXNotificationStream.swift b/Core/Sources/AXNotificationStream/AXNotificationStream.swift index 607999a1..f0a0387b 100644 --- a/Core/Sources/AXNotificationStream/AXNotificationStream.swift +++ b/Core/Sources/AXNotificationStream/AXNotificationStream.swift @@ -72,13 +72,24 @@ public final class AXNotificationStream: AsyncSequence { .commonModes ) } - for name in notificationNames { - AXObserverAddNotification(observer, observingElement, name as CFString, &continuation) + + Task { + for name in notificationNames { + var error = AXError.cannotComplete + var retryCount = 0 + while error == AXError.cannotComplete, retryCount < 5 { + error = AXObserverAddNotification(observer, observingElement, name as CFString, &continuation) + if error == .cannotComplete { + try await Task.sleep(nanoseconds: 1_000_000_000) + } + retryCount += 1 + } + } + CFRunLoopAddSource( + CFRunLoopGetMain(), + AXObserverGetRunLoopSource(observer), + .commonModes + ) } - CFRunLoopAddSource( - CFRunLoopGetMain(), - AXObserverGetRunLoopSource(observer), - .commonModes - ) } } diff --git a/Core/Sources/XcodeInspector/XcodeInspector.swift b/Core/Sources/XcodeInspector/XcodeInspector.swift index cd0ecc52..65e729f4 100644 --- a/Core/Sources/XcodeInspector/XcodeInspector.swift +++ b/Core/Sources/XcodeInspector/XcodeInspector.swift @@ -33,11 +33,11 @@ public final class XcodeInspector: ObservableObject { .first(where: \.isActive) .map(AppInstanceInspector.init(runningApplication:)) - if let activeXcode { - setActiveXcode(activeXcode) - } - Task { @MainActor in // Did activate app + if let activeXcode { + setActiveXcode(activeXcode) + } + let sequence = NSWorkspace.shared.notificationCenter .notifications(named: NSWorkspace.didActivateApplicationNotification) for await notification in sequence { @@ -87,7 +87,10 @@ public final class XcodeInspector: ObservableObject { } } + @MainActor func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { + xcode.refresh() + for task in activeXcodeObservations { task.cancel() } for cancellable in activeXcodeCancellable { cancellable.cancel() } activeXcodeObservations.removeAll() @@ -208,6 +211,51 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { super.init(runningApplication: runningApplication) observeFocusedWindow() + observe() + } + + func observeFocusedWindow() { + if let window = appElement.focusedWindow { + if window.identifier == "Xcode.WorkspaceWindow" { + let window = WorkspaceXcodeWindowInspector( + app: runningApplication, + uiElement: window + ) + focusedWindow = window + focusedWindowObservations.forEach { $0.cancel() } + focusedWindowObservations.removeAll() + + documentURL = window.documentURL + projectURL = window.projectURL + + window.$documentURL + .filter { $0 != .init(fileURLWithPath: "/") } + .sink { [weak self] url in + self?.documentURL = url + }.store(in: &focusedWindowObservations) + window.$projectURL + .filter { $0 != .init(fileURLWithPath: "/") } + .sink { [weak self] url in + self?.projectURL = url + }.store(in: &focusedWindowObservations) + } else { + let window = XcodeWindowInspector(uiElement: window) + focusedWindow = window + } + } else { + focusedWindow = nil + } + } + + func refresh() { + (focusedWindow as? WorkspaceXcodeWindowInspector)?.refresh() + observe() + } + + func observe() { + longRunningTasks.forEach { $0.cancel() } + longRunningTasks = [] + let focusedWindowChanged = Task { let notification = AXNotificationStream( app: runningApplication, @@ -243,14 +291,9 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { longRunningTasks.insert(updateTabsTask) - completionPanel = appElement.firstChild { element in - element.identifier == "_XC_COMPLETION_TABLE_" - }?.parent - let completionPanelTask = Task { let stream = AXNotificationStream( app: runningApplication, - element: appElement, notificationNames: kAXCreatedNotification, kAXUIElementDestroyedNotification ) @@ -279,39 +322,6 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { longRunningTasks.insert(completionPanelTask) } - func observeFocusedWindow() { - if let window = appElement.focusedWindow { - if window.identifier == "Xcode.WorkspaceWindow" { - let window = WorkspaceXcodeWindowInspector( - app: runningApplication, - uiElement: window - ) - focusedWindow = window - focusedWindowObservations.forEach { $0.cancel() } - focusedWindowObservations.removeAll() - - documentURL = window.documentURL - projectURL = window.projectURL - - window.$documentURL - .filter { $0 != .init(fileURLWithPath: "/") } - .sink { [weak self] url in - self?.documentURL = url - }.store(in: &focusedWindowObservations) - window.$projectURL - .filter { $0 != .init(fileURLWithPath: "/") } - .sink { [weak self] url in - self?.projectURL = url - }.store(in: &focusedWindowObservations) - } else { - let window = XcodeWindowInspector(uiElement: window) - focusedWindow = window - } - } else { - focusedWindow = nil - } - } - static func fetchWorkspaceInfo( _ app: NSRunningApplication ) -> [WorkspaceIdentifier: WorkspaceInfo] { diff --git a/Core/Sources/XcodeInspector/XcodeWindowInspector.swift b/Core/Sources/XcodeInspector/XcodeWindowInspector.swift index b4f34c81..217a9cbd 100644 --- a/Core/Sources/XcodeInspector/XcodeWindowInspector.swift +++ b/Core/Sources/XcodeInspector/XcodeWindowInspector.swift @@ -23,27 +23,26 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { updateTabsTask?.cancel() focusedElementChangedTask?.cancel() } + + public func refresh() { + updateURLs() + } public init(app: NSRunningApplication, uiElement: AXUIElement) { self.app = app super.init(uiElement: uiElement) focusedElementChangedTask = Task { @MainActor in - let update = { - let documentURL = Self.extractDocumentURL(windowElement: uiElement) - if let documentURL { - self.documentURL = documentURL - } - let projectURL = Self.extractProjectURL( - windowElement: uiElement, - fileURL: documentURL - ) - if let projectURL { - self.projectURL = projectURL + updateURLs() + + Task { @MainActor in + // prevent that documentURL may not be available yet + try await Task.sleep(nanoseconds: 500_000_000) + if documentURL == .init(fileURLWithPath: "/") { + updateURLs() } } - - update() + let notifications = AXNotificationStream( app: app, notificationNames: kAXFocusedUIElementChangedNotification @@ -51,10 +50,24 @@ public final class WorkspaceXcodeWindowInspector: XcodeWindowInspector { for await _ in notifications { try Task.checkCancellation() - update() + updateURLs() } } } + + func updateURLs() { + let documentURL = Self.extractDocumentURL(windowElement: uiElement) + if let documentURL { + self.documentURL = documentURL + } + let projectURL = Self.extractProjectURL( + windowElement: uiElement, + fileURL: documentURL + ) + if let projectURL { + self.projectURL = projectURL + } + } static func extractDocumentURL( windowElement: AXUIElement From 66ccada9ad65ac8701c703ef94a1a4aa74af7894 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 24 Jun 2023 00:29:18 +0800 Subject: [PATCH 32/35] Move suggestionWindow up --- Core/Sources/SuggestionWidget/SuggestionWidgetController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift index b0d30811..f8b9b4b4 100644 --- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift +++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift @@ -121,7 +121,7 @@ public final class SuggestionWidgetController: NSObject { it.isReleasedWhenClosed = false it.isOpaque = false it.backgroundColor = .clear - it.level = .init(NSWindow.Level.floating.rawValue - 1) + it.level = .init(NSWindow.Level.floating.rawValue + 1) it.collectionBehavior = [.fullScreenAuxiliary, .transient] it.hasShadow = true it.contentView = NSHostingView( From 5b40449987affee84807545bc220f307069d18b2 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 24 Jun 2023 00:45:48 +0800 Subject: [PATCH 33/35] Invalidate suggestion when the user has type all of them out --- Core/Sources/Service/Workspace.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Core/Sources/Service/Workspace.swift b/Core/Sources/Service/Workspace.swift index 8afd4224..fba0abe6 100644 --- a/Core/Sources/Service/Workspace.swift +++ b/Core/Sources/Service/Workspace.swift @@ -80,11 +80,17 @@ final class Filespace { } let editingLine = lines[cursorPosition.line].dropLast(1) // dropping \n - let suggestionFirstLine = presentingSuggestion?.text.split(separator: "\n").first ?? "" + let suggestionLines = presentingSuggestion?.text.split(separator: "\n") ?? [] + let suggestionFirstLine = suggestionLines.first ?? "" if !suggestionFirstLine.hasPrefix(editingLine) { reset() return false } + + if editingLine == suggestionFirstLine, suggestionLines.count <= 1 { + reset() + return false + } if editingLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { reset() From d046df64bda1058777346ee7296cc01908a7ec02 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 24 Jun 2023 02:11:20 +0800 Subject: [PATCH 34/35] Update README.md --- README.md | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b111744b..7111dbcf 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,6 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil - [Update](#update) - [Feature](#feature) - [Key Bindings](#key-bindings) -- [Prevent Suggestions Being Committed](#prevent-suggestions-being-committed) - [Limitations](#limitations) - [License](#license) @@ -148,6 +147,12 @@ If you find that some of the features are no longer working, please first try re The app can provide real-time code suggestions based on the files you have opened. It's powered by GitHub Copilot and Codeium. +The feature provides two presentation modes: +- Nearby Text Cursor: This mode shows suggestions based on the position of the text cursor. +- Floating Widget: This mode shows suggestions next to the circular widget. + +When using the "Nearby Text Cursor" mode, it is recommended to set the real-time suggestion debounce to 0.1. + If you're working on a company project and don't want the suggestion feature to be triggered, you can globally disable it and choose to enable it only for specific projects. Whenever your code is updated, the app will automatically fetch suggestions for you, you can cancel this by pressing **Escape**. @@ -189,7 +194,7 @@ You can detach the chat panel by simply dragging it away. Once detached, the cha | :------: | --------------------------------------------------------------------------------------------------- | | `⌘W` | Close the chat. | | `⌘M` | Minimize the chat, you can bring it back with any chat commands or by clicking the circular widget. | -| `⇧↩︎` | Add new line. | +| `⇧↩︎` | Add new line. | #### Chat Scope @@ -278,25 +283,9 @@ Essentially using `⌥⇧` as the "access" key combination for all bindings. Another convenient method to access commands is by using the `⇧⌘/` shortcut to search for a command in the menu bar. -## Prevent Suggestions Being Committed (in comment mode) - -Since the suggestions are presented as comments, they are in your code. If you are not careful enough, they can be committed to your git repo. To avoid that, I would recommend adding a pre-commit git hook to prevent this from happening. - -```sh -#!/bin/sh - -# Check if the commit message contains the string -if git diff --cached --diff-filter=ACMR | grep -q "/*========== Copilot Suggestion"; then - echo "Error: Commit contains Copilot suggestions generated by Copilot for Xcode." - exit 1 -fi -``` - ## Limitations -- The first run of the extension will be slow. Be patient. - The extension uses some dirty tricks to get the file and project/workspace paths. It may fail, it may be incorrect, especially when you have multiple Xcode windows running, and maybe even worse when they are in different displays. I am not sure about that though. -- The suggestions are presented as C-style comments in comment mode, they may break your code if you are editing a JSON file or something. ## License From f3964e8ca11a6eabf5dce41a3692ba03814666ed Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sat, 24 Jun 2023 02:13:15 +0800 Subject: [PATCH 35/35] Update appcast.xml --- appcast.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/appcast.xml b/appcast.xml index 4e4117f7..1d448803 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,6 +3,18 @@ Copilot for Xcode + + 0.19.0 + Sat, 24 Jun 2023 00:44:54 +0800 + 190 + 0.19.0 + 12.0 + + https://github.com/intitni/CopilotForXcode/releases/tag/0.19.0 + + + + 0.18.2 Wed, 14 Jun 2023 18:45:02 +0800