diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index d1ebe1c5..36aa77f0 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ C8009BFF2941C551007AA7E8 /* TurnOnRealtimeSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009BFE2941C551007AA7E8 /* TurnOnRealtimeSuggestionsCommand.swift */; }; C8009C012941C56C007AA7E8 /* TurnOffRealtimeSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009C002941C56C007AA7E8 /* TurnOffRealtimeSuggestionsCommand.swift */; }; C8009C032941C576007AA7E8 /* RealtimeSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */; }; + C800DBB1294C624D00B04CAC /* PrefetchSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */; }; C814588F2939EFDC00135263 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C814588E2939EFDC00135263 /* Cocoa.framework */; }; C81458942939EFDC00135263 /* SourceEditorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81458932939EFDC00135263 /* SourceEditorExtension.swift */; }; C81458962939EFDC00135263 /* GetSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81458952939EFDC00135263 /* GetSuggestionsCommand.swift */; }; @@ -129,6 +130,7 @@ C8009BFE2941C551007AA7E8 /* TurnOnRealtimeSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TurnOnRealtimeSuggestionsCommand.swift; sourceTree = ""; }; C8009C002941C56C007AA7E8 /* TurnOffRealtimeSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TurnOffRealtimeSuggestionsCommand.swift; sourceTree = ""; }; C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealtimeSuggestionCommand.swift; sourceTree = ""; }; + C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefetchSuggestionsCommand.swift; sourceTree = ""; }; C814588C2939EFDC00135263 /* Copilot.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Copilot.appex; sourceTree = BUILT_PRODUCTS_DIR; }; C814588E2939EFDC00135263 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = System/Library/Frameworks/Cocoa.framework; sourceTree = SDKROOT; }; C81458902939EFDC00135263 /* XcodeKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XcodeKit.framework; path = Library/Frameworks/XcodeKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -165,6 +167,7 @@ C87B03B3293B393100C77EAE /* ServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceDelegate.swift; sourceTree = ""; }; C87F3E5F293DC600008523E8 /* Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Section.swift; sourceTree = ""; }; C87F3E61293DD004008523E8 /* Styles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Styles.swift; sourceTree = ""; }; + C887BC832965D96000931567 /* DEVELOPMENT.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DEVELOPMENT.md; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -219,6 +222,7 @@ C8009BFE2941C551007AA7E8 /* TurnOnRealtimeSuggestionsCommand.swift */, C8009C002941C56C007AA7E8 /* TurnOffRealtimeSuggestionsCommand.swift */, C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */, + C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */, C81458972939EFDC00135263 /* Info.plist */, C81458982939EFDC00135263 /* EditorExtension.entitlements */, ); @@ -241,6 +245,7 @@ children = ( C832A47B2940C71D000989F2 /* copilot */, C8520308293D805800460097 /* README.md */, + C887BC832965D96000931567 /* DEVELOPMENT.md */, C81458AD293A009600135263 /* Config.xcconfig */, C81458AE293A009800135263 /* Config.debug.xcconfig */, C8189B282938979000C9DCDA /* Core */, @@ -434,6 +439,7 @@ C87B03AB293B262E00C77EAE /* PreviousSuggestionCommand.swift in Sources */, C87B03A7293B261900C77EAE /* RejectSuggestionCommand.swift in Sources */, C8009C032941C576007AA7E8 /* RealtimeSuggestionCommand.swift in Sources */, + C800DBB1294C624D00B04CAC /* PrefetchSuggestionsCommand.swift in Sources */, C81458962939EFDC00135263 /* GetSuggestionsCommand.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Core/Sources/Client/AsyncXPCService.swift b/Core/Sources/Client/AsyncXPCService.swift index 8e9748cc..dca282d6 100644 --- a/Core/Sources/Client/AsyncXPCService.swift +++ b/Core/Sources/Client/AsyncXPCService.swift @@ -140,6 +140,15 @@ public struct AsyncXPCService { } } } + + public func prefetchRealtimeSuggestions(editorContent: EditorContent) async { + guard let data = try? JSONEncoder().encode(editorContent) else { return } + try? await withXPCServiceConnected(connection: connection) { service, continuation in + service.prefetchRealtimeSuggestions(editorContent: data) { + continuation.resume(()) + } + } + } } struct AutoFinishContinuation { diff --git a/Core/Sources/Service/AutoTrigger.swift b/Core/Sources/Service/AutoTrigger.swift index ab5260e2..babce6f4 100644 --- a/Core/Sources/Service/AutoTrigger.swift +++ b/Core/Sources/Service/AutoTrigger.swift @@ -25,10 +25,11 @@ actor AutoTrigger { func start(by listener: ObjectIdentifier) { listeners.insert(listener) + if task == nil { task = Task { [stream = eventObserver.stream] in var triggerTask: Task? - try? await Environment.triggerAction("Real-time Suggestions") + try? await Environment.triggerAction("Prefetch Suggestions") for await _ in stream { triggerTask?.cancel() if Task.isCancelled { break } @@ -43,7 +44,7 @@ actor AutoTrigger { } triggerTask = Task { @ServiceActor in - try? await Task.sleep(nanoseconds: 3_000_000_000) + try? await Task.sleep(nanoseconds: 2_000_000_000) if Task.isCancelled { return } let fileURL = try? await Environment.fetchCurrentFileURL() guard let folderURL = try? await Environment.fetchCurrentProjectRootURL(fileURL), @@ -51,7 +52,7 @@ actor AutoTrigger { workspace.isRealtimeSuggestionEnabled else { return } if Task.isCancelled { return } - try? await Environment.triggerAction("Real-time Suggestions") + try? await Environment.triggerAction("Prefetch Suggestions") } } } diff --git a/Core/Sources/Service/XPCService.swift b/Core/Sources/Service/XPCService.swift index 78031d39..ad47e2a0 100644 --- a/Core/Sources/Service/XPCService.swift +++ b/Core/Sources/Service/XPCService.swift @@ -258,4 +258,30 @@ public class XPCService: NSObject, XPCServiceProtocol { reply(nil) } } + + public func prefetchRealtimeSuggestions( + editorContent: Data, + withReply reply: @escaping () -> Void + ) { + Task { @ServiceActor in + do { + let editor = try JSONDecoder().decode(EditorContent.self, from: editorContent) + let fileURL = try await Environment.fetchCurrentFileURL() + let workspace = try await fetchOrCreateWorkspaceIfNeeded(fileURL: fileURL) + _ = workspace.getRealtimeSuggestedCode( + forFileAt: fileURL, + content: editor.content, + lines: editor.lines, + cursorPosition: editor.cursorPosition, + tabSize: editor.tabSize, + indentSize: editor.indentSize, + usesTabsForIndentation: editor.usesTabsForIndentation + ) + reply() + } catch { + print(error) + reply() + } + } + } } diff --git a/Core/Sources/XPCShared/XPCServiceProtocol.swift b/Core/Sources/XPCShared/XPCServiceProtocol.swift index 84ccb6df..bd710e98 100644 --- a/Core/Sources/XPCShared/XPCServiceProtocol.swift +++ b/Core/Sources/XPCShared/XPCServiceProtocol.swift @@ -35,4 +35,9 @@ public protocol XPCServiceProtocol { ) func setAutoSuggestion(enabled: Bool, withReply reply: @escaping (Error?) -> Void) + + func prefetchRealtimeSuggestions( + editorContent: Data, + withReply reply: @escaping () -> Void + ) } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 00000000..67f836cb --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,37 @@ +# Development + +## Targets + +### Copilot for Xcode + +Copilot for Xcode is the app containing both the XPCService and the editor extension. + +### EditorExtension + +As its name suggests, the editor extension. Since an editor extension must be sandboxed, it will need to talk to a trusted non-sandboxed XPCService to break out the limitations. The identifier of the XPCService must be listed under `com.apple.security.temporary-exception.mach-lookup.global-name` in entitlements. + +### XPCService + +The XPCService is a program that runs in the background and does basically everything. It redirects the requests from EditorExtension to `CopilotService` and returns the updated code back to the extension. + +Since the Xcode source editor extension only allows its commands to be triggered manually, the XPCService has to use Apple Scripts to trigger the menu items to generate real-time suggestions. + +The XPCService is also using a lot of Apple Script tricks to get the file paths and project/workspace paths of the active Xcode window because Xcode is not providing this information. + +## Building and Archiving the App + +This project contains a Git submodule `copilot.vim`, so you will have to initialize the submodule or download it from [copilot.vim](https://github.com/github/copilot.vim). + +Then archive the target Copilot for Xcode. + +## Testing Extension + +### Testing Real-time Suggestions Commands + +Testing Real-time Suggestions is a little bit different because the Apple Script can't find the commands when debugging the extension in Xcode. Instead, you will have to archive the debug version of the app, run the XPCService target simultaneously and use them against each other. + +### Testing Other Commands + +Just run both the XPCService and the EditorExtension Target. + + diff --git a/EditorExtension/PrefetchSuggestionsCommand.swift b/EditorExtension/PrefetchSuggestionsCommand.swift new file mode 100644 index 00000000..27adb975 --- /dev/null +++ b/EditorExtension/PrefetchSuggestionsCommand.swift @@ -0,0 +1,20 @@ +import Client +import CopilotModel +import Foundation +import XcodeKit + +class PrefetchSuggestionsCommand: NSObject, XCSourceEditorCommand, CommandType { + var name: String { "Prefetch Suggestions" } + + func perform( + with invocation: XCSourceEditorCommandInvocation, + completionHandler: @escaping (Error?) -> Void + ) { + completionHandler(nil) + + Task { + let service = try getService() + await service.prefetchRealtimeSuggestions(editorContent: .init(invocation)) + } + } +} diff --git a/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift index ee9a4b9c..bb37acb3 100644 --- a/EditorExtension/SourceEditorExtension.swift +++ b/EditorExtension/SourceEditorExtension.swift @@ -12,6 +12,7 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension { TurnOnRealtimeSuggestionsCommand(), TurnOffRealtimeSuggestionsCommand(), RealtimeSuggestionsCommand(), + PrefetchSuggestionsCommand(), ].map(makeCommandDefinition) } } diff --git a/README.md b/README.md index d567a4e4..107d1882 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,11 @@ Thanks to [LSP-copilot](https://github.com/TerminalFi/LSP-copilot) for showing t 5. After signing in, go back to the app and click "Confirm Sign-in" to finish. 5. Enable the extension in the Settings.app, then maybe restart Xcode. -The first time the actions run, the extension will ask for 2 types of permissions: +The first time the commands run, the extension will ask for 2 types of permissions: 1. Accessibility API: which the extension uses to get the editing file path. 2. Folder Access: the extension needs, to run some Apple Scripts to get the project/workspace path. -## Actions +## Commands - Get Suggestions: Get suggestions for the editing file at the current cursor position. - Next Suggestion: If there is more than 1 suggestion, switch to the next one. @@ -40,19 +40,20 @@ The first time the actions run, the extension will ask for 2 types of permission - Reject Suggestion: Remove the suggestion comments. - Turn On Real-time Suggestions: When turn on, Copilot will auto-insert suggestion comments to your code while editing. You have to manually turn it on for every open window of Xcode. - Turn Off Real-time Suggestions: Turns the real-time suggestions off. -- Real-time Suggestions: It is an entry point only for Copilot for Xcode. In the background, Copilot for Xcode will occasionally run this action to bring you real-time suggestions. +- Real-time Suggestions: It is an entry point only for Copilot for Xcode. When suggestions are successfully fetched, Copilot for Xcode will run this command to present the suggestions. +- Prefetch Suggestions: It is an entry point only for Copilot for Xcode. In the background, Copilot for Xcode will occasionally run this command to prefetch real-time suggestions. **About real-time suggestions** The implementation won't feel as smooth as that of VSCode. -The magic behind it is that it will keep calling the action from the menu when you are not typing, or clicking mouse. So it will have to listen to those events, I am not sure if people like it. +The magic behind it is that it will keep calling the command from the menu when you are not typing, or clicking mouse. So it will have to listen to those events, I am not sure if people like it. Hope that next year, Apple can spend some time on Xcode Extensions. ## Prevent Suggestions Being Committed -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. Maybe later I will add an action for that. +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. Maybe later I will add an command for that. ```sh #!/bin/sh