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" } }, { 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/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..f0a0387b 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( @@ -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/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 { 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() {} 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/Environment/Environment.swift b/Core/Sources/Environment/Environment.swift index 8bf6d022..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 @@ -163,6 +155,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 +167,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 = """ 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/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..75efee3a 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) @@ -34,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) } @@ -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/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) + """ 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/Service/GUI/RealtimeSuggestionIndicatorController.swift b/Core/Sources/Service/GUI/RealtimeSuggestionIndicatorController.swift deleted file mode 100644 index 8b898e55..00000000 --- a/Core/Sources/Service/GUI/RealtimeSuggestionIndicatorController.swift +++ /dev/null @@ -1,286 +0,0 @@ -import ActiveApplicationMonitor -import AppKit -import AsyncAlgorithms -import AXNotificationStream -import DisplayLink -import Environment -import Preferences -import QuartzCore -import SwiftUI -import UserDefaultsObserver - -/// Present a tiny dot next to mouse cursor if real-time suggestion is enabled. -@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 isCommentMode = UserDefaults.shared - .value(for: \.suggestionPresentationMode) == .comment - let isXcodeActive = await Environment.isXcodeActive() - return isOn && isXcodeActive && isCommentMode - }() - - 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/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/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." } - } -} diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift index ee4e22cb..0f6fcbfb 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 { @@ -136,14 +129,24 @@ struct PseudoCommandHandler { } func acceptSuggestion() async { - if UserDefaults.shared.value(for: \.acceptSuggestionWithAccessibilityAPI) { + do { + if UserDefaults.shared.value(for: \.alwaysAcceptSuggestionWithAccessibilityAPI) { + throw CancellationError() + } + 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 +202,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) } diff --git a/Core/Sources/Service/Workspace.swift b/Core/Sources/Service/Workspace.swift index 4f6357b8..fba0abe6 100644 --- a/Core/Sources/Service/Workspace.swift +++ b/Core/Sources/Service/Workspace.swift @@ -67,25 +67,36 @@ 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 ?? "" + 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() + return false + } + return true } } @@ -135,7 +146,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 { @@ -166,7 +177,7 @@ final class Workspace { guard let self else { return } _ = self.suggestionService } - + let openedFiles = openedFileRecoverableStorage.openedFiles for fileURL in openedFiles { _ = createFilespaceIfNeeded(fileURL: fileURL) @@ -199,13 +210,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 +229,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 +439,9 @@ extension Workspace { guard let suggestionService else { return } await suggestionService.cancelRequest() } - + func terminateSuggestionService() async { await _suggestionService?.terminate() } } + 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/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/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index bd1f84b3..ba9a4798 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -5,6 +5,8 @@ import SwiftUI 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/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..4d4e1c9d 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift @@ -1,64 +1,31 @@ -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 isPanelOutOfFrame: Bool = false + @Published var isPanelDisplayed: Bool = false - public init( - content: Content? = nil, - isPanelDisplayed: Bool = false, - colorScheme: ColorScheme = .dark + init( + alignTopToAnchor: Bool = false, + isPanelOutOfFrame: 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 - } + self.isPanelOutOfFrame = isPanelOutOfFrame } } 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 +36,26 @@ 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 && !displayController.isPanelOutOfFrame + ) } } .frame(maxWidth: .infinity) - if viewModel.alignTopToAnchor { + if displayController.alignTopToAnchor { Spacer() .frame(minHeight: 0, maxHeight: .infinity) .allowsHitTesting(false) @@ -91,7 +63,8 @@ struct SuggestionPanelView: View { } .preferredColorScheme(viewModel.colorScheme) .opacity({ - guard viewModel.isPanelDisplayed else { return 0 } + guard displayController.isPanelDisplayed else { return 0 } + guard !displayController.isPanelOutOfFrame else { return 0 } guard viewModel.content != nil else { return 0 } return 1 }()) @@ -103,69 +76,14 @@ struct SuggestionPanelView: View { .animation( featureFlag: \.animationBCrashSuggestion, .easeInOut(duration: 0.2), - value: viewModel.isPanelDisplayed + 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 { - 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 - } - } + .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 8c9b458f..f8b9b4b4 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,9 +23,9 @@ 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.setIsVisible(false) it.canBecomeKeyChecker = { false } return it }() @@ -39,13 +41,15 @@ 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( viewModel: widgetViewModel, - panelViewModel: suggestionPanelViewModel, + panelViewModel: sharedPanelViewModel, chatWindowViewModel: chatWindowViewModel, + sharedPanelDisplayController: sharedPanelDisplayController, + suggestionPanelDisplayController: suggestionPanelDisplayController, onOpenChatClicked: { [weak self] in self?.onOpenChatClicked() }, @@ -70,7 +74,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) @@ -91,14 +95,44 @@ 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(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, .transient] + 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 @@ -115,7 +149,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) @@ -126,8 +160,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 +221,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 } @@ -192,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) @@ -205,21 +240,17 @@ 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() } } } - } - 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 } @@ -227,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) @@ -246,7 +275,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() @@ -260,6 +289,17 @@ public final class SuggestionWidgetController: NSObject { systemColorSchemeChangeObserver.onChange = { updateColorScheme() } + + 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) } } @@ -267,6 +307,7 @@ public final class SuggestionWidgetController: NSObject { widgetWindow.orderFrontRegardless() tabWindow.orderFrontRegardless() panelWindow.orderFrontRegardless() + suggestionWindow.orderFrontRegardless() chatWindow.orderFrontRegardless() } } @@ -279,8 +320,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 +346,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 +403,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 +497,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 } @@ -469,96 +515,49 @@ 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) { - 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 = { - 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 let widgetLocation = generateWidgetLocation() { + widgetWindow.setFrame(widgetLocation.widgetFrame, display: false, animate: animated) + tabWindow.setFrame(widgetLocation.tabFrame, display: false, animate: animated) + panelWindow.setFrame( + widgetLocation.defaultPanelLocation.frame, + display: false, + animate: animated + ) - 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 - } + sharedPanelDisplayController.alignTopToAnchor = widgetLocation.defaultPanelLocation + .alignPanelTop - return UpdateLocationStrategy.FixedToBottom().framesForWindows( - editorFrame: frame, - mainScreen: screen, - activeScreen: firstScreen, - preferredInsideEditorMinWidth: 9_999_999_999 // never - ) - } + if let suggestionPanelLocation = widgetLocation.suggestionPanelLocation { + suggestionWindow.setFrame( + suggestionPanelLocation.frame, + display: false, + animate: animated + ) + suggestionPanelDisplayController.isPanelOutOfFrame = false + suggestionPanelDisplayController.alignTopToAnchor = suggestionPanelLocation + .alignPanelTop + } else { + suggestionPanelDisplayController.isPanelOutOfFrame = true } - 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 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 + ) } } @@ -567,6 +566,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 @@ -579,13 +579,15 @@ 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 + suggestionWindow.alphaValue = noFocus ? 0 : 1 widgetWindow.alphaValue = noFocus ? 0 : 1 tabWindow.alphaValue = noFocus ? 0 : 1 if detachChat { @@ -595,6 +597,7 @@ extension SuggestionWidgetController { } } else { panelWindow.alphaValue = 0 + suggestionWindow.alphaValue = 0 widgetWindow.alphaValue = 0 tabWindow.alphaValue = 0 if !detachChat { @@ -608,7 +611,7 @@ extension SuggestionWidgetController { if let fileURL { return fileURL } return try? await Environment.fetchCurrentFileURL() }() else { - suggestionPanelViewModel.content = nil + sharedPanelViewModel.content = nil chatWindowViewModel.chat = nil return } @@ -622,17 +625,121 @@ 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 = XcodeInspector.shared.focusedEditor?.element, + let parent = focusElement.parent, + let frame = parent.rect, + let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), + let firstScreen = NSScreen.main + { + let positionMode = UserDefaults.shared + .value(for: \.suggestionWidgetPositionMode) + let suggestionMode = UserDefaults.shared + .value(for: \.suggestionPresentationMode) + + switch positionMode { + case .fixedToBottom: + var result = UpdateLocationStrategy.FixedToBottom().framesForWindows( + editorFrame: frame, + mainScreen: screen, + activeScreen: firstScreen + ) + switch suggestionMode { + case .nearbyTextCursor: + result.suggestionPanelLocation = UpdateLocationStrategy + .NearbyTextCursor() + .framesForSuggestionWindow( + editorFrame: frame, mainScreen: screen, + activeScreen: firstScreen, + editor: focusElement, + completionPanel: XcodeInspector.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 } @@ -668,6 +775,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..4604ae95 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,186 @@ 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 } + + // 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( + proposedY, + 4 + activeScreen.frame.minY + ) + let y = min( + maxY, + activeScreen.frame.maxY - 4 + ) + + // 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 + let x: Double = { + if proposedX + Style.inlineSuggestionMinWidth <= activeScreen.frame.maxX { + return proposedX + } + return activeScreen.frame.maxX - Style.inlineSuggestionMinWidth + }() + if alignPanelTopToAnchor { + // case: present under selection + return .init( + frame: .init( + x: x, + y: y - Style.inlineSuggestionMaxHeight, + width: Style.inlineSuggestionMinWidth, + height: Style.inlineSuggestionMaxHeight + ), + alignPanelTop: alignPanelTopToAnchor + ) + } else { + // case: present above selection + return .init( + frame: .init( + x: x, + y: y + selectionFrame.height - Style.widgetPadding, + width: Style.inlineSuggestionMinWidth, + height: Style.inlineSuggestionMaxHeight + ), + alignPanelTop: alignPanelTopToAnchor + ) + } + } + + let caseConsiderCompletionPanel = { + (completionPanelRect: CGRect) -> WidgetLocation.PanelLocation? in + let completionPanelBelowCursor = completionPanelRect.minY >= selectionFrame.midY + + 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 + 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 + }() { + return .init( + frame: .init( + x: x, + y: y, + width: Style.inlineSuggestionMinWidth, + height: Style.inlineSuggestionMaxHeight + ), + alignPanelTop: alignPanelTopToAnchor + ) + } + // case: no enough horizontal space, place the suggestion on the other side + return caseIgnoreCompletionPanel(!alignPanelTopToAnchor) + } + } + + if let completionPanel, let completionPanelRect = completionPanel.rect { + return caseConsiderCompletionPanel(completionPanelRect) + } else { + return caseIgnoreCompletionPanel(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 + } } + diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index 87e1faf8..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 @@ -161,7 +164,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 +229,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: { @@ -342,6 +335,8 @@ struct WidgetView_Preview: PreviewProvider { viewModel: .init(isProcessing: false), panelViewModel: .init(), chatWindowViewModel: .init(), + sharedPanelDisplayController: .init(), + suggestionPanelDisplayController: .init(), isHovering: false ) @@ -349,6 +344,8 @@ struct WidgetView_Preview: PreviewProvider { viewModel: .init(isProcessing: false), panelViewModel: .init(), chatWindowViewModel: .init(), + sharedPanelDisplayController: .init(), + suggestionPanelDisplayController: .init(), isHovering: true ) @@ -356,6 +353,8 @@ struct WidgetView_Preview: PreviewProvider { viewModel: .init(isProcessing: true), panelViewModel: .init(), chatWindowViewModel: .init(), + sharedPanelDisplayController: .init(), + suggestionPanelDisplayController: .init(), isHovering: false ) @@ -370,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 1d5c8668..3557a6ad 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( @@ -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 { diff --git a/Core/Sources/XcodeInspector/XcodeInspector.swift b/Core/Sources/XcodeInspector/XcodeInspector.swift index e8d74578..65e729f4 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,15 +33,11 @@ public final class XcodeInspector: ObservableObject { .first(where: \.isActive) .map(AppInstanceInspector.init(runningApplication:)) - for xcode in xcodes { - observeXcode(xcode) - } - - 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 { @@ -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,22 @@ 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) - } - + @MainActor func setActiveXcode(_ xcode: XcodeAppInstanceInspector) { + xcode.refresh() + 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 +130,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) } } @@ -167,6 +178,28 @@ 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 } + 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() @@ -178,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, @@ -212,39 +290,36 @@ public final class XcodeAppInstanceInspector: AppInstanceInspector { } longRunningTasks.insert(updateTabsTask) - } - 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() + let completionPanelTask = Task { + let stream = AXNotificationStream( + app: runningApplication, + notificationNames: kAXCreatedNotification, kAXUIElementDestroyedNotification + ) - documentURL = window.documentURL - projectURL = window.projectURL + 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 + } - 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 + try Task.checkCancellation() } - } else { - focusedWindow = nil } + + longRunningTasks.insert(completionPanelTask) } static func fetchWorkspaceInfo( 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 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/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/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 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"), ] ), diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift index 60cbbe7a..95adc247 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") } @@ -349,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") 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 } 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 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